From 418a4411f304fa0781e71b541def5f84bd76b570 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 10 May 2026 12:56:51 +0200 Subject: [PATCH 1/8] feat(web): implement Burgund design system foundation (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .husky/pre-commit | 5 +- web/docs/design-system.md | 172 +++++++++++ web/src/app.css | 273 +++++++++++------- web/src/lib/assets/favicon.svg | 6 +- web/src/lib/assets/marktvogt-logo.svg | 5 + web/src/lib/components/atoms/Caps.svelte | 19 ++ web/src/lib/components/atoms/Heraldry.svelte | 166 +++++++++++ .../lib/components/atoms/MarktvogtMark.svelte | 33 +++ web/src/lib/components/atoms/Rule.svelte | 25 ++ web/src/lib/components/atoms/Tag.svelte | 19 ++ web/src/lib/components/layout/Footer.svelte | 48 ++- web/src/lib/components/layout/Header.svelte | 126 ++++---- .../lib/components/layout/MobileNav.svelte | 42 ++- web/src/lib/components/layout/UserMenu.svelte | 18 +- .../lib/components/market/MarketCard.svelte | 85 ++---- web/src/lib/components/ui/Alert.svelte | 17 +- web/src/lib/components/ui/Button.svelte | 22 +- web/src/lib/components/ui/Input.svelte | 13 +- web/src/lib/components/ui/Select.svelte | 46 ++- web/src/lib/components/ui/Spinner.svelte | 2 +- web/src/lib/components/ui/ThemeToggle.svelte | 54 +--- web/src/routes/+layout.svelte | 2 +- .../fonts/cormorant-garamond-400-ext.woff2 | Bin 0 -> 33736 bytes web/static/fonts/cormorant-garamond-400.woff2 | Bin 0 -> 37640 bytes .../fonts/cormorant-garamond-400i-ext.woff2 | Bin 0 -> 20284 bytes .../fonts/cormorant-garamond-400i.woff2 | Bin 0 -> 23660 bytes web/static/fonts/eb-garamond-400-ext.woff2 | Bin 0 -> 116316 bytes web/static/fonts/eb-garamond-400.woff2 | Bin 0 -> 41316 bytes web/static/fonts/eb-garamond-400i-ext.woff2 | Bin 0 -> 45940 bytes web/static/fonts/eb-garamond-400i.woff2 | Bin 0 -> 22172 bytes web/static/fonts/inter-400-ext.woff2 | Bin 0 -> 85068 bytes web/static/fonts/inter-400.woff2 | Bin 0 -> 48256 bytes web/static/fonts/jetbrains-mono-400-ext.woff2 | Bin 0 -> 11624 bytes web/static/fonts/jetbrains-mono-400.woff2 | Bin 0 -> 31432 bytes web/static/logo-signet.svg | 16 +- 35 files changed, 846 insertions(+), 368 deletions(-) create mode 100644 web/docs/design-system.md create mode 100644 web/src/lib/assets/marktvogt-logo.svg create mode 100644 web/src/lib/components/atoms/Caps.svelte create mode 100644 web/src/lib/components/atoms/Heraldry.svelte create mode 100644 web/src/lib/components/atoms/MarktvogtMark.svelte create mode 100644 web/src/lib/components/atoms/Rule.svelte create mode 100644 web/src/lib/components/atoms/Tag.svelte create mode 100644 web/static/fonts/cormorant-garamond-400-ext.woff2 create mode 100644 web/static/fonts/cormorant-garamond-400.woff2 create mode 100644 web/static/fonts/cormorant-garamond-400i-ext.woff2 create mode 100644 web/static/fonts/cormorant-garamond-400i.woff2 create mode 100644 web/static/fonts/eb-garamond-400-ext.woff2 create mode 100644 web/static/fonts/eb-garamond-400.woff2 create mode 100644 web/static/fonts/eb-garamond-400i-ext.woff2 create mode 100644 web/static/fonts/eb-garamond-400i.woff2 create mode 100644 web/static/fonts/inter-400-ext.woff2 create mode 100644 web/static/fonts/inter-400.woff2 create mode 100644 web/static/fonts/jetbrains-mono-400-ext.woff2 create mode 100644 web/static/fonts/jetbrains-mono-400.woff2 diff --git a/.husky/pre-commit b/.husky/pre-commit index 9e972a9..2ffefef 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 ) diff --git a/web/docs/design-system.md b/web/docs/design-system.md new file mode 100644 index 0000000..8b52f57 --- /dev/null +++ b/web/docs/design-system.md @@ -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 | 76–88px | 500 | display | +| Display large | 56px | 500 | display | +| Display medium | 44px | 500 | display | +| Display small | 24px | 400–500 | 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.12–0.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 + +``` + +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 +Hessen · Nr. 015 +``` + +### `Tag` + +Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground. + +```svelte +Empfohlen +Burg +``` + +### `Rule` + +Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`). + +```svelte + + + + + + +``` + +### `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 + +``` + +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, 40–56px 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 `` (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 860–895 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 | diff --git a/web/src/app.css b/web/src/app.css index fa9170c..b6f8ec3 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -2,144 +2,199 @@ /* ── Fonts ───────────────────────────────────────────────── */ +/* Cormorant Garamond — display */ @font-face { - font-family: 'MedievalSharp'; - src: url('/fonts/medievalsharp-400.woff2') format('woff2'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Crimson Pro'; - src: url('/fonts/crimsonpro-400.woff2') format('woff2'); + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400-ext.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; } - @font-face { - font-family: 'Crimson Pro'; - src: url('/fonts/crimsonpro-400i.woff2') format('woff2'); - font-weight: 400; + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400i-ext.woff2') format('woff2'); + font-weight: 400 700; font-style: italic; font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Cormorant Garamond'; + src: url('/fonts/cormorant-garamond-400i.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } -/* ── Dark mode variant: .dark class only (JS resolves system preference) ── */ +/* EB Garamond — body serif */ +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400-ext.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400i-ext.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'EB Garamond'; + src: url('/fonts/eb-garamond-400i.woff2') format('woff2'); + font-weight: 400 700; + font-style: italic; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* Inter — sans UI */ +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-400-ext.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + src: url('/fonts/inter-400.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* JetBrains Mono — mono caps labels */ +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-400-ext.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, + U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('/fonts/jetbrains-mono-400.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, + U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ── Dark mode: .dark class (JS resolves system preference) ── */ @custom-variant dark (&:is(.dark *)); -/* ── Theme tokens ────────────────────────────────────────── */ +/* ── Burgund design token system ─────────────────────────── */ @theme { - /* Forest green primary */ - --color-primary-50: oklch(0.97 0.02 150); - --color-primary-100: oklch(0.93 0.04 150); - --color-primary-200: oklch(0.86 0.08 150); - --color-primary-300: oklch(0.77 0.13 150); - --color-primary-400: oklch(0.67 0.16 150); - --color-primary-500: oklch(0.55 0.16 150); - --color-primary-600: oklch(0.47 0.14 150); - --color-primary-700: oklch(0.4 0.12 150); - --color-primary-800: oklch(0.33 0.09 150); - --color-primary-900: oklch(0.27 0.07 150); - --color-primary-950: oklch(0.18 0.05 150); - - /* Gold / amber accent */ - --color-accent-50: oklch(0.98 0.02 75); - --color-accent-100: oklch(0.94 0.06 70); - --color-accent-200: oklch(0.88 0.11 65); - --color-accent-300: oklch(0.82 0.15 60); - --color-accent-400: oklch(0.75 0.16 58); - --color-accent-500: oklch(0.68 0.16 55); - --color-accent-600: oklch(0.58 0.14 55); - --color-accent-700: oklch(0.48 0.11 55); - --color-accent-800: oklch(0.4 0.08 55); - --color-accent-900: oklch(0.32 0.06 55); - --color-accent-950: oklch(0.22 0.04 55); - - /* Warm stone neutrals (constant scale — dark mode uses dark: utilities) */ - --color-stone-50: oklch(0.98 0.005 70); - --color-stone-100: oklch(0.96 0.008 60); - --color-stone-200: oklch(0.92 0.01 55); - --color-stone-300: oklch(0.87 0.012 50); - --color-stone-400: oklch(0.71 0.013 55); - --color-stone-500: oklch(0.56 0.013 58); - --color-stone-600: oklch(0.45 0.012 58); - --color-stone-700: oklch(0.38 0.011 55); - --color-stone-800: oklch(0.32 0.01 50); - --color-stone-900: oklch(0.25 0.008 45); - --color-stone-950: oklch(0.17 0.006 40); - - /* Semantic surface colors (overridden in .dark via CSS vars) */ - --color-parchment: oklch(0.96 0.012 70); - --color-vellum: oklch(0.99 0.006 70); - - /* Danger / brick red */ - --color-danger-50: oklch(0.97 0.015 25); - --color-danger-100: oklch(0.93 0.04 25); - --color-danger-200: oklch(0.87 0.08 25); - --color-danger-300: oklch(0.78 0.12 25); - --color-danger-400: oklch(0.68 0.15 25); - --color-danger-500: oklch(0.58 0.16 25); - --color-danger-600: oklch(0.5 0.15 25); - --color-danger-700: oklch(0.42 0.12 25); - --color-danger-800: oklch(0.35 0.09 25); - --color-danger-900: oklch(0.28 0.06 25); - --color-danger-950: oklch(0.2 0.04 25); + /* Color tokens */ + --color-bg: #f5efe4; + --color-surface: #ffffff; + --color-surface-alt: #ece4d4; + --color-ink: #181410; + --color-ink-soft: #3a322a; + --color-ink-muted: #6e6253; + --color-rule: #181410; + --color-rule-soft: #c9b58c; + --color-accent: #9a1e2c; + --color-accent-soft: #c84858; + --color-on-accent: #f5efe4; /* Typography */ - --font-heading: 'MedievalSharp', cursive; - --font-sans: 'Crimson Pro', 'Georgia', serif; + --font-display: 'Cormorant Garamond', 'Garamond', serif; + --font-serif: 'EB Garamond', 'Garamond', serif; + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; } -/* ── Dark surface overrides (.dark class set by JS) ──────── */ +/* ── Dark mode overrides (.dark on ) ───────────────── */ :root.dark { color-scheme: dark; - --color-parchment: oklch(0.14 0.007 20); - --color-vellum: oklch(0.19 0.009 18); + --color-bg: #0f0c0a; + --color-surface: #191411; + --color-surface-alt: #241c17; + --color-ink: #f0e6d2; + --color-ink-soft: #c0b094; + --color-ink-muted: #74644f; + --color-rule: #3a2e22; + --color-rule-soft: #2a221d; + --color-accent: #d86268; + --color-accent-soft: #8a2a32; + --color-on-accent: #0f0c0a; } /* ── Base layer ──────────────────────────────────────────── */ @layer base { body { - @apply bg-parchment text-stone-800 antialiased; + @apply bg-bg text-ink font-serif antialiased; } - .dark body { - @apply text-stone-200; - } - - /* Skip-to-content link (visible on focus only) */ + /* Skip-to-content link */ .skip-link { - @apply bg-primary-700 absolute -top-full left-4 z-50 rounded-b-lg px-4 py-2 text-sm font-medium text-white; - @apply focus:ring-primary-400 focus:top-0 focus:ring-2 focus:outline-none; + @apply bg-accent text-on-accent absolute -top-full left-4 z-50 px-4 py-2 text-sm font-medium; + @apply focus:ring-accent focus:top-0 focus:ring-2 focus:outline-none; } h1, h2, h3, h4 { - font-family: var(--font-heading); + font-family: var(--font-display); } - h1 { - @apply relative pb-3; - } - - h1::after { - content: ''; - @apply bg-accent-400 absolute bottom-0 left-0 h-0.5 w-16; - } - - h1:is([class*='text-center'])::after { - @apply left-1/2 -translate-x-1/2; - } - - /* ── Shared form control styles ───────────────────────── */ + /* ── Form controls ───────────────────────────────────── */ input:where( :not([type='hidden']):not([type='checkbox']):not([type='radio']):not([type='submit']):not( @@ -147,9 +202,10 @@ ):not([type='reset']) ), textarea { - @apply bg-vellum block w-full rounded-lg border border-stone-300 px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors; - @apply placeholder-stone-400; - @apply focus:border-primary-500 focus:ring-primary-500 focus:ring-2 focus:outline-none; + @apply border-rule-soft bg-surface text-ink block w-full border px-3 py-2 text-sm shadow-sm transition-colors; + @apply placeholder-ink-muted; + @apply focus:border-accent focus:ring-accent focus:ring-1 focus:outline-none; + border-radius: 0; } .dark @@ -159,22 +215,15 @@ ):not([type='reset']) ), .dark textarea { - @apply border-stone-600 bg-stone-800 text-stone-100 placeholder-stone-500; + @apply border-rule-soft bg-surface-alt text-ink placeholder-ink-muted; } - /* Error state */ input[aria-invalid='true'], textarea[aria-invalid='true'] { - @apply border-danger-400 text-danger-900 placeholder-danger-300; + @apply border-accent-soft; } - .dark input[aria-invalid='true'], - .dark textarea[aria-invalid='true'] { - @apply border-danger-500 text-danger-200; - } - - /* Focus ring offset matches background */ *:focus-visible { - --tw-ring-offset-color: var(--color-parchment); + --tw-ring-offset-color: var(--color-bg); } } diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg index fc8ed39..5aa07ac 100644 --- a/web/src/lib/assets/favicon.svg +++ b/web/src/lib/assets/favicon.svg @@ -1 +1,5 @@ -svelte-logo + + + + + diff --git a/web/src/lib/assets/marktvogt-logo.svg b/web/src/lib/assets/marktvogt-logo.svg new file mode 100644 index 0000000..4f66a53 --- /dev/null +++ b/web/src/lib/assets/marktvogt-logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/src/lib/components/atoms/Caps.svelte b/web/src/lib/components/atoms/Caps.svelte new file mode 100644 index 0000000..f06ab31 --- /dev/null +++ b/web/src/lib/components/atoms/Caps.svelte @@ -0,0 +1,19 @@ + + + + {@render children()} + diff --git a/web/src/lib/components/atoms/Heraldry.svelte b/web/src/lib/components/atoms/Heraldry.svelte new file mode 100644 index 0000000..7474f4e --- /dev/null +++ b/web/src/lib/components/atoms/Heraldry.svelte @@ -0,0 +1,166 @@ + + +
+ {#if variant === 'Stripes'} + + + + + + + + + + {:else if variant === 'Checky'} + + + {#each Array.from({ length: 64 }, (_, i) => i) as i} + {@const x = i % 8} + {@const y = Math.floor(i / 8)} + {#if (x + y) % 2 === 0} + + {/if} + {/each} + + {:else if variant === 'Chevron'} + + + + + + {:else if variant === 'Banner'} + + + + + + {:else if variant === 'Tower'} + + + + + + + + + + + + + + + + + + {:else if variant === 'Cross'} + + + + + + {:else if variant === 'Saltire'} + + + + + {:else} + + + + + + + + + + + + + + + + + {/if} +
diff --git a/web/src/lib/components/atoms/MarktvogtMark.svelte b/web/src/lib/components/atoms/MarktvogtMark.svelte new file mode 100644 index 0000000..1e45c87 --- /dev/null +++ b/web/src/lib/components/atoms/MarktvogtMark.svelte @@ -0,0 +1,33 @@ + + + diff --git a/web/src/lib/components/atoms/Rule.svelte b/web/src/lib/components/atoms/Rule.svelte new file mode 100644 index 0000000..40c4c66 --- /dev/null +++ b/web/src/lib/components/atoms/Rule.svelte @@ -0,0 +1,25 @@ + + +{#if kind === 'double'} +
+
+
+
+{:else if kind === 'ornament'} +
+
+ +
+
+{:else} +
+{/if} diff --git a/web/src/lib/components/atoms/Tag.svelte b/web/src/lib/components/atoms/Tag.svelte new file mode 100644 index 0000000..dd86941 --- /dev/null +++ b/web/src/lib/components/atoms/Tag.svelte @@ -0,0 +1,19 @@ + + + + {@render children()} + diff --git a/web/src/lib/components/layout/Footer.svelte b/web/src/lib/components/layout/Footer.svelte index ba685ed..44c0590 100644 --- a/web/src/lib/components/layout/Footer.svelte +++ b/web/src/lib/components/layout/Footer.svelte @@ -1,20 +1,52 @@ - diff --git a/web/src/lib/components/layout/Header.svelte b/web/src/lib/components/layout/Header.svelte index 46a7474..4123efb 100644 --- a/web/src/lib/components/layout/Header.svelte +++ b/web/src/lib/components/layout/Header.svelte @@ -1,7 +1,9 @@ -
-
- - - Marktvogt +
+ +
+ + Anno MMXXVI · Verzeichnis historischer Märkte + + + DACH-Region + +
+ + +
+ + + + + Marktvogt + - - - -
-
+ + +
{#if mobileOpen} diff --git a/web/src/lib/components/layout/MobileNav.svelte b/web/src/lib/components/layout/MobileNav.svelte index 15c8bdd..3e1367b 100644 --- a/web/src/lib/components/layout/MobileNav.svelte +++ b/web/src/lib/components/layout/MobileNav.svelte @@ -1,5 +1,6 @@ - -

+

Zwei-Faktor-Authentifizierung

-
+ + +
{#if form?.success} - {form.success} +
{form.success}
{/if} {#if form?.totpSecret} {:else} -
-

- Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy). -

+ Authenticator-App +

+ Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy). +

-
-
{ - setupLoading = true; - return async ({ update }) => { - setupLoading = false; - await update(); - }; - }} +
+ { + setupLoading = true; + return async ({ update }) => { + setupLoading = false; + await update(); + }; + }} + > + - + {#if setupLoading}{/if} + 2FA einrichten + + - {#if !showDisableConfirm} - - {/if} -
- - {#if showDisableConfirm} -
(showDisableConfirm = true)} + class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]" > -

- Bist du sicher? Dein Konto wird weniger sicher sein. -

-
-
{ - disableLoading = true; - return async ({ update }) => { - disableLoading = false; - await update(); - }; - }} - > - -
- -
-
- {/if} - - {#if form?.error && !form?.totpSecret} - {form.error} + 2FA deaktivieren + {/if}
+ + {#if showDisableConfirm} +
+

+ Bist du sicher? Dein Konto wird weniger sicher sein. +

+
+
{ + disableLoading = true; + return async ({ update }) => { + disableLoading = false; + await update(); + }; + }} + > + +
+ +
+
+ {/if} + + {#if form?.error && !form?.totpSecret} +
{form.error}
+ {/if} {/if} -
+
-- 2.54.0 From b5748121dd4744495174de6dd1126f648660239f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 10 May 2026 17:24:03 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(backend):=20Phase=204a=20PR1=20?= =?UTF-8?q?=E2=80=94=20user=20status/roles=20+=20admin=20approval=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/internal/domain/auth/service.go | 4 + .../domain/auth/service_refresh_test.go | 11 + backend/internal/domain/user/admin_handler.go | 113 +++++++ backend/internal/domain/user/admin_routes.go | 12 + .../domain/user/admin_security_test.go | 279 ++++++++++++++++++ backend/internal/domain/user/admin_service.go | 51 ++++ backend/internal/domain/user/model.go | 2 + backend/internal/domain/user/repository.go | 63 +++- backend/internal/domain/user/roles.go | 16 + backend/internal/server/routes.go | 5 + .../000034_user_status_and_roles.down.sql | 4 + .../000034_user_status_and_roles.up.sql | 17 ++ 12 files changed, 569 insertions(+), 8 deletions(-) create mode 100644 backend/internal/domain/user/admin_handler.go create mode 100644 backend/internal/domain/user/admin_routes.go create mode 100644 backend/internal/domain/user/admin_security_test.go create mode 100644 backend/internal/domain/user/admin_service.go create mode 100644 backend/internal/domain/user/roles.go create mode 100644 backend/migrations/000034_user_status_and_roles.down.sql create mode 100644 backend/migrations/000034_user_status_and_roles.up.sql diff --git a/backend/internal/domain/auth/service.go b/backend/internal/domain/auth/service.go index 6919179..2e07767 100644 --- a/backend/internal/domain/auth/service.go +++ b/backend/internal/domain/auth/service.go @@ -65,6 +65,10 @@ func (s *Service) Login(ctx context.Context, req LoginRequest, ip, ua string) (A return AuthData{}, fmt.Errorf("invalid credentials") } + 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 { diff --git a/backend/internal/domain/auth/service_refresh_test.go b/backend/internal/domain/auth/service_refresh_test.go index ed2b72b..d447a61 100644 --- a/backend/internal/domain/auth/service_refresh_test.go +++ b/backend/internal/domain/auth/service_refresh_test.go @@ -270,6 +270,17 @@ 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 makeService(authRepo auth.Repository, userRepo user.Repository) *auth.Service { return auth.NewService(authRepo, userRepo, auth.ServiceConfig{ diff --git a/backend/internal/domain/user/admin_handler.go b/backend/internal/domain/user/admin_handler.go new file mode 100644 index 0000000..eeb1234 --- /dev/null +++ b/backend/internal/domain/user/admin_handler.go @@ -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 +} diff --git a/backend/internal/domain/user/admin_routes.go b/backend/internal/domain/user/admin_routes.go new file mode 100644 index 0000000..4672828 --- /dev/null +++ b/backend/internal/domain/user/admin_routes.go @@ -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) + } +} diff --git a/backend/internal/domain/user/admin_security_test.go b/backend/internal/domain/user/admin_security_test.go new file mode 100644 index 0000000..4ae7394 --- /dev/null +++ b/backend/internal/domain/user/admin_security_test.go @@ -0,0 +1,279 @@ +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 string) (user.User, error) { + u := user.User{ID: uuid.New(), Email: email, DisplayName: name, Status: user.StatusActive} + 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 +} + +// 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") + } +} diff --git a/backend/internal/domain/user/admin_service.go b/backend/internal/domain/user/admin_service.go new file mode 100644 index 0000000..dda5c27 --- /dev/null +++ b/backend/internal/domain/user/admin_service.go @@ -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 +} diff --git a/backend/internal/domain/user/model.go b/backend/internal/domain/user/model.go index c66a7f9..7f32f88 100644 --- a/backend/internal/domain/user/model.go +++ b/backend/internal/domain/user/model.go @@ -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"` diff --git a/backend/internal/domain/user/repository.go b/backend/internal/domain/user/repository.go index 419c5ec..48ca41b 100644 --- a/backend/internal/domain/user/repository.go +++ b/backend/internal/domain/user/repository.go @@ -25,6 +25,8 @@ type Repository interface { 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) } type pgRepository struct { @@ -40,10 +42,10 @@ func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayN 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 + RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at `, email, passwordHash, displayName).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) { @@ -59,10 +61,10 @@ func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName s 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 + RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at `, email, emailVerified, displayName).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) { @@ -88,12 +90,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 +127,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 +155,51 @@ 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) Restore(ctx context.Context, id uuid.UUID) error { tag, err := r.db.Exec(ctx, ` UPDATE users SET deleted_at = NULL diff --git a/backend/internal/domain/user/roles.go b/backend/internal/domain/user/roles.go new file mode 100644 index 0000000..a0676aa --- /dev/null +++ b/backend/internal/domain/user/roles.go @@ -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" +) diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index de4b25b..4e5d579 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -85,6 +85,11 @@ func (s *Server) registerRoutes() { 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)) + // Market routes (public + submission + admin) tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey) diff --git a/backend/migrations/000034_user_status_and_roles.down.sql b/backend/migrations/000034_user_status_and_roles.down.sql new file mode 100644 index 0000000..4681dc7 --- /dev/null +++ b/backend/migrations/000034_user_status_and_roles.down.sql @@ -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; diff --git a/backend/migrations/000034_user_status_and_roles.up.sql b/backend/migrations/000034_user_status_and_roles.up.sql new file mode 100644 index 0000000..75f3d24 --- /dev/null +++ b/backend/migrations/000034_user_status_and_roles.up.sql @@ -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')); -- 2.54.0 From a37e79ec1647d408b4ed3acc5108082d726a6d9f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 10 May 2026 17:32:15 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat(backend):=20Phase=204a=20PR2=20?= =?UTF-8?q?=E2=80=94=20groups,=20group=5Fmembers,=20group=5Fprofiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../domain/group/group_security_test.go | 397 ++++++++++++++++++ backend/internal/domain/group/handler.go | 213 ++++++++++ backend/internal/domain/group/model.go | 66 +++ backend/internal/domain/group/repository.go | 220 ++++++++++ backend/internal/domain/group/routes.go | 17 + backend/internal/domain/group/service.go | 172 ++++++++ backend/internal/server/routes.go | 7 + backend/migrations/000035_groups.down.sql | 5 + backend/migrations/000035_groups.up.sql | 32 ++ 9 files changed, 1129 insertions(+) create mode 100644 backend/internal/domain/group/group_security_test.go create mode 100644 backend/internal/domain/group/handler.go create mode 100644 backend/internal/domain/group/model.go create mode 100644 backend/internal/domain/group/repository.go create mode 100644 backend/internal/domain/group/routes.go create mode 100644 backend/internal/domain/group/service.go create mode 100644 backend/migrations/000035_groups.down.sql create mode 100644 backend/migrations/000035_groups.up.sql diff --git a/backend/internal/domain/group/group_security_test.go b/backend/internal/domain/group/group_security_test.go new file mode 100644 index 0000000..095c99a --- /dev/null +++ b/backend/internal/domain/group/group_security_test.go @@ -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") + } +} diff --git a/backend/internal/domain/group/handler.go b/backend/internal/domain/group/handler.go new file mode 100644 index 0000000..c8b7e60 --- /dev/null +++ b/backend/internal/domain/group/handler.go @@ -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 +} diff --git a/backend/internal/domain/group/model.go b/backend/internal/domain/group/model.go new file mode 100644 index 0000000..2c43941 --- /dev/null +++ b/backend/internal/domain/group/model.go @@ -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") +) diff --git a/backend/internal/domain/group/repository.go b/backend/internal/domain/group/repository.go new file mode 100644 index 0000000..b8842bc --- /dev/null +++ b/backend/internal/domain/group/repository.go @@ -0,0 +1,220 @@ +package group + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +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 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 = 'admin' + `, groupID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("counting group admins: %w", err) + } + return count, nil +} + +func isDuplicateKey(err error) bool { + s := err.Error() + return contains(s, "duplicate key") || contains(s, "23505") +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/backend/internal/domain/group/routes.go b/backend/internal/domain/group/routes.go new file mode 100644 index 0000000..b3616d1 --- /dev/null +++ b/backend/internal/domain/group/routes.go @@ -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) + } +} diff --git a/backend/internal/domain/group/service.go b/backend/internal/domain/group/service.go new file mode 100644 index 0000000..8486659 --- /dev/null +++ b/backend/internal/domain/group/service.go @@ -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 +} diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index 4e5d579..504b179 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -11,6 +11,7 @@ import ( "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/group" "marktvogt.de/backend/internal/domain/market" "marktvogt.de/backend/internal/domain/settings" "marktvogt.de/backend/internal/domain/user" @@ -90,6 +91,12 @@ func (s *Server) registerRoutes() { 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) + // Market routes (public + submission + admin) tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey) diff --git a/backend/migrations/000035_groups.down.sql b/backend/migrations/000035_groups.down.sql new file mode 100644 index 0000000..b7af683 --- /dev/null +++ b/backend/migrations/000035_groups.down.sql @@ -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; diff --git a/backend/migrations/000035_groups.up.sql b/backend/migrations/000035_groups.up.sql new file mode 100644 index 0000000..67e6e73 --- /dev/null +++ b/backend/migrations/000035_groups.up.sql @@ -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); -- 2.54.0 From b62271eeb6157849df51dfb7438fa71cb2793ffb Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 10 May 2026 17:42:26 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(backend):=20Phase=204a=20PR3=20?= =?UTF-8?q?=E2=80=94=20applications=20+=20application=5Fstatus=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/application_security_test.go | 420 ++++++++++++++++++ .../internal/domain/application/handler.go | 164 +++++++ backend/internal/domain/application/model.go | 52 +++ .../internal/domain/application/repository.go | 198 +++++++++ backend/internal/domain/application/routes.go | 15 + .../internal/domain/application/service.go | 214 +++++++++ backend/internal/server/routes.go | 36 ++ .../migrations/000036_applications.down.sql | 5 + backend/migrations/000036_applications.up.sql | 41 ++ 9 files changed, 1145 insertions(+) create mode 100644 backend/internal/domain/application/application_security_test.go create mode 100644 backend/internal/domain/application/handler.go create mode 100644 backend/internal/domain/application/model.go create mode 100644 backend/internal/domain/application/repository.go create mode 100644 backend/internal/domain/application/routes.go create mode 100644 backend/internal/domain/application/service.go create mode 100644 backend/migrations/000036_applications.down.sql create mode 100644 backend/migrations/000036_applications.up.sql diff --git a/backend/internal/domain/application/application_security_test.go b/backend/internal/domain/application/application_security_test.go new file mode 100644 index 0000000..801b08f --- /dev/null +++ b/backend/internal/domain/application/application_security_test.go @@ -0,0 +1,420 @@ +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() + application.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) +} + +// 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()) + } +} diff --git a/backend/internal/domain/application/handler.go b/backend/internal/domain/application/handler.go new file mode 100644 index 0000000..56a4edc --- /dev/null +++ b/backend/internal/domain/application/handler.go @@ -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("groupId")) + 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 +} diff --git a/backend/internal/domain/application/model.go b/backend/internal/domain/application/model.go new file mode 100644 index 0000000..6b90f43 --- /dev/null +++ b/backend/internal/domain/application/model.go @@ -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") +) diff --git a/backend/internal/domain/application/repository.go b/backend/internal/domain/application/repository.go new file mode 100644 index 0000000..df1b907 --- /dev/null +++ b/backend/internal/domain/application/repository.go @@ -0,0 +1,198 @@ +package application + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +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 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() +} + +func isDuplicateKey(err error) bool { + s := err.Error() + for i := 0; i <= len(s)-13; i++ { + if s[i:i+13] == "duplicate key" { + return true + } + } + for i := 0; i <= len(s)-5; i++ { + if s[i:i+5] == "23505" { + return true + } + } + return false +} diff --git a/backend/internal/domain/application/routes.go b/backend/internal/domain/application/routes.go new file mode 100644 index 0000000..bb8499d --- /dev/null +++ b/backend/internal/domain/application/routes.go @@ -0,0 +1,15 @@ +package application + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) { + auth := rg.Group("", requireAuth) + { + auth.POST("/groups/:groupId/applications", h.Create) + auth.GET("/groups/:groupId/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) + } +} diff --git a/backend/internal/domain/application/service.go b/backend/internal/domain/application/service.go new file mode 100644 index 0000000..7fadf2a --- /dev/null +++ b/backend/internal/domain/application/service.go @@ -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") diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index 504b179..af892c4 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -2,11 +2,14 @@ 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" @@ -97,6 +100,14 @@ func (s *Server) registerRoutes() { 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) + // Market routes (public + submission + admin) tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey) @@ -158,6 +169,31 @@ func (s *Server) registerRoutes() { settings.RegisterRoutes(v1, settingsHandler, requireAuth, requireAdmin) } +// 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 +} + func (s *Server) healthz(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } diff --git a/backend/migrations/000036_applications.down.sql b/backend/migrations/000036_applications.down.sql new file mode 100644 index 0000000..c799999 --- /dev/null +++ b/backend/migrations/000036_applications.down.sql @@ -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; diff --git a/backend/migrations/000036_applications.up.sql b/backend/migrations/000036_applications.up.sql new file mode 100644 index 0000000..9429e34 --- /dev/null +++ b/backend/migrations/000036_applications.up.sql @@ -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); -- 2.54.0 From 808f07800eb742997ff60ed41939b258a6ec3658 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 10 May 2026 17:54:19 +0200 Subject: [PATCH 8/8] =?UTF-8?q?feat(backend):=20Phase=204a=20PR4=20?= =?UTF-8?q?=E2=80=94=20lagerleben=20articles=20+=20camps=20API;=20wire=20f?= =?UTF-8?q?rontend=20loaders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/lagerleben/handler.go | 76 ++++++ .../domain/lagerleben/lagerleben_test.go | 237 ++++++++++++++++++ backend/internal/domain/lagerleben/model.go | 59 +++++ .../internal/domain/lagerleben/repository.go | 122 +++++++++ backend/internal/domain/lagerleben/routes.go | 10 + backend/internal/domain/lagerleben/service.go | 27 ++ backend/internal/server/routes.go | 7 + backend/migrations/000037_lagerleben.down.sql | 2 + backend/migrations/000037_lagerleben.up.sql | 72 ++++++ web/src/lib/api/types.ts | 20 ++ web/src/routes/lagerleben/+page.server.ts | 15 +- .../lagerleben/lager/[slug]/+page.server.ts | 17 +- .../reportage/[slug]/+page.server.ts | 18 +- 13 files changed, 669 insertions(+), 13 deletions(-) create mode 100644 backend/internal/domain/lagerleben/handler.go create mode 100644 backend/internal/domain/lagerleben/lagerleben_test.go create mode 100644 backend/internal/domain/lagerleben/model.go create mode 100644 backend/internal/domain/lagerleben/repository.go create mode 100644 backend/internal/domain/lagerleben/routes.go create mode 100644 backend/internal/domain/lagerleben/service.go create mode 100644 backend/migrations/000037_lagerleben.down.sql create mode 100644 backend/migrations/000037_lagerleben.up.sql diff --git a/backend/internal/domain/lagerleben/handler.go b/backend/internal/domain/lagerleben/handler.go new file mode 100644 index 0000000..e74e8e3 --- /dev/null +++ b/backend/internal/domain/lagerleben/handler.go @@ -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}) +} diff --git a/backend/internal/domain/lagerleben/lagerleben_test.go b/backend/internal/domain/lagerleben/lagerleben_test.go new file mode 100644 index 0000000..87314e1 --- /dev/null +++ b/backend/internal/domain/lagerleben/lagerleben_test.go @@ -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) + } +} diff --git a/backend/internal/domain/lagerleben/model.go b/backend/internal/domain/lagerleben/model.go new file mode 100644 index 0000000..af2ca60 --- /dev/null +++ b/backend/internal/domain/lagerleben/model.go @@ -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 +} diff --git a/backend/internal/domain/lagerleben/repository.go b/backend/internal/domain/lagerleben/repository.go new file mode 100644 index 0000000..e251df5 --- /dev/null +++ b/backend/internal/domain/lagerleben/repository.go @@ -0,0 +1,122 @@ +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, published, created_at, updated_at + FROM lagerleben_articles + WHERE slug = $1 AND published = TRUE`, + slug) + a, err := scanArticle(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 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 +} diff --git a/backend/internal/domain/lagerleben/routes.go b/backend/internal/domain/lagerleben/routes.go new file mode 100644 index 0000000..c992255 --- /dev/null +++ b/backend/internal/domain/lagerleben/routes.go @@ -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) +} diff --git a/backend/internal/domain/lagerleben/service.go b/backend/internal/domain/lagerleben/service.go new file mode 100644 index 0000000..38ec129 --- /dev/null +++ b/backend/internal/domain/lagerleben/service.go @@ -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) +} diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index af892c4..c04934c 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -15,6 +15,7 @@ import ( "marktvogt.de/backend/internal/domain/discovery/crawler" "marktvogt.de/backend/internal/domain/discovery/enrich" "marktvogt.de/backend/internal/domain/group" + "marktvogt.de/backend/internal/domain/lagerleben" "marktvogt.de/backend/internal/domain/market" "marktvogt.de/backend/internal/domain/settings" "marktvogt.de/backend/internal/domain/user" @@ -108,6 +109,12 @@ func (s *Server) registerRoutes() { appHandler := application.NewHandler(appSvc) application.RegisterRoutes(v1, appHandler, requireAuth) + // 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) diff --git a/backend/migrations/000037_lagerleben.down.sql b/backend/migrations/000037_lagerleben.down.sql new file mode 100644 index 0000000..96a9dca --- /dev/null +++ b/backend/migrations/000037_lagerleben.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS lagerleben_camps; +DROP TABLE IF EXISTS lagerleben_articles; diff --git a/backend/migrations/000037_lagerleben.up.sql b/backend/migrations/000037_lagerleben.up.sql new file mode 100644 index 0000000..4ecc748 --- /dev/null +++ b/backend/migrations/000037_lagerleben.up.sql @@ -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; diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index eecfece..c637e30 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -352,3 +352,23 @@ export interface MarketSearchParams { page?: number; per_page?: number; } + +// Lagerleben types +export interface LagerlebenArticle { + slug: string; + title: string; + subtitle: string; + category: string; + date: string; // YYYY-MM-DD + excerpt: string; + body?: string; +} + +export interface LagerlebenCamp { + slug: string; + name: string; + region: string; + period: string; + excerpt: string; + members: number; +} diff --git a/web/src/routes/lagerleben/+page.server.ts b/web/src/routes/lagerleben/+page.server.ts index 868881c..ba4c81f 100644 --- a/web/src/routes/lagerleben/+page.server.ts +++ b/web/src/routes/lagerleben/+page.server.ts @@ -1,6 +1,15 @@ import type { PageServerLoad } from './$types.js'; -import mock from '$lib/mock/lagerleben.json'; +import { apiFetch } from '$lib/api/client.js'; +import type { LagerlebenArticle, LagerlebenCamp } from '$lib/api/types.js'; -export const load: PageServerLoad = () => { - return { articles: mock.articles, camps: mock.camps }; +export const load: PageServerLoad = async ({ fetch }) => { + const [articlesRes, campsRes] = await Promise.all([ + apiFetch('/lagerleben/articles', { fetch }).catch(() => ({ + data: [] as LagerlebenArticle[] + })), + apiFetch('/lagerleben/camps', { fetch }).catch(() => ({ + data: [] as LagerlebenCamp[] + })) + ]); + return { articles: articlesRes.data, camps: campsRes.data }; }; diff --git a/web/src/routes/lagerleben/lager/[slug]/+page.server.ts b/web/src/routes/lagerleben/lager/[slug]/+page.server.ts index 58fd084..3830436 100644 --- a/web/src/routes/lagerleben/lager/[slug]/+page.server.ts +++ b/web/src/routes/lagerleben/lager/[slug]/+page.server.ts @@ -1,9 +1,16 @@ import type { PageServerLoad } from './$types.js'; import { error } from '@sveltejs/kit'; -import mock from '$lib/mock/lagerleben.json'; +import { apiFetch, ApiClientError } from '$lib/api/client.js'; +import type { LagerlebenCamp } from '$lib/api/types.js'; -export const load: PageServerLoad = ({ params }) => { - const camp = mock.camps.find((c) => c.slug === params.slug); - if (!camp) error(404, 'Lager nicht gefunden'); - return { camp }; +export const load: PageServerLoad = async ({ params, fetch }) => { + try { + const res = await apiFetch(`/lagerleben/camps/${params.slug}`, { fetch }); + return { camp: res.data }; + } catch (err) { + if (err instanceof ApiClientError && err.status === 404) { + error(404, 'Lager nicht gefunden'); + } + error(500, 'Fehler beim Laden des Lagerporträts'); + } }; diff --git a/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts b/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts index cf8522a..92955a0 100644 --- a/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts +++ b/web/src/routes/lagerleben/reportage/[slug]/+page.server.ts @@ -1,9 +1,17 @@ import type { PageServerLoad } from './$types.js'; import { error } from '@sveltejs/kit'; -import mock from '$lib/mock/lagerleben.json'; +import { apiFetch } from '$lib/api/client.js'; +import type { LagerlebenArticle } from '$lib/api/types.js'; +import { ApiClientError } from '$lib/api/client.js'; -export const load: PageServerLoad = ({ params }) => { - const article = mock.articles.find((a) => a.slug === params.slug); - if (!article) error(404, 'Beitrag nicht gefunden'); - return { article }; +export const load: PageServerLoad = async ({ params, fetch }) => { + try { + const res = await apiFetch(`/lagerleben/articles/${params.slug}`, { fetch }); + return { article: res.data }; + } catch (err) { + if (err instanceof ApiClientError && err.status === 404) { + error(404, 'Beitrag nicht gefunden'); + } + error(500, 'Fehler beim Laden des Beitrags'); + } }; -- 2.54.0