52 Commits

Author SHA1 Message Date
vikingowl 284944e582 feat(profile): round avatar
ci/someci/push/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
2026-05-11 17:56:22 +02:00
vikingowl db3a24a4a6 fix(auth): update avatar_url on existing OAuth account login if currently empty 2026-05-11 17:54:01 +02:00
vikingowl a473878cb4 feat(profile): replace avatar URL input with image preview
Show Google avatar inline with descriptive label; preserve value via hidden field.
2026-05-11 17:50:43 +02:00
vikingowl 76886fdbf3 docs(datenschutz): reflect actual OAuth provider (Google only) + stored fields 2026-05-11 17:41:39 +02:00
vikingowl 591e3190be feat(auth): capture Google avatar URL on OAuth signup 2026-05-11 17:39:56 +02:00
vikingowl d1066fecbc feat(auth): OAuth exchange-code flow — redirect to frontend via short-lived Valkey code 2026-05-11 17:34:55 +02:00
vikingowl a2e1bf239e feat(auth): enable Google OAuth — uncomment routes, add PUBLIC_OAUTH_GOOGLE to web config
ci/someci/push/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
2026-05-11 17:15:59 +02:00
vikingowl 3b940d4e30 feat: launch-phase readiness — favorites embed, heart button, post-login redirect, error pages, UserMenu
- Backend: GET /me/favorites now returns embedded market summary (JOIN on
  market_series + LATERAL latest edition) so the frontend can render cards
  without extra requests. FavoriteWithMarket struct added to model; repository
  ListByUserWithMarkets wired through service and handler. Existing isolation
  PoC test kept green; new TestListFavorites_ReturnsEmbeddedMarketData added.

- Auth redirect: magic-link/verify and OAuth callback now redirect to
  /auth/post-login which branches on user.role (admin → /admin,
  pending → /freigabe-pending, otherwise → /).

- UserMenu: admin link target changed from /admin/maerkte to /admin; Favoriten
  entry added between Sicherheit and Admin separator.

- Error pages: (dashboard)/+error.svelte added (Burgund tokens, German copy);
  (public)/+error.svelte retokened from stone-* to ink/ink-muted + 403 case.

- Market detail: series_id added to MarketDetail type; isFavorited loaded in
  page server (silently ignored if no auth or API error); ♡/♥ heart button in
  action row with addFavorite/removeFavorite form actions.

- Favoriten page: /profile/favoriten — auth-guarded by profile layout; card
  grid with Heraldry fallback, date range, Entfernen form; empty state via
  EmptyState component.
2026-05-11 16:34:38 +02:00
vikingowl f81dba7132 feat: GET /me/stats endpoint + wire Veranstalter Übersicht stats 2026-05-11 15:24:50 +02:00
vikingowl 47d7173edc feat(web): Phase 4b — Lager, Händler, Admin dashboard skeletons + auth route cleanup 2026-05-11 15:08:28 +02:00
vikingowl e6b70279b1 feat(web): Phase 4b — dashboard shell + Veranstalter skeleton
- Add status field to backend ProfileData DTO + frontend type
- Route restructure: public routes → (public)/ group; root layout bare
- (dashboard)/ group: auth guard layout + freigabe-pending page
- DashboardShell, StatTile, EmptyState shared components
- Veranstalter dashboard: role guard, sidebar nav, Übersicht + 3 stub tabs
2026-05-11 14:38:19 +02:00
vikingowl 79dd71ff73 fix(backend): program routes — use /series/:seriesId prefix to avoid gin wildcard conflict with /markets/:slug 2026-05-11 14:08:21 +02:00
vikingowl 5bbb9814a4 feat(backend): Phase 4a-bis — favorites, messages, market_programs domains + migrations 40-42 2026-05-11 14:04:46 +02:00
vikingowl c9c210111b feat(web): disable non-Besucher role chips in register form 2026-05-11 13:41:26 +02:00
vikingowl 874e1798b8 fix(web): einreichen — restore type=date picker; drop unnecessary toISO conversion 2026-05-11 13:31:12 +02:00
vikingowl 0fd097fa73 fix(web): submit form — Input $bindable fixes button; date inputs use German TT.MM.JJJJ format with ISO conversion for backend 2026-05-11 13:28:27 +02:00
vikingowl 2624d1ed90 fix(web): email-bestaetigen — remove duplicate Zur Startseite link on success state 2026-05-11 13:15:21 +02:00
vikingowl bc0e9a49ee fix(web): email verify resend — proxy through SvelteKit server to forward httpOnly cookie as Bearer token 2026-05-11 13:12:27 +02:00
vikingowl 6588814321 feat(web): email verification — banner + /auth/email-bestaetigen/[token] page
- EmailVerifyBanner: shown for unverified users; resend button calls POST
  /auth/email-verify/resend; confirms with "Link erneut gesendet." on success
- +layout.svelte: renders banner between Header and main for unverified users
- /auth/email-bestaetigen/[token]: GET-then-POST pattern (prevents bot token
  consumption); success state with homepage link; error state with Alert atom
2026-05-11 13:05:39 +02:00
vikingowl 4f0669cca0 feat(auth): email verification flow — migration, domain, middleware, email template
- Migration 000039: email_verify_tokens table; backfills all existing users as verified
- EmailVerifyToken model, CreateEmailVerifyToken/ConsumeEmailVerifyToken/MarkEmailVerified
  repository methods with atomic UPDATE...RETURNING consume pattern
- InvalidateUserSessionCaches: flushes Valkey-cached sessions after verification so the
  updated EmailVerified flag is visible immediately instead of after AccessTTL expires
- EmailVerifyHandler: ConfirmEmailVerify (POST /auth/email-verify/confirm, unauthenticated),
  ResendEmailVerify (POST /auth/email-verify/resend, auth-gated + rate-limited)
- RequireEmailVerified middleware gating POST /groups/:id/applications
- Session.EmailVerified propagated through createTokenPair and cached in Valkey
- Password reset + magic link set email_verified=TRUE on success
- Burgund-styled email_verify.html template
- Iron Law PoC tests: expired/used/unknown token -> 400; hashing verified; resend -> 401
2026-05-11 13:05:28 +02:00
vikingowl 70cef9e179 chore(planning): newsletter feature spec — deferred until core rework is live 2026-05-11 12:35:19 +02:00
vikingowl c7d290773c feat(email): restyle all templates to Burgund design system
- Remove forest-green header and gold accents
- Parchment background (#f5efe4), ink wordmark, rule-soft thin line
- Accent (#9a1e2c) for CTA buttons and links, on-accent (#f5efe4) button text
- Ink (#181410) headings, ink-soft (#3a322a) body, ink-muted (#6e6253) captions
- Rule-soft (#c9b58c) separators and table borders
- Square corners throughout (no border-radius)
- Mono-caps style on table label column
2026-05-11 12:34:15 +02:00
vikingowl 85bf7355be fix(web): impressum + datenschutz — add Stand: Mai 2026 below title 2026-05-11 12:16:54 +02:00
vikingowl c7521687dd fix(web): footer copyright — DACH-Raum und Europa 2026-05-11 12:15:58 +02:00
vikingowl fa988b3a5b feat(web): Turnstile — interaction-only appearance, compact size 2026-05-11 12:14:20 +02:00
vikingowl e044a85007 feat(web): Turnstile — follow app theme, center widget 2026-05-11 12:13:05 +02:00
vikingowl 70983b19d6 feat(web): Turnstile on auth pages; header DACH-Raum; AGB in footer 2026-05-11 12:11:04 +02:00
vikingowl c6b2ca2717 feat(web): add /agb page; fix Turnstile explicit render in multi-step form 2026-05-11 12:00:19 +02:00
vikingowl 9ecdb2359f feat(web): auth — disable submit buttons until form valid; add Turnstile/OAuth test keys to env examples 2026-05-11 11:46:34 +02:00
vikingowl b08acfb5b3 feat(web): auth — fix role chip heights, add Facebook OAuth, disable unconfigured providers 2026-05-11 11:38:58 +02:00
vikingowl 42f6e1706a feat(web): Phase 3 — submit-flow Burgund wizard rewrite 2026-05-11 11:30:51 +02:00
vikingowl 6cf560ec43 feat(web): Phase 3 — auth shell polish, role chips, password reset flow
- LoginForm: add Passwort vergessen? link below password field
- RegisterForm: role chips (Besucher/Veranstalter/Händler/Lager), confirm password, AGB checkbox, pending notice for non-user roles
- anmelden: Heraldry mark above form, OAuthButtons below forms
- registrieren: Heraldry mark, pending confirmation screen for non-user role signups
- New routes: /auth/passwort-vergessen (request) + /auth/passwort-zuruecksetzen/[token] (confirm)
2026-05-11 11:23:18 +02:00
vikingowl d815d47242 feat(backend): Phase 3 — role-based registration + password reset flow
- Add role field to RegisterRequest; non-user roles get status=pending
- Update user.Repository.Create to accept role and status params
- Migration 000038: password_reset_tokens table
- Password reset domain: request endpoint (email-enumeration-safe), confirm endpoint (atomic token consume, session revoke)
- Wire password reset routes under /api/v1/auth/password-reset/*
- Add password_reset email template
2026-05-11 11:23:04 +02:00
vikingowl 034453fd05 feat(web): home — weekend filter CTAs, dynamic season copy, open submit CTA 2026-05-11 10:17:20 +02:00
vikingowl 97532b85bc feat(web): Phase 2 — detail page breadcrumb 3-tier URLs + ornament break 2026-05-10 19:36:10 +02:00
vikingowl eb8c395b16 feat(web): Phase 2 — MarketCard layout pass + MarketMap Burgund popup styling 2026-05-10 19:28:00 +02:00
vikingowl 4b191a3cfd feat(web): remove legacy [state] routes — redirect now in hooks.server.ts 2026-05-10 19:27:27 +02:00
vikingowl 6a8fc07a3d feat(web): Phase 2 — 3-tier /maerkte/[country]/[state]/[city] URL structure
- Add country helpers to slug.ts using Intl.DisplayNames (zero-dep, German locale)
- New routes: /maerkte/[country], /maerkte/[country]/[state], /maerkte/[country]/[state]/[city]
  with full Burgund styling (Caps, Rule atoms, display-font h1, surface-alt city pills)
- Old /maerkte/[state] and /maerkte/[state]/[city] 301-redirect via hooks.server.ts
  (avoided SvelteKit route conflict by moving redirect out of conflicting page routes)
- Sitemap updated to 3-tier URLs; old DE state slugs retained until 2026-12-01
2026-05-10 19:18:39 +02:00
vikingowl 740bad2176 merge(branch): bring in Phase 4a backend (user status/roles, groups, applications, lagerleben) 2026-05-10 19:08:36 +02:00
vikingowl 4be9e9bce1 fix(web): suppress DEP0205 + update tailwindcss 4.2.2 → 4.3.0 2026-05-10 18:41:39 +02:00
vikingowl 6b09d0c84e fix(backend): wrap /markets/stats in API data envelope 2026-05-10 18:36:20 +02:00
vikingowl a87c7026fe fix(backend): resolve Gin route conflict — /groups/:groupId → /groups/:id 2026-05-10 18:35:10 +02:00
vikingowl 5422217a74 fix(web): PR1-PR4 cleanup — origin env, stats, anno, Europa, sitemap pagination, del mock 2026-05-10 18:34:04 +02:00
vikingowl 8253093a16 fix(backend): PR1-PR4 cleanup — pgerr, body, CountAdmins, pending gate, stats 2026-05-10 18:33:43 +02:00
vikingowl 808f07800e feat(backend): Phase 4a PR4 — lagerleben articles + camps API; wire frontend loaders
ci/someci/push/web Pipeline was successful
ci/someci/pr/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
ci/someci/pr/backend Pipeline was successful
2026-05-10 17:54:19 +02:00
vikingowl b62271eeb6 feat(backend): Phase 4a PR3 — applications + application_status_log
ci/someci/pr/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
ci/someci/pr/backend Pipeline was successful
2026-05-10 17:42:26 +02:00
vikingowl a37e79ec16 feat(backend): Phase 4a PR2 — groups, group_members, group_profiles
ci/someci/pr/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
ci/someci/pr/backend Pipeline was successful
- Migration 000035: three new tables.
  groups: id, name, kind (haendler|kuenstler|lager), created_by.
  group_members: id, group_id, user_id, role (admin|member) + UNIQUE
  constraint; indexed on user_id and group_id for fast membership lookup.
  group_profiles: one-to-one with groups — description, categories (TEXT[]),
  avatar_url, website_url; upserted so the profile always exists after group
  creation.

- internal/domain/group package: model, repository (pgx), service, handler,
  routes. Public routes: GET /groups/:id, GET /groups/:id/members.
  Auth-gated routes: POST /groups, PATCH /groups/:id/profile,
  POST /groups/:id/members, DELETE /groups/:id/members/:userId,
  GET /users/me/groups.

- Authorization is group-scoped (not platform-role): UpdateProfile and
  AddMember require group admin role; RemoveMember allows self-remove or
  admin. Removing the last admin is blocked (ErrCannotRemoveLastAdmin).
  Creator is automatically added as admin on group creation.

- Security tests (Iron Law): 8 PoC tests covering 401 for unauthenticated
  requests on all auth-gated endpoints, 403 for non-admin on profile/member
  write endpoints, self-remove happy path, last-admin guard, create happy
  path (creator becomes admin), and public endpoint accessibility.
2026-05-10 17:32:15 +02:00
vikingowl b5748121dd feat(backend): Phase 4a PR1 — user status/roles + admin approval queue
ci/someci/pr/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
ci/someci/pr/backend Pipeline was successful
- Migration 000034: add users.status (pending|active|suspended) with
  CHECK, users.approved_at, and users.role CHECK constraint covering
  all 6 planned roles (gast, user, veranstalter, haendler, lager, admin).
  Existing rows default to status='active' to preserve behaviour.

- Role and status constants in user/roles.go (single source of truth).

- User model extended with Status and ApprovedAt; all repository
  RETURNING/Scan clauses updated accordingly.

- Repository interface extended with ListByStatus and SetStatus.

- Admin approval queue: GET /admin/users/pending, POST /admin/users/:id/approve,
  POST /admin/users/:id/reject. Guarded by RequireAuth + RequireRole("admin").
  Approve/reject revokes all target user sessions before updating status
  (session cache carries stale role data; revocation forces re-login).

- SessionRevoker interface defined in user package to avoid import cycle
  with auth package. auth.Repository satisfies it at the wiring layer.

- Login now gates suspended accounts (returns "account suspended" error
  so new sessions cannot be created after rejection).

- Security tests (Iron Law): failing PoC tests written for all three
  admin endpoints verifying 401 for unauthenticated, 403 for non-admin,
  and correct status transitions + session revocation for admin.
2026-05-10 17:24:03 +02:00
vikingowl 911439ebd8 feat(web): implement Burgund Phase 3 — profile, security, auth component cleanup
ci/someci/push/web Pipeline was successful
ci/someci/pr/web Pipeline was successful
ci/someci/pr/backend Pipeline was successful
2026-05-10 16:23:17 +02:00
vikingowl 5e24be03af feat(web): implement Burgund Phase 2 — public surfaces
ci/someci/pr/web Pipeline was successful
ci/someci/push/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
ci/someci/pr/backend Pipeline was successful
2026-05-10 14:54:27 +02:00
vikingowl 00d43675ff fix(web): replace old signet with Burgund shield-M favicon across all sizes
Updates static/favicon.svg (was the old forest-green MedievalSharp signet),
regenerates favicon.ico, favicon-32.png, apple-touch-icon.png. Fixes
site.webmanifest theme_color and background_color to Burgund parchment.
Swaps app.html font preloads to EB Garamond + Cormorant Garamond and
theme-color meta tags to Burgund bg values.
2026-05-10 13:14:46 +02:00
vikingowl 418a4411f3 feat(web): implement Burgund design system foundation (Phase 1)
Establishes the Burgund visual identity system: 11-token color palette
(sealing-wax burgundy #9a1e2c light / halbton rosé #d86268 dark), editorial
typography (Cormorant Garamond display, EB Garamond serif, JetBrains Mono
caps), and all atoms (MarktvogtMark, Caps, Tag, Rule, Heraldry). Rewrites
Header, Footer, MobileNav, all ui/ components, and MarketCard to Burgund
tokens. Self-hosts 12 woff2 variable font subsets. Adds design-system.md
reference doc. Also switches pre-commit hook prettier step to --write.
2026-05-10 12:56:51 +02:00
300 changed files with 15058 additions and 2981 deletions
+3 -2
View File
@@ -73,8 +73,9 @@ fi
# 6. Web checks — only when web/ files are staged.
if [ -n "$(staged_match '^web/')" ]; then
echo "→ web: prettier --check"
( cd web && pnpm run format:check )
echo "→ web: prettier --write"
( cd web && pnpm run format )
git add $(git diff --cached --name-only --diff-filter=ACMR | grep '^web/' | tr '\n' ' ')
echo "→ web: eslint"
( cd web && pnpm run lint )
+3 -1
View File
@@ -56,7 +56,9 @@ SMTP_PASSWORD=
SMTP_FROM=noreply@marktvogt.de
# Cloudflare Turnstile
TURNSTILE_SECRET_KEY=
# Local dev test key (always passes): 1x0000000000000000000000000000000AA
# Always-block test key: 2x0000000000000000000000000000000AB
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# Notifications
ADMIN_EMAIL=
@@ -0,0 +1,421 @@
package application_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/application"
)
func init() {
gin.SetMode(gin.TestMode)
}
// -- in-memory fakes --
type fakeRepo struct {
apps map[uuid.UUID]application.Application
log map[uuid.UUID][]application.StatusLogEntry
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
apps: make(map[uuid.UUID]application.Application),
log: make(map[uuid.UUID][]application.StatusLogEntry),
}
}
func (r *fakeRepo) Create(_ context.Context, a application.Application) (application.Application, error) {
for _, existing := range r.apps {
if existing.GroupID == a.GroupID && existing.MarketEditionID == a.MarketEditionID {
return application.Application{}, application.ErrDuplicateApplication
}
}
a.CreatedAt = time.Now()
a.UpdatedAt = time.Now()
r.apps[a.ID] = a
return a, nil
}
func (r *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (application.Application, error) {
a, ok := r.apps[id]
if !ok {
return application.Application{}, application.ErrApplicationNotFound
}
return a, nil
}
func (r *fakeRepo) ListByGroup(_ context.Context, groupID uuid.UUID) ([]application.Application, error) {
var out []application.Application
for _, a := range r.apps {
if a.GroupID == groupID {
out = append(out, a)
}
}
return out, nil
}
func (r *fakeRepo) Update(_ context.Context, id uuid.UUID, fields map[string]any) (application.Application, error) {
a, ok := r.apps[id]
if !ok {
return application.Application{}, application.ErrApplicationNotFound
}
a.UpdatedAt = time.Now()
r.apps[id] = a
return a, nil
}
func (r *fakeRepo) SetStatus(_ context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (application.Application, error) {
a, ok := r.apps[id]
if !ok {
return application.Application{}, application.ErrApplicationNotFound
}
a.Status = status
a.SubmittedBy = submittedBy
a.SubmittedAt = submittedAt
r.apps[id] = a
return a, nil
}
func (r *fakeRepo) AppendStatusLog(_ context.Context, e application.StatusLogEntry) error {
r.log[e.ApplicationID] = append(r.log[e.ApplicationID], e)
return nil
}
func (r *fakeRepo) ListStatusLog(_ context.Context, applicationID uuid.UUID) ([]application.StatusLogEntry, error) {
return r.log[applicationID], nil
}
// fakeGroupChecker controls IsGroupAdmin / IsGroupMember responses per user.
type fakeGroupChecker struct {
admins map[uuid.UUID]bool
members map[uuid.UUID]bool
}
func newFakeGroupChecker() *fakeGroupChecker {
return &fakeGroupChecker{
admins: make(map[uuid.UUID]bool),
members: make(map[uuid.UUID]bool),
}
}
func (f *fakeGroupChecker) withAdmin(userID uuid.UUID) *fakeGroupChecker {
f.admins[userID] = true
f.members[userID] = true
return f
}
func (f *fakeGroupChecker) withMember(userID uuid.UUID) *fakeGroupChecker {
f.members[userID] = true
return f
}
func (f *fakeGroupChecker) IsGroupAdmin(_ context.Context, _, userID uuid.UUID) (bool, error) {
return f.admins[userID], nil
}
func (f *fakeGroupChecker) IsGroupMember(_ context.Context, _, userID uuid.UUID) (bool, error) {
return f.members[userID], nil
}
// -- router helpers --
func newRouter(repo application.Repository, checker application.GroupMembershipChecker, authMiddleware gin.HandlerFunc) *gin.Engine {
svc := application.NewService(repo, checker)
h := application.NewHandler(svc)
router := gin.New()
noop := func(c *gin.Context) { c.Next() }
application.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware, noop)
return router
}
func stubAuth(userID uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", userID)
c.Next()
}
}
func noAuth() gin.HandlerFunc {
return func(c *gin.Context) { c.AbortWithStatus(http.StatusUnauthorized) }
}
func jsonBody(v any) *bytes.Reader {
b, _ := json.Marshal(v)
return bytes.NewReader(b)
}
// PoC: all application endpoints reject unauthenticated requests (401).
func TestApplicationEndpoints_Unauthenticated_Returns401(t *testing.T) {
t.Parallel()
repo := newFakeRepo()
checker := newFakeGroupChecker()
router := newRouter(repo, checker, noAuth())
groupID := uuid.New().String()
appID := uuid.New().String()
endpoints := []struct {
method string
path string
body any
}{
{http.MethodPost, "/api/v1/groups/" + groupID + "/applications", map[string]any{"market_edition_id": uuid.New(), "num_persons": 1}},
{http.MethodGet, "/api/v1/groups/" + groupID + "/applications", nil},
{http.MethodGet, "/api/v1/applications/" + appID, nil},
{http.MethodPatch, "/api/v1/applications/" + appID, map[string]string{"category": "Schmuck"}},
{http.MethodPost, "/api/v1/applications/" + appID + "/submit", nil},
{http.MethodGet, "/api/v1/applications/" + appID + "/history", nil},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
var body *bytes.Reader
if ep.body != nil {
body = jsonBody(ep.body)
} else {
body = bytes.NewReader(nil)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: create/update/submit return 403 for non-group-admin users.
func TestApplicationWriteEndpoints_NonAdmin_Returns403(t *testing.T) {
t.Parallel()
adminID := uuid.New()
memberID := uuid.New()
groupID := uuid.New()
editionID := uuid.New()
checker := newFakeGroupChecker().withAdmin(adminID).withMember(memberID)
repo := newFakeRepo()
// Seed a draft application.
draftApp := application.Application{
ID: uuid.New(),
GroupID: groupID,
MarketEditionID: editionID,
Status: application.StatusDraft,
NumPersons: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.apps[draftApp.ID] = draftApp
router := newRouter(repo, checker, stubAuth(memberID))
appPath := "/api/v1/applications/" + draftApp.ID.String()
endpoints := []struct {
method string
path string
body any
}{
{http.MethodPost, "/api/v1/groups/" + groupID.String() + "/applications",
map[string]any{"market_edition_id": uuid.New(), "num_persons": 1}},
{http.MethodPatch, appPath, map[string]string{"category": "Schmuck"}},
{http.MethodPost, appPath + "/submit", nil},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
var body *bytes.Reader
if ep.body != nil {
body = jsonBody(ep.body)
} else {
body = bytes.NewReader(nil)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: get/list/history return 403 for users who are not group members.
func TestApplicationReadEndpoints_NonMember_Returns403(t *testing.T) {
t.Parallel()
adminID := uuid.New()
outsiderID := uuid.New()
groupID := uuid.New()
checker := newFakeGroupChecker().withAdmin(adminID)
repo := newFakeRepo()
app := application.Application{
ID: uuid.New(),
GroupID: groupID,
Status: application.StatusDraft,
NumPersons: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.apps[app.ID] = app
router := newRouter(repo, checker, stubAuth(outsiderID))
appPath := "/api/v1/applications/" + app.ID.String()
endpoints := []struct{ method, path string }{
{http.MethodGet, "/api/v1/groups/" + groupID.String() + "/applications"},
{http.MethodGet, appPath},
{http.MethodGet, appPath + "/history"},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: submitting a non-draft application returns 400.
func TestSubmit_NonDraft_Returns400(t *testing.T) {
t.Parallel()
adminID := uuid.New()
groupID := uuid.New()
checker := newFakeGroupChecker().withAdmin(adminID)
repo := newFakeRepo()
submittedApp := application.Application{
ID: uuid.New(),
GroupID: groupID,
Status: application.StatusSubmitted,
NumPersons: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.apps[submittedApp.ID] = submittedApp
router := newRouter(repo, checker, stubAuth(adminID))
path := "/api/v1/applications/" + submittedApp.ID.String() + "/submit"
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d (body=%s)", w.Code, w.Body.String())
}
}
// PoC: group admin can create and submit an application; status log is appended.
func TestCreateAndSubmit_Admin_Succeeds(t *testing.T) {
t.Parallel()
adminID := uuid.New()
groupID := uuid.New()
editionID := uuid.New()
checker := newFakeGroupChecker().withAdmin(adminID)
repo := newFakeRepo()
router := newRouter(repo, checker, stubAuth(adminID))
// Create a draft.
createBody := jsonBody(map[string]any{
"market_edition_id": editionID,
"category": "Schmuck & Accessoires", //nolint:misspell
"description": "Wir verkaufen handgefertigten Schmuck",
"num_persons": 2,
"num_tents": 1,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/groups/"+groupID.String()+"/applications", createBody)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("create: want 201, got %d (body=%s)", w.Code, w.Body.String())
}
// Extract the application ID from the response.
var resp struct {
Data application.Application `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
if resp.Data.Status != application.StatusDraft {
t.Errorf("want status draft, got %s", resp.Data.Status)
}
// Submit it.
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/applications/"+resp.Data.ID.String()+"/submit", nil)
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("submit: want 200, got %d (body=%s)", w2.Code, w2.Body.String())
}
var resp2 struct {
Data application.Application `json:"data"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal submit response: %v", err)
}
if resp2.Data.Status != application.StatusSubmitted {
t.Errorf("want status submitted, got %s", resp2.Data.Status)
}
// Verify status log has two entries (draft creation + submission).
if len(repo.log[resp.Data.ID]) < 2 {
t.Errorf("expected at least 2 status log entries, got %d", len(repo.log[resp.Data.ID]))
}
}
// PoC: duplicate application (same group + edition) returns 400.
func TestCreate_Duplicate_Returns400(t *testing.T) {
t.Parallel()
adminID := uuid.New()
groupID := uuid.New()
editionID := uuid.New()
checker := newFakeGroupChecker().withAdmin(adminID)
repo := newFakeRepo()
repo.apps[uuid.New()] = application.Application{
ID: uuid.New(), GroupID: groupID, MarketEditionID: editionID,
Status: application.StatusDraft, NumPersons: 1,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
router := newRouter(repo, checker, stubAuth(adminID))
body := jsonBody(map[string]any{"market_edition_id": editionID, "num_persons": 1})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/groups/"+groupID.String()+"/applications", body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d (body=%s)", w.Code, w.Body.String())
}
}
@@ -0,0 +1,164 @@
package application
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/validate"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Create(c *gin.Context) {
groupID, ok := parseGroupID(c)
if !ok {
return
}
var req CreateRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
requesterID := getRequesterID(c)
a, err := h.svc.Create(c.Request.Context(), requesterID, groupID, req)
if err != nil {
h.handleServiceError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"data": a})
}
func (h *Handler) Get(c *gin.Context) {
id, ok := parseApplicationID(c)
if !ok {
return
}
requesterID := getRequesterID(c)
a, err := h.svc.Get(c.Request.Context(), requesterID, id)
if err != nil {
h.handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": a})
}
func (h *Handler) ListByGroup(c *gin.Context) {
groupID, ok := parseGroupID(c)
if !ok {
return
}
requesterID := getRequesterID(c)
apps, err := h.svc.ListByGroup(c.Request.Context(), requesterID, groupID)
if err != nil {
h.handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": apps})
}
func (h *Handler) Update(c *gin.Context) {
id, ok := parseApplicationID(c)
if !ok {
return
}
var req UpdateRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
requesterID := getRequesterID(c)
a, err := h.svc.Update(c.Request.Context(), requesterID, id, req)
if err != nil {
h.handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": a})
}
func (h *Handler) Submit(c *gin.Context) {
id, ok := parseApplicationID(c)
if !ok {
return
}
requesterID := getRequesterID(c)
a, err := h.svc.Submit(c.Request.Context(), requesterID, id)
if err != nil {
h.handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": a})
}
func (h *Handler) GetHistory(c *gin.Context) {
id, ok := parseApplicationID(c)
if !ok {
return
}
requesterID := getRequesterID(c)
entries, err := h.svc.GetHistory(c.Request.Context(), requesterID, id)
if err != nil {
h.handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"data": entries})
}
func (h *Handler) handleServiceError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrApplicationNotFound):
apiErr := apierror.NotFound("application")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, ErrForbidden):
apiErr := apierror.Forbidden("insufficient group permissions")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, ErrNotDraft):
apiErr := apierror.BadRequest("not_draft", "application is not in draft status")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, ErrDuplicateApplication):
apiErr := apierror.BadRequest("duplicate_application", "an application already exists for this group and market edition")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
default:
apiErr := apierror.Internal("internal error")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
}
}
func parseGroupID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_group_id", "invalid group id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func parseApplicationID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_application_id", "invalid application id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func getRequesterID(c *gin.Context) uuid.UUID {
v, _ := c.Get("user_id")
id, _ := v.(uuid.UUID)
return id
}
@@ -0,0 +1,52 @@
package application
import (
"fmt"
"time"
"github.com/google/uuid"
)
type Application struct {
ID uuid.UUID `json:"id"`
GroupID uuid.UUID `json:"group_id"`
MarketEditionID uuid.UUID `json:"market_edition_id"`
Status string `json:"status"`
Category string `json:"category"`
Description string `json:"description"`
AreaSqm *float64 `json:"area_sqm,omitempty"`
NeedsPower bool `json:"needs_power"`
NeedsWater bool `json:"needs_water"`
NumPersons int `json:"num_persons"`
NumTents int `json:"num_tents"`
Notes string `json:"notes"`
SubmittedBy *uuid.UUID `json:"submitted_by,omitempty"`
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type StatusLogEntry struct {
ID uuid.UUID `json:"id"`
ApplicationID uuid.UUID `json:"application_id"`
FromStatus *string `json:"from_status,omitempty"`
ToStatus string `json:"to_status"`
ChangedBy uuid.UUID `json:"changed_by"`
Note string `json:"note"`
ChangedAt time.Time `json:"changed_at"`
}
const (
StatusDraft = "draft"
StatusSubmitted = "submitted"
StatusReviewing = "reviewing"
StatusAccepted = "accepted"
StatusRejected = "rejected"
StatusWaitlisted = "waitlisted"
)
var (
ErrApplicationNotFound = fmt.Errorf("application not found")
ErrNotDraft = fmt.Errorf("application is not in draft status")
ErrDuplicateApplication = fmt.Errorf("application already exists for this group and market edition")
)
@@ -0,0 +1,185 @@
package application
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"marktvogt.de/backend/internal/pkg/pgerr"
)
type Repository interface {
Create(ctx context.Context, a Application) (Application, error)
GetByID(ctx context.Context, id uuid.UUID) (Application, error)
ListByGroup(ctx context.Context, groupID uuid.UUID) ([]Application, error)
Update(ctx context.Context, id uuid.UUID, fields map[string]any) (Application, error)
SetStatus(ctx context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (Application, error)
AppendStatusLog(ctx context.Context, entry StatusLogEntry) error
ListStatusLog(ctx context.Context, applicationID uuid.UUID) ([]StatusLogEntry, error)
}
type pgRepository struct {
db *pgxpool.Pool
}
func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
var scanCols = `id, group_id, market_edition_id, status, category, description,
area_sqm, needs_power, needs_water, num_persons, num_tents, notes,
submitted_by, submitted_at, created_at, updated_at`
func scanApplication(row interface{ Scan(...any) error }) (Application, error) {
var a Application
err := row.Scan(
&a.ID, &a.GroupID, &a.MarketEditionID, &a.Status,
&a.Category, &a.Description,
&a.AreaSqm, &a.NeedsPower, &a.NeedsWater, &a.NumPersons, &a.NumTents, &a.Notes,
&a.SubmittedBy, &a.SubmittedAt, &a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
func (r *pgRepository) Create(ctx context.Context, a Application) (Application, error) {
row := r.db.QueryRow(ctx, fmt.Sprintf(`
INSERT INTO applications
(id, group_id, market_edition_id, category, description,
area_sqm, needs_power, needs_water, num_persons, num_tents, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING %s
`, scanCols),
a.ID, a.GroupID, a.MarketEditionID, a.Category, a.Description,
a.AreaSqm, a.NeedsPower, a.NeedsWater, a.NumPersons, a.NumTents, a.Notes,
)
out, err := scanApplication(row)
if err != nil {
if pgerr.IsDuplicateKey(err) {
return Application{}, ErrDuplicateApplication
}
return Application{}, fmt.Errorf("creating application: %w", err)
}
return out, nil
}
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Application, error) {
row := r.db.QueryRow(ctx, fmt.Sprintf(`
SELECT %s FROM applications WHERE id = $1
`, scanCols), id)
a, err := scanApplication(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Application{}, ErrApplicationNotFound
}
return Application{}, fmt.Errorf("getting application: %w", err)
}
return a, nil
}
func (r *pgRepository) ListByGroup(ctx context.Context, groupID uuid.UUID) ([]Application, error) {
rows, err := r.db.Query(ctx, fmt.Sprintf(`
SELECT %s FROM applications WHERE group_id = $1 ORDER BY created_at DESC
`, scanCols), groupID)
if err != nil {
return nil, fmt.Errorf("listing applications: %w", err)
}
defer rows.Close()
var apps []Application
for rows.Next() {
a, scanErr := scanApplication(rows)
if scanErr != nil {
return nil, fmt.Errorf("scanning application: %w", scanErr)
}
apps = append(apps, a)
}
return apps, rows.Err()
}
func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[string]any) (Application, error) {
if len(fields) == 0 {
return r.GetByID(ctx, id)
}
setClauses := ""
args := []any{id}
i := 2
for k, v := range fields {
if setClauses != "" {
setClauses += ", "
}
setClauses += fmt.Sprintf("%s = $%d", k, i)
args = append(args, v)
i++
}
row := r.db.QueryRow(ctx, fmt.Sprintf(`
UPDATE applications SET %s, updated_at = NOW()
WHERE id = $1
RETURNING %s
`, setClauses, scanCols), args...)
a, err := scanApplication(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Application{}, ErrApplicationNotFound
}
return Application{}, fmt.Errorf("updating application: %w", err)
}
return a, nil
}
func (r *pgRepository) SetStatus(ctx context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (Application, error) {
row := r.db.QueryRow(ctx, fmt.Sprintf(`
UPDATE applications
SET status = $2, submitted_by = $3, submitted_at = $4, updated_at = NOW()
WHERE id = $1
RETURNING %s
`, scanCols), id, status, submittedBy, submittedAt)
a, err := scanApplication(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Application{}, ErrApplicationNotFound
}
return Application{}, fmt.Errorf("setting application status: %w", err)
}
return a, nil
}
func (r *pgRepository) AppendStatusLog(ctx context.Context, entry StatusLogEntry) error {
_, err := r.db.Exec(ctx, `
INSERT INTO application_status_log (id, application_id, from_status, to_status, changed_by, note)
VALUES ($1, $2, $3, $4, $5, $6)
`, entry.ID, entry.ApplicationID, entry.FromStatus, entry.ToStatus, entry.ChangedBy, entry.Note)
if err != nil {
return fmt.Errorf("appending status log: %w", err)
}
return nil
}
func (r *pgRepository) ListStatusLog(ctx context.Context, applicationID uuid.UUID) ([]StatusLogEntry, error) {
rows, err := r.db.Query(ctx, `
SELECT id, application_id, from_status, to_status, changed_by, note, changed_at
FROM application_status_log
WHERE application_id = $1
ORDER BY changed_at ASC
`, applicationID)
if err != nil {
return nil, fmt.Errorf("listing status log: %w", err)
}
defer rows.Close()
var entries []StatusLogEntry
for rows.Next() {
var e StatusLogEntry
if err := rows.Scan(&e.ID, &e.ApplicationID, &e.FromStatus, &e.ToStatus, &e.ChangedBy, &e.Note, &e.ChangedAt); err != nil {
return nil, fmt.Errorf("scanning status log entry: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
@@ -0,0 +1,15 @@
package application
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc, requireEmailVerified gin.HandlerFunc) {
auth := rg.Group("", requireAuth)
{
auth.POST("/groups/:id/applications", requireEmailVerified, h.Create)
auth.GET("/groups/:id/applications", h.ListByGroup)
auth.GET("/applications/:id", h.Get)
auth.PATCH("/applications/:id", h.Update)
auth.POST("/applications/:id/submit", h.Submit)
auth.GET("/applications/:id/history", h.GetHistory)
}
}
@@ -0,0 +1,214 @@
package application
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// GroupMembershipChecker is the subset of group.Repository the service needs.
// Defined here to avoid an import cycle; satisfied by an adapter in server/routes.go.
type GroupMembershipChecker interface {
IsGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) (bool, error)
IsGroupMember(ctx context.Context, groupID, userID uuid.UUID) (bool, error)
}
type Service struct {
repo Repository
groups GroupMembershipChecker
}
func NewService(repo Repository, groups GroupMembershipChecker) *Service {
return &Service{repo: repo, groups: groups}
}
type CreateRequest struct {
MarketEditionID uuid.UUID `json:"market_edition_id" validate:"required"`
Category string `json:"category" validate:"omitempty,max=100"`
Description string `json:"description" validate:"omitempty,max=5000"`
AreaSqm *float64 `json:"area_sqm" validate:"omitempty,gt=0"`
NeedsPower bool `json:"needs_power"`
NeedsWater bool `json:"needs_water"`
NumPersons int `json:"num_persons" validate:"min=1"`
NumTents int `json:"num_tents" validate:"min=0"`
Notes string `json:"notes" validate:"omitempty,max=2000"`
}
type UpdateRequest struct {
Category *string `json:"category" validate:"omitempty,max=100"`
Description *string `json:"description" validate:"omitempty,max=5000"`
AreaSqm *float64 `json:"area_sqm" validate:"omitempty,gt=0"`
NeedsPower *bool `json:"needs_power"`
NeedsWater *bool `json:"needs_water"`
NumPersons *int `json:"num_persons" validate:"omitempty,min=1"`
NumTents *int `json:"num_tents" validate:"omitempty,min=0"`
Notes *string `json:"notes" validate:"omitempty,max=2000"`
}
func (s *Service) Create(ctx context.Context, requesterID, groupID uuid.UUID, req CreateRequest) (Application, error) {
if err := s.requireGroupAdmin(ctx, groupID, requesterID); err != nil {
return Application{}, err
}
numPersons := req.NumPersons
if numPersons < 1 {
numPersons = 1
}
a := Application{
ID: uuid.New(),
GroupID: groupID,
MarketEditionID: req.MarketEditionID,
Status: StatusDraft,
Category: req.Category,
Description: req.Description,
AreaSqm: req.AreaSqm,
NeedsPower: req.NeedsPower,
NeedsWater: req.NeedsWater,
NumPersons: numPersons,
NumTents: req.NumTents,
Notes: req.Notes,
}
created, err := s.repo.Create(ctx, a)
if err != nil {
return Application{}, err
}
_ = s.repo.AppendStatusLog(ctx, StatusLogEntry{
ID: uuid.New(),
ApplicationID: created.ID,
ToStatus: StatusDraft,
ChangedBy: requesterID,
})
return created, nil
}
func (s *Service) Get(ctx context.Context, requesterID, id uuid.UUID) (Application, error) {
a, err := s.repo.GetByID(ctx, id)
if err != nil {
return Application{}, err
}
if err := s.requireGroupMember(ctx, a.GroupID, requesterID); err != nil {
return Application{}, err
}
return a, nil
}
func (s *Service) ListByGroup(ctx context.Context, requesterID, groupID uuid.UUID) ([]Application, error) {
if err := s.requireGroupMember(ctx, groupID, requesterID); err != nil {
return nil, err
}
return s.repo.ListByGroup(ctx, groupID)
}
func (s *Service) Update(ctx context.Context, requesterID, id uuid.UUID, req UpdateRequest) (Application, error) {
a, err := s.repo.GetByID(ctx, id)
if err != nil {
return Application{}, err
}
if a.Status != StatusDraft {
return Application{}, ErrNotDraft
}
if err := s.requireGroupAdmin(ctx, a.GroupID, requesterID); err != nil {
return Application{}, err
}
fields := make(map[string]any)
if req.Category != nil {
fields["category"] = *req.Category
}
if req.Description != nil {
fields["description"] = *req.Description
}
if req.AreaSqm != nil {
fields["area_sqm"] = *req.AreaSqm
}
if req.NeedsPower != nil {
fields["needs_power"] = *req.NeedsPower
}
if req.NeedsWater != nil {
fields["needs_water"] = *req.NeedsWater
}
if req.NumPersons != nil {
fields["num_persons"] = *req.NumPersons
}
if req.NumTents != nil {
fields["num_tents"] = *req.NumTents
}
if req.Notes != nil {
fields["notes"] = *req.Notes
}
return s.repo.Update(ctx, id, fields)
}
func (s *Service) Submit(ctx context.Context, requesterID, id uuid.UUID) (Application, error) {
a, err := s.repo.GetByID(ctx, id)
if err != nil {
return Application{}, err
}
if a.Status != StatusDraft {
return Application{}, ErrNotDraft
}
if err := s.requireGroupAdmin(ctx, a.GroupID, requesterID); err != nil {
return Application{}, err
}
from := StatusDraft
now := time.Now()
updated, err := s.repo.SetStatus(ctx, id, StatusSubmitted, &requesterID, &now)
if err != nil {
return Application{}, err
}
_ = s.repo.AppendStatusLog(ctx, StatusLogEntry{
ID: uuid.New(),
ApplicationID: id,
FromStatus: &from,
ToStatus: StatusSubmitted,
ChangedBy: requesterID,
})
return updated, nil
}
func (s *Service) GetHistory(ctx context.Context, requesterID, id uuid.UUID) ([]StatusLogEntry, error) {
a, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.requireGroupMember(ctx, a.GroupID, requesterID); err != nil {
return nil, err
}
return s.repo.ListStatusLog(ctx, id)
}
func (s *Service) requireGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) error {
ok, err := s.groups.IsGroupAdmin(ctx, groupID, userID)
if err != nil {
return fmt.Errorf("checking group admin: %w", err)
}
if !ok {
return ErrForbidden
}
return nil
}
func (s *Service) requireGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
ok, err := s.groups.IsGroupMember(ctx, groupID, userID)
if err != nil {
return fmt.Errorf("checking group membership: %w", err)
}
if !ok {
return ErrForbidden
}
return nil
}
// ErrForbidden is returned when the requester lacks the necessary group role.
var ErrForbidden = errors.New("forbidden")
+10
View File
@@ -10,6 +10,7 @@ type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
DisplayName string `json:"display_name" validate:"required,min=1,max=100"`
Role string `json:"role" validate:"omitempty,oneof=user veranstalter haendler lager"`
}
type LoginRequest struct {
@@ -82,3 +83,12 @@ type MessageResponse struct {
type MessageData struct {
Message string `json:"message"`
}
type PasswordResetRequestDTO struct {
Email string `json:"email" validate:"required,email"`
}
type PasswordResetConfirmRequest struct {
Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
@@ -0,0 +1,169 @@
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/user"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/email"
)
const emailVerifyTTL = 48 * time.Hour
type EmailVerifyHandler struct {
authRepo Repository
userRepo user.Repository
email email.Sender
frontendURL string
}
func NewEmailVerifyHandler(authRepo Repository, userRepo user.Repository, emailSender email.Sender, frontendURL string) *EmailVerifyHandler {
return &EmailVerifyHandler{
authRepo: authRepo,
userRepo: userRepo,
email: emailSender,
frontendURL: frontendURL,
}
}
// ConfirmEmailVerify validates the token and marks the user's email as verified.
func (h *EmailVerifyHandler) ConfirmEmailVerify(c *gin.Context) {
var req struct {
Token string `json:"token" validate:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
apiErr := apierror.BadRequest("invalid_request", "token is required")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
ctx := c.Request.Context()
tokenHash := HashToken(req.Token)
evt, err := h.authRepo.ConsumeEmailVerifyToken(ctx, tokenHash)
if err != nil {
if errors.Is(err, ErrEmailVerifyNotFound) || errors.Is(err, ErrEmailVerifyExpired) || errors.Is(err, ErrEmailVerifyUsed) {
apiErr := apierror.BadRequest("invalid_token", "verify link is invalid, expired, or already used")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to verify token")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.authRepo.MarkEmailVerified(ctx, evt.UserID); err != nil {
apiErr := apierror.Internal("failed to update email verification status")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
// Flush stale Valkey-cached sessions so the updated EmailVerified flag is
// reflected on the next request rather than after AccessTTL expires.
if err := h.authRepo.InvalidateUserSessionCaches(ctx, evt.UserID); err != nil {
slog.Warn("failed to invalidate session caches after email verify", "user_id", evt.UserID, "error", err)
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "email verified"}})
}
// ResendEmailVerify creates a fresh verify token and emails it to the authenticated user.
// Requires auth (but not email verification). Rate-limited to prevent abuse.
func (h *EmailVerifyHandler) ResendEmailVerify(c *gin.Context) {
userIDVal, exists := c.Get("user_id")
if !exists {
apiErr := apierror.Unauthorized("not authenticated")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID, ok := userIDVal.(uuid.UUID)
if !ok {
apiErr := apierror.Internal("invalid user context")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
ctx := c.Request.Context()
u, err := h.userRepo.GetByID(ctx, userID)
if err != nil {
apiErr := apierror.Internal("failed to load user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if u.EmailVerified {
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "email already verified"}})
return
}
h.SendVerifyEmail(ctx, u.ID, u.Email)
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "verification email sent"}})
}
// SendVerifyEmail creates a token and dispatches the verification email asynchronously.
// Used by ResendEmailVerify and by the auth.Service.Register hook.
func (h *EmailVerifyHandler) SendVerifyEmail(ctx context.Context, userID uuid.UUID, toEmail string) {
rawToken := GenerateOpaqueToken()
tokenHash := HashToken(rawToken)
evt := EmailVerifyToken{
ID: uuid.New(),
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(emailVerifyTTL),
}
if err := h.authRepo.CreateEmailVerifyToken(ctx, evt); err != nil {
slog.Error("failed to create email verify token", "user_id", userID, "error", err)
return
}
verifyURL := fmt.Sprintf("%s/auth/email-bestaetigen/%s", h.frontendURL, rawToken)
if h.email != nil {
go func() {
msg := email.Message{
To: toEmail,
Subject: "E-Mail-Adresse bestätigen Marktvogt",
Body: fmt.Sprintf("Bestätige deine E-Mail-Adresse:\n\n%s\n\nDer Link ist 48 Stunden gültig.", verifyURL),
}
html, err := email.Render("email_verify", email.TemplateData{
PreheaderText: "E-Mail-Adresse bestätigen",
BaseURL: h.frontendURL,
Year: time.Now().Year(),
Content: email.EmailVerifyData{
VerifyURL: verifyURL,
},
})
if err != nil {
slog.Error("failed to render email verify template", "error", err)
} else {
msg.HTML = html
}
if err := h.email.Send(context.Background(), msg); err != nil {
slog.Error("failed to send email verify message", "email", toEmail, "error", err)
}
}()
} else {
slog.Info("email verify token created (no email sender configured)", "email", toEmail, "url", verifyURL)
}
}
func RegisterEmailVerifyRoutes(rg *gin.RouterGroup, h *EmailVerifyHandler, requireAuth gin.HandlerFunc, limit gin.HandlerFunc) {
rg.POST("/auth/email-verify/confirm", h.ConfirmEmailVerify)
auth := rg.Group("", requireAuth)
{
auth.POST("/auth/email-verify/resend", limit, h.ResendEmailVerify)
}
}
@@ -0,0 +1,247 @@
package auth_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/auth"
"marktvogt.de/backend/internal/domain/user"
)
// --- fakeRepo extension: email verify tokens ---
// fakeRepo struct is defined in service_refresh_test.go (same package).
func (r *fakeRepo) CreateEmailVerifyToken(_ context.Context, token auth.EmailVerifyToken) error {
r.mu.Lock()
defer r.mu.Unlock()
clone := token
r.emailVerifyTokens[token.TokenHash] = &clone
return nil
}
func (r *fakeRepo) ConsumeEmailVerifyToken(_ context.Context, tokenHash string) (auth.EmailVerifyToken, error) {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.emailVerifyTokens[tokenHash]
if !ok {
return auth.EmailVerifyToken{}, auth.ErrEmailVerifyNotFound
}
if t.Used {
return auth.EmailVerifyToken{}, auth.ErrEmailVerifyUsed
}
if time.Now().After(t.ExpiresAt) {
return auth.EmailVerifyToken{}, auth.ErrEmailVerifyExpired
}
t.Used = true
return *t, nil
}
func (r *fakeRepo) MarkEmailVerified(_ context.Context, userID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
r.emailVerifiedUsers[userID] = true
return nil
}
func (r *fakeRepo) PutOAuthExchangeCode(_ context.Context, _ string, _ auth.AuthData, _ time.Duration) error {
return nil
}
func (r *fakeRepo) ConsumeOAuthExchangeCode(_ context.Context, _ string) (auth.AuthData, error) {
return auth.AuthData{}, auth.ErrOAuthStateUnknown
}
// --- Helpers ---
func newEmailVerifyRouter(h *auth.EmailVerifyHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
rg := r.Group("/api/v1")
auth.RegisterEmailVerifyRoutes(rg, h, func(c *gin.Context) {
// minimal auth middleware stub: inject user_id from query for tests
if uid := c.Query("user_id"); uid != "" {
id, _ := uuid.Parse(uid)
c.Set("user_id", id)
}
c.Next()
}, func(c *gin.Context) { c.Next() }) // no-op rate limiter
return r
}
func postConfirm(t *testing.T, router *gin.Engine, body any) *httptest.ResponseRecorder {
t.Helper()
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/email-verify/confirm", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// --- Iron Law PoC tests (written before the handler that makes them pass) ---
// TestVerifyEmail_ExpiredToken verifies that confirming an expired token returns 400.
func TestVerifyEmail_ExpiredToken(t *testing.T) {
repo := newFakeRepo()
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
rawToken := "expired-token-raw"
hash := auth.HashToken(rawToken)
_ = repo.CreateEmailVerifyToken(context.Background(), auth.EmailVerifyToken{
ID: uuid.New(),
UserID: testUser.ID,
TokenHash: hash,
ExpiresAt: time.Now().Add(-1 * time.Hour), // already expired
})
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
r := newEmailVerifyRouter(h)
w := postConfirm(t, r, map[string]string{"token": rawToken})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for expired token, got %d: %s", w.Code, w.Body.String())
}
}
// TestVerifyEmail_AlreadyUsedToken verifies that reusing a consumed token returns 400.
func TestVerifyEmail_AlreadyUsedToken(t *testing.T) {
repo := newFakeRepo()
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
rawToken := "used-token-raw"
hash := auth.HashToken(rawToken)
tok := auth.EmailVerifyToken{
ID: uuid.New(),
UserID: testUser.ID,
TokenHash: hash,
ExpiresAt: time.Now().Add(time.Hour),
}
_ = repo.CreateEmailVerifyToken(context.Background(), tok)
// Consume it once to mark it used.
_, _ = repo.ConsumeEmailVerifyToken(context.Background(), hash)
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
r := newEmailVerifyRouter(h)
w := postConfirm(t, r, map[string]string{"token": rawToken})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for already-used token, got %d: %s", w.Code, w.Body.String())
}
}
// TestVerifyEmail_UnknownToken verifies that an unrecognised token returns 400.
func TestVerifyEmail_UnknownToken(t *testing.T) {
repo := newFakeRepo()
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(), nil, "http://localhost")
r := newEmailVerifyRouter(h)
w := postConfirm(t, r, map[string]string{"token": "never-issued"})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for unknown token, got %d: %s", w.Code, w.Body.String())
}
}
// TestVerifyEmail_HappyPath verifies that a valid token marks the user verified.
func TestVerifyEmail_HappyPath(t *testing.T) {
repo := newFakeRepo()
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
rawToken := "valid-token"
hash := auth.HashToken(rawToken)
_ = repo.CreateEmailVerifyToken(context.Background(), auth.EmailVerifyToken{
ID: uuid.New(),
UserID: testUser.ID,
TokenHash: hash,
ExpiresAt: time.Now().Add(48 * time.Hour),
})
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
r := newEmailVerifyRouter(h)
w := postConfirm(t, r, map[string]string{"token": rawToken})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if !repo.emailVerifiedUsers[testUser.ID] {
t.Fatal("expected MarkEmailVerified to be called")
}
}
// TestVerifyEmail_Resend_RequiresAuth verifies that resend without auth returns 401.
func TestVerifyEmail_Resend_RequiresAuth(t *testing.T) {
repo := newFakeRepo()
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(), nil, "http://localhost")
gin.SetMode(gin.TestMode)
engine := gin.New()
rg := engine.Group("/api/v1")
// Use an auth middleware that rejects all requests (no user_id in context).
rejectAll := func(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
}
auth.RegisterEmailVerifyRoutes(rg, h, rejectAll, func(c *gin.Context) { c.Next() })
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/email-verify/resend", nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for unauthenticated resend, got %d", w.Code)
}
}
// TestVerifyEmail_TokenHashing_DoesNotStoreRawToken verifies that the stored token
// is a hash, not the raw value. Stored hash != raw token.
func TestVerifyEmail_TokenHashing_DoesNotStoreRawToken(t *testing.T) {
repo := newFakeRepo()
testUser := user.User{ID: uuid.New(), Email: "u@test.com"}
h := auth.NewEmailVerifyHandler(repo, newFakeUserRepo(testUser), nil, "http://localhost")
// Trigger token creation via resend endpoint with a stub auth middleware.
gin.SetMode(gin.TestMode)
engine := gin.New()
rg := engine.Group("/api/v1")
auth.RegisterEmailVerifyRoutes(rg, h, func(c *gin.Context) {
c.Set("user_id", testUser.ID)
c.Next()
}, func(c *gin.Context) { c.Next() })
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/email-verify/resend", nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("resend failed: %d %s", w.Code, w.Body.String())
}
// Inspect stored tokens: none should have the value equal to its own hash key.
repo.mu.Lock()
defer repo.mu.Unlock()
for storedHash, tok := range repo.emailVerifyTokens {
// The key is the hash; if it equaled the raw token that would mean no hashing occurred.
// We just verify the hash key doesn't appear verbatim in the row's TokenHash field
// (which IS the hash), and that the row exists at all.
if storedHash != tok.TokenHash {
t.Errorf("map key %q does not match token.TokenHash %q", storedHash, tok.TokenHash)
}
// A SHA-256 hex string is 64 chars; a UUID is 36. If we stored the raw UUID as
// the token hash it would be shorter.
if len(storedHash) < 32 {
t.Errorf("token hash looks too short to be a real hash: %q", storedHash)
}
}
if len(repo.emailVerifyTokens) == 0 {
t.Fatal("expected at least one email verify token to be stored")
}
}
// Ensure errors package is used (for ErrEmailVerifyExpired assertion in the test).
var _ = errors.Is
+1 -1
View File
@@ -160,7 +160,7 @@ func (h *MagicLinkHandler) findOrCreateUser(ctx context.Context, email string) (
}
// Create new user without password
return h.userRepo.CreateOAuthUser(ctx, email, user.GenerateDisplayName(), true)
return h.userRepo.CreateOAuthUser(ctx, email, user.GenerateDisplayName(), "", true)
}
func RegisterMagicLinkRoutes(rg *gin.RouterGroup, h *MagicLinkHandler, requestLimit gin.HandlerFunc) {
+25 -2
View File
@@ -7,13 +7,14 @@ import (
)
// Session represents an opaque-token session. AccessTokenHash and RefreshTokenHash
// are SHA-256 hashes of the raw bearer tokens. UserEmail and UserRole are cached
// from the user record at creation time; role changes take effect within accessTTL (≤30m).
// are SHA-256 hashes of the raw bearer tokens. UserEmail, UserRole, and EmailVerified
// are cached from the user record at creation time; changes take effect within accessTTL (≤30m).
type Session struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
UserEmail string `json:"user_email"`
UserRole string `json:"user_role"`
EmailVerified bool `json:"email_verified"`
AccessTokenHash string `json:"-"`
RefreshTokenHash string `json:"-"`
FamilyID uuid.UUID `json:"family_id"`
@@ -66,3 +67,25 @@ type BackupCode struct {
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PasswordResetToken is a single-use time-limited token for password recovery.
// TokenHash is SHA-256 of the raw opaque token sent in the reset link.
type PasswordResetToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
TokenHash string `json:"-"`
Used bool `json:"used"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// EmailVerifyToken is a single-use time-limited token for email address verification.
// TokenHash is SHA-256 of the raw opaque token sent in the verify link.
type EmailVerifyToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
TokenHash string `json:"-"`
Used bool `json:"used"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
+63 -13
View File
@@ -23,6 +23,10 @@ import (
// IdP's callback. 15 min is generous for slow consent + 2FA at the IdP.
const oauthStateTTL = 15 * time.Minute
// oauthExchangeTTL is how long the one-time exchange code lives in Valkey.
// The SvelteKit server should redeem it immediately after the redirect lands.
const oauthExchangeTTL = 2 * time.Minute
var googleEndpoint = oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
@@ -34,13 +38,14 @@ var facebookEndpoint = oauth2.Endpoint{
}
type OAuthHandler struct {
providers map[string]*oauth2.Config
service *Service
userRepo user.Repository
authRepo Repository
providers map[string]*oauth2.Config
frontendURL string
service *Service
userRepo user.Repository
authRepo Repository
}
func NewOAuthHandler(cfg config.OAuthConfig, service *Service, userRepo user.Repository, authRepo Repository) *OAuthHandler {
func NewOAuthHandler(cfg config.OAuthConfig, frontendURL string, service *Service, userRepo user.Repository, authRepo Repository) *OAuthHandler {
baseURL := cfg.RedirectBaseURL
providers := make(map[string]*oauth2.Config)
@@ -75,10 +80,11 @@ func NewOAuthHandler(cfg config.OAuthConfig, service *Service, userRepo user.Rep
}
return &OAuthHandler{
providers: providers,
service: service,
userRepo: userRepo,
authRepo: authRepo,
providers: providers,
frontendURL: frontendURL,
service: service,
userRepo: userRepo,
authRepo: authRepo,
}
}
@@ -102,7 +108,7 @@ func (h *OAuthHandler) StartOAuth(c *gin.Context) {
}
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"url": url, "state": state}})
c.Redirect(http.StatusFound, url)
}
func (h *OAuthHandler) Callback(c *gin.Context) {
@@ -163,6 +169,13 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
if u.AvatarURL == "" && info.AvatarURL != "" {
updated, updateErr := h.userRepo.Update(ctx, u.ID, map[string]any{"avatar_url": info.AvatarURL})
if updateErr == nil {
u = updated
}
}
data, err := h.service.createTokenPair(ctx, u, uuid.Nil, nil, c.ClientIP(), c.GetHeader("User-Agent"))
if err != nil {
apiErr := apierror.Internal("failed to create session")
@@ -170,7 +183,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
c.JSON(http.StatusOK, AuthResponse{Data: data})
h.redirectWithExchangeCode(c, data)
return
}
@@ -189,7 +202,7 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
case errors.Is(err, user.ErrUserNotFound):
// Brand-new account. Pass the IdP's verified-email claim through so the
// user record reflects whether we trust the email.
u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, displayName, info.EmailVerified)
u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, displayName, info.AvatarURL, info.EmailVerified)
if err != nil {
apiErr := apierror.Internal("failed to create user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
@@ -234,13 +247,47 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, AuthResponse{Data: data})
h.redirectWithExchangeCode(c, data)
}
// redirectWithExchangeCode stores the token pair as a single-use exchange code
// in Valkey and redirects the browser to the SvelteKit /auth/oauth/complete page.
// This avoids cross-origin cookie issues between the backend (localhost:8080) and
// the frontend (localhost:5173 in dev; same domain in prod via nginx).
func (h *OAuthHandler) redirectWithExchangeCode(c *gin.Context, data AuthData) {
code := GenerateOpaqueToken()
if err := h.authRepo.PutOAuthExchangeCode(c.Request.Context(), code, data, oauthExchangeTTL); err != nil {
apiErr := apierror.Internal("failed to create exchange code")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.Redirect(http.StatusFound, h.frontendURL+"/auth/oauth/complete?code="+code)
}
// ExchangeOAuthCode redeems a one-time exchange code for an auth token pair.
// Called by the SvelteKit /auth/oauth/complete server load immediately after
// the browser lands on that page.
func (h *OAuthHandler) ExchangeOAuthCode(c *gin.Context) {
code := c.Query("code")
if code == "" {
apiErr := apierror.BadRequest("missing_code", "code parameter is required")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
data, err := h.authRepo.ConsumeOAuthExchangeCode(c.Request.Context(), code)
if err != nil {
apiErr := apierror.BadRequest("invalid_code", "exchange code is invalid or expired")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, AuthResponse{Data: data})
}
type oauthUserInfo struct {
ID string
Email string
Name string
AvatarURL string
EmailVerified bool
}
@@ -267,6 +314,7 @@ func fetchGoogleUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, e
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
VerifiedEmail bool `json:"verified_email"`
}
if err := json.Unmarshal(resp, &data); err != nil {
@@ -277,6 +325,7 @@ func fetchGoogleUser(ctx context.Context, token *oauth2.Token) (oauthUserInfo, e
ID: data.ID,
Email: data.Email,
Name: data.Name,
AvatarURL: data.Picture,
EmailVerified: data.VerifiedEmail,
}, nil
}
@@ -403,5 +452,6 @@ func RegisterOAuthRoutes(rg *gin.RouterGroup, h *OAuthHandler) {
{
oauth.GET("/:provider", h.StartOAuth)
oauth.GET("/:provider/callback", h.Callback)
oauth.GET("/exchange", h.ExchangeOAuthCode)
}
}
@@ -34,7 +34,7 @@ func newOAuthHandler(t *testing.T, repo *fakeRepo) *auth.OAuthHandler {
ClientSecret: "google-secret",
},
}
return auth.NewOAuthHandler(cfg, svc, users, repo)
return auth.NewOAuthHandler(cfg, "http://localhost:5173", svc, users, repo)
}
// PoC for audit C1: Callback rejects requests without a state parameter.
@@ -0,0 +1,158 @@
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/user"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/email"
"marktvogt.de/backend/internal/pkg/password"
"marktvogt.de/backend/internal/pkg/validate"
)
const passwordResetTTL = time.Hour
type PasswordResetHandler struct {
authRepo Repository
userRepo user.Repository
email email.Sender
frontendURL string
}
func NewPasswordResetHandler(authRepo Repository, userRepo user.Repository, emailSender email.Sender, frontendURL string) *PasswordResetHandler {
return &PasswordResetHandler{
authRepo: authRepo,
userRepo: userRepo,
email: emailSender,
frontendURL: frontendURL,
}
}
// RequestPasswordReset generates a reset token and emails it. Always returns 200
// so the response reveals no information about whether the email is registered.
func (h *PasswordResetHandler) RequestPasswordReset(c *gin.Context) {
var req PasswordResetRequestDTO
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
ctx := c.Request.Context()
u, err := h.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
// Silently succeed — don't reveal whether the email exists.
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{
Message: "if an account exists with that email, a reset link has been sent",
}})
return
}
token := uuid.New().String()
tokenHash := HashToken(token)
prt := PasswordResetToken{
ID: uuid.New(),
UserID: u.ID,
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(passwordResetTTL),
}
if err := h.authRepo.CreatePasswordResetToken(ctx, prt); err != nil {
apiErr := apierror.Internal("failed to create reset token")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
resetURL := fmt.Sprintf("%s/auth/passwort-zuruecksetzen/%s", h.frontendURL, token)
if h.email != nil {
go func() {
msg := email.Message{
To: req.Email,
Subject: "Passwort zurücksetzen Marktvogt",
Body: fmt.Sprintf("Klicke auf den folgenden Link, um dein Passwort zurückzusetzen:\n\n%s\n\nDer Link ist 1 Stunde gültig.", resetURL),
}
html, err := email.Render("password_reset", email.TemplateData{
PreheaderText: "Passwort zurücksetzen",
BaseURL: h.frontendURL,
Year: time.Now().Year(),
Content: email.PasswordResetData{
ResetURL: resetURL,
},
})
if err != nil {
slog.Error("failed to render password reset email template", "error", err)
} else {
msg.HTML = html
}
if err := h.email.Send(context.Background(), msg); err != nil {
slog.Error("failed to send password reset email", "email", req.Email, "error", err)
}
}()
} else {
slog.Info("password reset token created (no email sender configured)", "email", req.Email, "url", resetURL)
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{
Message: "if an account exists with that email, a reset link has been sent",
}})
}
// ConfirmPasswordReset validates the token and updates the user's password.
func (h *PasswordResetHandler) ConfirmPasswordReset(c *gin.Context) {
var req PasswordResetConfirmRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
ctx := c.Request.Context()
tokenHash := HashToken(req.Token)
prt, err := h.authRepo.ConsumePasswordResetToken(ctx, tokenHash)
if err != nil {
if errors.Is(err, ErrPasswordResetNotFound) || errors.Is(err, ErrPasswordResetExpired) || errors.Is(err, ErrPasswordResetUsed) {
apiErr := apierror.BadRequest("invalid_token", "reset link is invalid, expired, or already used")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to verify reset token")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
hash, err := password.Hash(req.Password)
if err != nil {
apiErr := apierror.Internal("failed to process new password")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if _, err := h.userRepo.Update(ctx, prt.UserID, map[string]any{"password_hash": hash, "email_verified": true}); err != nil {
apiErr := apierror.Internal("failed to update password")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
// Revoke all existing sessions so stolen sessions are invalidated after a reset.
if err := h.authRepo.DeleteUserSessions(ctx, prt.UserID); err != nil {
slog.Warn("failed to revoke sessions after password reset", "user_id", prt.UserID, "error", err)
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "password updated"}})
}
func RegisterPasswordResetRoutes(rg *gin.RouterGroup, h *PasswordResetHandler, limit gin.HandlerFunc) {
rg.POST("/auth/password-reset/request", limit, h.RequestPasswordReset)
rg.POST("/auth/password-reset/confirm", h.ConfirmPasswordReset)
}
+193 -8
View File
@@ -27,11 +27,17 @@ type EncryptionKeys struct {
}
var (
ErrSessionNotFound = fmt.Errorf("session not found")
ErrSessionExpired = fmt.Errorf("session expired")
ErrMagicLinkNotFound = fmt.Errorf("magic link not found")
ErrMagicLinkExpired = fmt.Errorf("magic link expired")
ErrMagicLinkUsed = fmt.Errorf("magic link already used")
ErrSessionNotFound = fmt.Errorf("session not found")
ErrSessionExpired = fmt.Errorf("session expired")
ErrMagicLinkNotFound = fmt.Errorf("magic link not found")
ErrMagicLinkExpired = fmt.Errorf("magic link expired")
ErrMagicLinkUsed = fmt.Errorf("magic link already used")
ErrPasswordResetNotFound = fmt.Errorf("password reset token not found")
ErrPasswordResetExpired = fmt.Errorf("password reset token expired")
ErrPasswordResetUsed = fmt.Errorf("password reset token already used")
ErrEmailVerifyNotFound = fmt.Errorf("email verify token not found")
ErrEmailVerifyExpired = fmt.Errorf("email verify token expired")
ErrEmailVerifyUsed = fmt.Errorf("email verify token already used")
// ErrOAuthStateUnknown is returned when the callback presents a state value that
// was never issued (CSRF attempt) or has already been consumed (replay).
@@ -79,6 +85,12 @@ type Repository interface {
PutOAuthState(ctx context.Context, state, provider string, ttl time.Duration) error
ConsumeOAuthState(ctx context.Context, state string) (string, error)
// OAuth exchange codes — single-use short-lived codes that let the SvelteKit
// server exchange a just-completed OAuth callback for the issued token pair.
// Stored as JSON in valkey; ConsumeOAuthExchangeCode is GETDEL (single-use).
PutOAuthExchangeCode(ctx context.Context, code string, data AuthData, ttl time.Duration) error
ConsumeOAuthExchangeCode(ctx context.Context, code string) (AuthData, error)
// TOTP code replay guard — rejects a (user_id, code) pair that has already been
// used inside the validity window. TTL covers period * (skew + 1) seconds with a
// safety margin. Returns ErrTOTPCodeReplayed when the same code is submitted twice.
@@ -100,6 +112,25 @@ type Repository interface {
ConsumeBackupCode(ctx context.Context, userID uuid.UUID, codeHash string) error
DeleteUserBackupCodes(ctx context.Context, userID uuid.UUID) error
// Password reset tokens — single-use time-limited tokens for account recovery.
// CreatePasswordResetToken persists the hashed token. ConsumePasswordResetToken
// atomically marks it used and returns it; returns ErrPasswordResetNotFound,
// ErrPasswordResetUsed, or ErrPasswordResetExpired on failure paths.
CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error
ConsumePasswordResetToken(ctx context.Context, tokenHash string) (PasswordResetToken, error)
// Email verify tokens — single-use time-limited tokens for email address verification.
// CreateEmailVerifyToken persists the hashed token. ConsumeEmailVerifyToken
// atomically marks it used and returns it; returns ErrEmailVerifyNotFound,
// ErrEmailVerifyUsed, or ErrEmailVerifyExpired on failure paths.
// MarkEmailVerified sets users.email_verified = TRUE for the given user.
// InvalidateUserSessionCaches removes Valkey cache entries for all active sessions
// of the user so that the updated EmailVerified flag is reflected immediately.
CreateEmailVerifyToken(ctx context.Context, token EmailVerifyToken) error
ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (EmailVerifyToken, error)
MarkEmailVerified(ctx context.Context, userID uuid.UUID) error
InvalidateUserSessionCaches(ctx context.Context, userID uuid.UUID) error
// Session listing and targeted revocation
ListUserSessions(ctx context.Context, userID uuid.UUID) ([]Session, error)
GetSessionByID(ctx context.Context, id uuid.UUID) (Session, error)
@@ -197,10 +228,10 @@ func (r *pgRepository) GetSessionByAccessHash(ctx context.Context, hash string)
}
}
// Postgres fallback — join users to get email and role without a second query.
// Postgres fallback — join users to get email, role, and email_verified without a second query.
var s Session
err := r.db.QueryRow(ctx, `
SELECT s.id, s.user_id, u.email, u.role,
SELECT s.id, s.user_id, u.email, u.role, u.email_verified,
s.access_token_hash, s.refresh_token_hash, s.family_id, s.parent_session_id,
s.ip_address::text, s.user_agent,
s.access_expires_at, s.absolute_expires_at, s.last_used_at, s.revoked_at, s.created_at
@@ -210,7 +241,7 @@ func (r *pgRepository) GetSessionByAccessHash(ctx context.Context, hash string)
AND s.revoked_at IS NULL
AND s.access_expires_at > NOW()
`, hash).Scan(
&s.ID, &s.UserID, &s.UserEmail, &s.UserRole,
&s.ID, &s.UserID, &s.UserEmail, &s.UserRole, &s.EmailVerified,
&s.AccessTokenHash, &s.RefreshTokenHash, &s.FamilyID, &s.ParentSessionID,
&s.IPAddress, &s.UserAgent,
&s.AccessExpiresAt, &s.AbsoluteExpiresAt, &s.LastUsedAt, &s.RevokedAt, &s.CreatedAt,
@@ -324,6 +355,8 @@ func (r *pgRepository) CreateMagicLink(ctx context.Context, link MagicLink) erro
// calls with the same token race against the WHERE clause (used = FALSE AND
// expires_at > NOW()) — exactly one returns the row; the other gets pgx.ErrNoRows
// which we then disambiguate against the row-existence check.
//
//nolint:dupl // same atomic-consume pattern as ConsumePasswordResetToken; different table, types, and errors
func (r *pgRepository) ConsumeMagicLink(ctx context.Context, tokenHash string) (MagicLink, error) {
var ml MagicLink
err := r.db.QueryRow(ctx, `
@@ -631,6 +664,38 @@ func oauthStateValkeyKey(state string) string {
return "mv:v2:auth:oauth:state:" + state
}
func oauthExchangeValkeyKey(code string) string {
return "mv:v2:auth:oauth:exchange:" + code
}
func (r *pgRepository) PutOAuthExchangeCode(ctx context.Context, code string, data AuthData, ttl time.Duration) error {
b, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("put oauth exchange code: marshal: %w", err)
}
key := oauthExchangeValkeyKey(code)
if err := r.vk.Do(ctx, r.vk.B().Set().Key(key).Value(string(b)).Nx().Ex(ttl).Build()).Error(); err != nil {
return fmt.Errorf("put oauth exchange code: %w", err)
}
return nil
}
func (r *pgRepository) ConsumeOAuthExchangeCode(ctx context.Context, code string) (AuthData, error) {
if code == "" {
return AuthData{}, ErrOAuthStateUnknown
}
key := oauthExchangeValkeyKey(code)
raw, err := r.vk.Do(ctx, r.vk.B().Getdel().Key(key).Build()).ToString()
if err != nil || raw == "" {
return AuthData{}, ErrOAuthStateUnknown
}
var data AuthData
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return AuthData{}, fmt.Errorf("consume oauth exchange code: unmarshal: %w", err)
}
return data, nil
}
func totpReplayValkeyKey(userID uuid.UUID, codeHash string) string {
return "mv:v2:auth:totp:used:" + userID.String() + ":" + codeHash
}
@@ -714,6 +779,126 @@ func (r *pgRepository) revokeBulk(ctx context.Context, sql string, args ...any)
return hashes, rows.Err()
}
// Password reset token methods
func (r *pgRepository) CreatePasswordResetToken(ctx context.Context, token PasswordResetToken) error {
_, err := r.db.Exec(ctx, `
INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
`, token.ID, token.UserID, token.TokenHash, token.ExpiresAt)
return err
}
//nolint:dupl // same atomic-consume pattern as ConsumeMagicLink; different table, types, and errors
func (r *pgRepository) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (PasswordResetToken, error) {
var t PasswordResetToken
err := r.db.QueryRow(ctx, `
UPDATE password_reset_tokens SET used = TRUE
WHERE token_hash = $1 AND used = FALSE AND expires_at > NOW()
RETURNING id, user_id, token_hash, used, expires_at, created_at
`, tokenHash).Scan(&t.ID, &t.UserID, &t.TokenHash, &t.Used, &t.ExpiresAt, &t.CreatedAt)
if err == nil {
return t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return PasswordResetToken{}, fmt.Errorf("consuming password reset token: %w", err)
}
var used bool
var expires time.Time
lookupErr := r.db.QueryRow(ctx,
`SELECT used, expires_at FROM password_reset_tokens WHERE token_hash = $1`,
tokenHash,
).Scan(&used, &expires)
if errors.Is(lookupErr, pgx.ErrNoRows) {
return PasswordResetToken{}, ErrPasswordResetNotFound
}
if lookupErr != nil {
return PasswordResetToken{}, fmt.Errorf("password reset token lookup: %w", lookupErr)
}
if used {
return PasswordResetToken{}, ErrPasswordResetUsed
}
return PasswordResetToken{}, ErrPasswordResetExpired
}
// Email verify token methods
func (r *pgRepository) CreateEmailVerifyToken(ctx context.Context, token EmailVerifyToken) error {
_, err := r.db.Exec(ctx, `
INSERT INTO email_verify_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
`, token.ID, token.UserID, token.TokenHash, token.ExpiresAt)
return err
}
//nolint:dupl // same atomic-consume pattern as ConsumeMagicLink/ConsumePasswordResetToken; different table, types, errors
func (r *pgRepository) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (EmailVerifyToken, error) {
var t EmailVerifyToken
err := r.db.QueryRow(ctx, `
UPDATE email_verify_tokens SET used = TRUE
WHERE token_hash = $1 AND used = FALSE AND expires_at > NOW()
RETURNING id, user_id, token_hash, used, expires_at, created_at
`, tokenHash).Scan(&t.ID, &t.UserID, &t.TokenHash, &t.Used, &t.ExpiresAt, &t.CreatedAt)
if err == nil {
return t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return EmailVerifyToken{}, fmt.Errorf("consuming email verify token: %w", err)
}
var used bool
var expires time.Time
lookupErr := r.db.QueryRow(ctx,
`SELECT used, expires_at FROM email_verify_tokens WHERE token_hash = $1`,
tokenHash,
).Scan(&used, &expires)
if errors.Is(lookupErr, pgx.ErrNoRows) {
return EmailVerifyToken{}, ErrEmailVerifyNotFound
}
if lookupErr != nil {
return EmailVerifyToken{}, fmt.Errorf("email verify token lookup: %w", lookupErr)
}
if used {
return EmailVerifyToken{}, ErrEmailVerifyUsed
}
return EmailVerifyToken{}, ErrEmailVerifyExpired
}
func (r *pgRepository) MarkEmailVerified(ctx context.Context, userID uuid.UUID) error {
_, err := r.db.Exec(ctx,
`UPDATE users SET email_verified = TRUE WHERE id = $1`,
userID)
return err
}
// InvalidateUserSessionCaches removes the Valkey cache entries for all active sessions
// belonging to userID. Called after MarkEmailVerified so the updated EmailVerified
// flag is visible on the next request rather than after the AccessTTL expires.
func (r *pgRepository) InvalidateUserSessionCaches(ctx context.Context, userID uuid.UUID) error {
rows, err := r.db.Query(ctx, `
SELECT access_token_hash FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND access_expires_at > NOW()
`, userID)
if err != nil {
return fmt.Errorf("querying active session hashes: %w", err)
}
defer rows.Close()
var hashes []string
for rows.Next() {
var h string
if scanErr := rows.Scan(&h); scanErr != nil {
return fmt.Errorf("scanning session hash: %w", scanErr)
}
hashes = append(hashes, h)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating session hashes: %w", err)
}
r.invalidateCachedSessions(ctx, hashes)
return nil
}
// invalidateCachedSessions removes Valkey cache entries for the given access
// token hashes. The cache stores the JSON-serialized Session at the time of
// CreateSession and is not auto-refreshed when the row is updated, so without
+33 -4
View File
@@ -21,22 +21,38 @@ type ServiceConfig struct {
}
type Service struct {
authRepo Repository
userRepo user.Repository
cfg ServiceConfig
authRepo Repository
userRepo user.Repository
cfg ServiceConfig
sendVerifyFn func(ctx context.Context, userID uuid.UUID, email string)
}
func NewService(authRepo Repository, userRepo user.Repository, cfg ServiceConfig) *Service {
return &Service{authRepo: authRepo, userRepo: userRepo, cfg: cfg}
}
// SetEmailVerifySender wires the email verification send function so Register()
// can dispatch a verify email without a circular dependency on EmailVerifyHandler.
func (s *Service) SetEmailVerifySender(fn func(ctx context.Context, userID uuid.UUID, email string)) {
s.sendVerifyFn = fn
}
func (s *Service) Register(ctx context.Context, req RegisterRequest, ip, ua string) (AuthData, error) {
hash, err := password.Hash(req.Password)
if err != nil {
return AuthData{}, fmt.Errorf("hashing password: %w", err)
}
u, err := s.userRepo.Create(ctx, req.Email, hash, req.DisplayName)
role := req.Role
if role == "" {
role = user.RoleUser
}
status := user.StatusActive
if role != user.RoleUser {
status = user.StatusPending
}
u, err := s.userRepo.Create(ctx, req.Email, hash, req.DisplayName, role, status)
if err != nil {
if errors.Is(err, user.ErrEmailAlreadyTaken) {
return AuthData{}, err
@@ -44,6 +60,10 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest, ip, ua stri
return AuthData{}, fmt.Errorf("creating user: %w", err)
}
if s.sendVerifyFn != nil {
go s.sendVerifyFn(ctx, u.ID, u.Email)
}
return s.createTokenPair(ctx, u, uuid.Nil, nil, ip, ua)
}
@@ -65,6 +85,14 @@ func (s *Service) Login(ctx context.Context, req LoginRequest, ip, ua string) (A
return AuthData{}, fmt.Errorf("invalid credentials")
}
if u.Status == user.StatusPending {
return AuthData{}, fmt.Errorf("account pending review")
}
if u.Status == user.StatusSuspended {
return AuthData{}, fmt.Errorf("account suspended")
}
if password.NeedsRehash(*u.PasswordHash) {
if newHash, hashErr := password.Hash(req.Password); hashErr == nil {
if _, updateErr := s.userRepo.Update(ctx, u.ID, map[string]any{"password_hash": newHash}); updateErr != nil {
@@ -147,6 +175,7 @@ func (s *Service) createTokenPair(ctx context.Context, u user.User, familyID uui
UserID: u.ID,
UserEmail: u.Email,
UserRole: u.Role,
EmailVerified: u.EmailVerified,
AccessTokenHash: HashToken(accessToken),
RefreshTokenHash: HashToken(refreshToken),
FamilyID: familyID,
@@ -20,10 +20,13 @@ type fakeRepo struct {
sessions map[string]*auth.Session // keyed by access hash
byRefresh map[string]*auth.Session // keyed by refresh hash
magicLinks map[string]*auth.MagicLink
totpSecrets map[string]*auth.TOTPSecret
oauthAccounts []auth.OAuthAccount
backupCodes map[string]*auth.BackupCode // keyed by code hash
magicLinks map[string]*auth.MagicLink
totpSecrets map[string]*auth.TOTPSecret
oauthAccounts []auth.OAuthAccount
backupCodes map[string]*auth.BackupCode // keyed by code hash
passwordResetTokens map[string]*auth.PasswordResetToken // keyed by token hash
emailVerifyTokens map[string]*auth.EmailVerifyToken // keyed by token hash
emailVerifiedUsers map[uuid.UUID]bool
oauthStates map[string]string // state -> provider
consumedTOTP map[string]bool // userID:codeHash -> seen
@@ -36,13 +39,16 @@ type fakeRepo struct {
func newFakeRepo() *fakeRepo {
return &fakeRepo{
sessions: make(map[string]*auth.Session),
byRefresh: make(map[string]*auth.Session),
magicLinks: make(map[string]*auth.MagicLink),
totpSecrets: make(map[string]*auth.TOTPSecret),
backupCodes: make(map[string]*auth.BackupCode),
oauthStates: make(map[string]string),
consumedTOTP: make(map[string]bool),
sessions: make(map[string]*auth.Session),
byRefresh: make(map[string]*auth.Session),
magicLinks: make(map[string]*auth.MagicLink),
totpSecrets: make(map[string]*auth.TOTPSecret),
backupCodes: make(map[string]*auth.BackupCode),
passwordResetTokens: make(map[string]*auth.PasswordResetToken),
emailVerifyTokens: make(map[string]*auth.EmailVerifyToken),
emailVerifiedUsers: make(map[uuid.UUID]bool),
oauthStates: make(map[string]string),
consumedTOTP: make(map[string]bool),
}
}
@@ -135,7 +141,8 @@ func (r *fakeRepo) BumpLastUsedAt(_ context.Context, id uuid.UUID) error {
return nil
}
func (r *fakeRepo) DeleteUserSessions(_ context.Context, _ uuid.UUID) error { return nil }
func (r *fakeRepo) DeleteUserSessions(_ context.Context, _ uuid.UUID) error { return nil }
func (r *fakeRepo) InvalidateUserSessionCaches(_ context.Context, _ uuid.UUID) error { return nil }
// Magic link stubs — atomic ConsumeMagicLink mirrors the prod UPDATE...RETURNING
// behaviour: exactly one caller wins on a Used=false row.
@@ -235,12 +242,18 @@ func newFakeUserRepo(users ...user.User) *fakeUserRepo {
return r
}
func (r *fakeUserRepo) Create(_ context.Context, email, hash, name string) (user.User, error) {
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, PasswordHash: &hash}
func (r *fakeUserRepo) Create(_ context.Context, email, hash, name, role, status string) (user.User, error) {
if role == "" {
role = user.RoleUser
}
if status == "" {
status = user.StatusActive
}
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, PasswordHash: &hash, Role: role, Status: status}
r.users[u.ID] = u
return u, nil
}
func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name string, _ bool) (user.User, error) {
func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name, _ string, _ bool) (user.User, error) {
u := user.User{ID: uuid.New(), Email: email, DisplayName: name}
r.users[u.ID] = u
return u, nil
@@ -270,6 +283,20 @@ func (r *fakeUserRepo) Restore(_ context.Context, _ uuid.UUID) error { return
func (r *fakeUserRepo) GetDeletedByID(_ context.Context, id uuid.UUID) (user.User, error) {
return user.User{}, user.ErrUserNotFound
}
func (r *fakeUserRepo) ListByStatus(_ context.Context, _ string) ([]user.User, error) {
return nil, nil
}
func (r *fakeUserRepo) SetStatus(_ context.Context, id uuid.UUID, status string, _ *time.Time) (user.User, error) {
if u, ok := r.users[id]; ok {
u.Status = status
r.users[id] = u
return u, nil
}
return user.User{}, user.ErrUserNotFound
}
func (r *fakeUserRepo) GetDashboardStats(_ context.Context, _ uuid.UUID) (user.DashboardStats, error) {
return user.DashboardStats{}, nil
}
func makeService(authRepo auth.Repository, userRepo user.Repository) *auth.Service {
return auth.NewService(authRepo, userRepo, auth.ServiceConfig{
@@ -83,6 +83,31 @@ func (r *fakeRepo) RevokeOtherSessions(_ context.Context, userID, exceptID uuid.
return nil
}
func (r *fakeRepo) CreatePasswordResetToken(_ context.Context, token auth.PasswordResetToken) error {
r.mu.Lock()
defer r.mu.Unlock()
clone := token
r.passwordResetTokens[token.TokenHash] = &clone
return nil
}
func (r *fakeRepo) ConsumePasswordResetToken(_ context.Context, tokenHash string) (auth.PasswordResetToken, error) {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.passwordResetTokens[tokenHash]
if !ok {
return auth.PasswordResetToken{}, auth.ErrPasswordResetNotFound
}
if t.Used {
return auth.PasswordResetToken{}, auth.ErrPasswordResetUsed
}
if time.Now().After(t.ExpiresAt) {
return auth.PasswordResetToken{}, auth.ErrPasswordResetExpired
}
t.Used = true
return *t, nil
}
// --- Tests ---
func TestGenerateBackupCodes_ProducesCorrectFormatAndCount(t *testing.T) {
@@ -0,0 +1,218 @@
package favorite_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/favorite"
)
func init() {
gin.SetMode(gin.TestMode)
}
// -- in-memory repository --
type fakeRepo struct {
mu sync.Mutex
favorites map[uuid.UUID][]favorite.Favorite // keyed by userID
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
favorites: make(map[uuid.UUID][]favorite.Favorite),
}
}
func (r *fakeRepo) Add(_ context.Context, userID, marketID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, f := range r.favorites[userID] {
if f.MarketID == marketID {
return favorite.ErrAlreadyFavorited
}
}
r.favorites[userID] = append(r.favorites[userID], favorite.Favorite{
UserID: userID,
MarketID: marketID,
CreatedAt: time.Now(),
})
return nil
}
func (r *fakeRepo) Remove(_ context.Context, userID, marketID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
items := r.favorites[userID]
for i, f := range items {
if f.MarketID == marketID {
r.favorites[userID] = append(items[:i], items[i+1:]...)
return nil
}
}
return favorite.ErrFavoriteNotFound
}
func (r *fakeRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]favorite.Favorite, error) {
r.mu.Lock()
defer r.mu.Unlock()
return append([]favorite.Favorite(nil), r.favorites[userID]...), nil
}
func (r *fakeRepo) ListByUserWithMarkets(_ context.Context, userID uuid.UUID) ([]favorite.FavoriteWithMarket, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]favorite.FavoriteWithMarket, 0, len(r.favorites[userID]))
for _, f := range r.favorites[userID] {
out = append(out, favorite.FavoriteWithMarket{
ID: f.MarketID,
Slug: "test-slug",
Name: "Test Market",
City: "Teststadt",
FavoritedAt: f.CreatedAt,
})
}
return out, nil
}
// -- router helpers --
func newRouter(repo favorite.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
svc := favorite.NewService(repo)
h := favorite.NewHandler(svc)
router := gin.New()
favorite.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
return router
}
func stubAuth(userID uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", userID)
c.Next()
}
}
func noAuth() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
}
}
// TestFavorites_RequireAuth — unauthenticated requests to all favorite endpoints return 401.
func TestFavorites_RequireAuth(t *testing.T) {
t.Parallel()
repo := newFakeRepo()
router := newRouter(repo, noAuth())
marketID := uuid.New().String()
endpoints := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/me/favorites"},
{http.MethodPost, "/api/v1/me/favorites/" + marketID},
{http.MethodDelete, "/api/v1/me/favorites/" + marketID},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// TestFavorites_CannotReadOtherUser — list only returns the authenticated user's own favorites.
func TestFavorites_CannotReadOtherUser(t *testing.T) {
t.Parallel()
userA := uuid.New()
userB := uuid.New()
marketID := uuid.New()
repo := newFakeRepo()
// Seed a favorite for user A directly.
repo.favorites[userA] = []favorite.Favorite{
{UserID: userA, MarketID: marketID, CreatedAt: time.Now()},
}
// User B authenticates and calls GET /me/favorites — must get empty list.
router := newRouter(repo, stubAuth(userB))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/favorites", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
// Response should not contain user A's favorite.
body := w.Body.String()
if len(repo.favorites[userA]) == 0 {
t.Error("test setup error: user A has no favorites")
}
// User B's favorites in the repo are empty; the response data should be [].
if len(repo.favorites[userB]) != 0 {
t.Error("user B should have no favorites but repo has some")
}
// The response body should show an empty data array.
if body == "" {
t.Error("expected non-empty response body")
}
// Verify user A's market ID is NOT in user B's response.
if contains(body, marketID.String()) {
t.Errorf("user B's response contains user A's market ID %s — isolation failure", marketID)
}
}
// TestListFavorites_ReturnsEmbeddedMarketData — GET /me/favorites response includes market fields.
func TestListFavorites_ReturnsEmbeddedMarketData(t *testing.T) {
t.Parallel()
userID := uuid.New()
marketID := uuid.New()
repo := newFakeRepo()
repo.favorites[userID] = []favorite.Favorite{
{UserID: userID, MarketID: marketID, CreatedAt: time.Now()},
}
router := newRouter(repo, stubAuth(userID))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/favorites", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
body := w.Body.String()
for _, field := range []string{"slug", "name", "city", "favorited_at"} {
if !containsStr(body, field) {
t.Errorf("response missing field %q: %s", field, body)
}
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
@@ -0,0 +1,92 @@
package favorite
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Add(c *gin.Context) {
marketID, ok := parseMarketID(c)
if !ok {
return
}
userID := getRequesterID(c)
if err := h.svc.Add(c.Request.Context(), userID, marketID); err != nil {
if errors.Is(err, ErrAlreadyFavorited) {
apiErr := apierror.Conflict("already favorited")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to add favorite")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) Remove(c *gin.Context) {
marketID, ok := parseMarketID(c)
if !ok {
return
}
userID := getRequesterID(c)
if err := h.svc.Remove(c.Request.Context(), userID, marketID); err != nil {
if errors.Is(err, ErrFavoriteNotFound) {
apiErr := apierror.NotFound("favorite")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to remove favorite")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) List(c *gin.Context) {
userID := getRequesterID(c)
favorites, err := h.svc.ListByUserWithMarkets(c.Request.Context(), userID)
if err != nil {
apiErr := apierror.Internal("failed to list favorites")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if favorites == nil {
favorites = []FavoriteWithMarket{}
}
c.JSON(http.StatusOK, gin.H{"data": favorites})
}
func parseMarketID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("marketId"))
if err != nil {
apiErr := apierror.BadRequest("invalid_market_id", "invalid market id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func getRequesterID(c *gin.Context) uuid.UUID {
v, _ := c.Get("user_id")
id, _ := v.(uuid.UUID)
return id
}
+39
View File
@@ -0,0 +1,39 @@
package favorite
import (
"fmt"
"time"
"github.com/google/uuid"
)
type Favorite struct {
UserID uuid.UUID `json:"user_id"`
MarketID uuid.UUID `json:"market_id"`
CreatedAt time.Time `json:"created_at"`
}
// FavoriteWithMarket embeds market summary data from a JOIN so the list endpoint
// returns everything the frontend needs to render cards without extra requests.
// The ID field is the market_series.id (same value used by add/remove endpoints).
type FavoriteWithMarket struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
ImageURL string `json:"image_url"`
LogoURL string `json:"logo_url"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
OrganizerName string `json:"organizer_name"`
FavoritedAt time.Time `json:"favorited_at"`
}
var (
ErrAlreadyFavorited = fmt.Errorf("already favorited")
ErrFavoriteNotFound = fmt.Errorf("favorite not found")
)
@@ -0,0 +1,134 @@
package favorite
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"marktvogt.de/backend/internal/pkg/pgerr"
)
type Repository interface {
Add(ctx context.Context, userID, marketID uuid.UUID) error
Remove(ctx context.Context, userID, marketID uuid.UUID) error
ListByUser(ctx context.Context, userID uuid.UUID) ([]Favorite, error)
ListByUserWithMarkets(ctx context.Context, userID uuid.UUID) ([]FavoriteWithMarket, error)
}
type pgRepository struct {
db *pgxpool.Pool
}
func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) Add(ctx context.Context, userID, marketID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
INSERT INTO favorites (user_id, market_id) VALUES ($1, $2)
`, userID, marketID)
if err != nil {
if pgerr.IsDuplicateKey(err) {
return ErrAlreadyFavorited
}
return fmt.Errorf("adding favorite: %w", err)
}
return nil
}
func (r *pgRepository) Remove(ctx context.Context, userID, marketID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `
DELETE FROM favorites WHERE user_id = $1 AND market_id = $2
`, userID, marketID)
if err != nil {
return fmt.Errorf("removing favorite: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrFavoriteNotFound
}
return nil
}
func (r *pgRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Favorite, error) {
rows, err := r.db.Query(ctx, `
SELECT user_id, market_id, created_at
FROM favorites
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("listing favorites: %w", err)
}
defer rows.Close()
var favorites []Favorite
for rows.Next() {
var f Favorite
if err := rows.Scan(&f.UserID, &f.MarketID, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning favorite: %w", err)
}
favorites = append(favorites, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating favorites: %w", err)
}
return favorites, nil
}
func (r *pgRepository) ListByUserWithMarkets(ctx context.Context, userID uuid.UUID) ([]FavoriteWithMarket, error) {
rows, err := r.db.Query(ctx, `
SELECT
f.market_id,
f.created_at,
ms.slug,
COALESCE(me.name, ms.name),
COALESCE(me.city, ms.city),
COALESCE(me.state, ms.state),
COALESCE(me.country, ms.country),
me.start_date,
me.end_date,
COALESCE(me.image_url, ''),
COALESCE(me.logo_url, ''),
CASE WHEN me.location IS NOT NULL THEN ST_Y(me.location::geometry) END,
CASE WHEN me.location IS NOT NULL THEN ST_X(me.location::geometry) END,
COALESCE(me.organizer_name, '')
FROM favorites f
JOIN market_series ms ON ms.id = f.market_id
LEFT JOIN LATERAL (
SELECT name, city, state, country, start_date, end_date,
image_url, logo_url, location, organizer_name
FROM market_editions
WHERE series_id = ms.id
AND status IN ('active', 'confirmed', 'completed')
ORDER BY start_date DESC LIMIT 1
) me ON true
WHERE f.user_id = $1
ORDER BY f.created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("listing favorites with markets: %w", err)
}
defer rows.Close()
var favorites []FavoriteWithMarket
for rows.Next() {
var f FavoriteWithMarket
if err := rows.Scan(
&f.ID, &f.FavoritedAt,
&f.Slug, &f.Name, &f.City, &f.State, &f.Country,
&f.StartDate, &f.EndDate,
&f.ImageURL, &f.LogoURL,
&f.Latitude, &f.Longitude,
&f.OrganizerName,
); err != nil {
return nil, fmt.Errorf("scanning favorite with market: %w", err)
}
favorites = append(favorites, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating favorites with markets: %w", err)
}
return favorites, nil
}
@@ -0,0 +1,12 @@
package favorite
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
auth := rg.Group("", requireAuth)
{
auth.GET("/me/favorites", h.List)
auth.POST("/me/favorites/:marketId", h.Add)
auth.DELETE("/me/favorites/:marketId", h.Remove)
}
}
@@ -0,0 +1,31 @@
package favorite
import (
"context"
"github.com/google/uuid"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Add(ctx context.Context, userID, marketID uuid.UUID) error {
return s.repo.Add(ctx, userID, marketID)
}
func (s *Service) Remove(ctx context.Context, userID, marketID uuid.UUID) error {
return s.repo.Remove(ctx, userID, marketID)
}
func (s *Service) ListByUser(ctx context.Context, userID uuid.UUID) ([]Favorite, error) {
return s.repo.ListByUser(ctx, userID)
}
func (s *Service) ListByUserWithMarkets(ctx context.Context, userID uuid.UUID) ([]FavoriteWithMarket, error) {
return s.repo.ListByUserWithMarkets(ctx, userID)
}
@@ -0,0 +1,397 @@
package group_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/group"
)
func init() {
gin.SetMode(gin.TestMode)
}
// -- in-memory repository --
type fakeRepo struct {
groups map[uuid.UUID]group.Group
members map[uuid.UUID][]group.GroupMember // keyed by groupID
profiles map[uuid.UUID]group.GroupProfile
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
groups: make(map[uuid.UUID]group.Group),
members: make(map[uuid.UUID][]group.GroupMember),
profiles: make(map[uuid.UUID]group.GroupProfile),
}
}
func (r *fakeRepo) Create(_ context.Context, g group.Group) (group.Group, error) {
g.CreatedAt = time.Now()
g.UpdatedAt = time.Now()
r.groups[g.ID] = g
return g, nil
}
func (r *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (group.Group, error) {
g, ok := r.groups[id]
if !ok {
return group.Group{}, group.ErrGroupNotFound
}
return g, nil
}
func (r *fakeRepo) GetProfile(_ context.Context, groupID uuid.UUID) (group.GroupProfile, error) {
p, ok := r.profiles[groupID]
if !ok {
return group.GroupProfile{GroupID: groupID, Categories: []string{}}, nil
}
return p, nil
}
func (r *fakeRepo) UpsertProfile(_ context.Context, p group.GroupProfile) error {
if p.Categories == nil {
p.Categories = []string{}
}
r.profiles[p.GroupID] = p
return nil
}
func (r *fakeRepo) AddMember(_ context.Context, m group.GroupMember) error {
for _, existing := range r.members[m.GroupID] {
if existing.UserID == m.UserID {
return group.ErrAlreadyMember
}
}
m.JoinedAt = time.Now()
r.members[m.GroupID] = append(r.members[m.GroupID], m)
return nil
}
func (r *fakeRepo) RemoveMember(_ context.Context, groupID, userID uuid.UUID) error {
members := r.members[groupID]
for i, m := range members {
if m.UserID == userID {
r.members[groupID] = append(members[:i], members[i+1:]...)
return nil
}
}
return group.ErrMemberNotFound
}
func (r *fakeRepo) GetMember(_ context.Context, groupID, userID uuid.UUID) (group.GroupMember, error) {
for _, m := range r.members[groupID] {
if m.UserID == userID {
return m, nil
}
}
return group.GroupMember{}, group.ErrMemberNotFound
}
func (r *fakeRepo) ListMembers(_ context.Context, groupID uuid.UUID) ([]group.GroupMemberView, error) {
src := r.members[groupID]
views := make([]group.GroupMemberView, len(src))
for i, m := range src {
views[i] = group.GroupMemberView{GroupMember: m}
}
return views, nil
}
func (r *fakeRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]group.Group, error) {
var out []group.Group
for gid, members := range r.members {
for _, m := range members {
if m.UserID == userID {
if g, ok := r.groups[gid]; ok {
out = append(out, g)
}
}
}
}
return out, nil
}
func (r *fakeRepo) CountAdmins(_ context.Context, groupID uuid.UUID) (int, error) {
count := 0
for _, m := range r.members[groupID] {
if m.Role == group.MemberRoleAdmin {
count++
}
}
return count, nil
}
// -- router helpers --
func newRouter(repo group.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
svc := group.NewService(repo)
h := group.NewHandler(svc)
router := gin.New()
group.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
return router
}
func stubAuth(userID uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", userID)
c.Set("user_role", "user")
c.Next()
}
}
func noAuth() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
}
}
func jsonBody(v any) *bytes.Reader {
b, _ := json.Marshal(v)
return bytes.NewReader(b)
}
// PoC: authenticated-only endpoints reject unauthenticated requests (401).
func TestGroupEndpoints_Unauthenticated_Returns401(t *testing.T) {
t.Parallel()
repo := newFakeRepo()
router := newRouter(repo, noAuth())
groupID := uuid.New().String()
userID := uuid.New().String()
endpoints := []struct {
method string
path string
body any
}{
{http.MethodPost, "/api/v1/groups", map[string]string{"name": "Thors Schmiede", "kind": "haendler"}},
{http.MethodPatch, "/api/v1/groups/" + groupID + "/profile", map[string]string{"description": "test"}},
{http.MethodPost, "/api/v1/groups/" + groupID + "/members", map[string]string{"user_id": userID}},
{http.MethodDelete, "/api/v1/groups/" + groupID + "/members/" + userID, nil},
{http.MethodGet, "/api/v1/users/me/groups", nil},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
var body *bytes.Reader
if ep.body != nil {
body = jsonBody(ep.body)
} else {
body = bytes.NewReader(nil)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: UpdateProfile and AddMember reject non-admins with 403.
func TestGroupAdminEndpoints_NonAdmin_Returns403(t *testing.T) {
t.Parallel()
adminID := uuid.New()
nonMemberID := uuid.New()
repo := newFakeRepo()
// create a group and add admin; non-member has no membership
g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindHaendler, CreatedBy: adminID}
repo.groups[g.ID] = g
repo.members[g.ID] = []group.GroupMember{
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
}
router := newRouter(repo, stubAuth(nonMemberID))
groupPath := "/api/v1/groups/" + g.ID.String()
endpoints := []struct {
method string
path string
body any
}{
{http.MethodPatch, groupPath + "/profile", map[string]string{"description": "hijacked"}},
{http.MethodPost, groupPath + "/members", map[string]string{"user_id": uuid.New().String()}},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, jsonBody(ep.body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: RemoveMember by a regular member (not self) returns 403.
func TestRemoveMember_NonAdminNonSelf_Returns403(t *testing.T) {
t.Parallel()
adminID := uuid.New()
memberID := uuid.New()
otherMemberID := uuid.New()
repo := newFakeRepo()
g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindLager, CreatedBy: adminID}
repo.groups[g.ID] = g
repo.members[g.ID] = []group.GroupMember{
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
{ID: uuid.New(), GroupID: g.ID, UserID: memberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
{ID: uuid.New(), GroupID: g.ID, UserID: otherMemberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
}
// memberID tries to remove otherMemberID (not self, not admin) → 403
router := newRouter(repo, stubAuth(memberID))
path := "/api/v1/groups/" + g.ID.String() + "/members/" + otherMemberID.String()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
}
// PoC: a member can remove themselves (self-remove is allowed without admin).
func TestRemoveMember_SelfRemove_Succeeds(t *testing.T) {
t.Parallel()
adminID := uuid.New()
memberID := uuid.New()
repo := newFakeRepo()
g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindKuenstler, CreatedBy: adminID}
repo.groups[g.ID] = g
repo.members[g.ID] = []group.GroupMember{
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
{ID: uuid.New(), GroupID: g.ID, UserID: memberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
}
router := newRouter(repo, stubAuth(memberID))
path := "/api/v1/groups/" + g.ID.String() + "/members/" + memberID.String()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("want 204, got %d (body=%s)", w.Code, w.Body.String())
}
}
// PoC: removing the last admin is rejected (409 / 400).
func TestRemoveMember_LastAdmin_Rejected(t *testing.T) {
t.Parallel()
adminID := uuid.New()
repo := newFakeRepo()
g := group.Group{ID: uuid.New(), Name: "Solo Admin Group", Kind: group.KindHaendler, CreatedBy: adminID}
repo.groups[g.ID] = g
repo.members[g.ID] = []group.GroupMember{
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
}
// Admin tries to remove themselves — must be rejected because they're the last admin.
router := newRouter(repo, stubAuth(adminID))
path := "/api/v1/groups/" + g.ID.String() + "/members/" + adminID.String()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, path, nil)
router.ServeHTTP(w, req)
if w.Code == http.StatusNoContent {
t.Errorf("expected rejection (4xx), got 204")
}
}
// PoC: admin can create a group and the creator is automatically admin.
func TestCreateGroup_Admin_SucceedsAndCreatorIsAdmin(t *testing.T) {
t.Parallel()
creatorID := uuid.New()
repo := newFakeRepo()
router := newRouter(repo, stubAuth(creatorID))
body := jsonBody(map[string]string{"name": "Thors Schmiede", "kind": "haendler"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/groups", body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("want 201, got %d (body=%s)", w.Code, w.Body.String())
}
// Verify creator was added as admin in the repo.
var foundAdmin bool
for _, members := range repo.members {
for _, m := range members {
if m.UserID == creatorID && m.Role == group.MemberRoleAdmin {
foundAdmin = true
}
}
}
if !foundAdmin {
t.Error("creator was not added as admin after group creation")
}
}
// PoC: public endpoints (GET group, GET members) are accessible without auth.
func TestGroupPublicEndpoints_NoAuth_Succeeds(t *testing.T) {
t.Parallel()
repo := newFakeRepo()
g := group.Group{ID: uuid.New(), Name: "Open Group", Kind: group.KindLager, CreatedBy: uuid.New()}
g.CreatedAt = time.Now()
g.UpdatedAt = time.Now()
repo.groups[g.ID] = g
// noAuth middleware aborts auth-required routes; public routes bypass it.
router := newRouter(repo, noAuth())
t.Run("GET /groups/:id", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/groups/"+g.ID.String(), nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
})
t.Run("GET /groups/:id/members", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/groups/"+g.ID.String()+"/members", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
// Verify ErrMemberNotFound sentinel satisfies errors.Is chain.
func TestErrors_SentinelIdentity(t *testing.T) {
if !errors.Is(group.ErrGroupNotFound, group.ErrGroupNotFound) {
t.Error("ErrGroupNotFound sentinel broken")
}
if !errors.Is(group.ErrNotGroupAdmin, group.ErrNotGroupAdmin) {
t.Error("ErrNotGroupAdmin sentinel broken")
}
}
+213
View File
@@ -0,0 +1,213 @@
package group
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/validate"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Create(c *gin.Context) {
var req CreateRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
creatorID := getRequesterID(c)
view, err := h.svc.CreateGroup(c.Request.Context(), creatorID, req)
if err != nil {
apiErr := apierror.Internal("failed to create group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, gin.H{"data": view})
}
func (h *Handler) Get(c *gin.Context) {
id, ok := parseGroupID(c)
if !ok {
return
}
view, err := h.svc.GetGroup(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrGroupNotFound) {
apiErr := apierror.NotFound("group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to get group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": view})
}
func (h *Handler) UpdateProfile(c *gin.Context) {
id, ok := parseGroupID(c)
if !ok {
return
}
var req UpdateProfileRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
requestorID := getRequesterID(c)
profile, err := h.svc.UpdateProfile(c.Request.Context(), requestorID, id, req)
if err != nil {
if errors.Is(err, ErrGroupNotFound) {
apiErr := apierror.NotFound("group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if errors.Is(err, ErrNotGroupAdmin) {
apiErr := apierror.Forbidden("not a group admin")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to update group profile")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": profile})
}
func (h *Handler) ListMembers(c *gin.Context) {
id, ok := parseGroupID(c)
if !ok {
return
}
members, err := h.svc.ListMembers(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrGroupNotFound) {
apiErr := apierror.NotFound("group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to list members")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": members})
}
func (h *Handler) AddMember(c *gin.Context) {
id, ok := parseGroupID(c)
if !ok {
return
}
var req AddMemberRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
requestorID := getRequesterID(c)
err := h.svc.AddMember(c.Request.Context(), requestorID, id, req.UserID)
if err != nil {
switch {
case errors.Is(err, ErrGroupNotFound):
apiErr := apierror.NotFound("group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, ErrNotGroupAdmin):
apiErr := apierror.Forbidden("not a group admin")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(err, ErrAlreadyMember):
apiErr := apierror.BadRequest("already_member", "user is already a member")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
default:
apiErr := apierror.Internal("failed to add member")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
}
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) RemoveMember(c *gin.Context) {
groupID, ok := parseGroupID(c)
if !ok {
return
}
targetID, err := uuid.Parse(c.Param("userId"))
if err != nil {
apiErr := apierror.BadRequest("invalid_user_id", "invalid user id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
requestorID := getRequesterID(c)
if removeErr := h.svc.RemoveMember(c.Request.Context(), requestorID, groupID, targetID); removeErr != nil {
switch {
case errors.Is(removeErr, ErrGroupNotFound):
apiErr := apierror.NotFound("group")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(removeErr, ErrNotGroupAdmin):
apiErr := apierror.Forbidden("not a group admin")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(removeErr, ErrMemberNotFound):
apiErr := apierror.NotFound("member")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
case errors.Is(removeErr, ErrCannotRemoveLastAdmin):
apiErr := apierror.BadRequest("last_admin", "cannot remove the last group admin")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
default:
apiErr := apierror.Internal("failed to remove member")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
}
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) ListMyGroups(c *gin.Context) {
userID := getRequesterID(c)
groups, err := h.svc.ListMyGroups(c.Request.Context(), userID)
if err != nil {
apiErr := apierror.Internal("failed to list groups")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": groups})
}
func parseGroupID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_group_id", "invalid group id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func getRequesterID(c *gin.Context) uuid.UUID {
v, _ := c.Get("user_id")
id, _ := v.(uuid.UUID)
return id
}
+66
View File
@@ -0,0 +1,66 @@
package group
import (
"fmt"
"time"
"github.com/google/uuid"
)
type Group struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type GroupProfile struct {
GroupID uuid.UUID `json:"group_id"`
Description string `json:"description"`
Categories []string `json:"categories"`
AvatarURL string `json:"avatar_url"`
WebsiteURL string `json:"website_url"`
UpdatedAt time.Time `json:"updated_at"`
}
type GroupMember struct {
ID uuid.UUID `json:"id"`
GroupID uuid.UUID `json:"group_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
// GroupView is the aggregated response for a single group (group + profile).
type GroupView struct {
Group
Profile GroupProfile `json:"profile"`
}
// GroupMemberView augments GroupMember with user display fields for list responses.
type GroupMemberView struct {
GroupMember
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
const (
MemberRoleAdmin = "admin"
MemberRoleMember = "member"
)
const (
KindHaendler = "haendler"
KindKuenstler = "kuenstler"
KindLager = "lager"
)
var (
ErrGroupNotFound = fmt.Errorf("group not found")
ErrNotGroupAdmin = fmt.Errorf("not a group admin")
ErrAlreadyMember = fmt.Errorf("already a member")
ErrMemberNotFound = fmt.Errorf("member not found")
ErrCannotRemoveLastAdmin = fmt.Errorf("cannot remove the last group admin")
)
+208
View File
@@ -0,0 +1,208 @@
package group
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"marktvogt.de/backend/internal/pkg/pgerr"
)
type Repository interface {
Create(ctx context.Context, g Group) (Group, error)
GetByID(ctx context.Context, id uuid.UUID) (Group, error)
GetProfile(ctx context.Context, groupID uuid.UUID) (GroupProfile, error)
UpsertProfile(ctx context.Context, p GroupProfile) error
AddMember(ctx context.Context, m GroupMember) error
RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error
GetMember(ctx context.Context, groupID, userID uuid.UUID) (GroupMember, error)
ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error)
ListByUser(ctx context.Context, userID uuid.UUID) ([]Group, error)
CountAdmins(ctx context.Context, groupID uuid.UUID) (int, error)
}
type pgRepository struct {
db *pgxpool.Pool
}
func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) Create(ctx context.Context, g Group) (Group, error) {
var out Group
err := r.db.QueryRow(ctx, `
INSERT INTO groups (id, name, kind, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, name, kind, created_by, created_at, updated_at
`, g.ID, g.Name, g.Kind, g.CreatedBy).Scan(
&out.ID, &out.Name, &out.Kind, &out.CreatedBy, &out.CreatedAt, &out.UpdatedAt,
)
if err != nil {
return Group{}, fmt.Errorf("creating group: %w", err)
}
return out, nil
}
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Group, error) {
var g Group
err := r.db.QueryRow(ctx, `
SELECT id, name, kind, created_by, created_at, updated_at
FROM groups WHERE id = $1
`, id).Scan(&g.ID, &g.Name, &g.Kind, &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Group{}, ErrGroupNotFound
}
return Group{}, fmt.Errorf("getting group: %w", err)
}
return g, nil
}
func (r *pgRepository) GetProfile(ctx context.Context, groupID uuid.UUID) (GroupProfile, error) {
var p GroupProfile
err := r.db.QueryRow(ctx, `
SELECT group_id, description, categories, avatar_url, website_url, updated_at
FROM group_profiles WHERE group_id = $1
`, groupID).Scan(&p.GroupID, &p.Description, &p.Categories, &p.AvatarURL, &p.WebsiteURL, &p.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GroupProfile{GroupID: groupID, Categories: []string{}}, nil
}
return GroupProfile{}, fmt.Errorf("getting group profile: %w", err)
}
if p.Categories == nil {
p.Categories = []string{}
}
return p, nil
}
func (r *pgRepository) UpsertProfile(ctx context.Context, p GroupProfile) error {
categories := p.Categories
if categories == nil {
categories = []string{}
}
_, err := r.db.Exec(ctx, `
INSERT INTO group_profiles (group_id, description, categories, avatar_url, website_url, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (group_id) DO UPDATE SET
description = EXCLUDED.description,
categories = EXCLUDED.categories,
avatar_url = EXCLUDED.avatar_url,
website_url = EXCLUDED.website_url,
updated_at = NOW()
`, p.GroupID, p.Description, categories, p.AvatarURL, p.WebsiteURL)
if err != nil {
return fmt.Errorf("upserting group profile: %w", err)
}
return nil
}
func (r *pgRepository) AddMember(ctx context.Context, m GroupMember) error {
_, err := r.db.Exec(ctx, `
INSERT INTO group_members (id, group_id, user_id, role)
VALUES ($1, $2, $3, $4)
`, m.ID, m.GroupID, m.UserID, m.Role)
if err != nil {
if pgerr.IsDuplicateKey(err) {
return ErrAlreadyMember
}
return fmt.Errorf("adding group member: %w", err)
}
return nil
}
func (r *pgRepository) RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error {
tag, err := r.db.Exec(ctx, `
DELETE FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID)
if err != nil {
return fmt.Errorf("removing group member: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrMemberNotFound
}
return nil
}
func (r *pgRepository) GetMember(ctx context.Context, groupID, userID uuid.UUID) (GroupMember, error) {
var m GroupMember
err := r.db.QueryRow(ctx, `
SELECT id, group_id, user_id, role, joined_at
FROM group_members WHERE group_id = $1 AND user_id = $2
`, groupID, userID).Scan(&m.ID, &m.GroupID, &m.UserID, &m.Role, &m.JoinedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return GroupMember{}, ErrMemberNotFound
}
return GroupMember{}, fmt.Errorf("getting group member: %w", err)
}
return m, nil
}
func (r *pgRepository) ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error) {
rows, err := r.db.Query(ctx, `
SELECT gm.id, gm.group_id, gm.user_id, gm.role, gm.joined_at,
u.display_name, u.avatar_url
FROM group_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.group_id = $1
ORDER BY gm.joined_at ASC
`, groupID)
if err != nil {
return nil, fmt.Errorf("listing group members: %w", err)
}
defer rows.Close()
var members []GroupMemberView
for rows.Next() {
var v GroupMemberView
if err := rows.Scan(
&v.ID, &v.GroupID, &v.UserID, &v.Role, &v.JoinedAt,
&v.DisplayName, &v.AvatarURL,
); err != nil {
return nil, fmt.Errorf("scanning group member: %w", err)
}
members = append(members, v)
}
return members, rows.Err()
}
func (r *pgRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Group, error) {
rows, err := r.db.Query(ctx, `
SELECT g.id, g.name, g.kind, g.created_by, g.created_at, g.updated_at
FROM groups g
JOIN group_members gm ON gm.group_id = g.id
WHERE gm.user_id = $1
ORDER BY g.created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("listing groups by user: %w", err)
}
defer rows.Close()
var groups []Group
for rows.Next() {
var g Group
if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning group: %w", err)
}
groups = append(groups, g)
}
return groups, rows.Err()
}
func (r *pgRepository) CountAdmins(ctx context.Context, groupID uuid.UUID) (int, error) {
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND role = $2
`, groupID, MemberRoleAdmin).Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting group admins: %w", err)
}
return count, nil
}
+17
View File
@@ -0,0 +1,17 @@
package group
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
rg.GET("/groups/:id", h.Get)
rg.GET("/groups/:id/members", h.ListMembers)
auth := rg.Group("", requireAuth)
{
auth.POST("/groups", h.Create)
auth.PATCH("/groups/:id/profile", h.UpdateProfile)
auth.POST("/groups/:id/members", h.AddMember)
auth.DELETE("/groups/:id/members/:userId", h.RemoveMember)
auth.GET("/users/me/groups", h.ListMyGroups)
}
}
+172
View File
@@ -0,0 +1,172 @@
package group
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
type CreateRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Kind string `json:"kind" validate:"required,oneof=haendler kuenstler lager"`
}
type UpdateProfileRequest struct {
Description *string `json:"description" validate:"omitempty,max=2000"`
Categories []string `json:"categories" validate:"omitempty,max=10,dive,min=1,max=100"`
AvatarURL *string `json:"avatar_url" validate:"omitempty,url"`
WebsiteURL *string `json:"website_url" validate:"omitempty,url"`
}
type AddMemberRequest struct {
UserID uuid.UUID `json:"user_id" validate:"required"`
}
func (s *Service) CreateGroup(ctx context.Context, creatorID uuid.UUID, req CreateRequest) (GroupView, error) {
g := Group{
ID: uuid.New(),
Name: req.Name,
Kind: req.Kind,
CreatedBy: creatorID,
}
g, err := s.repo.Create(ctx, g)
if err != nil {
return GroupView{}, fmt.Errorf("create group: %w", err)
}
if err := s.repo.AddMember(ctx, GroupMember{
ID: uuid.New(),
GroupID: g.ID,
UserID: creatorID,
Role: MemberRoleAdmin,
}); err != nil {
return GroupView{}, fmt.Errorf("adding creator as admin: %w", err)
}
profile := GroupProfile{GroupID: g.ID, Categories: []string{}}
if err := s.repo.UpsertProfile(ctx, profile); err != nil {
return GroupView{}, fmt.Errorf("creating group profile: %w", err)
}
return GroupView{Group: g, Profile: profile}, nil
}
func (s *Service) GetGroup(ctx context.Context, id uuid.UUID) (GroupView, error) {
g, err := s.repo.GetByID(ctx, id)
if err != nil {
return GroupView{}, err
}
p, err := s.repo.GetProfile(ctx, id)
if err != nil {
return GroupView{}, fmt.Errorf("get group profile: %w", err)
}
return GroupView{Group: g, Profile: p}, nil
}
func (s *Service) UpdateProfile(ctx context.Context, requestorID, groupID uuid.UUID, req UpdateProfileRequest) (GroupProfile, error) {
if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
return GroupProfile{}, err
}
current, err := s.repo.GetProfile(ctx, groupID)
if err != nil {
return GroupProfile{}, fmt.Errorf("get current profile: %w", err)
}
if req.Description != nil {
current.Description = *req.Description
}
if req.Categories != nil {
current.Categories = req.Categories
}
if req.AvatarURL != nil {
current.AvatarURL = *req.AvatarURL
}
if req.WebsiteURL != nil {
current.WebsiteURL = *req.WebsiteURL
}
if err := s.repo.UpsertProfile(ctx, current); err != nil {
return GroupProfile{}, fmt.Errorf("update profile: %w", err)
}
updated, err := s.repo.GetProfile(ctx, groupID)
if err != nil {
return GroupProfile{}, fmt.Errorf("re-fetch profile: %w", err)
}
return updated, nil
}
func (s *Service) AddMember(ctx context.Context, requestorID, groupID, targetUserID uuid.UUID) error {
if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
return err
}
return s.repo.AddMember(ctx, GroupMember{
ID: uuid.New(),
GroupID: groupID,
UserID: targetUserID,
Role: MemberRoleMember,
})
}
func (s *Service) RemoveMember(ctx context.Context, requestorID, groupID, targetUserID uuid.UUID) error {
// Self-remove is always allowed; otherwise require admin.
if requestorID != targetUserID {
if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
return err
}
}
// Guard against removing the last admin.
target, err := s.repo.GetMember(ctx, groupID, targetUserID)
if err != nil {
return err
}
if target.Role == MemberRoleAdmin {
n, err := s.repo.CountAdmins(ctx, groupID)
if err != nil {
return fmt.Errorf("count admins: %w", err)
}
if n <= 1 {
return ErrCannotRemoveLastAdmin
}
}
return s.repo.RemoveMember(ctx, groupID, targetUserID)
}
func (s *Service) ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error) {
if _, err := s.repo.GetByID(ctx, groupID); err != nil {
return nil, err
}
return s.repo.ListMembers(ctx, groupID)
}
func (s *Service) ListMyGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) {
return s.repo.ListByUser(ctx, userID)
}
func (s *Service) requireAdmin(ctx context.Context, groupID, userID uuid.UUID) error {
m, err := s.repo.GetMember(ctx, groupID, userID)
if err != nil {
if errors.Is(err, ErrMemberNotFound) {
return ErrNotGroupAdmin
}
return fmt.Errorf("checking group membership: %w", err)
}
if m.Role != MemberRoleAdmin {
return ErrNotGroupAdmin
}
return nil
}
@@ -0,0 +1,76 @@
package lagerleben
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"marktvogt.de/backend/internal/pkg/apierror"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) ListArticles(c *gin.Context) {
articles, err := h.svc.ListArticles(c.Request.Context())
if err != nil {
apiErr := apierror.Internal("internal error")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if articles == nil {
articles = []Article{}
}
c.JSON(http.StatusOK, gin.H{"data": articles})
}
func (h *Handler) GetArticle(c *gin.Context) {
slug := c.Param("slug")
article, err := h.svc.GetArticle(c.Request.Context(), slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
apiErr := apierror.NotFound("article")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("internal error")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": article})
}
func (h *Handler) ListCamps(c *gin.Context) {
camps, err := h.svc.ListCamps(c.Request.Context())
if err != nil {
apiErr := apierror.Internal("internal error")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if camps == nil {
camps = []Camp{}
}
c.JSON(http.StatusOK, gin.H{"data": camps})
}
func (h *Handler) GetCamp(c *gin.Context) {
slug := c.Param("slug")
camp, err := h.svc.GetCamp(c.Request.Context(), slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
apiErr := apierror.NotFound("camp")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("internal error")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": camp})
}
@@ -0,0 +1,237 @@
package lagerleben_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"marktvogt.de/backend/internal/domain/lagerleben"
)
func init() {
gin.SetMode(gin.TestMode)
}
// -- in-memory fake --
type fakeRepo struct {
articles []lagerleben.Article
camps []lagerleben.Camp
}
func (r *fakeRepo) ListArticles(_ context.Context) ([]lagerleben.Article, error) {
var out []lagerleben.Article
for _, a := range r.articles {
if a.Published {
out = append(out, a)
}
}
return out, nil
}
func (r *fakeRepo) GetArticleBySlug(_ context.Context, slug string) (lagerleben.Article, error) {
for _, a := range r.articles {
if a.Slug == slug && a.Published {
return a, nil
}
}
return lagerleben.Article{}, lagerleben.ErrNotFound
}
func (r *fakeRepo) ListCamps(_ context.Context) ([]lagerleben.Camp, error) {
var out []lagerleben.Camp
for _, c := range r.camps {
if c.Published {
out = append(out, c)
}
}
return out, nil
}
func (r *fakeRepo) GetCampBySlug(_ context.Context, slug string) (lagerleben.Camp, error) {
for _, c := range r.camps {
if c.Slug == slug && c.Published {
return c, nil
}
}
return lagerleben.Camp{}, lagerleben.ErrNotFound
}
func newRouter(repo lagerleben.Repository) *gin.Engine {
svc := lagerleben.NewService(repo)
h := lagerleben.NewHandler(svc)
r := gin.New()
lagerleben.RegisterRoutes(r.Group("/api/v1"), h)
return r
}
func seedArticle() lagerleben.Article {
return lagerleben.Article{
Slug: "test-artikel",
Title: "Test Artikel",
Subtitle: "Untertitel",
Category: "Handwerk",
PublishedOn: lagerleben.NewDateOnly(time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)),
Excerpt: "Kurzbeschreibung",
Published: true,
}
}
func seedCamp() lagerleben.Camp {
return lagerleben.Camp{
Slug: "test-lager",
Name: "Test Lager",
Region: "Bayern",
Period: "um 1350",
Excerpt: "Beschreibung",
Members: 12,
Published: true,
}
}
// PoC: list articles is public — no auth required.
func TestListArticles_Public_Returns200(t *testing.T) {
t.Parallel()
repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
var resp struct {
Data []lagerleben.Article `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data) != 1 {
t.Errorf("want 1 article, got %d", len(resp.Data))
}
}
// PoC: unpublished articles are excluded from the list.
func TestListArticles_ExcludesUnpublished(t *testing.T) {
t.Parallel()
a := seedArticle()
a.Published = false
repo := &fakeRepo{articles: []lagerleben.Article{a}}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles", nil)
router.ServeHTTP(w, req)
var resp struct {
Data []lagerleben.Article `json:"data"`
}
_ = json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("want 0 articles (all unpublished), got %d", len(resp.Data))
}
}
// PoC: get article by slug returns 200 with correct data.
func TestGetArticle_KnownSlug_Returns200(t *testing.T) {
t.Parallel()
repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/test-artikel", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
var resp struct {
Data lagerleben.Article `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Data.Slug != "test-artikel" {
t.Errorf("want slug test-artikel, got %s", resp.Data.Slug)
}
}
// PoC: unknown article slug returns 404.
func TestGetArticle_UnknownSlug_Returns404(t *testing.T) {
t.Parallel()
repo := &fakeRepo{}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/does-not-exist", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d", w.Code)
}
}
// PoC: list camps is public.
func TestListCamps_Public_Returns200(t *testing.T) {
t.Parallel()
repo := &fakeRepo{camps: []lagerleben.Camp{seedCamp()}}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/camps", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
var resp struct {
Data []lagerleben.Camp `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Data) != 1 {
t.Errorf("want 1 camp, got %d", len(resp.Data))
}
}
// PoC: unknown camp slug returns 404.
func TestGetCamp_UnknownSlug_Returns404(t *testing.T) {
t.Parallel()
repo := &fakeRepo{}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/camps/does-not-exist", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d", w.Code)
}
}
// PoC: date is serialized as YYYY-MM-DD (not full RFC3339).
func TestArticle_DateFormat(t *testing.T) {
t.Parallel()
repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
router := newRouter(repo)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/test-artikel", nil)
router.ServeHTTP(w, req)
var raw map[string]map[string]any
_ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&raw)
date, _ := raw["data"]["date"].(string)
if date != "2026-04-12" {
t.Errorf("want date 2026-04-12, got %q", date)
}
}
@@ -0,0 +1,59 @@
package lagerleben
import (
"errors"
"time"
"github.com/google/uuid"
)
var ErrNotFound = errors.New("not found")
type Article struct {
ID uuid.UUID `json:"-"`
Slug string `json:"slug"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Category string `json:"category"`
PublishedOn dateOnly `json:"date"`
Excerpt string `json:"excerpt"`
Body string `json:"body,omitempty"`
Published bool `json:"-"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}
type Camp struct {
ID uuid.UUID `json:"-"`
Slug string `json:"slug"`
Name string `json:"name"`
Region string `json:"region"`
Period string `json:"period"`
Excerpt string `json:"excerpt"`
Members int `json:"members"`
Published bool `json:"-"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}
// dateOnly marshals a time.Time as "YYYY-MM-DD" for JSON.
type dateOnly struct {
time.Time
}
func NewDateOnly(t time.Time) dateOnly {
return dateOnly{t}
}
func (d dateOnly) MarshalJSON() ([]byte, error) {
return []byte(`"` + d.UTC().Format("2006-01-02") + `"`), nil
}
func (d *dateOnly) UnmarshalJSON(data []byte) error {
t, err := time.Parse(`"2006-01-02"`, string(data))
if err != nil {
return err
}
d.Time = t
return nil
}
@@ -0,0 +1,136 @@
package lagerleben
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
ListArticles(ctx context.Context) ([]Article, error)
GetArticleBySlug(ctx context.Context, slug string) (Article, error)
ListCamps(ctx context.Context) ([]Camp, error)
GetCampBySlug(ctx context.Context, slug string) (Camp, error)
}
type pgRepository struct {
db *pgxpool.Pool
}
func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) ListArticles(ctx context.Context) ([]Article, error) {
rows, err := r.db.Query(ctx,
`SELECT id, slug, title, subtitle, category, published_on, excerpt, published, created_at, updated_at
FROM lagerleben_articles
WHERE published = TRUE
ORDER BY published_on DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Article
for rows.Next() {
a, err := scanArticle(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (r *pgRepository) GetArticleBySlug(ctx context.Context, slug string) (Article, error) {
row := r.db.QueryRow(ctx,
`SELECT id, slug, title, subtitle, category, published_on, excerpt, body, published, created_at, updated_at
FROM lagerleben_articles
WHERE slug = $1 AND published = TRUE`,
slug)
a, err := scanArticleFull(row)
if errors.Is(err, pgx.ErrNoRows) {
return Article{}, ErrNotFound
}
return a, err
}
func (r *pgRepository) ListCamps(ctx context.Context) ([]Camp, error) {
rows, err := r.db.Query(ctx,
`SELECT id, slug, name, region, period, excerpt, members, published, created_at, updated_at
FROM lagerleben_camps
WHERE published = TRUE
ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Camp
for rows.Next() {
c, err := scanCamp(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *pgRepository) GetCampBySlug(ctx context.Context, slug string) (Camp, error) {
row := r.db.QueryRow(ctx,
`SELECT id, slug, name, region, period, excerpt, members, published, created_at, updated_at
FROM lagerleben_camps
WHERE slug = $1 AND published = TRUE`,
slug)
c, err := scanCamp(row)
if errors.Is(err, pgx.ErrNoRows) {
return Camp{}, ErrNotFound
}
return c, err
}
type scanner interface {
Scan(dest ...any) error
}
func scanArticle(row scanner) (Article, error) {
var a Article
var publishedOn time.Time
err := row.Scan(
&a.ID, &a.Slug, &a.Title, &a.Subtitle, &a.Category,
&publishedOn, &a.Excerpt, &a.Published, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return Article{}, err
}
a.PublishedOn = dateOnly{publishedOn}
return a, nil
}
func scanArticleFull(row scanner) (Article, error) {
var a Article
var publishedOn time.Time
err := row.Scan(
&a.ID, &a.Slug, &a.Title, &a.Subtitle, &a.Category,
&publishedOn, &a.Excerpt, &a.Body, &a.Published, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return Article{}, err
}
a.PublishedOn = dateOnly{publishedOn}
return a, nil
}
func scanCamp(row scanner) (Camp, error) {
var c Camp
err := row.Scan(
&c.ID, &c.Slug, &c.Name, &c.Region, &c.Period,
&c.Excerpt, &c.Members, &c.Published, &c.CreatedAt, &c.UpdatedAt,
)
return c, err
}
@@ -0,0 +1,10 @@
package lagerleben
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler) {
rg.GET("/lagerleben/articles", h.ListArticles)
rg.GET("/lagerleben/articles/:slug", h.GetArticle)
rg.GET("/lagerleben/camps", h.ListCamps)
rg.GET("/lagerleben/camps/:slug", h.GetCamp)
}
@@ -0,0 +1,27 @@
package lagerleben
import "context"
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) ListArticles(ctx context.Context) ([]Article, error) {
return s.repo.ListArticles(ctx)
}
func (s *Service) GetArticle(ctx context.Context, slug string) (Article, error) {
return s.repo.GetArticleBySlug(ctx, slug)
}
func (s *Service) ListCamps(ctx context.Context) ([]Camp, error) {
return s.repo.ListCamps(ctx)
}
func (s *Service) GetCamp(ctx context.Context, slug string) (Camp, error) {
return s.repo.GetCampBySlug(ctx, slug)
}
+14
View File
@@ -2,6 +2,7 @@ package market
import (
"errors"
"log/slog"
"net/http"
"strconv"
@@ -30,6 +31,7 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to
markets, total, err := h.service.Search(c.Request.Context(), params)
if err != nil {
slog.ErrorContext(c.Request.Context(), "market search failed", "error", err)
apiErr := apierror.Internal("failed to search markets")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
@@ -56,6 +58,17 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to
})
}
func (h *Handler) Stats(c *gin.Context) {
stats, err := h.service.Stats(c.Request.Context())
if err != nil {
slog.ErrorContext(c.Request.Context(), "market stats failed", "error", err)
apiErr := apierror.Internal("failed to fetch market stats")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": stats})
}
func (h *Handler) GetBySlug(c *gin.Context) {
slug := c.Param("slug")
@@ -77,6 +90,7 @@ func (h *Handler) GetBySlug(c *gin.Context) {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
slog.ErrorContext(c.Request.Context(), "market get by slug failed", "slug", slug, "error", err)
apiErr := apierror.Internal("failed to get market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
+6
View File
@@ -87,3 +87,9 @@ const (
StatusArchived = "archived"
StatusMerged = "merged"
)
type MarketStats struct {
Total int `json:"total"`
Countries int `json:"countries"`
Regions int `json:"regions"`
}
@@ -16,6 +16,7 @@ import (
type Repository interface {
// Public queries
Stats(ctx context.Context) (MarketStats, error)
Search(ctx context.Context, params SearchParams) ([]Market, int, error)
GetBySlug(ctx context.Context, slug string) (Market, error)
GetBySlugAndYear(ctx context.Context, slug string, year int) (Market, error)
@@ -51,6 +52,9 @@ type Repository interface {
// sets status='merged', merged_into_id, merged_at on the source edition;
// reparents discovered_markets.created_edition_id; writes a market_merge_log row.
MarkMerged(ctx context.Context, sourceID, targetID, mergedBy uuid.UUID, proposalJSON []byte) error
// IsOwner reports whether userID is the creator of the market series.
IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error)
}
type pgRepository struct {
@@ -146,6 +150,22 @@ func scanReturnedEdition(sc scanner) (Market, error) {
// --- Public queries ---
func (r *pgRepository) Stats(ctx context.Context) (MarketStats, error) {
var s MarketStats
err := r.db.QueryRow(ctx, `
SELECT
COUNT(DISTINCT e.series_id),
COUNT(DISTINCT e.country),
COUNT(DISTINCT e.state)
FROM market_editions e
WHERE e.is_published = TRUE
`).Scan(&s.Total, &s.Countries, &s.Regions)
if err != nil {
return MarketStats{}, fmt.Errorf("fetching market stats: %w", err)
}
return s, nil
}
func (r *pgRepository) Search(ctx context.Context, params SearchParams) ([]Market, int, error) {
var (
conditions []string
@@ -1132,3 +1152,14 @@ func (r *pgRepository) MarkMerged(ctx context.Context, sourceID, targetID, merge
return tx.Commit(ctx)
}
func (r *pgRepository) IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error) {
var exists bool
err := r.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM market_series WHERE id = $1 AND created_by = $2)
`, marketID, userID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking market ownership: %w", err)
}
return exists, nil
}
+1
View File
@@ -6,6 +6,7 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, ge
markets := rg.Group("/markets")
{
markets.GET("", h.Search)
markets.GET("/stats", h.Stats)
markets.GET("/:slug", h.GetBySlug)
markets.POST("/submit", submitLimit, subH.Submit)
markets.POST("/:slug/feedback", feedbackLimit, fbH.Submit)
@@ -39,6 +39,10 @@ func NewServiceFull(repo Repository, emailSender email.Sender, ts turnstile.Veri
}
}
func (s *Service) Stats(ctx context.Context) (MarketStats, error) {
return s.repo.Stats(ctx)
}
func (s *Service) Search(ctx context.Context, params SearchParams) ([]Market, int, error) {
params.Defaults()
return s.repo.Search(ctx, params)
+210
View File
@@ -0,0 +1,210 @@
package message
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/validate"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
type createThreadRequest struct {
Subject string `json:"subject" validate:"required,min=1,max=500"`
ContextType string `json:"context_type" validate:"required,min=1,max=100"`
ContextID *uuid.UUID `json:"context_id"`
Participants []participantInput `json:"participants" validate:"omitempty,dive"`
}
type participantInput struct {
UserID uuid.UUID `json:"user_id" validate:"required"`
Role string `json:"role" validate:"required,oneof=owner replier"`
}
type postMessageRequest struct {
Body string `json:"body" validate:"required,min=1,max=10000"`
}
func (h *Handler) CreateThread(c *gin.Context) {
var req createThreadRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
participants := []Participant{
{UserID: userID, Role: ParticipantRoleOwner},
}
for _, p := range req.Participants {
if p.UserID == userID {
continue
}
participants = append(participants, Participant{UserID: p.UserID, Role: p.Role})
}
t := Thread{
ID: uuid.New(),
Subject: req.Subject,
ContextType: req.ContextType,
ContextID: req.ContextID,
}
created, err := h.svc.CreateThread(c.Request.Context(), t, participants)
if err != nil {
apiErr := apierror.Internal("failed to create thread")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, gin.H{"data": created})
}
func (h *Handler) ListMyThreads(c *gin.Context) {
userID := getRequesterID(c)
threads, err := h.svc.ListThreadsByUser(c.Request.Context(), userID)
if err != nil {
apiErr := apierror.Internal("failed to list threads")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if threads == nil {
threads = []Thread{}
}
c.JSON(http.StatusOK, gin.H{"data": threads})
}
func (h *Handler) GetMessages(c *gin.Context) {
threadID, ok := parseThreadID(c)
if !ok {
return
}
userID := getRequesterID(c)
ok, err := h.svc.IsParticipant(c.Request.Context(), threadID, userID)
if err != nil {
apiErr := apierror.Internal("failed to check participation")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if !ok {
apiErr := apierror.Forbidden("not a participant in this thread")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
messages, err := h.svc.ListMessages(c.Request.Context(), threadID)
if err != nil {
if errors.Is(err, ErrThreadNotFound) {
apiErr := apierror.NotFound("thread")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to list messages")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if messages == nil {
messages = []Message{}
}
c.JSON(http.StatusOK, gin.H{"data": messages})
}
func (h *Handler) PostMessage(c *gin.Context) {
threadID, ok := parseThreadID(c)
if !ok {
return
}
userID := getRequesterID(c)
isParticipant, err := h.svc.IsParticipant(c.Request.Context(), threadID, userID)
if err != nil {
apiErr := apierror.Internal("failed to check participation")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if !isParticipant {
apiErr := apierror.Forbidden("not a participant in this thread")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req postMessageRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
m := Message{
ID: uuid.New(),
ThreadID: threadID,
SenderID: userID,
Body: req.Body,
}
created, err := h.svc.CreateMessage(c.Request.Context(), m)
if err != nil {
apiErr := apierror.Internal("failed to post message")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, gin.H{"data": created})
}
func (h *Handler) MarkRead(c *gin.Context) {
threadID, ok := parseThreadID(c)
if !ok {
return
}
userID := getRequesterID(c)
isParticipant, err := h.svc.IsParticipant(c.Request.Context(), threadID, userID)
if err != nil {
apiErr := apierror.Internal("failed to check participation")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if !isParticipant {
apiErr := apierror.Forbidden("not a participant in this thread")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.svc.MarkRead(c.Request.Context(), threadID, userID); err != nil {
apiErr := apierror.Internal("failed to mark thread as read")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusNoContent, nil)
}
func parseThreadID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_thread_id", "invalid thread id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func getRequesterID(c *gin.Context) uuid.UUID {
v, _ := c.Get("user_id")
id, _ := v.(uuid.UUID)
return id
}
@@ -0,0 +1,243 @@
package message_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/message"
)
func init() {
gin.SetMode(gin.TestMode)
}
// -- in-memory repository --
type fakeRepo struct {
mu sync.Mutex
threads map[uuid.UUID]message.Thread
participants map[uuid.UUID][]message.Participant // keyed by threadID
messages map[uuid.UUID][]message.Message // keyed by threadID
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
threads: make(map[uuid.UUID]message.Thread),
participants: make(map[uuid.UUID][]message.Participant),
messages: make(map[uuid.UUID][]message.Message),
}
}
func (r *fakeRepo) CreateThread(_ context.Context, t message.Thread, participants []message.Participant) (message.Thread, error) {
r.mu.Lock()
defer r.mu.Unlock()
t.CreatedAt = time.Now()
t.LastMessageAt = time.Now()
r.threads[t.ID] = t
r.participants[t.ID] = append(r.participants[t.ID], participants...)
return t, nil
}
func (r *fakeRepo) GetThread(_ context.Context, id uuid.UUID) (message.Thread, error) {
r.mu.Lock()
defer r.mu.Unlock()
t, ok := r.threads[id]
if !ok {
return message.Thread{}, message.ErrThreadNotFound
}
return t, nil
}
func (r *fakeRepo) IsParticipant(_ context.Context, threadID, userID uuid.UUID) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, p := range r.participants[threadID] {
if p.UserID == userID {
return true, nil
}
}
return false, nil
}
func (r *fakeRepo) CreateMessage(_ context.Context, m message.Message) (message.Message, error) {
r.mu.Lock()
defer r.mu.Unlock()
m.CreatedAt = time.Now()
r.messages[m.ThreadID] = append(r.messages[m.ThreadID], m)
if t, ok := r.threads[m.ThreadID]; ok {
t.LastMessageAt = time.Now()
r.threads[m.ThreadID] = t
}
return m, nil
}
func (r *fakeRepo) ListMessages(_ context.Context, threadID uuid.UUID) ([]message.Message, error) {
r.mu.Lock()
defer r.mu.Unlock()
return append([]message.Message(nil), r.messages[threadID]...), nil
}
func (r *fakeRepo) ListThreadsByUser(_ context.Context, userID uuid.UUID) ([]message.Thread, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []message.Thread
for threadID, parts := range r.participants {
for _, p := range parts {
if p.UserID == userID {
if t, ok := r.threads[threadID]; ok {
out = append(out, t)
}
break
}
}
}
return out, nil
}
func (r *fakeRepo) MarkRead(_ context.Context, threadID, userID uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
parts := r.participants[threadID]
now := time.Now()
for i, p := range parts {
if p.UserID == userID {
parts[i].LastReadAt = &now
r.participants[threadID] = parts
return nil
}
}
return nil
}
// -- router helpers --
func newRouter(repo message.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
svc := message.NewService(repo)
h := message.NewHandler(svc)
router := gin.New()
message.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
return router
}
func stubAuth(userID uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", userID)
c.Next()
}
}
func noAuth() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
}
}
func jsonBody(v any) *bytes.Reader {
b, _ := json.Marshal(v)
return bytes.NewReader(b)
}
// TestMessages_RequireAuth — GET /me/threads returns 401 without auth.
func TestMessages_RequireAuth(t *testing.T) {
t.Parallel()
repo := newFakeRepo()
router := newRouter(repo, noAuth())
threadID := uuid.New().String()
endpoints := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/me/threads"},
{http.MethodPost, "/api/v1/threads"},
{http.MethodGet, "/api/v1/threads/" + threadID + "/messages"},
{http.MethodPost, "/api/v1/threads/" + threadID + "/messages"},
{http.MethodPost, "/api/v1/threads/" + threadID + "/read"},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// TestMessages_CannotReadOtherThread — user B (not participant) gets 403 on GET /threads/:id/messages.
func TestMessages_CannotReadOtherThread(t *testing.T) {
t.Parallel()
userA := uuid.New()
userB := uuid.New()
repo := newFakeRepo()
// Create thread with user A as participant only.
thread := message.Thread{
ID: uuid.New(),
Subject: "Private thread",
ContextType: "direct",
CreatedAt: time.Now(),
}
repo.threads[thread.ID] = thread
repo.participants[thread.ID] = []message.Participant{
{ThreadID: thread.ID, UserID: userA, Role: message.ParticipantRoleOwner},
}
// User B tries to read the thread.
router := newRouter(repo, stubAuth(userB))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/threads/"+thread.ID.String()+"/messages", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
}
// TestMessages_CannotPostToOtherThread — user B gets 403 on POST /threads/:id/messages.
func TestMessages_CannotPostToOtherThread(t *testing.T) {
t.Parallel()
userA := uuid.New()
userB := uuid.New()
repo := newFakeRepo()
thread := message.Thread{
ID: uuid.New(),
Subject: "Private thread",
ContextType: "direct",
CreatedAt: time.Now(),
}
repo.threads[thread.ID] = thread
repo.participants[thread.ID] = []message.Participant{
{ThreadID: thread.ID, UserID: userA, Role: message.ParticipantRoleOwner},
}
router := newRouter(repo, stubAuth(userB))
body := jsonBody(map[string]string{"body": "hijacked message"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/threads/"+thread.ID.String()+"/messages", body)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
}
+44
View File
@@ -0,0 +1,44 @@
package message
import (
"fmt"
"time"
"github.com/google/uuid"
)
type Thread struct {
ID uuid.UUID `json:"id"`
Subject string `json:"subject"`
ContextType string `json:"context_type"`
ContextID *uuid.UUID `json:"context_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastMessageAt time.Time `json:"last_message_at"`
}
type Participant struct {
ThreadID uuid.UUID `json:"thread_id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
LastReadAt *time.Time `json:"last_read_at,omitempty"`
}
type Message struct {
ID uuid.UUID `json:"id"`
ThreadID uuid.UUID `json:"thread_id"`
SenderID uuid.UUID `json:"sender_id"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
}
var (
ErrThreadNotFound = fmt.Errorf("thread not found")
ErrNotParticipant = fmt.Errorf("not a participant in this thread")
ErrMessageNotFound = fmt.Errorf("message not found")
)
const (
ParticipantRoleOwner = "owner"
ParticipantRoleReplier = "replier"
)
@@ -0,0 +1,191 @@
package message
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
CreateThread(ctx context.Context, t Thread, participants []Participant) (Thread, error)
GetThread(ctx context.Context, id uuid.UUID) (Thread, error)
IsParticipant(ctx context.Context, threadID, userID uuid.UUID) (bool, error)
CreateMessage(ctx context.Context, m Message) (Message, error)
ListMessages(ctx context.Context, threadID uuid.UUID) ([]Message, error)
ListThreadsByUser(ctx context.Context, userID uuid.UUID) ([]Thread, error)
MarkRead(ctx context.Context, threadID, userID uuid.UUID) error
}
type pgRepository struct {
db *pgxpool.Pool
}
func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) CreateThread(ctx context.Context, t Thread, participants []Participant) (Thread, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return Thread{}, fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var out Thread
err = tx.QueryRow(ctx, `
INSERT INTO message_threads (id, subject, context_type, context_id)
VALUES ($1, $2, $3, $4)
RETURNING id, subject, context_type, context_id, created_at, last_message_at
`, t.ID, t.Subject, t.ContextType, t.ContextID).Scan(
&out.ID, &out.Subject, &out.ContextType, &out.ContextID,
&out.CreatedAt, &out.LastMessageAt,
)
if err != nil {
return Thread{}, fmt.Errorf("inserting thread: %w", err)
}
for _, p := range participants {
_, err := tx.Exec(ctx, `
INSERT INTO message_thread_participants (thread_id, user_id, role)
VALUES ($1, $2, $3)
`, out.ID, p.UserID, p.Role)
if err != nil {
return Thread{}, fmt.Errorf("inserting participant: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return Thread{}, fmt.Errorf("commit transaction: %w", err)
}
return out, nil
}
func (r *pgRepository) GetThread(ctx context.Context, id uuid.UUID) (Thread, error) {
var t Thread
err := r.db.QueryRow(ctx, `
SELECT id, subject, context_type, context_id, created_at, last_message_at
FROM message_threads WHERE id = $1
`, id).Scan(&t.ID, &t.Subject, &t.ContextType, &t.ContextID, &t.CreatedAt, &t.LastMessageAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Thread{}, ErrThreadNotFound
}
return Thread{}, fmt.Errorf("getting thread: %w", err)
}
return t, nil
}
func (r *pgRepository) IsParticipant(ctx context.Context, threadID, userID uuid.UUID) (bool, error) {
var exists bool
err := r.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM message_thread_participants
WHERE thread_id = $1 AND user_id = $2
)
`, threadID, userID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking participant: %w", err)
}
return exists, nil
}
func (r *pgRepository) CreateMessage(ctx context.Context, m Message) (Message, error) {
tx, err := r.db.Begin(ctx)
if err != nil {
return Message{}, fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var out Message
err = tx.QueryRow(ctx, `
INSERT INTO messages (id, thread_id, sender_id, body)
VALUES ($1, $2, $3, $4)
RETURNING id, thread_id, sender_id, body, created_at, edited_at
`, m.ID, m.ThreadID, m.SenderID, m.Body).Scan(
&out.ID, &out.ThreadID, &out.SenderID, &out.Body, &out.CreatedAt, &out.EditedAt,
)
if err != nil {
return Message{}, fmt.Errorf("inserting message: %w", err)
}
_, err = tx.Exec(ctx, `
UPDATE message_threads SET last_message_at = NOW() WHERE id = $1
`, m.ThreadID)
if err != nil {
return Message{}, fmt.Errorf("updating last_message_at: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Message{}, fmt.Errorf("commit transaction: %w", err)
}
return out, nil
}
func (r *pgRepository) ListMessages(ctx context.Context, threadID uuid.UUID) ([]Message, error) {
rows, err := r.db.Query(ctx, `
SELECT id, thread_id, sender_id, body, created_at, edited_at
FROM messages
WHERE thread_id = $1
ORDER BY created_at ASC
`, threadID)
if err != nil {
return nil, fmt.Errorf("listing messages: %w", err)
}
defer rows.Close()
var messages []Message
for rows.Next() {
var m Message
if err := rows.Scan(&m.ID, &m.ThreadID, &m.SenderID, &m.Body, &m.CreatedAt, &m.EditedAt); err != nil {
return nil, fmt.Errorf("scanning message: %w", err)
}
messages = append(messages, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating messages: %w", err)
}
return messages, nil
}
func (r *pgRepository) ListThreadsByUser(ctx context.Context, userID uuid.UUID) ([]Thread, error) {
rows, err := r.db.Query(ctx, `
SELECT t.id, t.subject, t.context_type, t.context_id, t.created_at, t.last_message_at
FROM message_threads t
JOIN message_thread_participants p ON p.thread_id = t.id
WHERE p.user_id = $1
ORDER BY t.last_message_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("listing threads: %w", err)
}
defer rows.Close()
var threads []Thread
for rows.Next() {
var t Thread
if err := rows.Scan(&t.ID, &t.Subject, &t.ContextType, &t.ContextID, &t.CreatedAt, &t.LastMessageAt); err != nil {
return nil, fmt.Errorf("scanning thread: %w", err)
}
threads = append(threads, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating threads: %w", err)
}
return threads, nil
}
func (r *pgRepository) MarkRead(ctx context.Context, threadID, userID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
UPDATE message_thread_participants
SET last_read_at = NOW()
WHERE thread_id = $1 AND user_id = $2
`, threadID, userID)
if err != nil {
return fmt.Errorf("marking thread as read: %w", err)
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package message
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
auth := rg.Group("", requireAuth)
{
auth.POST("/threads", h.CreateThread)
auth.GET("/me/threads", h.ListMyThreads)
auth.GET("/threads/:id/messages", h.GetMessages)
auth.POST("/threads/:id/messages", h.PostMessage)
auth.POST("/threads/:id/read", h.MarkRead)
}
}
@@ -0,0 +1,43 @@
package message
import (
"context"
"github.com/google/uuid"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) CreateThread(ctx context.Context, t Thread, participants []Participant) (Thread, error) {
return s.repo.CreateThread(ctx, t, participants)
}
func (s *Service) GetThread(ctx context.Context, id uuid.UUID) (Thread, error) {
return s.repo.GetThread(ctx, id)
}
func (s *Service) IsParticipant(ctx context.Context, threadID, userID uuid.UUID) (bool, error) {
return s.repo.IsParticipant(ctx, threadID, userID)
}
func (s *Service) CreateMessage(ctx context.Context, m Message) (Message, error) {
return s.repo.CreateMessage(ctx, m)
}
func (s *Service) ListMessages(ctx context.Context, threadID uuid.UUID) ([]Message, error) {
return s.repo.ListMessages(ctx, threadID)
}
func (s *Service) ListThreadsByUser(ctx context.Context, userID uuid.UUID) ([]Thread, error) {
return s.repo.ListThreadsByUser(ctx, userID)
}
func (s *Service) MarkRead(ctx context.Context, threadID, userID uuid.UUID) error {
return s.repo.MarkRead(ctx, threadID, userID)
}
+604
View File
@@ -0,0 +1,604 @@
package program
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/validate"
)
// MarketOwnerChecker checks whether a user owns a given market series.
// Defined here to avoid importing the market package directly.
type MarketOwnerChecker interface {
IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error)
}
type Handler struct {
svc *Service
checker MarketOwnerChecker
}
func NewHandler(svc *Service, checker MarketOwnerChecker) *Handler {
return &Handler{svc: svc, checker: checker}
}
type createProgramRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Description string `json:"description" validate:"omitempty,max=5000"`
}
type updateProgramRequest struct {
Title *string `json:"title" validate:"omitempty,min=1,max=500"`
Description *string `json:"description" validate:"omitempty,max=5000"`
}
type createStageRequest struct {
Name string `json:"name" validate:"required,min=1,max=200"`
Position int `json:"position" validate:"min=0"`
}
type updateStageRequest struct {
Name *string `json:"name" validate:"omitempty,min=1,max=200"`
Position *int `json:"position" validate:"omitempty,min=0"`
}
type createActRequest struct {
Title string `json:"title" validate:"required,min=1,max=500"`
Description string `json:"description" validate:"omitempty,max=5000"`
GroupID *uuid.UUID `json:"group_id"`
StartsAt *time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
Position int `json:"position" validate:"min=0"`
}
type updateActRequest struct {
Title *string `json:"title" validate:"omitempty,min=1,max=500"`
Description *string `json:"description" validate:"omitempty,max=5000"`
Position *int `json:"position" validate:"omitempty,min=0"`
}
// ProgramView is the aggregated response for a market's full program.
type ProgramView struct {
Program Program `json:"program"`
Stages []StageView `json:"stages"`
}
type StageView struct {
Stage Stage `json:"stage"`
Acts []Act `json:"acts"`
}
func (h *Handler) GetProgram(c *gin.Context) {
marketID, ok := parseMarketID(c)
if !ok {
return
}
p, err := h.svc.GetByMarket(c.Request.Context(), marketID)
if err != nil {
if errors.Is(err, ErrProgramNotFound) {
apiErr := apierror.NotFound("program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to get program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
stages, err := h.svc.ListStages(c.Request.Context(), p.ID)
if err != nil {
apiErr := apierror.Internal("failed to list stages")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
stageViews := make([]StageView, 0, len(stages))
for _, stage := range stages {
acts, err := h.svc.ListActs(c.Request.Context(), stage.ID)
if err != nil {
apiErr := apierror.Internal("failed to list acts")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if acts == nil {
acts = []Act{}
}
stageViews = append(stageViews, StageView{Stage: stage, Acts: acts})
}
c.JSON(http.StatusOK, gin.H{"data": ProgramView{Program: p, Stages: stageViews}})
}
func (h *Handler) CreateProgram(c *gin.Context) {
marketID, ok := parseMarketID(c)
if !ok {
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), marketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req createProgramRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
p := Program{
ID: uuid.New(),
MarketID: marketID,
Title: req.Title,
Description: req.Description,
}
created, err := h.svc.Create(c.Request.Context(), p)
if err != nil {
if errors.Is(err, ErrProgramExists) {
apiErr := apierror.Conflict("program already exists for this market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to create program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, gin.H{"data": created})
}
func (h *Handler) UpdateProgram(c *gin.Context) {
programID, ok := parseProgramID(c)
if !ok {
return
}
prog, err := h.svc.GetByID(c.Request.Context(), programID)
if err != nil {
if errors.Is(err, ErrProgramNotFound) {
apiErr := apierror.NotFound("program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req updateProgramRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
updates := map[string]any{}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = *req.Description
}
updated, err := h.svc.UpdateProgram(c.Request.Context(), programID, updates)
if err != nil {
apiErr := apierror.Internal("failed to update program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *Handler) CreateStage(c *gin.Context) {
programID, ok := parseProgramID(c)
if !ok {
return
}
prog, err := h.svc.GetByID(c.Request.Context(), programID)
if err != nil {
if errors.Is(err, ErrProgramNotFound) {
apiErr := apierror.NotFound("program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req createStageRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
stage := Stage{
ID: uuid.New(),
ProgramID: programID,
Name: req.Name,
Position: req.Position,
}
created, err := h.svc.CreateStage(c.Request.Context(), stage)
if err != nil {
apiErr := apierror.Internal("failed to create stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, gin.H{"data": created})
}
func (h *Handler) UpdateStage(c *gin.Context) {
stageID, ok := parseStageID(c)
if !ok {
return
}
stage, err := h.svc.GetStage(c.Request.Context(), stageID)
if err != nil {
if errors.Is(err, ErrStageNotFound) {
apiErr := apierror.NotFound("stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
if err != nil {
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req updateStageRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
updates := map[string]any{}
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Position != nil {
updates["position"] = *req.Position
}
updated, err := h.svc.UpdateStage(c.Request.Context(), stageID, updates)
if err != nil {
apiErr := apierror.Internal("failed to update stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *Handler) DeleteStage(c *gin.Context) {
stageID, ok := parseStageID(c)
if !ok {
return
}
stage, err := h.svc.GetStage(c.Request.Context(), stageID)
if err != nil {
if errors.Is(err, ErrStageNotFound) {
apiErr := apierror.NotFound("stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
if err != nil {
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.svc.DeleteStage(c.Request.Context(), stageID); err != nil {
apiErr := apierror.Internal("failed to delete stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) CreateAct(c *gin.Context) {
stageID, ok := parseStageID(c)
if !ok {
return
}
stage, err := h.svc.GetStage(c.Request.Context(), stageID)
if err != nil {
if errors.Is(err, ErrStageNotFound) {
apiErr := apierror.NotFound("stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
if err != nil {
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req createActRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
act := Act{
ID: uuid.New(),
StageID: stageID,
GroupID: req.GroupID,
Title: req.Title,
Description: req.Description,
StartsAt: req.StartsAt,
EndsAt: req.EndsAt,
Position: req.Position,
}
created, err := h.svc.CreateAct(c.Request.Context(), act)
if err != nil {
apiErr := apierror.Internal("failed to create act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, gin.H{"data": created})
}
func (h *Handler) UpdateAct(c *gin.Context) {
actID, ok := parseActID(c)
if !ok {
return
}
// Traverse act → stage → program → market for ownership check.
act, err := h.svc.GetAct(c.Request.Context(), actID)
if err != nil {
if errors.Is(err, ErrActNotFound) {
apiErr := apierror.NotFound("act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
stage, err := h.svc.GetStage(c.Request.Context(), act.StageID)
if err != nil {
apiErr := apierror.Internal("failed to fetch stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
if err != nil {
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req updateActRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
updates := map[string]any{}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Position != nil {
updates["position"] = *req.Position
}
updated, err := h.svc.UpdateAct(c.Request.Context(), actID, updates)
if err != nil {
apiErr := apierror.Internal("failed to update act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": updated})
}
func (h *Handler) DeleteAct(c *gin.Context) {
actID, ok := parseActID(c)
if !ok {
return
}
act, err := h.svc.GetAct(c.Request.Context(), actID)
if err != nil {
if errors.Is(err, ErrActNotFound) {
apiErr := apierror.NotFound("act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to fetch act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
stage, err := h.svc.GetStage(c.Request.Context(), act.StageID)
if err != nil {
apiErr := apierror.Internal("failed to fetch stage")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
prog, err := h.svc.GetByID(c.Request.Context(), stage.ProgramID)
if err != nil {
apiErr := apierror.Internal("failed to fetch program")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := getRequesterID(c)
if isOwner, err := h.checker.IsOwner(c.Request.Context(), prog.MarketID, userID); err != nil {
apiErr := apierror.Internal("failed to check market ownership")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
} else if !isOwner {
apiErr := apierror.Forbidden("not the market owner")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.svc.DeleteAct(c.Request.Context(), actID); err != nil {
apiErr := apierror.Internal("failed to delete act")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusNoContent, nil)
}
func parseMarketID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("seriesId"))
if err != nil {
apiErr := apierror.BadRequest("invalid_market_id", "invalid market id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func parseProgramID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("programId"))
if err != nil {
apiErr := apierror.BadRequest("invalid_program_id", "invalid program id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func parseStageID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("stageId"))
if err != nil {
apiErr := apierror.BadRequest("invalid_stage_id", "invalid stage id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func parseActID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("actId"))
if err != nil {
apiErr := apierror.BadRequest("invalid_act_id", "invalid act id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
func getRequesterID(c *gin.Context) uuid.UUID {
v, _ := c.Get("user_id")
id, _ := v.(uuid.UUID)
return id
}
+44
View File
@@ -0,0 +1,44 @@
package program
import (
"fmt"
"time"
"github.com/google/uuid"
)
type Program struct {
ID uuid.UUID `json:"id"`
MarketID uuid.UUID `json:"market_id"`
EditionID *uuid.UUID `json:"edition_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Stage struct {
ID uuid.UUID `json:"id"`
ProgramID uuid.UUID `json:"program_id"`
Name string `json:"name"`
Position int `json:"position"`
}
type Act struct {
ID uuid.UUID `json:"id"`
StageID uuid.UUID `json:"stage_id"`
GroupID *uuid.UUID `json:"group_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
StartsAt *time.Time `json:"starts_at,omitempty"`
EndsAt *time.Time `json:"ends_at,omitempty"`
Position int `json:"position"`
}
var (
ErrProgramNotFound = fmt.Errorf("program not found")
ErrStageNotFound = fmt.Errorf("stage not found")
ErrActNotFound = fmt.Errorf("act not found")
ErrNotMarketOwner = fmt.Errorf("not the market owner")
ErrProgramExists = fmt.Errorf("program already exists for this market")
)
@@ -0,0 +1,327 @@
package program
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
GetByMarket(ctx context.Context, marketID uuid.UUID) (Program, error)
GetByID(ctx context.Context, id uuid.UUID) (Program, error)
Create(ctx context.Context, p Program) (Program, error)
UpdateProgram(ctx context.Context, id uuid.UUID, updates map[string]any) (Program, error)
CreateStage(ctx context.Context, s Stage) (Stage, error)
UpdateStage(ctx context.Context, id uuid.UUID, updates map[string]any) (Stage, error)
DeleteStage(ctx context.Context, id uuid.UUID) error
ListStages(ctx context.Context, programID uuid.UUID) ([]Stage, error)
GetStage(ctx context.Context, id uuid.UUID) (Stage, error)
CreateAct(ctx context.Context, a Act) (Act, error)
GetAct(ctx context.Context, id uuid.UUID) (Act, error)
UpdateAct(ctx context.Context, id uuid.UUID, updates map[string]any) (Act, error)
DeleteAct(ctx context.Context, id uuid.UUID) error
ListActs(ctx context.Context, stageID uuid.UUID) ([]Act, error)
}
type pgRepository struct {
db *pgxpool.Pool
}
func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) GetByMarket(ctx context.Context, marketID uuid.UUID) (Program, error) {
var p Program
err := r.db.QueryRow(ctx, `
SELECT id, market_id, edition_id, title, description, created_at, updated_at
FROM market_programs WHERE market_id = $1
LIMIT 1
`, marketID).Scan(&p.ID, &p.MarketID, &p.EditionID, &p.Title, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Program{}, ErrProgramNotFound
}
return Program{}, fmt.Errorf("getting program by market: %w", err)
}
return p, nil
}
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Program, error) {
var p Program
err := r.db.QueryRow(ctx, `
SELECT id, market_id, edition_id, title, description, created_at, updated_at
FROM market_programs WHERE id = $1
`, id).Scan(&p.ID, &p.MarketID, &p.EditionID, &p.Title, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Program{}, ErrProgramNotFound
}
return Program{}, fmt.Errorf("getting program by id: %w", err)
}
return p, nil
}
func (r *pgRepository) Create(ctx context.Context, p Program) (Program, error) {
var out Program
err := r.db.QueryRow(ctx, `
INSERT INTO market_programs (id, market_id, edition_id, title, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, market_id, edition_id, title, description, created_at, updated_at
`, p.ID, p.MarketID, p.EditionID, p.Title, p.Description).Scan(
&out.ID, &out.MarketID, &out.EditionID, &out.Title, &out.Description,
&out.CreatedAt, &out.UpdatedAt,
)
if err != nil {
return Program{}, fmt.Errorf("creating program: %w", err)
}
return out, nil
}
func (r *pgRepository) UpdateProgram(ctx context.Context, id uuid.UUID, updates map[string]any) (Program, error) {
var p Program
err := r.db.QueryRow(ctx, `
SELECT id, market_id, edition_id, title, description, created_at, updated_at
FROM market_programs WHERE id = $1
`, id).Scan(&p.ID, &p.MarketID, &p.EditionID, &p.Title, &p.Description, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Program{}, ErrProgramNotFound
}
return Program{}, fmt.Errorf("fetching program: %w", err)
}
if v, ok := updates["title"]; ok {
if s, ok := v.(string); ok {
p.Title = s
}
}
if v, ok := updates["description"]; ok {
if s, ok := v.(string); ok {
p.Description = s
}
}
var out Program
err = r.db.QueryRow(ctx, `
UPDATE market_programs
SET title = $1, description = $2, updated_at = NOW()
WHERE id = $3
RETURNING id, market_id, edition_id, title, description, created_at, updated_at
`, p.Title, p.Description, id).Scan(
&out.ID, &out.MarketID, &out.EditionID, &out.Title, &out.Description,
&out.CreatedAt, &out.UpdatedAt,
)
if err != nil {
return Program{}, fmt.Errorf("updating program: %w", err)
}
return out, nil
}
func (r *pgRepository) CreateStage(ctx context.Context, s Stage) (Stage, error) {
var out Stage
err := r.db.QueryRow(ctx, `
INSERT INTO program_stages (id, program_id, name, position)
VALUES ($1, $2, $3, $4)
RETURNING id, program_id, name, position
`, s.ID, s.ProgramID, s.Name, s.Position).Scan(
&out.ID, &out.ProgramID, &out.Name, &out.Position,
)
if err != nil {
return Stage{}, fmt.Errorf("creating stage: %w", err)
}
return out, nil
}
func (r *pgRepository) GetStage(ctx context.Context, id uuid.UUID) (Stage, error) {
var s Stage
err := r.db.QueryRow(ctx, `
SELECT id, program_id, name, position FROM program_stages WHERE id = $1
`, id).Scan(&s.ID, &s.ProgramID, &s.Name, &s.Position)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Stage{}, ErrStageNotFound
}
return Stage{}, fmt.Errorf("getting stage: %w", err)
}
return s, nil
}
func (r *pgRepository) UpdateStage(ctx context.Context, id uuid.UUID, updates map[string]any) (Stage, error) {
s, err := r.GetStage(ctx, id)
if err != nil {
return Stage{}, err
}
if v, ok := updates["name"]; ok {
if str, ok := v.(string); ok {
s.Name = str
}
}
if v, ok := updates["position"]; ok {
switch pos := v.(type) {
case int:
s.Position = pos
case float64:
s.Position = int(pos)
}
}
var out Stage
err = r.db.QueryRow(ctx, `
UPDATE program_stages SET name = $1, position = $2 WHERE id = $3
RETURNING id, program_id, name, position
`, s.Name, s.Position, id).Scan(&out.ID, &out.ProgramID, &out.Name, &out.Position)
if err != nil {
return Stage{}, fmt.Errorf("updating stage: %w", err)
}
return out, nil
}
func (r *pgRepository) DeleteStage(ctx context.Context, id uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM program_stages WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deleting stage: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrStageNotFound
}
return nil
}
func (r *pgRepository) ListStages(ctx context.Context, programID uuid.UUID) ([]Stage, error) {
rows, err := r.db.Query(ctx, `
SELECT id, program_id, name, position
FROM program_stages WHERE program_id = $1
ORDER BY position ASC
`, programID)
if err != nil {
return nil, fmt.Errorf("listing stages: %w", err)
}
defer rows.Close()
var stages []Stage
for rows.Next() {
var s Stage
if err := rows.Scan(&s.ID, &s.ProgramID, &s.Name, &s.Position); err != nil {
return nil, fmt.Errorf("scanning stage: %w", err)
}
stages = append(stages, s)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating stages: %w", err)
}
return stages, nil
}
func (r *pgRepository) GetAct(ctx context.Context, id uuid.UUID) (Act, error) {
var a Act
err := r.db.QueryRow(ctx, `
SELECT id, stage_id, group_id, title, description, starts_at, ends_at, position
FROM program_acts WHERE id = $1
`, id).Scan(&a.ID, &a.StageID, &a.GroupID, &a.Title, &a.Description, &a.StartsAt, &a.EndsAt, &a.Position)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Act{}, ErrActNotFound
}
return Act{}, fmt.Errorf("getting act: %w", err)
}
return a, nil
}
func (r *pgRepository) CreateAct(ctx context.Context, a Act) (Act, error) {
var out Act
err := r.db.QueryRow(ctx, `
INSERT INTO program_acts (id, stage_id, group_id, title, description, starts_at, ends_at, position)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, stage_id, group_id, title, description, starts_at, ends_at, position
`, a.ID, a.StageID, a.GroupID, a.Title, a.Description, a.StartsAt, a.EndsAt, a.Position).Scan(
&out.ID, &out.StageID, &out.GroupID, &out.Title, &out.Description,
&out.StartsAt, &out.EndsAt, &out.Position,
)
if err != nil {
return Act{}, fmt.Errorf("creating act: %w", err)
}
return out, nil
}
func (r *pgRepository) UpdateAct(ctx context.Context, id uuid.UUID, updates map[string]any) (Act, error) {
a, err := r.GetAct(ctx, id)
if err != nil {
return Act{}, err
}
if v, ok := updates["title"]; ok {
if str, ok := v.(string); ok {
a.Title = str
}
}
if v, ok := updates["description"]; ok {
if str, ok := v.(string); ok {
a.Description = str
}
}
if v, ok := updates["position"]; ok {
switch pos := v.(type) {
case int:
a.Position = pos
case float64:
a.Position = int(pos)
}
}
var out Act
err = r.db.QueryRow(ctx, `
UPDATE program_acts
SET title = $1, description = $2, position = $3
WHERE id = $4
RETURNING id, stage_id, group_id, title, description, starts_at, ends_at, position
`, a.Title, a.Description, a.Position, id).Scan(
&out.ID, &out.StageID, &out.GroupID, &out.Title, &out.Description,
&out.StartsAt, &out.EndsAt, &out.Position,
)
if err != nil {
return Act{}, fmt.Errorf("updating act: %w", err)
}
return out, nil
}
func (r *pgRepository) DeleteAct(ctx context.Context, id uuid.UUID) error {
tag, err := r.db.Exec(ctx, `DELETE FROM program_acts WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deleting act: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrActNotFound
}
return nil
}
func (r *pgRepository) ListActs(ctx context.Context, stageID uuid.UUID) ([]Act, error) {
rows, err := r.db.Query(ctx, `
SELECT id, stage_id, group_id, title, description, starts_at, ends_at, position
FROM program_acts WHERE stage_id = $1
ORDER BY position ASC
`, stageID)
if err != nil {
return nil, fmt.Errorf("listing acts: %w", err)
}
defer rows.Close()
var acts []Act
for rows.Next() {
var a Act
if err := rows.Scan(&a.ID, &a.StageID, &a.GroupID, &a.Title, &a.Description, &a.StartsAt, &a.EndsAt, &a.Position); err != nil {
return nil, fmt.Errorf("scanning act: %w", err)
}
acts = append(acts, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating acts: %w", err)
}
return acts, nil
}
+19
View File
@@ -0,0 +1,19 @@
package program
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
rg.GET("/series/:seriesId/program", h.GetProgram)
auth := rg.Group("", requireAuth)
{
auth.POST("/series/:seriesId/program", h.CreateProgram)
auth.PATCH("/programs/:programId", h.UpdateProgram)
auth.POST("/programs/:programId/stages", h.CreateStage)
auth.PATCH("/stages/:stageId", h.UpdateStage)
auth.DELETE("/stages/:stageId", h.DeleteStage)
auth.POST("/stages/:stageId/acts", h.CreateAct)
auth.PATCH("/acts/:actId", h.UpdateAct)
auth.DELETE("/acts/:actId", h.DeleteAct)
}
}
@@ -0,0 +1,79 @@
package program
import (
"context"
"errors"
"github.com/google/uuid"
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetByMarket(ctx context.Context, marketID uuid.UUID) (Program, error) {
return s.repo.GetByMarket(ctx, marketID)
}
func (s *Service) GetByID(ctx context.Context, id uuid.UUID) (Program, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) Create(ctx context.Context, p Program) (Program, error) {
_, err := s.repo.GetByMarket(ctx, p.MarketID)
if err == nil {
return Program{}, ErrProgramExists
}
if !errors.Is(err, ErrProgramNotFound) {
return Program{}, err
}
return s.repo.Create(ctx, p)
}
func (s *Service) UpdateProgram(ctx context.Context, id uuid.UUID, updates map[string]any) (Program, error) {
return s.repo.UpdateProgram(ctx, id, updates)
}
func (s *Service) CreateStage(ctx context.Context, stage Stage) (Stage, error) {
return s.repo.CreateStage(ctx, stage)
}
func (s *Service) UpdateStage(ctx context.Context, id uuid.UUID, updates map[string]any) (Stage, error) {
return s.repo.UpdateStage(ctx, id, updates)
}
func (s *Service) DeleteStage(ctx context.Context, id uuid.UUID) error {
return s.repo.DeleteStage(ctx, id)
}
func (s *Service) ListStages(ctx context.Context, programID uuid.UUID) ([]Stage, error) {
return s.repo.ListStages(ctx, programID)
}
func (s *Service) GetStage(ctx context.Context, id uuid.UUID) (Stage, error) {
return s.repo.GetStage(ctx, id)
}
func (s *Service) GetAct(ctx context.Context, id uuid.UUID) (Act, error) {
return s.repo.GetAct(ctx, id)
}
func (s *Service) CreateAct(ctx context.Context, act Act) (Act, error) {
return s.repo.CreateAct(ctx, act)
}
func (s *Service) UpdateAct(ctx context.Context, id uuid.UUID, updates map[string]any) (Act, error) {
return s.repo.UpdateAct(ctx, id, updates)
}
func (s *Service) DeleteAct(ctx context.Context, id uuid.UUID) error {
return s.repo.DeleteAct(ctx, id)
}
func (s *Service) ListActs(ctx context.Context, stageID uuid.UUID) ([]Act, error) {
return s.repo.ListActs(ctx, stageID)
}
@@ -0,0 +1,113 @@
package user
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
)
type AdminHandler struct {
svc *AdminService
}
func NewAdminHandler(svc *AdminService) *AdminHandler {
return &AdminHandler{svc: svc}
}
func (h *AdminHandler) ListPending(c *gin.Context) {
users, err := h.svc.ListPending(c.Request.Context())
if err != nil {
apiErr := apierror.Internal("failed to list pending users")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
items := make([]AdminUserData, len(users))
for i, u := range users {
items[i] = toAdminUserData(u)
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *AdminHandler) Approve(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
u, err := h.svc.Approve(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
apiErr := apierror.NotFound("user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to approve user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": toAdminUserData(u)})
}
func (h *AdminHandler) Reject(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
u, err := h.svc.Reject(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
apiErr := apierror.NotFound("user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to reject user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": toAdminUserData(u)})
}
func parseUserID(c *gin.Context) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_user_id", "invalid user id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return uuid.Nil, false
}
return id, true
}
// AdminUserData is the response shape for admin user endpoints.
type AdminUserData struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
ApprovedAt *string `json:"approved_at,omitempty"`
CreatedAt string `json:"created_at"`
}
func toAdminUserData(u User) AdminUserData {
d := AdminUserData{
ID: u.ID,
Email: u.Email,
DisplayName: u.DisplayName,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if u.ApprovedAt != nil {
s := u.ApprovedAt.Format("2006-01-02T15:04:05Z")
d.ApprovedAt = &s
}
return d
}
@@ -0,0 +1,12 @@
package user
import "github.com/gin-gonic/gin"
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAuth, requireAdmin gin.HandlerFunc) {
admin := rg.Group("/admin/users", requireAuth, requireAdmin)
{
admin.GET("/pending", h.ListPending)
admin.POST("/:id/approve", h.Approve)
admin.POST("/:id/reject", h.Reject)
}
}
@@ -0,0 +1,286 @@
package user_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/user"
"marktvogt.de/backend/internal/middleware"
)
func init() {
gin.SetMode(gin.TestMode)
}
// fakeUserRepo satisfies user.Repository in-memory.
type fakeUserRepo struct {
users map[uuid.UUID]user.User
}
func newFakeUserRepo(initial ...user.User) *fakeUserRepo {
r := &fakeUserRepo{users: make(map[uuid.UUID]user.User)}
for _, u := range initial {
r.users[u.ID] = u
}
return r
}
func (r *fakeUserRepo) Create(_ context.Context, email, hash, name, role, status string) (user.User, error) {
if status == "" {
status = user.StatusActive
}
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, Status: status}
r.users[u.ID] = u
return u, nil
}
func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name, _ string, verified bool) (user.User, error) {
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, EmailVerified: verified, Status: user.StatusActive}
r.users[u.ID] = u
return u, nil
}
func (r *fakeUserRepo) GetByID(_ context.Context, id uuid.UUID) (user.User, error) {
u, ok := r.users[id]
if !ok {
return user.User{}, user.ErrUserNotFound
}
return u, nil
}
func (r *fakeUserRepo) GetByEmail(_ context.Context, email string) (user.User, error) {
for _, u := range r.users {
if u.Email == email {
return u, nil
}
}
return user.User{}, user.ErrUserNotFound
}
func (r *fakeUserRepo) Update(_ context.Context, id uuid.UUID, _ map[string]any) (user.User, error) {
u, ok := r.users[id]
if !ok {
return user.User{}, user.ErrUserNotFound
}
return u, nil
}
func (r *fakeUserRepo) SoftDelete(_ context.Context, _ uuid.UUID) error { return nil }
func (r *fakeUserRepo) Restore(_ context.Context, _ uuid.UUID) error { return nil }
func (r *fakeUserRepo) GetDeletedByID(_ context.Context, id uuid.UUID) (user.User, error) {
return user.User{}, user.ErrUserNotFound
}
func (r *fakeUserRepo) ListByStatus(_ context.Context, status string) ([]user.User, error) {
var out []user.User
for _, u := range r.users {
if u.Status == status {
out = append(out, u)
}
}
return out, nil
}
func (r *fakeUserRepo) SetStatus(_ context.Context, id uuid.UUID, status string, approvedAt *time.Time) (user.User, error) {
u, ok := r.users[id]
if !ok {
return user.User{}, user.ErrUserNotFound
}
u.Status = status
u.ApprovedAt = approvedAt
r.users[id] = u
return u, nil
}
func (r *fakeUserRepo) GetDashboardStats(_ context.Context, _ uuid.UUID) (user.DashboardStats, error) {
return user.DashboardStats{}, nil
}
// fakeSessionRevoker satisfies user.SessionRevoker in-memory.
type fakeSessionRevoker struct {
revoked []uuid.UUID
}
func (r *fakeSessionRevoker) DeleteUserSessions(_ context.Context, userID uuid.UUID) error {
r.revoked = append(r.revoked, userID)
return nil
}
// adminRouter builds a gin.Engine wired with the admin user routes.
// The roleMiddleware parameter injects user_id and user_role into the context,
// mimicking what RequireAuth does with real tokens.
func adminRouter(repo user.Repository, roleMiddleware gin.HandlerFunc) *gin.Engine {
svc := user.NewAdminService(repo, &fakeSessionRevoker{})
h := user.NewAdminHandler(svc)
router := gin.New()
v1 := router.Group("/api/v1")
requireAdmin := middleware.RequireRole(user.RoleAdmin)
user.RegisterAdminRoutes(v1, h, roleMiddleware, requireAdmin)
return router
}
// stubAuth returns a middleware that sets gin context values to simulate an authenticated user.
func stubAuth(userRole string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user_id", uuid.New())
c.Set("user_role", userRole)
c.Next()
}
}
// noAuth returns a middleware that aborts with 401, matching RequireAuth with no valid token.
func noAuth() gin.HandlerFunc {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusUnauthorized)
}
}
// PoC: admin user endpoints must reject unauthenticated requests (401).
func TestAdminUserEndpoints_Unauthenticated_Returns401(t *testing.T) {
t.Parallel()
repo := newFakeUserRepo()
router := adminRouter(repo, noAuth())
endpoints := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/admin/users/pending"},
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/approve"},
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/reject"},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: admin user endpoints must reject non-admin authenticated users (403).
func TestAdminUserEndpoints_NonAdmin_Returns403(t *testing.T) {
t.Parallel()
repo := newFakeUserRepo()
router := adminRouter(repo, stubAuth(user.RoleUser))
endpoints := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/admin/users/pending"},
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/approve"},
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/reject"},
}
for _, ep := range endpoints {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(ep.method, ep.path, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
}
})
}
}
// PoC: admin can list pending users and approve/reject them.
func TestAdminUserEndpoints_Admin_Succeeds(t *testing.T) {
t.Parallel()
pendingID := uuid.New()
pending := user.User{
ID: pendingID,
Email: "pending@example.com",
DisplayName: "Pending User",
Role: user.RoleVeranstalter,
Status: user.StatusPending,
CreatedAt: time.Now(),
}
repo := newFakeUserRepo(pending)
revoker := &fakeSessionRevoker{}
svc := user.NewAdminService(repo, revoker)
h := user.NewAdminHandler(svc)
router := gin.New()
v1 := router.Group("/api/v1")
requireAdmin := middleware.RequireRole(user.RoleAdmin)
user.RegisterAdminRoutes(v1, h, stubAuth(user.RoleAdmin), requireAdmin)
t.Run("list pending returns pending user", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/pending", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "pending@example.com") {
t.Errorf("response missing pending user email: %s", w.Body.String())
}
})
t.Run("approve changes status to active and revokes sessions", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+pendingID.String()+"/approve", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"status":"active"`) {
t.Errorf("response missing active status: %s", w.Body.String())
}
if len(revoker.revoked) == 0 {
t.Error("expected sessions to be revoked on approval")
}
})
}
// PoC: reject changes status to suspended and revokes sessions.
func TestAdminRejectUser_RevokesSessionsAndSuspends(t *testing.T) {
t.Parallel()
targetID := uuid.New()
target := user.User{
ID: targetID,
Email: "target@example.com",
DisplayName: "Target User",
Role: user.RoleVeranstalter,
Status: user.StatusPending,
CreatedAt: time.Now(),
}
repo := newFakeUserRepo(target)
revoker := &fakeSessionRevoker{}
svc := user.NewAdminService(repo, revoker)
h := user.NewAdminHandler(svc)
router := gin.New()
v1 := router.Group("/api/v1")
requireAdmin := middleware.RequireRole(user.RoleAdmin)
user.RegisterAdminRoutes(v1, h, stubAuth(user.RoleAdmin), requireAdmin)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+targetID.String()+"/reject", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"status":"suspended"`) {
t.Errorf("response missing suspended status: %s", w.Body.String())
}
if len(revoker.revoked) == 0 {
t.Error("expected sessions to be revoked on rejection")
}
}
@@ -0,0 +1,51 @@
package user
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
)
// SessionRevoker is the subset of auth.Repository needed by AdminService.
// Defined here to avoid an import cycle between the user and auth packages.
type SessionRevoker interface {
DeleteUserSessions(ctx context.Context, userID uuid.UUID) error
}
type AdminService struct {
repo Repository
sessions SessionRevoker
}
func NewAdminService(repo Repository, sessions SessionRevoker) *AdminService {
return &AdminService{repo: repo, sessions: sessions}
}
func (s *AdminService) ListPending(ctx context.Context) ([]User, error) {
return s.repo.ListByStatus(ctx, StatusPending)
}
func (s *AdminService) Approve(ctx context.Context, id uuid.UUID) (User, error) {
if err := s.sessions.DeleteUserSessions(ctx, id); err != nil {
return User{}, fmt.Errorf("revoking sessions before approval: %w", err)
}
now := time.Now()
u, err := s.repo.SetStatus(ctx, id, StatusActive, &now)
if err != nil {
return User{}, fmt.Errorf("approving user: %w", err)
}
return u, nil
}
func (s *AdminService) Reject(ctx context.Context, id uuid.UUID) (User, error) {
if err := s.sessions.DeleteUserSessions(ctx, id); err != nil {
return User{}, fmt.Errorf("revoking sessions before rejection: %w", err)
}
u, err := s.repo.SetStatus(ctx, id, StatusSuspended, nil)
if err != nil {
return User{}, fmt.Errorf("rejecting user: %w", err)
}
return u, nil
}
+12
View File
@@ -13,10 +13,21 @@ type ProfileData struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Role string `json:"role"`
Status string `json:"status"`
HasPassword bool `json:"has_password"`
CreatedAt string `json:"created_at"`
}
type DashboardStats struct {
OwnedMarkets int `json:"owned_markets"`
PendingApplications int `json:"pending_applications"`
UnreadThreads int `json:"unread_threads"`
}
type DashboardStatsResponse struct {
Data DashboardStats `json:"data"`
}
type UpdateProfileRequest struct {
DisplayName *string `json:"display_name" validate:"omitempty,min=1,max=100"`
AvatarURL *string `json:"avatar_url" validate:"omitempty,url"`
@@ -30,6 +41,7 @@ func ToProfileData(u User) ProfileData {
DisplayName: u.DisplayName,
AvatarURL: u.AvatarURL,
Role: u.Role,
Status: u.Status,
HasPassword: u.PasswordHash != nil,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
+13
View File
@@ -83,6 +83,19 @@ func (h *Handler) RestoreProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "account restored"}})
}
func (h *Handler) GetDashboardStats(c *gin.Context) {
userID := getUserID(c)
stats, err := h.service.GetDashboardStats(c.Request.Context(), userID)
if err != nil {
apiErr := apierror.Internal("failed to get dashboard stats")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, DashboardStatsResponse{Data: stats})
}
func getUserID(c *gin.Context) uuid.UUID {
v, exists := c.Get("user_id")
if !exists {
+2
View File
@@ -14,6 +14,8 @@ type User struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Role string `json:"role"`
Status string `json:"status"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+94 -39
View File
@@ -9,6 +9,8 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"marktvogt.de/backend/internal/pkg/pgerr"
)
var (
@@ -17,14 +19,17 @@ var (
)
type Repository interface {
Create(ctx context.Context, email, passwordHash, displayName string) (User, error)
CreateOAuthUser(ctx context.Context, email, displayName string, emailVerified bool) (User, error)
Create(ctx context.Context, email, passwordHash, displayName, role, status string) (User, error)
CreateOAuthUser(ctx context.Context, email, displayName, avatarURL string, emailVerified bool) (User, error)
GetByID(ctx context.Context, id uuid.UUID) (User, error)
GetByEmail(ctx context.Context, email string) (User, error)
Update(ctx context.Context, id uuid.UUID, fields map[string]any) (User, error)
SoftDelete(ctx context.Context, id uuid.UUID) error
Restore(ctx context.Context, id uuid.UUID) error
GetDeletedByID(ctx context.Context, id uuid.UUID) (User, error)
ListByStatus(ctx context.Context, status string) ([]User, error)
SetStatus(ctx context.Context, id uuid.UUID, status string, approvedAt *time.Time) (User, error)
GetDashboardStats(ctx context.Context, userID uuid.UUID) (DashboardStats, error)
}
type pgRepository struct {
@@ -35,18 +40,18 @@ func NewRepository(db *pgxpool.Pool) Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayName string) (User, error) {
func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayName, role, status string) (User, error) {
var u User
err := r.db.QueryRow(ctx, `
INSERT INTO users (email, password_hash, display_name)
VALUES ($1, $2, $3)
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
`, email, passwordHash, displayName).Scan(
INSERT INTO users (email, password_hash, display_name, role, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, email, passwordHash, displayName, role, status).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if isDuplicateKeyError(err) {
if pgerr.IsDuplicateKey(err) {
return User{}, ErrEmailAlreadyTaken
}
return User{}, fmt.Errorf("creating user: %w", err)
@@ -54,18 +59,18 @@ func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayN
return u, nil
}
func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName string, emailVerified bool) (User, error) {
func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName, avatarURL string, emailVerified bool) (User, error) {
var u User
err := r.db.QueryRow(ctx, `
INSERT INTO users (email, email_verified, display_name)
VALUES ($1, $2, $3)
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
`, email, emailVerified, displayName).Scan(
INSERT INTO users (email, email_verified, display_name, avatar_url)
VALUES ($1, $2, $3, NULLIF($4, ''))
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, email, emailVerified, displayName, avatarURL).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if isDuplicateKeyError(err) {
if pgerr.IsDuplicateKey(err) {
return User{}, ErrEmailAlreadyTaken
}
return User{}, fmt.Errorf("creating oauth user: %w", err)
@@ -88,12 +93,12 @@ func (r *pgRepository) GetDeletedByID(ctx context.Context, id uuid.UUID) (User,
func (r *pgRepository) getUser(ctx context.Context, where string, arg any) (User, error) {
var u User
err := r.db.QueryRow(ctx, fmt.Sprintf(`
SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
FROM users
WHERE %s
`, where), arg).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -125,10 +130,10 @@ func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[stri
err := r.db.QueryRow(ctx, fmt.Sprintf(`
UPDATE users SET %s
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, setClauses), args...).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -153,6 +158,75 @@ func (r *pgRepository) SoftDelete(ctx context.Context, id uuid.UUID) error {
return nil
}
func (r *pgRepository) ListByStatus(ctx context.Context, status string) ([]User, error) {
rows, err := r.db.Query(ctx, `
SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
FROM users
WHERE status = $1 AND deleted_at IS NULL
ORDER BY created_at ASC
`, status)
if err != nil {
return nil, fmt.Errorf("listing users by status: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scanning user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
func (r *pgRepository) SetStatus(ctx context.Context, id uuid.UUID, status string, approvedAt *time.Time) (User, error) {
var u User
err := r.db.QueryRow(ctx, `
UPDATE users SET status = $2, approved_at = $3
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
`, id, status, approvedAt).Scan(
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
return User{}, fmt.Errorf("setting user status: %w", err)
}
return u, nil
}
func (r *pgRepository) GetDashboardStats(ctx context.Context, userID uuid.UUID) (DashboardStats, error) {
var s DashboardStats
err := r.db.QueryRow(ctx, `
SELECT
(SELECT COUNT(*)::int
FROM market_series
WHERE created_by = $1) AS owned_markets,
(SELECT COUNT(*)::int
FROM applications a
JOIN market_editions e ON a.market_edition_id = e.id
JOIN market_series s ON e.series_id = s.id
WHERE s.created_by = $1 AND a.status = 'submitted') AS pending_applications,
(SELECT COUNT(*)::int
FROM message_threads t
JOIN message_thread_participants p ON p.thread_id = t.id
WHERE p.user_id = $1
AND (p.last_read_at IS NULL OR p.last_read_at < t.last_message_at)) AS unread_threads
`, userID).Scan(&s.OwnedMarkets, &s.PendingApplications, &s.UnreadThreads)
if err != nil {
return DashboardStats{}, fmt.Errorf("getting dashboard stats: %w", err)
}
return s, nil
}
func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error {
tag, err := r.db.Exec(ctx, `
UPDATE users SET deleted_at = NULL
@@ -166,22 +240,3 @@ func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error {
}
return nil
}
func isDuplicateKeyError(err error) bool {
return err != nil && (fmt.Sprintf("%v", err) == "ERROR: duplicate key value violates unique constraint" ||
contains(err.Error(), "duplicate key") ||
contains(err.Error(), "23505"))
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+16
View File
@@ -0,0 +1,16 @@
package user
const (
RoleGast = "gast"
RoleUser = "user"
RoleVeranstalter = "veranstalter"
RoleHaendler = "haendler"
RoleLager = "lager"
RoleAdmin = "admin"
)
const (
StatusPending = "pending"
StatusActive = "active"
StatusSuspended = "suspended"
)
+1
View File
@@ -9,5 +9,6 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc
users.PATCH("/me", requireAuth, h.UpdateProfile)
users.DELETE("/me", requireAuth, h.DeleteProfile)
users.POST("/me/restore", requireAuth, h.RestoreProfile)
users.GET("/me/stats", requireAuth, h.GetDashboardStats)
}
}
+4
View File
@@ -58,3 +58,7 @@ func (s *Service) Restore(ctx context.Context, userID uuid.UUID) error {
return s.repo.Restore(ctx, userID)
}
func (s *Service) GetDashboardStats(ctx context.Context, userID uuid.UUID) (DashboardStats, error) {
return s.repo.GetDashboardStats(ctx, userID)
}
+2
View File
@@ -34,6 +34,7 @@ func RequireAuth(repo SessionLookup, accessTTL time.Duration) gin.HandlerFunc {
c.Set("user_id", session.UserID)
c.Set("user_email", session.UserEmail)
c.Set("user_role", session.UserRole)
c.Set("email_verified", session.EmailVerified)
c.Set("session_id", session.ID)
c.Next()
}
@@ -51,6 +52,7 @@ func OptionalAuth(repo SessionLookup, accessTTL time.Duration) gin.HandlerFunc {
c.Set("user_id", session.UserID)
c.Set("user_email", session.UserEmail)
c.Set("user_role", session.UserRole)
c.Set("email_verified", session.EmailVerified)
c.Set("session_id", session.ID)
}
c.Next()
@@ -0,0 +1,21 @@
package middleware
import (
"github.com/gin-gonic/gin"
"marktvogt.de/backend/internal/pkg/apierror"
)
// RequireEmailVerified rejects requests from authenticated users whose email
// address has not yet been confirmed. Must be applied after RequireAuth.
func RequireEmailVerified() gin.HandlerFunc {
return func(c *gin.Context) {
verified, _ := c.Get("email_verified")
if v, ok := verified.(bool); !ok || !v {
apiErr := apierror.Forbidden("email address not verified")
c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.Next()
}
}
@@ -0,0 +1,68 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/auth"
"marktvogt.de/backend/internal/middleware"
)
func TestRequireEmailVerified_UnverifiedUser_Returns403(t *testing.T) {
stub := &stubSessionRepo{
session: auth.Session{
ID: uuid.New(),
UserID: uuid.New(),
UserEmail: "a@b.c",
UserRole: "user",
EmailVerified: false,
LastUsedAt: time.Now().Add(-2 * time.Minute),
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
noop := func(c *gin.Context) {}
r := newRouter(noop, middleware.RequireAuth(stub, 30*time.Minute), middleware.RequireEmailVerified())
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("any-token"))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden, got %d", w.Code)
}
}
func TestRequireEmailVerified_VerifiedUser_Passes(t *testing.T) {
stub := &stubSessionRepo{
session: auth.Session{
ID: uuid.New(),
UserID: uuid.New(),
UserEmail: "a@b.c",
UserRole: "user",
EmailVerified: true,
LastUsedAt: time.Now().Add(-2 * time.Minute),
AccessExpiresAt: time.Now().Add(28 * time.Minute),
},
}
reached := false
handler := func(c *gin.Context) { reached = true }
r := newRouter(handler, middleware.RequireAuth(stub, 30*time.Minute), middleware.RequireEmailVerified())
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("any-token"))
if !reached {
t.Fatal("handler not reached for verified user")
}
}
func TestRequireEmailVerified_Unauthenticated_Returns401(t *testing.T) {
stub := &stubSessionRepo{err: auth.ErrSessionNotFound}
noop := func(c *gin.Context) {}
r := newRouter(noop, middleware.RequireAuth(stub, 30*time.Minute), middleware.RequireEmailVerified())
w := httptest.NewRecorder()
r.ServeHTTP(w, bearerReq("bad-token"))
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized, got %d", w.Code)
}
}
+10
View File
@@ -21,6 +21,8 @@ func init() {
contentFiles := []string{
"market_submission",
"magic_link",
"password_reset",
"email_verify",
}
templates = make(map[string]*template.Template, len(contentFiles))
@@ -67,6 +69,14 @@ type MagicLinkData struct {
ExpiresMin int
}
type PasswordResetData struct {
ResetURL string
}
type EmailVerifyData struct {
VerifyURL string
}
func Render(name string, data TemplateData) (string, error) {
tmpl, ok := templates[name]
if !ok {
+9 -20
View File
@@ -6,32 +6,21 @@
<title>Marktvogt</title>
<!--[if mso]><style>table,td{font-family:Georgia,'Times New Roman',serif!important}</style><![endif]-->
</head>
<body style="margin:0;padding:0;background-color:#f5f0e8;font-family:Georgia,'Times New Roman',serif;">
<body style="margin:0;padding:0;background-color:#f5efe4;font-family:Georgia,'Times New Roman',serif;">
{{if .PreheaderText}}<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">{{.PreheaderText}}</div>{{end}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f5f0e8;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f5efe4;">
<tr>
<td align="center" style="padding:24px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
<!-- Header -->
<tr>
<td align="center" style="background-color:#14472a;padding:28px 24px;border-radius:8px 8px 0 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="padding-bottom:12px;">
<div style="width:48px;height:48px;border-radius:50%;background-color:#d4a63a;text-align:center;line-height:48px;font-size:22px;color:#14472a;">&#9876;</div>
</td>
</tr>
<tr>
<td align="center">
<span style="font-size:26px;font-weight:bold;color:#d4a63a;letter-spacing:1px;">Marktvogt</span>
</td>
</tr>
</table>
<td align="center" style="background-color:#f5efe4;padding:28px 24px 20px;">
<span style="font-size:13px;font-weight:700;color:#181410;letter-spacing:0.18em;font-family:Georgia,'Times New Roman',serif;text-transform:uppercase;">Marktvogt</span>
</td>
</tr>
<!-- Gold accent line -->
<!-- Rule -->
<tr>
<td style="background-color:#d4a63a;height:3px;font-size:1px;line-height:1px;">&nbsp;</td>
<td style="background-color:#c9b58c;height:1px;font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Content -->
<tr>
@@ -41,12 +30,12 @@
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#f5f3f0;padding:20px 28px;border-radius:0 0 8px 8px;border-top:1px solid #e8e5e0;">
<td style="background-color:#f5efe4;padding:20px 28px;border-top:1px solid #c9b58c;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="font-size:13px;color:#8a8580;line-height:1.5;">
<td align="center" style="font-size:12px;color:#6e6253;letter-spacing:0.1em;text-transform:uppercase;font-family:Georgia,'Times New Roman',serif;">
&copy; {{.Year}} Marktvogt &middot;
<a href="{{.BaseURL}}" style="color:#1a6b3a;text-decoration:none;">marktvogt.de</a>
<a href="{{.BaseURL}}" style="color:#9a1e2c;text-decoration:none;">marktvogt.de</a>
</td>
</tr>
</table>
@@ -0,0 +1,36 @@
{{define "content"}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
E-Mail-Adresse best&auml;tigen
</td>
</tr>
<tr>
<td style="font-size:15px;color:#3a322a;padding-bottom:24px;line-height:1.6;">
Klicke auf den Button, um deine E-Mail-Adresse zu best&auml;tigen. Der Link ist 48 Stunden g&uuml;ltig.
</td>
</tr>
<tr>
<td align="center" style="padding-bottom:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="background-color:#9a1e2c;">
<a href="{{.Content.VerifyURL}}" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">E-Mail best&auml;tigen</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#6e6253;line-height:1.5;border-top:1px solid #c9b58c;padding-top:16px;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
<a href="{{.Content.VerifyURL}}" style="color:#9a1e2c;word-break:break-all;text-decoration:none;">{{.Content.VerifyURL}}</a>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#6e6253;padding-top:16px;line-height:1.5;">
Du hast kein Konto bei Marktvogt angelegt? Dann kannst du diese E-Mail ignorieren.
</td>
</tr>
</table>
{{end}}
@@ -1,12 +1,12 @@
{{define "content"}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:20px;font-weight:bold;color:#14472a;padding-bottom:8px;">
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
Dein Anmeldelink
</td>
</tr>
<tr>
<td style="font-size:15px;color:#4a4745;padding-bottom:24px;line-height:1.5;">
<td style="font-size:15px;color:#3a322a;padding-bottom:24px;line-height:1.6;">
Klicke auf den Button, um dich bei Marktvogt anzumelden. Der Link ist {{.Content.ExpiresMin}} Minuten g&uuml;ltig.
</td>
</tr>
@@ -14,21 +14,21 @@
<td align="center" style="padding-bottom:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="background-color:#1a6b3a;border-radius:6px;">
<a href="{{.Content.MagicURL}}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Jetzt anmelden</a>
<td align="center" style="background-color:#9a1e2c;">
<a href="{{.Content.MagicURL}}" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Jetzt anmelden</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#8a8580;line-height:1.5;border-top:1px solid #e8e5e0;padding-top:16px;">
<td style="font-size:13px;color:#6e6253;line-height:1.5;border-top:1px solid #c9b58c;padding-top:16px;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
<a href="{{.Content.MagicURL}}" style="color:#1a6b3a;word-break:break-all;text-decoration:none;">{{.Content.MagicURL}}</a>
<a href="{{.Content.MagicURL}}" style="color:#9a1e2c;word-break:break-all;text-decoration:none;">{{.Content.MagicURL}}</a>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#8a8580;padding-top:16px;line-height:1.5;">
<td style="font-size:13px;color:#6e6253;padding-top:16px;line-height:1.5;">
Du hast diese E-Mail nicht angefordert? Dann kannst du sie ignorieren.
</td>
</tr>
@@ -1,38 +1,38 @@
{{define "content"}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:20px;font-weight:bold;color:#14472a;padding-bottom:8px;">
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
Neuer Markt eingereicht
</td>
</tr>
<tr>
<td style="font-size:15px;color:#4a4745;padding-bottom:20px;line-height:1.5;">
<td style="font-size:15px;color:#3a322a;padding-bottom:20px;line-height:1.6;">
Ein neuer Markt wurde zur Pr&uuml;fung eingereicht.
</td>
</tr>
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #e8e5e0;border-radius:6px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #c9b58c;">
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;width:140px;">Marktname</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;font-weight:bold;">{{.Content.MarketName}}</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;width:140px;">Marktname</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#181410;font-weight:bold;">{{.Content.MarketName}}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Stadt</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.City}}</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">Stadt</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#3a322a;">{{.Content.City}}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Zeitraum</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.StartDate}} &ndash; {{.Content.EndDate}}</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">Zeitraum</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#3a322a;">{{.Content.StartDate}} &ndash; {{.Content.EndDate}}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Eingereicht von</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.SubmitterName}}</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">Eingereicht von</td>
<td style="padding:12px 16px;border-bottom:1px solid #c9b58c;font-size:15px;color:#3a322a;">{{.Content.SubmitterName}}</td>
</tr>
<tr>
<td style="padding:12px 16px;font-size:13px;color:#8a8580;">E-Mail</td>
<td style="padding:12px 16px;font-size:15px;color:#3a3836;">
<a href="mailto:{{.Content.SubmitterEmail}}" style="color:#1a6b3a;text-decoration:none;">{{.Content.SubmitterEmail}}</a>
<td style="padding:12px 16px;font-size:12px;color:#6e6253;letter-spacing:0.08em;text-transform:uppercase;">E-Mail</td>
<td style="padding:12px 16px;font-size:15px;color:#3a322a;">
<a href="mailto:{{.Content.SubmitterEmail}}" style="color:#9a1e2c;text-decoration:none;">{{.Content.SubmitterEmail}}</a>
</td>
</tr>
</table>
@@ -42,8 +42,8 @@
<td align="center" style="padding-top:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="background-color:#1a6b3a;border-radius:6px;">
<a href="{{.Content.AdminURL}}" style="display:inline-block;padding:12px 28px;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Im Admin-Panel pr&uuml;fen</a>
<td align="center" style="background-color:#9a1e2c;">
<a href="{{.Content.AdminURL}}" style="display:inline-block;padding:12px 28px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Im Admin-Panel pr&uuml;fen</a>
</td>
</tr>
</table>
@@ -0,0 +1,36 @@
{{define "content"}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:20px;font-weight:bold;color:#181410;padding-bottom:8px;">
Passwort zur&uuml;cksetzen
</td>
</tr>
<tr>
<td style="font-size:15px;color:#3a322a;padding-bottom:24px;line-height:1.6;">
Du hast eine Anfrage zum Zur&uuml;cksetzen deines Passworts gestellt. Klicke auf den Button, um ein neues Passwort zu w&auml;hlen. Der Link ist 1 Stunde g&uuml;ltig.
</td>
</tr>
<tr>
<td align="center" style="padding-bottom:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="background-color:#9a1e2c;">
<a href="{{.Content.ResetURL}}" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:bold;color:#f5efe4;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Passwort zur&uuml;cksetzen</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#6e6253;line-height:1.5;border-top:1px solid #c9b58c;padding-top:16px;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
<a href="{{.Content.ResetURL}}" style="color:#9a1e2c;word-break:break-all;text-decoration:none;">{{.Content.ResetURL}}</a>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#6e6253;padding-top:16px;line-height:1.5;">
Du hast keine Passwortzur&uuml;cksetzung angefordert? Dann kannst du diese E-Mail ignorieren. Dein Passwort bleibt unver&auml;ndert.
</td>
</tr>
</table>
{{end}}
+13
View File
@@ -0,0 +1,13 @@
package pgerr
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
// IsDuplicateKey reports whether err is a PostgreSQL unique constraint violation (SQLSTATE 23505).
func IsDuplicateKey(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+107 -8
View File
@@ -2,16 +2,24 @@ package server
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/application"
"marktvogt.de/backend/internal/domain/auth"
"marktvogt.de/backend/internal/domain/discovery"
"marktvogt.de/backend/internal/domain/discovery/crawler"
"marktvogt.de/backend/internal/domain/discovery/enrich"
"marktvogt.de/backend/internal/domain/favorite"
"marktvogt.de/backend/internal/domain/group"
"marktvogt.de/backend/internal/domain/lagerleben"
"marktvogt.de/backend/internal/domain/market"
"marktvogt.de/backend/internal/domain/message"
"marktvogt.de/backend/internal/domain/program"
"marktvogt.de/backend/internal/domain/settings"
"marktvogt.de/backend/internal/domain/user"
"marktvogt.de/backend/internal/middleware"
@@ -50,6 +58,7 @@ func (s *Server) registerRoutes() {
})
authHandler := auth.NewHandler(authSvc, userRepo)
requireAuth := middleware.RequireAuth(authRepo, s.cfg.Auth.AccessTTL)
requireEmailVerified := middleware.RequireEmailVerified()
// Per-route auth rate limiters (keyed by IP; user_id unavailable before auth completes)
userIDKey := func(c *gin.Context) string {
@@ -58,17 +67,17 @@ func (s *Server) registerRoutes() {
}
return c.ClientIP()
}
loginLimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
refreshLimit := middleware.RateLimitByKey(1, 10, middleware.IPKey) // 1/s, burst 10
twoFALimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
passwordLimit := middleware.RateLimitByKey(0.1, 3, userIDKey) // 1 per 10s, burst 3
magicLinkLimit := middleware.RateLimitByKey(0.1, 3, middleware.IPKey) // 1 per 10s, burst 3
loginLimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
refreshLimit := middleware.RateLimitByKey(1, 10, middleware.IPKey) // 1/s, burst 10
twoFALimit := middleware.RateLimitByKey(0.2, 5, middleware.IPKey) // 1 per 5s, burst 5
passwordLimit := middleware.RateLimitByKey(0.1, 3, userIDKey) // 1 per 10s, burst 3
magicLinkLimit := middleware.RateLimitByKey(0.1, 3, middleware.IPKey) // 1 per 10s, burst 3
emailVerifyLimit := middleware.RateLimitByKey(1.0/20.0/60.0, 3, userIDKey) // 1 per 20min, burst 3
auth.RegisterRoutes(v1, authHandler, requireAuth, loginLimit, refreshLimit, twoFALimit, passwordLimit)
// OAuth routes — disabled until provider apps are configured
// oauthHandler := auth.NewOAuthHandler(s.cfg.OAuth, authSvc, userRepo, authRepo)
// auth.RegisterOAuthRoutes(v1, oauthHandler)
oauthHandler := auth.NewOAuthHandler(s.cfg.OAuth, s.cfg.Notification.FrontendURL, authSvc, userRepo, authRepo)
auth.RegisterOAuthRoutes(v1, oauthHandler)
// Shared email sender
emailSender := email.New(
@@ -80,11 +89,46 @@ func (s *Server) registerRoutes() {
magicLinkHandler := auth.NewMagicLinkHandler(authRepo, userRepo, authSvc, s.cfg.Magic, emailSender, s.cfg.Notification.FrontendURL)
auth.RegisterMagicLinkRoutes(v1, magicLinkHandler, magicLinkLimit)
// Password reset routes
pwResetHandler := auth.NewPasswordResetHandler(authRepo, userRepo, emailSender, s.cfg.Notification.FrontendURL)
auth.RegisterPasswordResetRoutes(v1, pwResetHandler, magicLinkLimit)
// Email verification routes
emailVerifyHandler := auth.NewEmailVerifyHandler(authRepo, userRepo, emailSender, s.cfg.Notification.FrontendURL)
auth.RegisterEmailVerifyRoutes(v1, emailVerifyHandler, requireAuth, emailVerifyLimit)
// Wire the verify-send function into the auth service so Register() dispatches it.
authSvc.SetEmailVerifySender(emailVerifyHandler.SendVerifyEmail)
// User profile routes
userSvc := user.NewService(userRepo)
userHandler := user.NewHandler(userSvc)
user.RegisterRoutes(v1, userHandler, requireAuth)
// Admin user management routes
adminUserSvc := user.NewAdminService(userRepo, authRepo)
adminUserHandler := user.NewAdminHandler(adminUserSvc)
user.RegisterAdminRoutes(v1, adminUserHandler, requireAuth, middleware.RequireRole(user.RoleAdmin))
// Group routes
groupRepo := group.NewRepository(s.db)
groupSvc := group.NewService(groupRepo)
groupHandler := group.NewHandler(groupSvc)
group.RegisterRoutes(v1, groupHandler, requireAuth)
// Application routes — GroupMembershipChecker is adapted from groupRepo
// to avoid a direct import of the group package from the application package.
groupChecker := &groupCheckerAdapter{repo: groupRepo}
appRepo := application.NewRepository(s.db)
appSvc := application.NewService(appRepo, groupChecker)
appHandler := application.NewHandler(appSvc)
application.RegisterRoutes(v1, appHandler, requireAuth, requireEmailVerified)
// Lagerleben routes (public read-only)
lagerlebenRepo := lagerleben.NewRepository(s.db)
lagerlebenSvc := lagerleben.NewService(lagerlebenRepo)
lagerlebenHandler := lagerleben.NewHandler(lagerlebenSvc)
lagerleben.RegisterRoutes(v1, lagerlebenHandler)
// Market routes (public + submission + admin)
tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey)
@@ -144,6 +188,61 @@ func (s *Server) registerRoutes() {
// AI settings routes
settingsHandler := settings.NewHandler(aiProvider, settingsStore, usageRepo)
settings.RegisterRoutes(v1, settingsHandler, requireAuth, requireAdmin)
// Favorite routes
favoriteRepo := favorite.NewRepository(s.db)
favoriteSvc := favorite.NewService(favoriteRepo)
favoriteHandler := favorite.NewHandler(favoriteSvc)
favorite.RegisterRoutes(v1, favoriteHandler, requireAuth)
// Message routes
messageRepo := message.NewRepository(s.db)
messageSvc := message.NewService(messageRepo)
messageHandler := message.NewHandler(messageSvc)
message.RegisterRoutes(v1, messageHandler, requireAuth)
// Program routes — MarketOwnerChecker is adapted from marketRepo
// to avoid a direct import of the market package from the program package.
marketOwnerChecker := &marketOwnerAdapter{repo: marketRepo}
programRepo := program.NewRepository(s.db)
programSvc := program.NewService(programRepo)
programHandler := program.NewHandler(programSvc, marketOwnerChecker)
program.RegisterRoutes(v1, programHandler, requireAuth)
}
// groupCheckerAdapter adapts group.Repository to application.GroupMembershipChecker
// so the application package does not import the group package directly.
type groupCheckerAdapter struct {
repo group.Repository
}
func (a *groupCheckerAdapter) IsGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
m, err := a.repo.GetMember(ctx, groupID, userID)
if errors.Is(err, group.ErrMemberNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return m.Role == group.MemberRoleAdmin, nil
}
func (a *groupCheckerAdapter) IsGroupMember(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
_, err := a.repo.GetMember(ctx, groupID, userID)
if errors.Is(err, group.ErrMemberNotFound) {
return false, nil
}
return err == nil, err
}
// marketOwnerAdapter adapts market.Repository to program.MarketOwnerChecker
// so the program package does not import the market package directly.
type marketOwnerAdapter struct {
repo market.Repository
}
func (a *marketOwnerAdapter) IsOwner(ctx context.Context, marketID, userID uuid.UUID) (bool, error) {
return a.repo.IsOwner(ctx, marketID, userID)
}
func (s *Server) healthz(c *gin.Context) {
@@ -0,0 +1,4 @@
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_role_check,
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS status;
@@ -0,0 +1,17 @@
-- Extend users table with approval workflow columns.
-- status tracks the lifecycle of elevated-role accounts (pending → active | suspended).
-- All existing users default to 'active' to preserve current behaviour.
-- The role CHECK constraint formalises the allowed role values without an ENUM
-- so future roles can be added with a simple ALTER TABLE + constraint update.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('pending', 'active', 'suspended')),
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_role_check;
ALTER TABLE users
ADD CONSTRAINT users_role_check
CHECK (role IN ('gast', 'user', 'veranstalter', 'haendler', 'lager', 'admin'));
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS group_members_group_id_idx;
DROP INDEX IF EXISTS group_members_user_id_idx;
DROP TABLE IF EXISTS group_profiles;
DROP TABLE IF EXISTS group_members;
DROP TABLE IF EXISTS groups;
+32
View File
@@ -0,0 +1,32 @@
-- Groups are the unit through which Haendler/Kuenstler/Lager apply to markets.
-- A solo merchant is a one-person group; the model is uniform either way.
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('haendler', 'kuenstler', 'lager')),
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (group_id, user_id)
);
CREATE TABLE group_profiles (
group_id UUID PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE,
description TEXT NOT NULL DEFAULT '',
categories TEXT[] NOT NULL DEFAULT '{}',
avatar_url TEXT NOT NULL DEFAULT '',
website_url TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX group_members_user_id_idx ON group_members(user_id);
CREATE INDEX group_members_group_id_idx ON group_members(group_id);
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS application_status_log_app_id_idx;
DROP INDEX IF EXISTS applications_market_edition_id_idx;
DROP INDEX IF EXISTS applications_group_id_idx;
DROP TABLE IF EXISTS application_status_log;
DROP TABLE IF EXISTS applications;
@@ -0,0 +1,41 @@
-- Applications are submitted by groups to specific market editions.
-- One application per group per market edition (UNIQUE constraint).
-- Status transitions: draft -> submitted -> reviewing -> accepted|rejected|waitlisted.
-- Group-side transitions (draft, submit) live here; veranstalter review is Phase 4b.
CREATE TABLE applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id),
market_edition_id UUID NOT NULL REFERENCES market_editions(id),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'submitted', 'reviewing', 'accepted', 'rejected', 'waitlisted')),
-- Standard template fields (09-bewerbung.md)
category TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
area_sqm NUMERIC(8,2),
needs_power BOOLEAN NOT NULL DEFAULT FALSE,
needs_water BOOLEAN NOT NULL DEFAULT FALSE,
num_persons INT NOT NULL DEFAULT 1 CHECK (num_persons >= 1),
num_tents INT NOT NULL DEFAULT 0 CHECK (num_tents >= 0),
notes TEXT NOT NULL DEFAULT '',
submitted_by UUID REFERENCES users(id),
submitted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (group_id, market_edition_id)
);
-- Full audit trail of every status change on an application.
CREATE TABLE application_status_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
changed_by UUID NOT NULL REFERENCES users(id),
note TEXT NOT NULL DEFAULT '',
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX applications_group_id_idx ON applications(group_id);
CREATE INDEX applications_market_edition_id_idx ON applications(market_edition_id);
CREATE INDEX application_status_log_app_id_idx ON application_status_log(application_id);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS lagerleben_camps;
DROP TABLE IF EXISTS lagerleben_articles;
@@ -0,0 +1,72 @@
CREATE TABLE lagerleben_articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
published_on DATE NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lagerleben_camps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
region TEXT NOT NULL DEFAULT '',
period TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
members INT NOT NULL DEFAULT 0 CHECK (members >= 0),
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed articles from the design mock so the frontend isn't empty on first deploy.
INSERT INTO lagerleben_articles (slug, title, subtitle, category, published_on, excerpt, published) VALUES
('das-handwerk-des-schwertschmieds',
'Das Handwerk des Schwertschmieds',
'Zwischen Amboss und Feuer — ein Tag in der Werkstatt von Konrad Brenner',
'Handwerk', '2026-04-12',
'Seit dreißig Jahren schmiedet Konrad Brenner Schwerter für Mittelaltermärkte in ganz Europa. Wir haben ihn in seiner Werkstatt in Dinkelsbühl besucht und zugeschaut.',
TRUE),
('lager-aufbauen-checkliste',
'Lager aufbauen in 4 Stunden',
'Die bewährte Checkliste des Compagnie du Cerf Rouge',
'Praxis', '2026-03-28',
'Wer ein Lager auf dem Markt aufbaut, kennt das Chaos der ersten Stunden. Die Compagnie du Cerf Rouge hat ihre Routine über Jahre verfeinert und teilt sie hier.',
TRUE),
('historische-stoffe-1350',
'Stoffe des 14. Jahrhunderts',
'Was ist historisch korrekt — und was sieht nur so aus?',
'Recherche', '2026-03-10',
'Wollköper, Leinen, gelegentlich Seide: Die Auswahl historisch korrekter Stoffe ist kleiner als der Markt suggeriert. Ein Überblick über Quellen und Fallstricke.',
TRUE),
('kinder-im-lager',
'Kinder im Lager',
'Wie Familien das Lagerleben gestalten — ohne auf Authentizität zu verzichten',
'Gemeinschaft', '2026-02-20',
'Immer mehr Familien sind Teil der Mittelalterszene. Was bedeutet das für das Lagerkonzept, den Marktablauf und die Gemeinschaft?',
TRUE)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO lagerleben_camps (slug, name, region, period, excerpt, members, published) VALUES
('compagnie-du-cerf-rouge',
'Compagnie du Cerf Rouge',
'Bayern', 'um 1350',
'Lebendige Darstellung eines fahrenden Söldnertrupps des 14. Jahrhunderts. Schwerpunkt: textile Handarbeit, Feldkochen und Waffenkunde.',
14, TRUE),
('lagergemeinschaft-nordmark',
'Lagergemeinschaft Nordmark',
'Schleswig-Holstein', 'Wikingerzeit',
'Gemeinschaft zur Darstellung des wikingerzeitlichen Alltags auf skandinavischen und deutschen Märkten.',
22, TRUE),
('familia-von-hohenstein',
'Familia von Hohenstein',
'Baden-Württemberg', 'Hochmittelalter',
'Adelige Haushaltung mit vollständiger Küchenausstattung, Weberei und Kinderdarstellung. Besonderen Wert legen wir auf quellenbasierte Kostümierung.',
8, TRUE)
ON CONFLICT (slug) DO NOTHING;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_tokens;
@@ -0,0 +1,10 @@
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
used BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_prt_token_hash ON password_reset_tokens(token_hash);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS email_verify_tokens;
@@ -0,0 +1,15 @@
CREATE TABLE email_verify_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
used BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_evt_token_hash ON email_verify_tokens(token_hash);
CREATE INDEX idx_evt_user_id ON email_verify_tokens(user_id);
-- Existing accounts pre-date the verify gate; mark them verified so they
-- are not locked out when RequireEmailVerified is applied to POST /applications.
UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_favorites_user_created;
DROP TABLE IF EXISTS favorites;
@@ -0,0 +1,7 @@
CREATE TABLE favorites (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
market_id UUID NOT NULL REFERENCES market_series(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, market_id)
);
CREATE INDEX idx_favorites_user_created ON favorites(user_id, created_at DESC);
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_thread_participants_user;
DROP INDEX IF EXISTS idx_messages_thread_created;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS message_thread_participants;
DROP TABLE IF EXISTS message_threads;
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE message_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT NOT NULL,
context_type TEXT NOT NULL,
context_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_message_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE message_thread_participants (
thread_id UUID NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
last_read_at TIMESTAMPTZ,
PRIMARY KEY (thread_id, user_id)
);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ
);
CREATE INDEX idx_messages_thread_created ON messages(thread_id, created_at DESC);
CREATE INDEX idx_thread_participants_user ON message_thread_participants(user_id);
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_program_acts_stage;
DROP INDEX IF EXISTS idx_market_programs_market;
DROP TABLE IF EXISTS program_acts;
DROP TABLE IF EXISTS program_stages;
DROP TABLE IF EXISTS market_programs;
ALTER TABLE market_series DROP COLUMN IF EXISTS created_by;
@@ -0,0 +1,29 @@
ALTER TABLE market_series ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id);
CREATE TABLE market_programs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
market_id UUID NOT NULL REFERENCES market_series(id) ON DELETE CASCADE,
edition_id UUID REFERENCES market_editions(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE program_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
program_id UUID NOT NULL REFERENCES market_programs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE program_acts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stage_id UUID NOT NULL REFERENCES program_stages(id) ON DELETE CASCADE,
group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_market_programs_market ON market_programs(market_id);
CREATE INDEX idx_program_acts_stage ON program_acts(stage_id);
+1
View File
@@ -237,6 +237,7 @@ web:
HOST: "0.0.0.0"
PUBLIC_TURNSTILE_SITE_KEY: "0x4AAAAAACjLCV-78Ql1oTPz"
PRIVATE_API_BASE_URL: "http://marktvogt-backend"
PUBLIC_OAUTH_GOOGLE: "true"
networkPolicy:
# Audit H16: web has no NetworkPolicy template historically; this enables
+171
View File
@@ -0,0 +1,171 @@
# Marktvogt -- Newsletter (Saisonbrief)
## Scope & Activation Gate
The public opt-in form on the homepage is **disabled** until the full
newsletter lifecycle is in place. Enabling opt-in without a send flow
would collect addresses into a dead-end table -- not acceptable.
**Activation order:**
1. Backend: subscriber table + subscribe/confirm/unsubscribe endpoints
2. Backend: newsletter table + admin send flow
3. Frontend admin: newsletter creation + send UI (proper planning required)
4. Frontend public: enable opt-in form
5. New subscriber welcome: last sent newsletter is mailed on confirm
---
## Subscriber Lifecycle
```
anonymous -> POST /newsletter/subscribe (Turnstile) -> insert unconfirmed
-> send confirm email (double opt-in) -> GET /newsletter/confirm/:token
-> status=confirmed -> optionally send last newsletter
-> ... receives newsletters ...
-> GET /newsletter/unsubscribe/:token -> confirm-page (one-click with visual confirm)
-> status=unsubscribed (row kept for audit, never re-added without fresh opt-in)
```
### Why double opt-in
- GDPR: legitimate interest requires explicit consent; confirmed opt-in is
the cleanest proof
- Prevents spam/typo additions (someone entering another person's email)
### Unsubscribe UX
- Every newsletter email contains a prominent **"Abbestellen"** link,
not a small gray footer line
- The link is a signed, expiring URL with the subscriber's token
- Clicking opens a simple page: "Saisonbrief abbestellen?" + one confirm button
- No login required, no re-entry of email
- After confirm: friendly "Du wirst nicht mehr kontaktiert." page + homepage link
- Unsubscribe is permanent; subscriber must re-opt-in from scratch if they
change their mind
---
## Data Model
### newsletter_subscribers
```sql
CREATE TABLE newsletter_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending', -- pending | confirmed | unsubscribed
confirm_token_hash TEXT UNIQUE,
confirm_expires_at TIMESTAMPTZ,
unsubscribe_token_hash TEXT NOT NULL UNIQUE, -- long-lived, rotated on each send
subscribed_at TIMESTAMPTZ,
unsubscribed_at TIMESTAMPTZ,
last_newsletter_id UUID, -- FK newsletters(id), last delivered
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ns_status ON newsletter_subscribers(status);
CREATE INDEX idx_ns_confirm_token ON newsletter_subscribers(confirm_token_hash);
CREATE INDEX idx_ns_unsub_token ON newsletter_subscribers(unsubscribe_token_hash);
```
### newsletters
```sql
CREATE TABLE newsletters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT NOT NULL,
body_html TEXT NOT NULL, -- Burgund-styled, stored as rendered HTML
body_text TEXT NOT NULL, -- plain-text fallback
status TEXT NOT NULL DEFAULT 'draft', -- draft | sending | sent
sent_at TIMESTAMPTZ,
sent_count INTEGER NOT NULL DEFAULT 0,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
---
## Endpoints
### Public
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /newsletter/subscribe | Turnstile | Insert pending subscriber, send confirm email |
| GET | /newsletter/confirm/:token | none | Confirm subscription, optionally send last newsletter |
| GET | /newsletter/unsubscribe/:token | none | Show confirm page |
| POST | /newsletter/unsubscribe/:token | none | Execute unsubscribe |
### Admin (requires admin role)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /admin/newsletters | admin | List all newsletters (paginated) |
| POST | /admin/newsletters | admin | Create draft |
| GET | /admin/newsletters/:id | admin | View newsletter |
| PATCH | /admin/newsletters/:id | admin | Update draft (subject, body) |
| POST | /admin/newsletters/:id/send | admin | Send to all confirmed subscribers |
| GET | /admin/newsletter-subscribers | admin | List subscribers with status filter |
| DELETE | /admin/newsletter-subscribers/:id | admin | Hard-delete a subscriber (GDPR erasure) |
---
## Admin UI (requires planning before build)
This is a mini editor, not just a form. Needs proper planning session.
Open questions to resolve before building:
1. **Editor type**: raw HTML (risky, but full control) vs. markdown-to-HTML
vs. block editor (simplest for non-devs). Recommendation: Markdown + live
preview rendered with Burgund styles. Store both raw Markdown and rendered HTML.
2. **Preview / test send**: admin should be able to send to themselves before
sending to all subscribers
3. **Send confirmation**: explicit "send to N subscribers?" confirmation modal
with subscriber count before firing
4. **Scheduling**: send now vs. scheduled send (nice-to-have; defer to v2)
5. **Unsubscribe token rotation**: rotate per send (more privacy, harder to
extract list from leaked tokens) vs. per subscriber (simpler)
-- Recommendation: per send (fresh token in each email, old tokens invalidated)
6. **Bounce handling**: no infrastructure for this yet; v1 ignores bounces,
v2 integrates bounce webhooks from SMTP provider
---
## Email Template
Newsletter emails use the Burgund base template but with a full-width
content area and a more article-like layout:
- Display-font headline (subject)
- Optional intro paragraph
- Body content (markdown-rendered sections)
- Prominent "Abbestellen" button/link above the footer rule
(not buried in footer text)
- Footer: copyright + marktvogt.de link
The unsubscribe link placement is a first-class design element, not an
afterthought. A user who wants to stop receiving email should find the
link immediately without hunting.
---
## Security
- Double opt-in: confirm token, 48h TTL
- Unsubscribe: signed token, no auth required, no re-entry of email
- Admin send: rate-limited, requires 2FA if admin account has it enabled
- No subscriber email addresses exposed in any public response
- Turnstile on subscribe form (bot protection)
---
## Implementation Order
1. Migration: newsletter_subscribers + newsletters tables
2. Backend domain: subscriber CRUD + confirm/unsubscribe handlers
3. Email templates: confirm + newsletter (Burgund style) + unsubscribe CTA
4. Admin API: newsletter CRUD + send endpoint
5. Admin UI: plan separately, then build (see open questions above)
6. Frontend: enable opt-in form, integrate Turnstile
7. On confirm: fetch and send latest newsletter if one exists
**Do not enable the opt-in form until step 4 (admin send) is complete.**
+12 -1
View File
@@ -1,4 +1,15 @@
PUBLIC_API_BASE_URL=http://localhost:8080
# Cloudflare Turnstile (site key - public, safe to expose)
PUBLIC_TURNSTILE_SITE_KEY=
# Local dev test keys (always pass): use 1x00000000000000000000AA
# Always-block test key: 2x00000000000000000000AB
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
# Cloudflare Turnstile secret key (server-side verification, never expose to client)
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# OAuth providers — set to any non-empty value to enable the button in the UI
# Must match backend OAUTH_*_CLIENT_ID being configured
PUBLIC_OAUTH_GOOGLE=
PUBLIC_OAUTH_GITHUB=
PUBLIC_OAUTH_FACEBOOK=
+172
View File
@@ -0,0 +1,172 @@
# Marktvogt Design System — Burgund
Established May 2026 via Claude Design handoff (bundle: `marktvogt-de.tar.gz`).
## Identity
**Burgund** is the locked visual identity for Marktvogt. The name comes from the deep sealing-wax red at its core.
Design principles chosen in the original session:
- **Editorial-classical** — Cormorant Garamond display, EB Garamond body, editorial proportions
- **Mid-density** — enough whitespace to breathe, enough content to inform
- **Minimal decoration** — one ornament glyph (✦), drop-caps, double rules only. No rounded corners, no gradient badges, no color-coded semantic variants.
- **Body-lead hierarchy** — lead paragraph at 22px italic, then 16/15/14px body; display headlines at 76/56/44px
## Tokens
Source of truth: `web/src/app.css` (`@theme` block and `:root.dark` overrides).
| Token | Light | Dark |
| --------------------- | -------------------------------- | ------------------------ |
| `--color-bg` | `#f5efe4` (warm parchment) | `#0f0c0a` (deep ink) |
| `--color-surface` | `#ffffff` | `#191411` |
| `--color-surface-alt` | `#ece4d4` | `#241c17` |
| `--color-ink` | `#181410` | `#f0e6d2` |
| `--color-ink-soft` | `#3a322a` | `#c0b094` |
| `--color-ink-muted` | `#6e6253` | `#74644f` |
| `--color-rule` | `#181410` | `#3a2e22` |
| `--color-rule-soft` | `#c9b58c` | `#2a221d` |
| `--color-accent` | `#9a1e2c` (sealing-wax burgundy) | `#d86268` (halbton rose) |
| `--color-accent-soft` | `#c84858` | `#8a2a32` |
| `--color-on-accent` | `#f5efe4` | `#0f0c0a` |
The dark accent is the "Halbton" step chosen from the design session — midway between the original loud `#e84a5e` and the subdued `#c87a7a`. The dark Submit-CTA "Bordeau block" uses `surface-alt #241c17` with cream `ink` foreground.
## Typography
| Role | Font | Usage |
| -------------- | ------------------ | ------------------------------------------------------------------- |
| `font-display` | Cormorant Garamond | Headlines, section titles, drop-caps |
| `font-serif` | EB Garamond | Body text, nav links, buttons, table content |
| `font-sans` | Inter | Reserved; currently unused in UI — available for UI forms if needed |
| `font-mono` | JetBrains Mono | CAPS labels, tags, mono counters |
All fonts self-hosted under `web/static/fonts/`. Variable fonts covering the declared weight ranges.
### Type scale (derived from design files)
| Use | Size | Weight | Font |
| --------------- | ----------- | ------- | --------------------------------------- |
| Display hero | 7688px | 500 | display |
| Display large | 56px | 500 | display |
| Display medium | 44px | 500 | display |
| Display small | 24px | 400500 | display |
| Lead / intro | 22px italic | 400 | serif |
| Body large | 19px | 400 | serif |
| Body | 16px | 400 | serif |
| Body small | 15px | 400 | serif |
| UI text | 14px | 400 | serif |
| Caption | 13px | 400 | serif |
| Mono caps large | 11px | 400 | mono + uppercase + tracking 0.18em |
| Mono caps | 10px | 400 | mono + uppercase + tracking 0.120.15em |
| Mono caps small | 9px | 400 | mono + uppercase + tracking 0.1em |
## Atoms
Source files: `web/src/lib/components/atoms/`
### `MarktvogtMark`
The shield-M logo. Props: `size` (default 32). Uses `currentColor` — inherits from parent element color.
```svelte
<MarktvogtMark size={36} />
```
The SVG source lives at `web/src/lib/assets/marktvogt-logo.svg`.
### `Caps`
Mono uppercase label. Props: `size` (px, default 11), `color` (CSS value, defaults to `ink-muted`).
```svelte
<Caps size={10}>Hessen · Nr. 015</Caps>
```
### `Tag`
Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground.
```svelte
<Tag accent>Empfohlen</Tag>
<Tag>Burg</Tag>
```
### `Rule`
Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`).
```svelte
<Rule kind="ornament" />
<!-- ─────── ✦ ─────── -->
<Rule kind="double" />
<!-- thin + thin gap -->
<Rule />
<!-- single rule-soft border -->
```
### `Heraldry`
Procedural heraldic SVG by string seed. Used as market card hero fallback when no real photo is available. Props: `seed` (string), `palette` (`{ a, b, bg, fg }`).
```svelte
<Heraldry seed={market.slug} />
```
8 deterministic variants: Stripes, Checky, Chevron, Banner, Tower, Cross, Saltire, Fleury.
## Decoration vocabulary
**Allowed:**
- `✦` ornament glyph — ornament Rule, section transitions
- Drop-cap — first character of lead paragraph, 4056px Cormorant Garamond, accent color
- Double rule — between major page sections
- Section headers: mono-caps label + thin rule below
- Breadcrumb line: mono-caps "Verzeichnis · Region · Nr. XXX"
**Forbidden:**
- Gold underline on headings (the old `h1::after` accent-400 stripe — removed)
- Color-coded semantic Alert variants (info/success/warning/error with distinct hues) — use `surface-alt + border-rule-soft` for neutral blocks, `border-accent` for errors only
- Rounded corners (`rounded-*`) on content elements — squares only; `rounded-sm` allowed on focus rings only
- Badge pills with backgrounds other than accent (the full surface-alt + rule-soft pair only)
## Dark mode
Dark mode applies via `.dark` class on `<html>` (set by `lib/theme.ts`, user-controlled via ThemeToggle). Three states: light / dark / system.
The dark "Bordeau block" pattern (used in Submit-CTA, strong CTAs):
```css
background: var(--color-surface-alt); /* #241c17 */
color: var(--color-ink); /* #f0e6d2 cream */
accent: var(--color-accent); /* #d86268 */
```
## Voice & tone
Editorial-warm. "Hannes" is the editorial voice — a Kunstfigur (constructed character) representing the collective editorial team. The disclaimer appears under his signature:
> ✦ — Hannes, der Marktvogt · Hessen · Met-Brauer · Lagergänger seit 2003
> _eine Kunstfigur · die Redaktion arbeitet kollektiv_
This is intentional and not removable. See `chat1.md` lines 860895 for the design decision.
## Open items (Phase 1 does not address)
- Photography pipeline — Lagerleben articles, camp profiles, real market photos
- OCR/AI program parsing — Submit-Flow optional upload step
- Real-time messaging — dashboard inboxes use REST for Phase 4b; WebSocket later
- Newsletter/Saisonbrief backend — Home form ships as non-functional in Phase 2
## Phase roadmap
| Phase | Branch | Status |
| ---------------------------------------------------------------------------------- | ------------------------- | ----------- |
| 1 · Design system | `feat/burgund-redesign` | In progress |
| 2 · Public surfaces (Startseite, Verzeichnis, Detail, Kalender, Karte, Lagerleben) | `feat/burgund-public` | Planned |
| 3 · Flows (Auth, Submit-Flow) | `feat/burgund-flows` | Planned |
| 4a · Backend (roles, groups, applications, Lagerleben CMS) | `feat/burgund-backend` | Planned |
| 4b · Dashboards (Veranstalter, Admin, Händler, Lager) | `feat/burgund-dashboards` | Planned |
+4 -4
View File
@@ -5,8 +5,8 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"dev": "NODE_OPTIONS='--disable-warning=DEP0205' vite dev",
"build": "NODE_OPTIONS='--disable-warning=DEP0205' vite build",
"bundle": "node scripts/bundle.mjs",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
@@ -22,7 +22,7 @@
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/kit": "^2.57.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.0.0",
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.0",
"@typescript/native-preview": "7.0.0-dev.20260428.1",
"esbuild": "^0.27.4",
@@ -35,7 +35,7 @@
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.0.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"vite": "^7.3.2"
+136 -136
View File
@@ -17,19 +17,19 @@ importers:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.1.0(jiti@2.6.1))
version: 10.0.1(eslint@10.1.0(jiti@2.7.0))
'@sveltejs/adapter-node':
specifier: ^5.5.3
version: 5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))
version: 5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))
'@sveltejs/kit':
specifier: ^2.57.1
version: 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
version: 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.4
version: 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
version: 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
specifier: ^4.3.0
version: 4.3.0(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@types/leaflet':
specifier: ^1.9.0
version: 1.9.21
@@ -41,13 +41,13 @@ importers:
version: 0.27.4
eslint:
specifier: ^10.0.1
version: 10.1.0(jiti@2.6.1)
version: 10.1.0(jiti@2.7.0)
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
version: 10.1.8(eslint@10.1.0(jiti@2.7.0))
eslint-plugin-svelte:
specifier: ^3.15.0
version: 3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.1)
version: 3.16.0(eslint@10.1.0(jiti@2.7.0))(svelte@5.55.1)
globals:
specifier: ^17.3.0
version: 17.4.0
@@ -67,17 +67,17 @@ importers:
specifier: ^4.3.6
version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3)
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
specifier: ^4.3.0
version: 4.3.0
typescript:
specifier: ^5.9.3
version: 5.9.3
typescript-eslint:
specifier: ^8.56.0
version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
version: 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
vite:
specifier: ^7.3.2
version: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
version: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
packages:
@@ -529,69 +529,69 @@ packages:
svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0
'@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
'@tailwindcss/node@4.3.0':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
'@tailwindcss/oxide-android-arm64@4.2.2':
resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
'@tailwindcss/oxide-android-arm64@4.3.0':
resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.2.2':
resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
'@tailwindcss/oxide-darwin-arm64@4.3.0':
resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.2.2':
resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
'@tailwindcss/oxide-darwin-x64@4.3.0':
resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.2.2':
resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
'@tailwindcss/oxide-freebsd-x64@4.3.0':
resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
'@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
@@ -602,24 +602,24 @@ packages:
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
'@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.2.2':
resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
'@tailwindcss/oxide-win32-x64-msvc@4.3.0':
resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.2.2':
resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
'@tailwindcss/oxide@4.3.0':
resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.2.2':
resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
'@tailwindcss/vite@4.3.0':
resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
@@ -829,8 +829,8 @@ packages:
devalue@5.6.4:
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
enhanced-resolve@5.21.2:
resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==}
engines: {node: '>=10.13.0'}
esbuild@0.27.4:
@@ -1017,8 +1017,8 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
json-buffer@3.0.1:
@@ -1364,11 +1364,11 @@ packages:
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
engines: {node: '>=18'}
tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
tapable@2.3.2:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tinyglobby@0.2.15:
@@ -1555,9 +1555,9 @@ snapshots:
'@esbuild/win32-x64@0.27.4':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))':
'@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.7.0))':
dependencies:
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
@@ -1578,9 +1578,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))':
'@eslint/js@10.0.1(eslint@10.1.0(jiti@2.7.0))':
optionalDependencies:
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
'@eslint/object-schema@3.0.3': {}
@@ -1738,19 +1738,19 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))':
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))':
dependencies:
'@rollup/plugin-commonjs': 29.0.2(rollup@4.60.1)
'@rollup/plugin-json': 6.1.0(rollup@4.60.1)
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1)
'@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
'@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
rollup: 4.60.1
'@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
'@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.7.2
@@ -1762,94 +1762,94 @@ snapshots:
set-cookie-parser: 3.1.0
sirv: 3.0.2
svelte: 5.55.1
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
optionalDependencies:
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
obug: 2.1.1
svelte: 5.55.1
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.55.1
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
vitefu: 1.1.3(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
vitefu: 1.1.3(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@tailwindcss/node@4.2.2':
'@tailwindcss/node@4.3.0':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.20.1
jiti: 2.6.1
enhanced-resolve: 5.21.2
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.2.2
tailwindcss: 4.3.0
'@tailwindcss/oxide-android-arm64@4.2.2':
'@tailwindcss/oxide-android-arm64@4.3.0':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.2.2':
'@tailwindcss/oxide-darwin-arm64@4.3.0':
optional: true
'@tailwindcss/oxide-darwin-x64@4.2.2':
'@tailwindcss/oxide-darwin-x64@4.3.0':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.2.2':
'@tailwindcss/oxide-freebsd-x64@4.3.0':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
'@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
'@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.2.2':
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.2.2':
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.2.2':
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.2.2':
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
'@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.2.2':
'@tailwindcss/oxide-win32-x64-msvc@4.3.0':
optional: true
'@tailwindcss/oxide@4.2.2':
'@tailwindcss/oxide@4.3.0':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.2.2
'@tailwindcss/oxide-darwin-arm64': 4.2.2
'@tailwindcss/oxide-darwin-x64': 4.2.2
'@tailwindcss/oxide-freebsd-x64': 4.2.2
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
'@tailwindcss/oxide-linux-arm64-musl': 4.2.2
'@tailwindcss/oxide-linux-x64-gnu': 4.2.2
'@tailwindcss/oxide-linux-x64-musl': 4.2.2
'@tailwindcss/oxide-wasm32-wasi': 4.2.2
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
'@tailwindcss/oxide-android-arm64': 4.3.0
'@tailwindcss/oxide-darwin-arm64': 4.3.0
'@tailwindcss/oxide-darwin-x64': 4.3.0
'@tailwindcss/oxide-freebsd-x64': 4.3.0
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0
'@tailwindcss/oxide-linux-arm64-gnu': 4.3.0
'@tailwindcss/oxide-linux-arm64-musl': 4.3.0
'@tailwindcss/oxide-linux-x64-gnu': 4.3.0
'@tailwindcss/oxide-linux-x64-musl': 4.3.0
'@tailwindcss/oxide-wasm32-wasi': 4.3.0
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
'@tailwindcss/oxide-win32-x64-msvc': 4.3.0
'@tailwindcss/vite@4.2.2(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0))':
'@tailwindcss/vite@4.3.0(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
'@tailwindcss/node': 4.3.0
'@tailwindcss/oxide': 4.3.0
tailwindcss: 4.3.0
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
'@types/cookie@0.6.0': {}
@@ -1869,15 +1869,15 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.58.0
'@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.58.0
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -1885,14 +1885,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.58.0
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -1915,13 +1915,13 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
debug: 4.4.3
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -1944,13 +1944,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.7.0))
'@typescript-eslint/scope-manager': 8.58.0
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -2044,10 +2044,10 @@ snapshots:
devalue@5.6.4: {}
enhanced-resolve@5.20.1:
enhanced-resolve@5.21.2:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.2
tapable: 2.3.3
esbuild@0.27.4:
optionalDependencies:
@@ -2080,15 +2080,15 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)):
eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.7.0)):
dependencies:
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
eslint-plugin-svelte@3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.1):
eslint-plugin-svelte@3.16.0(eslint@10.1.0(jiti@2.7.0))(svelte@5.55.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.7.0))
'@jridgewell/sourcemap-codec': 1.5.5
eslint: 10.1.0(jiti@2.6.1)
eslint: 10.1.0(jiti@2.7.0)
esutils: 2.0.3
globals: 16.5.0
known-css-properties: 0.37.0
@@ -2120,9 +2120,9 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@10.1.0(jiti@2.6.1):
eslint@10.1.0(jiti@2.7.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.7.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.3
'@eslint/config-helpers': 0.5.3
@@ -2153,7 +2153,7 @@ snapshots:
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 2.6.1
jiti: 2.7.0
transitivePeerDependencies:
- supports-color
@@ -2263,7 +2263,7 @@ snapshots:
isexe@2.0.0: {}
jiti@2.6.1: {}
jiti@2.7.0: {}
json-buffer@3.0.1: {}
@@ -2539,9 +2539,9 @@ snapshots:
magic-string: 0.30.21
zimmerframe: 1.1.4
tailwindcss@4.2.2: {}
tailwindcss@4.3.0: {}
tapable@2.3.2: {}
tapable@2.3.3: {}
tinyglobby@0.2.15:
dependencies:
@@ -2558,13 +2558,13 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3):
typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)
eslint: 10.1.0(jiti@2.6.1)
'@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.7.0))(typescript@5.9.3)
eslint: 10.1.0(jiti@2.7.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -2577,7 +2577,7 @@ snapshots:
util-deprecate@1.0.2: {}
vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0):
vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.4)
@@ -2587,12 +2587,12 @@ snapshots:
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
jiti: 2.6.1
jiti: 2.7.0
lightningcss: 1.32.0
vitefu@1.1.3(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)):
vitefu@1.1.3(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0)):
optionalDependencies:
vite: 7.3.2(jiti@2.6.1)(lightningcss@1.32.0)
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
which@2.0.2:
dependencies:

Some files were not shown because too many files have changed in this diff Show More