From 253c3c1c6d400834e695a46be6bdd59f01623cf5 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 27 Feb 2026 15:19:24 +0100 Subject: [PATCH] push --- BUSINESS_MODEL.md | 66 +- GTM_STRATEGY.md | 60 +- README.md | 6 +- dsvgo+.md | 35 + innungsapp/.dockerignore | 44 + innungsapp/.env.example | 2 + innungsapp/.env.production.example | 24 + innungsapp/README.md | 139 ++- innungsapp/apps/admin/Dockerfile | 82 ++ innungsapp/apps/admin/app/[slug]/actions.ts | 66 ++ .../[slug]/dashboard/ForcePasswordChange.tsx | 70 ++ .../dashboard/einstellungen/page.tsx | 27 + .../admin/app/[slug]/dashboard/layout.tsx | 120 +++ .../[slug]/dashboard/mitglieder/[id]/page.tsx | 271 ++++++ .../dashboard/mitglieder/neu/page.tsx | 56 +- .../dashboard/mitglieder/page.tsx | 82 +- .../{ => [slug]}/dashboard/news/[id]/page.tsx | 65 ++ .../app/[slug]/dashboard/news/neu/page.tsx | 179 ++++ .../app/{ => [slug]}/dashboard/news/page.tsx | 5 +- .../apps/admin/app/[slug]/dashboard/page.tsx | 126 +++ .../dashboard/stellen/DeactivateButton.tsx | 0 .../app/[slug]/dashboard/stellen/neu/page.tsx | 191 +++++ .../{ => [slug]}/dashboard/stellen/page.tsx | 5 +- .../[slug]/dashboard/termine/[id]/page.tsx | 214 +++++ .../dashboard/termine/neu/page.tsx | 0 .../{ => [slug]}/dashboard/termine/page.tsx | 5 +- innungsapp/apps/admin/app/[slug]/page.tsx | 379 +++++++++ .../app/api/ai/generate-landing-page/route.ts | 94 +++ .../apps/admin/app/api/ai/generate/route.ts | 160 ++++ .../auth/clear-must-change-password/route.ts | 18 + .../admin/app/api/export/termin/[id]/route.ts | 50 ++ innungsapp/apps/admin/app/api/health/route.ts | 5 + .../app/api/registrierung/[slug]/route.ts | 72 ++ .../api/registrierung/[slug]/signup/route.ts | 82 ++ .../admin/app/api/uploads/[...path]/route.ts | 1 + .../admin/app/components/LegalPageShell.tsx | 337 ++++++++ .../apps/admin/app/dashboard/layout.tsx | 33 - .../app/dashboard/mitglieder/[id]/page.tsx | 175 ---- .../admin/app/dashboard/news/neu/page.tsx | 159 ---- innungsapp/apps/admin/app/dashboard/page.tsx | 209 +++-- .../admin/app/dashboard/stellen/neu/page.tsx | 182 ---- .../apps/admin/app/datenschutz/layout.tsx | 14 + .../apps/admin/app/datenschutz/page.tsx | 218 +++++ innungsapp/apps/admin/app/favicon.ico | Bin 0 -> 133 bytes innungsapp/apps/admin/app/icon.png | Bin 0 -> 416907 bytes .../apps/admin/app/impressum/layout.tsx | 14 + innungsapp/apps/admin/app/impressum/page.tsx | 50 ++ innungsapp/apps/admin/app/layout.tsx | 47 +- innungsapp/apps/admin/app/login/layout.tsx | 14 + innungsapp/apps/admin/app/login/page.tsx | 262 +++--- innungsapp/apps/admin/app/page.tsx | 283 ++++++- .../apps/admin/app/passwort-aendern/page.tsx | 133 +++ .../admin/app/registrierung/[slug]/page.tsx | 95 +++ innungsapp/apps/admin/app/robots.ts | 15 + innungsapp/apps/admin/app/sitemap.ts | 28 + .../admin/app/superadmin/CreateOrgForm.tsx | 471 +++++++++-- .../app/superadmin/LandingPagePreview.tsx | 355 ++++++++ .../apps/admin/app/superadmin/actions.ts | 505 ++++++++++- .../apps/admin/app/superadmin/create/page.tsx | 30 + .../app/superadmin/landingpages/page.tsx | 119 +++ .../apps/admin/app/superadmin/layout.tsx | 23 +- .../organizations/[id]/CreateAdminForm.tsx | 89 ++ .../organizations/[id]/CreateMemberForm.tsx | 94 +++ .../organizations/[id]/DeleteOrgButton.tsx | 19 + .../organizations/[id]/EditOrgForm.tsx | 344 ++++++++ .../organizations/[id]/MemberActions.tsx | 25 + .../organizations/[id]/UserRoleActions.tsx | 42 + .../superadmin/organizations/[id]/page.tsx | 234 ++++++ innungsapp/apps/admin/app/superadmin/page.tsx | 252 ++++-- .../apps/admin/components/ai-generator.tsx | 181 ++++ .../apps/admin/components/auth/LoginForm.tsx | 118 +++ .../apps/admin/components/layout/Header.tsx | 7 +- .../apps/admin/components/layout/Sidebar.tsx | 22 +- innungsapp/apps/admin/dev_detailed2.txt | Bin 0 -> 1198 bytes innungsapp/apps/admin/docker-entrypoint.sh | 11 + .../apps/admin/instrumentation-client.ts | 109 +++ innungsapp/apps/admin/lib/auth.ts | 39 +- innungsapp/apps/admin/lib/email.ts | 48 ++ innungsapp/apps/admin/lib/tenant.ts | 20 + innungsapp/apps/admin/middleware.ts | 83 +- innungsapp/apps/admin/next.config.ts | 8 + innungsapp/apps/admin/package.json | 36 +- innungsapp/apps/admin/public/logo.png | Bin 0 -> 416907 bytes .../apps/admin/public/mobile-mockup.png | Bin 60682 -> 66080 bytes innungsapp/apps/admin/public/noise.png | Bin 0 -> 9145 bytes .../apps/admin/scripts/recover-actions.ps1 | 20 + innungsapp/apps/admin/seed-auth.ts | 34 - innungsapp/apps/admin/server/routers/index.ts | 2 + .../apps/admin/server/routers/members.ts | 597 ++++++++++++- .../apps/admin/server/routers/messages.ts | 184 ++++ innungsapp/apps/admin/server/routers/news.ts | 61 +- innungsapp/apps/admin/server/trpc.ts | 2 + innungsapp/apps/admin/tailwind.config.ts | 7 +- innungsapp/apps/admin/tsc-errors.txt | 22 + innungsapp/apps/admin/tsconfig.tsbuildinfo | 2 +- .../apps/mobile/.claude/settings.local.json | 4 +- innungsapp/apps/mobile/app.json | 6 +- innungsapp/apps/mobile/app/(app)/_layout.tsx | 219 ++++- .../apps/mobile/app/(app)/chat/[id].tsx | 410 +++++++++ .../apps/mobile/app/(app)/chat/index.tsx | 401 +++++++++ .../apps/mobile/app/(app)/home/index.tsx | 197 +++-- .../apps/mobile/app/(app)/members/[id].tsx | 18 + .../apps/mobile/app/(app)/members/index.tsx | 5 +- .../apps/mobile/app/(app)/news/index.tsx | 55 +- .../apps/mobile/app/(app)/profil/index.tsx | 792 ++++++++++++------ .../apps/mobile/app/(app)/stellen/index.tsx | 5 +- .../apps/mobile/app/(app)/termine/[id].tsx | 80 +- .../apps/mobile/app/(app)/termine/index.tsx | 5 +- innungsapp/apps/mobile/app/(auth)/login.tsx | 32 +- .../apps/mobile/app/(auth)/registrierung.tsx | 253 ++++++ .../apps/mobile/app/stellen-public/index.tsx | 4 + .../mobile/components/news/AttachmentRow.tsx | 5 +- .../components/termine/AnmeldeButton.tsx | 21 +- innungsapp/apps/mobile/lib/api-url.ts | 44 + innungsapp/apps/mobile/lib/auth-client.ts | 10 +- innungsapp/apps/mobile/lib/notifications.ts | 10 +- innungsapp/apps/mobile/lib/org-config.ts | 9 + innungsapp/apps/mobile/lib/trpc.tsx | 7 +- innungsapp/apps/mobile/metro.config.js | 3 +- innungsapp/apps/mobile/store/auth.store.ts | 12 +- innungsapp/dev_detailed.txt | Bin 0 -> 1628 bytes innungsapp/dev_output.txt | Bin 0 -> 3652 bytes innungsapp/docker-compose.yml | 51 ++ innungsapp/packages/shared/package.json | 5 +- innungsapp/packages/shared/prisma/dev.db | Bin 0 -> 258048 bytes .../packages/shared/prisma/prisma/dev.db | Bin 184320 -> 184320 bytes .../packages/shared/prisma/schema.prisma | 61 ++ .../shared/prisma/seed-admin-password.ts | 47 ++ .../shared/prisma/seed-demo-members.ts | 80 ++ .../packages/shared/prisma/seed-superadmin.ts | 28 +- innungsapp/packages/shared/prisma/seed.ts | 9 +- .../packages/shared/prisma/test-hash.ts | 14 - innungsapp/pnpm-lock.yaml | 617 +++++++++++--- leads/leads.csv | 256 +++--- 134 files changed, 11188 insertions(+), 1871 deletions(-) create mode 100644 dsvgo+.md create mode 100644 innungsapp/.dockerignore create mode 100644 innungsapp/.env.production.example create mode 100644 innungsapp/apps/admin/Dockerfile create mode 100644 innungsapp/apps/admin/app/[slug]/actions.ts create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/einstellungen/page.tsx (80%) create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/mitglieder/neu/page.tsx (83%) rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/mitglieder/page.tsx (64%) rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/news/[id]/page.tsx (70%) create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/news/neu/page.tsx rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/news/page.tsx (96%) create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/page.tsx rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/stellen/DeactivateButton.tsx (100%) create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/stellen/neu/page.tsx rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/stellen/page.tsx (94%) create mode 100644 innungsapp/apps/admin/app/[slug]/dashboard/termine/[id]/page.tsx rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/termine/neu/page.tsx (100%) rename innungsapp/apps/admin/app/{ => [slug]}/dashboard/termine/page.tsx (95%) create mode 100644 innungsapp/apps/admin/app/[slug]/page.tsx create mode 100644 innungsapp/apps/admin/app/api/ai/generate-landing-page/route.ts create mode 100644 innungsapp/apps/admin/app/api/ai/generate/route.ts create mode 100644 innungsapp/apps/admin/app/api/auth/clear-must-change-password/route.ts create mode 100644 innungsapp/apps/admin/app/api/export/termin/[id]/route.ts create mode 100644 innungsapp/apps/admin/app/api/health/route.ts create mode 100644 innungsapp/apps/admin/app/api/registrierung/[slug]/route.ts create mode 100644 innungsapp/apps/admin/app/api/registrierung/[slug]/signup/route.ts create mode 100644 innungsapp/apps/admin/app/components/LegalPageShell.tsx delete mode 100644 innungsapp/apps/admin/app/dashboard/layout.tsx delete mode 100644 innungsapp/apps/admin/app/dashboard/mitglieder/[id]/page.tsx delete mode 100644 innungsapp/apps/admin/app/dashboard/news/neu/page.tsx delete mode 100644 innungsapp/apps/admin/app/dashboard/stellen/neu/page.tsx create mode 100644 innungsapp/apps/admin/app/datenschutz/layout.tsx create mode 100644 innungsapp/apps/admin/app/datenschutz/page.tsx create mode 100644 innungsapp/apps/admin/app/favicon.ico create mode 100644 innungsapp/apps/admin/app/icon.png create mode 100644 innungsapp/apps/admin/app/impressum/layout.tsx create mode 100644 innungsapp/apps/admin/app/impressum/page.tsx create mode 100644 innungsapp/apps/admin/app/login/layout.tsx create mode 100644 innungsapp/apps/admin/app/passwort-aendern/page.tsx create mode 100644 innungsapp/apps/admin/app/registrierung/[slug]/page.tsx create mode 100644 innungsapp/apps/admin/app/robots.ts create mode 100644 innungsapp/apps/admin/app/sitemap.ts create mode 100644 innungsapp/apps/admin/app/superadmin/LandingPagePreview.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/create/page.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/landingpages/page.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/CreateAdminForm.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/CreateMemberForm.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/DeleteOrgButton.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/EditOrgForm.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/MemberActions.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/UserRoleActions.tsx create mode 100644 innungsapp/apps/admin/app/superadmin/organizations/[id]/page.tsx create mode 100644 innungsapp/apps/admin/components/ai-generator.tsx create mode 100644 innungsapp/apps/admin/components/auth/LoginForm.tsx create mode 100644 innungsapp/apps/admin/dev_detailed2.txt create mode 100644 innungsapp/apps/admin/docker-entrypoint.sh create mode 100644 innungsapp/apps/admin/instrumentation-client.ts create mode 100644 innungsapp/apps/admin/lib/tenant.ts create mode 100644 innungsapp/apps/admin/public/logo.png create mode 100644 innungsapp/apps/admin/public/noise.png create mode 100644 innungsapp/apps/admin/scripts/recover-actions.ps1 delete mode 100644 innungsapp/apps/admin/seed-auth.ts create mode 100644 innungsapp/apps/admin/server/routers/messages.ts create mode 100644 innungsapp/apps/admin/tsc-errors.txt create mode 100644 innungsapp/apps/mobile/app/(app)/chat/[id].tsx create mode 100644 innungsapp/apps/mobile/app/(app)/chat/index.tsx create mode 100644 innungsapp/apps/mobile/app/(auth)/registrierung.tsx create mode 100644 innungsapp/apps/mobile/lib/api-url.ts create mode 100644 innungsapp/apps/mobile/lib/org-config.ts create mode 100644 innungsapp/dev_detailed.txt create mode 100644 innungsapp/dev_output.txt create mode 100644 innungsapp/docker-compose.yml create mode 100644 innungsapp/packages/shared/prisma/seed-admin-password.ts create mode 100644 innungsapp/packages/shared/prisma/seed-demo-members.ts delete mode 100644 innungsapp/packages/shared/prisma/test-hash.ts diff --git a/BUSINESS_MODEL.md b/BUSINESS_MODEL.md index 1a4e980..d1c66d9 100644 --- a/BUSINESS_MODEL.md +++ b/BUSINESS_MODEL.md @@ -5,9 +5,9 @@ ## 1. Geschäftsmodell-Überblick **Typ:** B2B SaaS (Business-to-Business, Software as a Service) -**Käufer:** Innungen und Kreishandwerkerschaften +**Käufer:** Kreisverbände (als Multiplikatoren) & Innungen **Endnutzer:** Mitglieder der Innungen (Handwerksbetriebe, Azubis) -**Vertrieb:** Direct Sales (Phase 1) → Verbands-Partnerschaft (Phase 2-3) +**Vertrieb:** Multiplier Strategy (targeting 240 Kreisverbände) --- @@ -17,10 +17,11 @@ | Plan | Preis | Mitglieder | Laufzeit | |---|---|---|---| -| **Pilot** | 0 € (3 Monate) | bis 50 | Testphase | -| **Starter** | 99 € / Monat | bis 100 | Monatlich kündbar | -| **Standard** | 199 € / Monat | bis 300 | Monatlich kündbar | -| **Pro** | 349 € / Monat | unbegrenzt | Monatlich kündbar | +| **Kreisverband Setup** | 5.000 € (einmalig) | p. Verband | Implementierung | +| **Gilden-Account** | 150–300 € / Monat | p. Innung | Jährlich / Monatlich | +| **Starter (Direkt)** | 99 € / Monat | bis 100 | Monatlich kündbar | +| **Standard (Direkt)** | 199 € / Monat | bis 300 | Monatlich kündbar | +| **Pro (Direkt)** | 349 € / Monat | unbegrenzt | Monatlich kündbar | ### Jahresvertrag (15 % Rabatt) @@ -88,31 +89,26 @@ Erwarteter Churn: **< 1 % pro Monat** (= 11,4 % annual) für das erste Jahr, sin --- -## 5. Revenue-Projektionen +## 5. Revenue-Projektionen (Multiplier-Modell) -### Szenario: Konservativ +### Fokus: NRW Markt-Potential -| Quartal | Neue Innungen | Gesamt Innungen | MRR | -|---|---|---|---| -| Q1 2026 | 5 (Piloten, kostenlos) | 5 | 0 € | -| Q2 2026 | 5 zahlend | 10 | 750 € | -| Q3 2026 | 10 zahlend | 20 | 2.200 € | -| Q4 2026 | 15 zahlend | 35 | 5.500 € | -| Q1 2027 | 20 zahlend | 55 | 9.500 € | -| Q2 2027 | 30 zahlend | 85 | 16.000 € | +| Segment | Anzahl | Setup Rev. (einmalig) | MRR (recurring) | ARR | +|---|---|---|---|---| +| **Top 10 NRW Kreisverbände** | 10 KV / 311 Gilden | 50.000 € | 61.889 € | 742.668 € | +| **Gesamt NRW Potential** | 40 KV / ~1.000 Gilden | 200.000 € | 199.000 € | 2.388.000 € | +| **Gesamt DE Potential** | 240 KV / ~7.500 Gilden | 1.200.000 € | 1.492.500 € | 17.910.000 € | -**ARR Ende 2026:** ~66.000 € -**ARR Ende 2027:** ~192.000 € +*Annahmen: Durchschnitt €199 MRR pro Gilde/Innung; €5.000 Setup pro Kreisverband.* -### Szenario: Optimistisch (mit HWK-Partner) +--- -| Zeitpunkt | Innungen | MRR | ARR | -|---|---|---|---| -| Q4 2026 (HWK-Deal) | 80 | 14.000 € | 168.000 € | -| Q2 2027 | 250 | 47.000 € | 564.000 € | -| Q4 2027 | 500 | 100.000 € | 1.200.000 € | +**Top 3 NRW "Cash-Cow" Targets:** +1. **KH Niederrhein:** 41 Innungen → €5.000 Setup + €8.159 MRR. +2. **KH Gütersloh-Bielefeld:** 43 Innungen → €5.000 Setup + €8.557 MRR. +3. **KH Ruhr:** 39 Innungen → €5.000 Setup + €7.761 MRR. -**Break-even bei:** ~8 zahlende Innungen à 199 € (Infrastrukturkosten ~80 €/Monat) +**Break-even:** Bereits nach dem 1. Kreisverband-Setup (NRW Niederrhein) ist die Basis-Infrastruktur für das erste Jahr finanziert. --- @@ -144,24 +140,24 @@ Erwarteter Churn: **< 1 % pro Monat** (= 11,4 % annual) für das erste Jahr, sin ## 7. Distributionsstrategie -### Phase 1: Direct Sales (Monat 1–6) +### Phase 1: Kreisverband Multiplier (NRW) -**Ziel:** 5–10 Piloten in Baden-Württemberg +**Ziel:** 5 Kreisverbände in NRW als Kunden (ca. 250 angeschlossene Innungen) **Taktik:** -1. Kaltakquise-Mail an 50 Innungen in BW (SHK, Elektro, Bau, Dachdecker) -2. Kostenloser 3-Monats-Pilot als Türöffner -3. Demo-Call → Figma-Prototype zeigen -4. Pilot live, Feedback sammeln, Testimonials sammeln +1. Kaltakquise-LinkedIn/Mail an 40 Hauptgeschäftsführer (HGF) in NRW +2. White-Label-Demo für den Kreisverband (Multiplier-Effekt) +3. Demo-Call → "InnungsApp NRW Edition" zeigen +4. Pilot mit einem Verband, Onboarding der ersten 10–20 Innungen **Aufwand:** ~10h/Woche Sales, 1 Person **Erwartete Conversion:** -- 50 angeschriebene Innungen -- 15 Antworten (30 %) +- 40 angeschriebene Kreisverbände +- 12 Antworten (30 %) - 8 Demo-Calls -- 5 Piloten (10 %) -- 3 zahlende Kunden nach Pilot (60 % Pilot-Conversion) +- 5 Abschlüsse (KV-Setup) +- 250 automatisch erreichte Innungsendnutzer (indirekt) ### Phase 2: Regionale HWK-Partnerschaft (Monat 6–12) diff --git a/GTM_STRATEGY.md b/GTM_STRATEGY.md index 9c6227a..53e02b9 100644 --- a/GTM_STRATEGY.md +++ b/GTM_STRATEGY.md @@ -4,54 +4,68 @@ ## 1. Markt-Entry Strategie -### Fokus: Baden-Württemberg First +### Fokus: North Rhine-Westphalia (NRW) First -**Warum BW als erstes Bundesland?** -- 1.000+ Innungen in BW (10–15 % des Gesamtmarkts) -- HWK Baden-Württemberg hat 3 Kammern (Stuttgart, Karlsruhe, Freiburg) mit regionaler Entscheidungshoheit -- Geografisch erreichbar für persönliche Demos -- ZDH-Präsident kommt aus BW (Netzwerkeffekt) +**Warum NRW als erstes Bundesland?** +- Größter Markt in DE (17.5M+ Einwohner, höchste Dichte an Handwerksbetrieben) +- 40+ Kreishandwerkerschaften in NRW (ca. 1/6 des Gesamtmarkts) +- Starke Ballungszentren (Ruhrgebiet, Köln/Düsseldorf) erlauben hohe Lead-Dichte +- Bekannte digitale Pionier-Verbände in der Region (z.B. Köln, Düsseldorf, Münster) **Zielsegment Phase 1:** -- Handwerk-Innungen mit 50–300 Mitgliedern +- Kreisverbände (240 in DE) als Multiplikatoren +- Handwerk-Innungen mit 50–300 Mitgliedern via Kreisverbände - Branchen: SHK, Elektrotechnik, Bau, Dachdecker (hoher Fachkräftemangel) -- Geschäftsführer ist digital affin (< 55 Jahre, LinkedIn-Präsenz) +- Entscheidungsebene: Hauptgeschäftsführer (HGF) der Kreisverbände --- ## 2. Phase 1: Direct Sales (Monat 1–6) ### Ziel -5 zahlende Innungen in BW +5 zahlende Kreisverbände in NRW ### Prospecting **Lead-Generierung:** -1. Scraping: innungsverzeichnis.de / HWK-Websites → alle BW-Innungen mit Kontaktdaten -2. LinkedIn: Geschäftsführer und Obermeister identifizieren -3. Branchen-Priorisierung: SHK > Elektro > Bau > Dachdecker +1. Scraping: kh-online.de / HWK-Websites → alle NRW Kreishandwerkerschaften +2. LinkedIn: Hauptgeschäftsführer (HGF) identifizieren +3. Ballungszentrum-Fokus: Köln > Düsseldorf > Ruhrgebiet -**Ziel-Lead-Liste: 100 Innungen in BW** +**Ziel-Lead-Liste: Top 10 Kreisverbände in NRW** +| Rang | Kreisverband / Kreishandwerkerschaft (KH) | Innungen | Fokus-Region | +|---|---|---|---| +| 1 | **KH Niederrhein** (Krefeld, Viersen, Neuss) | 41 | Niederrhein | +| 2 | **KH Ruhr** (Bochum, Herne, Ennepe-Ruhr) | 39 | Ruhrgebiet | +| 3 | **KH Gütersloh-Bielefeld** | 43 | Ostwestfalen-Lippe | +| 4 | **KH Köln** | 31 | Köln / Rheinschiene | +| 5 | **KH Münster** | 34 | Münsterland | +| 6 | **KH Dortmund und Lünen** | 23 | Westfalen | +| 7 | **KH Düsseldorf** | 25+ | Düsseldorf | +| 8 | **KH Borken** | 31 | Münsterland | +| 9 | **KH Steinfurt-Warendorf** | 24 | Münsterland | +| 10 | **KH Bonn • Rhein-Sieg** | 20+ | Bonn / Rhein-Sieg | ### Outreach-Sequenz -**E-Mail 1 (Tag 1): Kaltakquise** +**E-Mail 1 (Tag 1): Kreisverband-Outreach (HGF-Fokus)** ``` -Betreff: Digitale Mitgliederverwaltung für [Innungsname] +Betreff: Digitale Lösung für Ihre [Anzahl] Innungen — InnungsApp Hallo Frau/Herr [Name], -ich bin Timo Knuth und entwickle InnungsApp — eine App, die Innungen von -Excel und WhatsApp befreit. +ich bin Timo Knuth und entwickle InnungsApp — eine Plattform, mit der Sie +Ihre [Anzahl] Innungen zentral digitalisieren und von Excel/WhatsApp befreien. -Drei Dinge in 30 Sekunden: -- Mitgliederverzeichnis mit Suche auf dem Smartphone -- Rundschreiben mit Push-Benachrichtigung (Sie sehen, wer es gelesen hat) -- Lehrlingsbörse — Azubis finden, direkt über Ihre Innung +Drei Vorteile für Ihren Kreisverband: +- Zentrale Mitgliederverwaltung für alle angeschlossenen Innungen +- Digitale Rundschreiben mit Push-Benachrichtigung & Lesebestätigung +- Exklusive Lehrlingsbörse für den gesamten Kreisverband -Für [Innungsname] würde ich das 3 Monate kostenlos zur Verfügung stellen. +Wir bieten für Kreisverbände ein White-Label-Setup (€5.000 einmalig) +und attraktive Gilden-Konditionen an. -Haben Sie 20 Minuten für eine kurze Demo nächste Woche? +Haben Sie 20 Minuten für eine kurze Demo für Ihre Innungsgeschäftsführer? Mit freundlichen Grüßen Timo Knuth diff --git a/README.md b/README.md index cc009c8..84d958c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ npx expo start ``` -pnpm --filter @innungsapp/admin dev +pnpm --filter @innungsapp/admin dev -- --port 3032 -px expo start --clear +npx expo start --clear + +Demo: admin@demo.de / demo1234 diff --git a/dsvgo+.md b/dsvgo+.md new file mode 100644 index 0000000..dfb6244 --- /dev/null +++ b/dsvgo+.md @@ -0,0 +1,35 @@ +1. Pflichtdokumente (Rechtstexte) +Impressum (Anbieterkennzeichnung): +Wo? Auf der Landingpage, im Admin-Dashboard und in der mobilen App (meist im Einstellungs-Menü). +Was muss rein? Name, Adresse, Rechtsform (z. B. GmbH, UG), Vertretungsberechtigte, Kontaktmöglichkeiten (E-Mail, Telefon), Handelsregister (falls vorhanden) und USt-IdNr. +Darf maximal 2 Klicks entfernt sein (Impressumspflicht nach § 5 DDG). +Datenschutzerklärung: +Wo? Auf der Landingpage, im Admin-Dashboard und in der mobilen App. Außerdem musst du sie beim Einreichen der App in den Apple App Store und Google Play Store verlinken. +Was muss rein? Welche Daten du sammelst (z. B. E-Mail, IP-Adresse, hochgeladene Dateien), auf welcher Rechtsgrundlage (z. B. Vertragserfüllung für die App-Nutzung), wie lange du sie speicherst und an wen du sie weitergibst (deine Hosting-Anbieter). Du musst auch über die Nutzerrechte (Auskunft, Löschung) aufklären. +2. Cookie-Banner & Tracking (TDDDG) +Technisch notwendige Cookies: Wenn du nur Cookies für den Login-Check nutzt (das macht better-auth voraussichtlich mit Session-Cookies), brauchst du kein nerviges Cookie-Banner. Du musst diese Cookies nur in der Datenschutzerklärung erwähnen. +Tracking & Analytics: Falls du auf der Landingpage oder in der App Dinge wie Google Analytics, Facebook Pixel, Mixpanel oder PostHog einbaust, musst du dir vorher die aktive, freiwillige Zustimmung der Nutzer holen (Cookie-Banner / Consent Dialog in der App). +3. Account-Löschung (Besonders wichtig für die App Stores!) +Sowohl Apple als auch Google schreiben mittlerweile streng vor, dass Nutzer, die in einer App ein Konto erstellen können, dieses Konto auch in der App wieder löschen können müssen. +Wichtig: Es reicht nicht, das Konto nur zu deaktivieren. Die User-Daten müssen in der Datenbank gelöscht werden (Ausnahme: Aufbewahrungspflichten wie Rechnungen). +Du brauchst zudem einen Web-Link, über den Nutzer die Löschung außerhalb der App beantragen/durchführen können (z. B. auf deiner Landingpage). +4. Verträge zur Auftragsverarbeitung (AV-Verträge / DPA) +Du darfst personenbezogene Daten nicht einfach so auf fremden Servern speichern, ohne einen Vertrag mit dem Anbieter zu haben (Art. 28 DSGVO). Du brauchst (bzw. musst digital akzeptieren) AV-Verträge von: + +Deinem Server/Hosting-Anbieter (z. B. Hetzner, Vercel, AWS). +Dem Anbieter deines SMTP-Servers (der die E-Mails wie Magic Links versendet). +Jedem externen Tool, das Nutzerdaten sieht (z. B. Sentry für Error Tracking, falls genutzt). +5. Technische Sicherheit & Grundsätze (In deiner App) +Datenminimierung: Sammle nur Daten, die du wirklich für die App brauchst. +Verschlüsselung: Alle Verbindungen (EXPO_PUBLIC_API_URL und NEXT_PUBLIC_APP_URL) müssen im Live-Betrieb zwingend über HTTPS laufen. +Passwörter/Sicherheit: Da du better-auth nutzt, wird das Thema Passwortverschlüsselung & Session-Management glücklicherweise schon sicher für dich geklärt, aber du bist dennoch für die sichere Konfiguration verantwortlich (z. B. einen sicheren BETTER_AUTH_SECRET im Live-Betrieb nutzen). +Server-Standort: Achte darauf, wo deine SQLite-Datenbank bzw. dein Server liegt. Ein Serverstandort in Deutschland oder der EU macht den Datenschutz erheblich einfacher, da du keine komplizierten "Drittland-Transfers" belegen musst. +6. Spezifische App Store Anforderungen +Apple App Store ("App Privacy"): Du musst in App Store Connect genaue Fragen beantworten (welche Daten sammelst du? Sind sie mit dem Benutzer verknüpft? Wofür werden sie genutzt?), die dann als "Privacy Nutrition Labels" im App Store angezeigt werden. +Google Play Store ("Data Safety"): Ähnliches Formular in der Google Play Console. Auch hier musst du erklären, was du sammelst, ob es verschlüsselt ist und ob der Nutzer die Löschung beantragen kann. +Zusammenfassende To-Do-Liste für den Live-Gang: + Impressum erstellen und in Web/App verlinken. + Datenschutzerklärung für App und Webseite generieren lassen (geht gut über Tools wie eRecht24, IT-Recht Kanzlei oder den Datenschutz-Generator von Dr. Schwenke). + Einen "Account Löschen"-Button tief in den App-Einstellungen einbauen. + AV-Verträge mit dem Hoster (und z. B. dem E-Mail-Provider) abschließen (sind meist nur 2 Klicks im Dashboard der Anbieter). + SSL/HTTPS auf dem Server aktivieren. \ No newline at end of file diff --git a/innungsapp/.dockerignore b/innungsapp/.dockerignore new file mode 100644 index 0000000..04825f6 --- /dev/null +++ b/innungsapp/.dockerignore @@ -0,0 +1,44 @@ +# Dependencies (rebuilt in Docker) +node_modules +**/node_modules + +# Next.js build cache +**/.next +**/out + +# Expo / Mobile (not needed for admin Docker build) +apps/mobile + +# Dev databases +**/*.db +**/*.db-journal +**/*.db-wal +**/*.db-shm + +# Uploads (mounted as volume) +apps/admin/uploads + +# Env files +**/.env +**/.env.local +**/.env.development +**/.env.production + +# Git +.git +.gitignore + +# Logs +**/*.log +**/npm-debug.log* + +# TypeScript build info +**/*.tsbuildinfo + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode +.idea diff --git a/innungsapp/.env.example b/innungsapp/.env.example index e9fc4f5..2c08eff 100644 --- a/innungsapp/.env.example +++ b/innungsapp/.env.example @@ -25,6 +25,8 @@ SMTP_PASS="" # ADMIN APP (Next.js) # ============================================= NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_POSTHOG_KEY="" +NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" # ============================================= # MOBILE APP (Expo) diff --git a/innungsapp/.env.production.example b/innungsapp/.env.production.example new file mode 100644 index 0000000..a8a8347 --- /dev/null +++ b/innungsapp/.env.production.example @@ -0,0 +1,24 @@ +# ============================================= +# Produktion — .env Vorlage +# Kopieren als: innungsapp/.env +# ============================================= + +# Auth — UNBEDINGT ändern! +BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string" +BETTER_AUTH_URL="https://yourdomain.com" + +# Email (SMTP) +EMAIL_FROM="noreply@yourdomain.com" +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_SECURE="false" +SMTP_USER="user@example.com" +SMTP_PASS="your-smtp-password" + +# Öffentliche URLs +NEXT_PUBLIC_APP_URL="https://yourdomain.com" +NEXT_PUBLIC_POSTHOG_KEY="" +NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" + +# Uploads +UPLOAD_MAX_SIZE_MB="10" diff --git a/innungsapp/README.md b/innungsapp/README.md index 77a07bc..469e44c 100644 --- a/innungsapp/README.md +++ b/innungsapp/README.md @@ -108,13 +108,148 @@ Alle API-Endpunkte sind typsicher über tRPC definiert: ## Deployment -### Admin (Vercel) +### Admin — Docker (empfohlen für Self-Hosting) + +**Voraussetzungen:** Docker + Docker Compose auf dem Server installiert. + +#### Schritt 1: Repository klonen + +```bash +git clone +cd innungsapp +``` + +#### Schritt 2: Umgebungsvariablen anlegen + +```bash +cp .env.production.example .env +``` + +Dann `.env` öffnen und **alle Werte** befüllen: + +| Variable | Beschreibung | +|---|---| +| `BETTER_AUTH_SECRET` | Zufälliger String (min. 32 Zeichen) — z.B. `openssl rand -hex 32` | +| `BETTER_AUTH_URL` | Öffentliche URL der App, z.B. `https://app.deine-innung.de` | +| `NEXT_PUBLIC_APP_URL` | Gleicher Wert wie `BETTER_AUTH_URL` | +| `EMAIL_FROM` | Absender-Adresse für Magic Links | +| `SMTP_HOST` | SMTP-Server-Adresse | +| `SMTP_PORT` | Meistens `587` (STARTTLS) oder `465` (SSL) | +| `SMTP_USER` | SMTP-Benutzername | +| `SMTP_PASS` | SMTP-Passwort | + +#### Schritt 3: Container bauen und starten + +```bash +docker compose up -d --build +``` + +Der Build dauert beim ersten Mal ~2–3 Minuten. Danach läuft die App auf **Port 3000**. + +Logs prüfen: +```bash +docker compose logs -f admin +``` + +#### Schritt 4: Superadmin anlegen (nur beim ersten Start) + +```bash +docker compose exec admin node -e " +const { PrismaClient } = require('@prisma/client'); +const { scryptSync, randomBytes } = require('crypto'); +const prisma = new PrismaClient(); +// Superadmin wird via seed-superadmin.ts angelegt +" +``` + +Einfacher: Den Seed direkt ausführen: + +```bash +docker compose exec -w /app admin \ + node packages/shared/prisma/seed-superadmin.js +``` + +> Standard-Login nach Seed: `superadmin@innungsapp.de` / `demo1234` +> **Passwort sofort in den Einstellungen ändern!** + +#### Schritt 5: Reverse Proxy (HTTPS) + +Nginx-Beispielkonfiguration für `app.deine-innung.de`: + +```nginx +server { + listen 80; + server_name app.deine-innung.de; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name app.deine-innung.de; + + ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem; + + client_max_body_size 20M; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} +``` + +SSL-Zertifikat mit Certbot: +```bash +certbot --nginx -d app.deine-innung.de +``` + +#### Updates einspielen + +```bash +git pull +docker compose up -d --build +``` + +Datenbank und Uploads bleiben dabei erhalten (Docker Volumes). + +#### Häufige Befehle + +```bash +# Status prüfen +docker compose ps + +# Logs ansehen +docker compose logs -f admin + +# Container neustarten +docker compose restart admin + +# In Container einloggen +docker compose exec admin sh + +# App stoppen +docker compose down + +# App stoppen + Daten löschen (Vorsicht!) +docker compose down -v +``` + +--- + +### Admin — Vercel (Alternative) ```bash # Umgebungsvariablen in Vercel setzen: # DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_* -# Deploy vercel --cwd apps/admin ``` diff --git a/innungsapp/apps/admin/Dockerfile b/innungsapp/apps/admin/Dockerfile new file mode 100644 index 0000000..2bcbec4 --- /dev/null +++ b/innungsapp/apps/admin/Dockerfile @@ -0,0 +1,82 @@ +# ============================================= +# Stage 1: Dependencies +# ============================================= +FROM node:20-alpine AS deps +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +WORKDIR /app + +# Copy workspace config files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/admin/package.json ./apps/admin/ +COPY packages/shared/package.json ./packages/shared/ + +# Install all dependencies +RUN pnpm install --frozen-lockfile + +# ============================================= +# Stage 2: Build +# ============================================= +FROM node:20-alpine AS builder +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules 2>/dev/null || true + +COPY . . + +# Generate Prisma client +RUN pnpm --filter @innungsapp/shared prisma:generate + +# Build the admin app +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm --filter @innungsapp/admin build + +# ============================================= +# Stage 3: Production Runner +# ============================================= +FROM node:20-alpine AS runner +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy built output +COPY --from=builder /app/apps/admin/.next/standalone ./ +COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static +COPY --from=builder /app/apps/admin/public ./apps/admin/public + +# Copy Prisma schema + migrations for runtime migrations +COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma +COPY --from=builder /app/node_modules/.pnpm ./node_modules/.pnpm 2>/dev/null || true +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma + +# Create uploads directory +RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads + +# Create SQLite data directory +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + +# Copy entrypoint +COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/innungsapp/apps/admin/app/[slug]/actions.ts b/innungsapp/apps/admin/app/[slug]/actions.ts new file mode 100644 index 0000000..57abc74 --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/actions.ts @@ -0,0 +1,66 @@ +'use server' + +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { prisma } from '@innungsapp/shared' +import { headers } from 'next/headers' +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) { + const currentPassword = formData.get('currentPassword') as string + const newPassword = formData.get('newPassword') as string + const confirmPassword = formData.get('confirmPassword') as string + + if (newPassword !== confirmPassword) { + return { success: false, error: 'Passwörter stimmen nicht überein.' } + } + + if (newPassword.length < 8) { + return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' } + } + + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) { + return { success: false, error: 'Nicht authentifiziert.' } + } + + let redirectUrl: string | null = null + + try { + // Update password using better-auth + // This will throw if the current password is invalid or other error occurs + await auth.api.changePassword({ + headers: sanitizedHeaders, + body: { + newPassword, + currentPassword, + } + }) + + // Update mustChangePassword flag in database + await prisma.user.update({ + where: { id: session.user.id }, + data: { mustChangePassword: false } + }) + + const slug = formData.get('slug') as string + + // Sign out so the user has to re-login with the new password + await auth.api.signOut({ headers: sanitizedHeaders }) + + redirectUrl = `/login?message=password_changed&callbackUrl=/${slug}/dashboard` + } catch (e: any) { + console.error('Password reset exception:', e) + // BetterAuth errors often have a message or code + const errorMessage = e?.message?.toLowerCase() || '' + if (errorMessage.includes('invalid') && errorMessage.includes('password')) { + return { success: false, error: 'Das aktuelle Passwort ist nicht korrekt.' } + } + return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' } + } + + if (redirectUrl) { + redirect(redirectUrl) + } +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx new file mode 100644 index 0000000..3a86620 --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useActionState, useState } from 'react' +import { changePasswordAndDisableMustChange } from '../actions' + +export function ForcePasswordChange({ slug }: { slug: string }) { + const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '' }) + + return ( +
+
+

Passwort ändern

+

+ Dies ist Ihre erste Anmeldung mit den vom Administrator vergebenen Zugangsdaten. + Bitte vergeben Sie ein neues, sicheres Passwort. +

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {state?.error && ( +

{state?.error}

+ )} + + +
+
+ ) +} diff --git a/innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/einstellungen/page.tsx similarity index 80% rename from innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx rename to innungsapp/apps/admin/app/[slug]/dashboard/einstellungen/page.tsx index f2b64d0..743253d 100644 --- a/innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/einstellungen/page.tsx @@ -100,6 +100,33 @@ export default function EinstellungenPage() { )} + {/* Registrierungslink */} +
+

Registrierungslink

+

+ Teilen Sie diesen Link mit neuen Mitgliedern. Sie können sich damit selbst registrieren + und erhalten einen Aktivierungslink per E-Mail. +

+
+ + +
+
+ {/* Plan Info */}

Plan

diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx new file mode 100644 index 0000000..6dce40a --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/dashboard/layout.tsx @@ -0,0 +1,120 @@ +import { Sidebar } from '@/components/layout/Sidebar' +import { Header } from '@/components/layout/Header' +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { prisma } from '@innungsapp/shared' +import { ForcePasswordChange } from './ForcePasswordChange' + +export default async function DashboardLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ slug: string }> +}) { + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) { + redirect('/login') + } + + // Superadmin Redirect + const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de' + if (session.user.email === superAdminEmail) { + redirect('/superadmin') + } + + const { slug } = await params + const org = await prisma.organization.findUnique({ + where: { slug } + }) + + // Basic security: Check if the user is an admin of this organization + const userRole = org + ? await prisma.userRole.findUnique({ + where: { orgId_userId: { orgId: org.id, userId: session.user.id } } + }) + : null + + // If not found for this slug, check if user is admin of ANY org and redirect there + if (!userRole || userRole.role !== 'admin') { + const anyAdminRole = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: 'admin' }, + include: { org: true }, + orderBy: { createdAt: 'asc' }, + }) + console.error('[Dashboard] Zugriff verweigert Debug:', { + sessionUserId: session.user.id, + sessionUserEmail: session.user.email, + slug, + orgFound: !!org, + orgId: org?.id, + userRoleFound: !!userRole, + userRoleRole: userRole?.role, + anyAdminRoleFound: !!anyAdminRole, + anyAdminRoleOrgSlug: anyAdminRole?.org?.slug, + }) + if (anyAdminRole?.org?.slug && anyAdminRole.org.slug !== slug) { + redirect(`/${anyAdminRole.org.slug}/dashboard`) + } + } + + // ONLY admins are allowed in the administrative portal + if (!userRole || userRole.role !== 'admin') { + return ( +
+
+
+ + + +
+

Zugriff verweigert

+

+ Dieses Portal ist ausschließlich für Administratoren reserviert. Ihr Account verfügt nicht über die notwendigen Berechtigungen für diesen Bereich. +

+
{ + 'use server' + const { auth } = await import('@/lib/auth') + const { headers } = await import('next/headers') + await auth.api.signOut({ headers: await headers() }) + redirect('/login') + }}> + +
+
+
+ ) + } + + // Force Password Change Check + // @ts-ignore - mustChangePassword is added via additionalFields + if (session.user.mustChangePassword) { + return ( +
+ +
+ ) + } + + // Inject Primary Color Theme + const primaryColor = org?.primaryColor || '#E63946' + + return ( +
+ + +
+
+
{children}
+
+
+ ) +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx new file mode 100644 index 0000000..c2fbef7 --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/[id]/page.tsx @@ -0,0 +1,271 @@ +'use client' + +import { use } from 'react' +import { useRouter } from 'next/navigation' +import { trpc } from '@/lib/trpc-client' +import { getTrpcErrorMessage } from '@/lib/trpc-error' +import Link from 'next/link' +import { useState, useEffect } from 'react' +import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared' +import { Trash2 } from 'lucide-react' + +export default function MitgliedEditPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + const { data: member, isLoading } = trpc.members.byId.useQuery({ id }) + const updateMutation = trpc.members.update.useMutation({ + onSuccess: () => router.push('/dashboard/mitglieder'), + }) + const deleteMutation = trpc.members.delete.useMutation({ + onSuccess: () => router.push('/dashboard/mitglieder'), + }) + const resendMutation = trpc.members.resendInvite.useMutation() + + const [form, setForm] = useState({ + name: '', + betrieb: '', + sparte: '', + ort: '', + telefon: '', + email: '', + status: 'aktiv' as 'aktiv' | 'ruhend' | 'ausgetreten', + istAusbildungsbetrieb: false, + seit: undefined as number | undefined, + role: 'member' as 'member' | 'admin', + password: '', + }) + const [isChangingPassword, setIsChangingPassword] = useState(false) + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + + useEffect(() => { + if (member) { + setForm({ + name: member.name || '', + betrieb: member.betrieb || '', + sparte: member.sparte || '', + ort: member.ort || '', + telefon: member.telefon ?? '', + email: member.email || '', + status: (member.status as 'aktiv' | 'ruhend' | 'ausgetreten') || 'aktiv', + istAusbildungsbetrieb: member.istAusbildungsbetrieb || false, + seit: member.seit ?? undefined, + // @ts-ignore + role: member.role || 'member', + password: '', + }) + } + }, [member]) + + if (isLoading) return
Wird geladen...
+ if (!member) return null + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + updateMutation.mutate({ id, data: form }) + } + + const inputClass = + 'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent' + + return ( +
+
+ + ← Zurück + + / +

Mitglied bearbeiten

+
+ + {/* Invite Status */} +
+
+

App-Zugang

+

+ {member.userId + ? 'Mitglied hat sich eingeloggt' + : 'Noch nicht eingeladen / eingeloggt'} +

+
+ {!member.userId && ( + + )} +
+ +
+
+ {/* Section: Stammdaten */} +
+

Stammdaten

+
+
+ + setForm({ ...form, name: e.target.value })} className={inputClass} /> +
+
+ + setForm({ ...form, betrieb: e.target.value })} className={inputClass} /> +
+
+ + +
+
+ + setForm({ ...form, ort: e.target.value })} className={inputClass} /> +
+
+
+ + {/* Section: Kontakt */} +
+

Kontakt

+
+
+ + setForm({ ...form, email: e.target.value })} className={inputClass} /> +
+
+ + setForm({ ...form, telefon: e.target.value })} className={inputClass} /> +
+
+
+ + {/* Section: Status */} +
+

Status

+
+
+ + +
+
+ + +
+
+ + {isChangingPassword ? ( +
+ setForm({ ...form, password: e.target.value })} + className={inputClass} + /> + +
+ ) : ( +
+ + +
+ )} +
+
+ + setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} /> +
+
+ +
+
+
+ + {(updateMutation.error || deleteMutation.error) && ( +

+ {getTrpcErrorMessage(updateMutation.error || deleteMutation.error)} +

+ )} + +
+ + + Abbrechen + +
+
+ + {/* Danger Zone */} +
+
+

Mitglied löschen

+

+ Dies entfernt das Mitglied permanent. Der App-Zugang wird ebenfalls entzogen. + Diese Aktion kann nicht rückgängig gemacht werden. +

+
+ + {showConfirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx similarity index 83% rename from innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx rename to innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx index 6d7b10f..db99938 100644 --- a/innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/neu/page.tsx @@ -8,7 +8,6 @@ import { SPARTEN } from '@innungsapp/shared' export default function MitgliedNeuPage() { const router = useRouter() - const [sendInvite, setSendInvite] = useState(true) const [form, setForm] = useState({ name: '', betrieb: '', @@ -19,25 +18,20 @@ export default function MitgliedNeuPage() { status: 'aktiv' as const, istAusbildungsbetrieb: false, seit: new Date().getFullYear(), + role: 'member' as 'member' | 'admin', + password: '', }) const createMutation = trpc.members.create.useMutation({ onSuccess: () => router.push('/dashboard/mitglieder'), }) - const inviteMutation = trpc.members.invite.useMutation({ - onSuccess: () => router.push('/dashboard/mitglieder'), - }) - const isPending = createMutation.isPending || inviteMutation.isPending - const error = createMutation.error ?? inviteMutation.error + const isPending = createMutation.isPending + const error = createMutation.error function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (sendInvite) { - inviteMutation.mutate(form) - } else { - createMutation.mutate(form) - } + createMutation.mutate(form) } return ( @@ -130,6 +124,27 @@ export default function MitgliedNeuPage() {
+
+ + +
+
+ + setForm({ ...form, password: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
-
- -

- Das Mitglied erhält eine E-Mail mit einem Login-Link. -

-
- {error && (

{error.message} @@ -172,7 +170,7 @@ export default function MitgliedNeuPage() { disabled={isPending} className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors" > - {isPending ? 'Wird gespeichert...' : sendInvite ? 'Speichern & Einladung senden' : 'Speichern'} + {isPending ? 'Wird gespeichert...' : 'Speichern'} a.userId)) + // Map userId → member record so admin entries show real member data + const memberByUserId = new Map(members.filter(m => m.userId).map(m => [m.userId!, m])) + + const combinedList = [ + // Include admins only if there's no status filter, or if filtering for 'aktiv' + ...(!statusFilter || statusFilter === 'aktiv' ? admins.map(a => { + const m = memberByUserId.get(a.user.id) + return { + id: m ? m.id : `admin-${a.user.id}`, + name: m?.name ?? a.user.name, + betrieb: m?.betrieb ?? a.user.email, + sparte: m?.sparte ?? 'Sonderfunktion', + ort: m?.ort ?? '—', + seit: m?.seit ?? null as number | null, + status: m?.status ?? 'aktiv', + userId: a.user.id, + isAdmin: true, + realId: m ? m.id : a.user.id, + role: 'Administrator', + } + }) : []), + ...members.filter(m => !adminUserIds.has(m.userId ?? '')).map(m => ({ + id: m.id, + name: m.name, + betrieb: m.betrieb, + sparte: m.sparte, + ort: m.ort, + seit: m.seit, + status: m.status, + userId: m.userId, + isAdmin: false, + realId: m.id, + role: 'Mitglied', + })) + ] + + combinedList.sort((a, b) => a.name.localeCompare(b.name)) + return (

Mitglieder

-

{members.length} Einträge

+

{combinedList.length} Einträge

Name / Betrieb - Sparte + Rolle Ort Mitglied seit Status @@ -101,7 +161,7 @@ export default async function MitgliederPage(props: { - {members.map((m) => ( + {combinedList.map((m) => (
@@ -109,14 +169,18 @@ export default async function MitgliederPage(props: {

{m.betrieb}

- {m.sparte} + + + {m.role} + + {m.ort} {m.seit ?? '—'} - {MEMBER_STATUS_LABELS[m.status]} + {MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'} @@ -128,7 +192,7 @@ export default async function MitgliederPage(props: { Bearbeiten @@ -138,7 +202,7 @@ export default async function MitgliederPage(props: { ))} - {members.length === 0 && ( + {combinedList.length === 0 && (
Keine Mitglieder gefunden
diff --git a/innungsapp/apps/admin/app/dashboard/news/[id]/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx similarity index 70% rename from innungsapp/apps/admin/app/dashboard/news/[id]/page.tsx rename to innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx index 02043be..351c7db 100644 --- a/innungsapp/apps/admin/app/dashboard/news/[id]/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx @@ -32,18 +32,42 @@ export default function NewsEditPage({ params }: { params: Promise<{ id: string const [title, setTitle] = useState('') const [body, setBody] = useState('') const [kategorie, setKategorie] = useState('Allgemein') + const [uploading, setUploading] = useState(false) + const [attachments, setAttachments] = useState< + Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }> + >([]) useEffect(() => { if (news) { setTitle(news.title) setBody(news.body) setKategorie(news.kategorie) + if (news.attachments) { + setAttachments(news.attachments.map(a => ({ ...a, sizeBytes: a.sizeBytes ?? 0 }))) + } } }, [news]) if (isLoading) return
Wird geladen...
if (!news) return
Beitrag nicht gefunden.
+ async function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch('/api/upload', { method: 'POST', body: formData }) + const data = await res.json() + setAttachments((prev) => [...prev, data]) + } catch { + alert('Upload fehlgeschlagen') + } finally { + setUploading(false) + } + } + function handleSave(publishNow: boolean) { if (!title.trim() || !body.trim()) return updateMutation.mutate({ @@ -53,6 +77,12 @@ export default function NewsEditPage({ params }: { params: Promise<{ id: string body, kategorie: kategorie as never, publishedAt: publishNow ? new Date().toISOString() : undefined, + attachments: attachments.map((a) => ({ + name: a.name, + storagePath: a.storagePath, + sizeBytes: a.sizeBytes, + mimeType: a.mimeType || 'application/pdf', + })), }, }) } @@ -120,6 +150,41 @@ export default function NewsEditPage({ params }: { params: Promise<{ id: string
+ {/* Attachments */} +
+ + + {attachments.length > 0 && ( +
    + {attachments.map((a, i) => ( +
  • + 📄 + {a.name} + {a.sizeBytes != null && ( + ({Math.round(a.sizeBytes / 1024)} KB) + )} + +
  • + ))} +
+ )} +
+ {updateMutation.error && (

{getTrpcErrorMessage(updateMutation.error)} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/news/neu/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/news/neu/page.tsx new file mode 100644 index 0000000..ed68df1 --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/dashboard/news/neu/page.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { trpc } from '@/lib/trpc-client' +import { getTrpcErrorMessage } from '@/lib/trpc-error' +import Link from 'next/link' +import dynamic from 'next/dynamic' +import { AIGenerator } from '@/components/ai-generator' + +const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }) + +const KATEGORIEN = [ + { value: 'Wichtig', label: 'Wichtig' }, + { value: 'Pruefung', label: 'Prüfung' }, + { value: 'Foerderung', label: 'Förderung' }, + { value: 'Veranstaltung', label: 'Veranstaltung' }, + { value: 'Allgemein', label: 'Allgemein' }, +] + +export default function NewsNeuPage() { + const router = useRouter() + const [title, setTitle] = useState('') + const DEFAULT_BODY = '## Inhalt\n\nHier können Sie Ihren Beitrag verfassen.' + const [body, setBody] = useState(DEFAULT_BODY) + const [kategorie, setKategorie] = useState('Allgemein') + const [uploading, setUploading] = useState(false) + const [attachments, setAttachments] = useState< + Array<{ name: string; storagePath: string; sizeBytes: number; url: string }> + >([]) + + const createMutation = trpc.news.create.useMutation({ + onSuccess: () => router.push('/dashboard/news'), + }) + + function handleSubmit(publishNow: boolean) { + if (!title.trim() || !body.trim()) return + createMutation.mutate({ + title, + body, + kategorie: kategorie as never, + publishedAt: publishNow ? new Date().toISOString() : null, + attachments: attachments.map((a) => ({ + name: a.name, + storagePath: a.storagePath, + sizeBytes: a.sizeBytes, + mimeType: 'application/pdf', // fallback/default; the API handles it + })), + }) + } + + async function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch('/api/upload', { method: 'POST', body: formData }) + const data = await res.json() + setAttachments((prev) => [...prev, data]) + } catch { + alert('Upload fehlgeschlagen') + } finally { + setUploading(false) + } + } + + return ( +

+
+ + ← Zurück + +

Beitrag erstellen

+
+ +
+
+
+
+ + setTitle(e.target.value)} + placeholder="Aussagekräftiger Titel..." + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+
+ + +
+
+ +
+ +
+ setBody(v ?? '')} + height={400} + preview="live" + /> +
+
+ + {/* Attachments */} +
+ + + {attachments.length > 0 && ( +
    + {attachments.map((a, i) => ( +
  • + 📄 + {a.name} + ({Math.round(a.sizeBytes / 1024)} KB) +
  • + ))} +
+ )} +
+ + {createMutation.error && ( +

+ {getTrpcErrorMessage(createMutation.error)} +

+ )} + +
+ + +
+
+ +
+ { + // Replace placeholder if untouched, otherwise append + setBody(body === DEFAULT_BODY ? generated : body + '\n\n' + generated) + }} + /> +
+
+
+ ) +} diff --git a/innungsapp/apps/admin/app/dashboard/news/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx similarity index 96% rename from innungsapp/apps/admin/app/dashboard/news/page.tsx rename to innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx index 31455f7..408b0c5 100644 --- a/innungsapp/apps/admin/app/dashboard/news/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx @@ -1,5 +1,5 @@ import { prisma } from '@innungsapp/shared' -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' import { headers } from 'next/headers' import { redirect } from 'next/navigation' import Link from 'next/link' @@ -16,7 +16,8 @@ const KATEGORIE_COLORS: Record = { } export default async function NewsPage() { - const session = await auth.api.getSession({ headers: await headers() }) + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) if (!session?.user) redirect('/login') const userRole = await prisma.userRole.findFirst({ where: { userId: session.user.id, role: 'admin' }, diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx new file mode 100644 index 0000000..daaa870 --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx @@ -0,0 +1,126 @@ +import { prisma } from '@innungsapp/shared' +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { StatsCards } from '@/components/stats/StatsCards' +import Link from 'next/link' +import { format } from 'date-fns' +import { de } from 'date-fns/locale' +import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared' + +export default async function DashboardPage() { + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) redirect('/login') + + const userRole = await prisma.userRole.findFirst({ + where: { userId: session.user.id }, + include: { org: true }, + }) + if (!userRole) redirect('/login') + + const orgId = userRole.orgId + const now = new Date() + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] = + await Promise.all([ + prisma.member.count({ where: { orgId, status: 'aktiv' } }), + prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }), + prisma.termin.count({ where: { orgId, datum: { gte: now } } }), + prisma.stelle.count({ where: { orgId, aktiv: true } }), + prisma.news.findMany({ + where: { orgId, publishedAt: { not: null } }, + orderBy: { publishedAt: 'desc' }, + take: 5, + include: { author: { select: { name: true } } }, + }), + prisma.termin.findMany({ + where: { orgId, datum: { gte: now } }, + orderBy: { datum: 'asc' }, + take: 3, + }), + ]) + + return ( +
+
+

Übersicht

+

{userRole.org.name}

+
+ + + +
+ {/* Recent News */} +
+
+

Neueste Beiträge

+ + Alle anzeigen + +
+
+ {recentNews.map((n) => ( +
+
+

{n.title}

+

+ {n.publishedAt + ? format(n.publishedAt, 'dd. MMM yyyy', { locale: de }) + : 'Entwurf'}{' '} + · {n.author?.name ?? 'Unbekannt'} +

+
+ + {NEWS_KATEGORIE_LABELS[n.kategorie]} + +
+ ))} +
+
+ + {/* Upcoming Termine */} +
+
+

Nächste Termine

+ + Alle anzeigen + +
+
+ {nextTermine.length === 0 && ( +

Keine bevorstehenden Termine

+ )} + {nextTermine.map((t) => ( +
+
+

+ {format(t.datum, 'dd', { locale: de })} +

+

+ {format(t.datum, 'MMM', { locale: de })} +

+
+
+

{t.titel}

+

{t.ort ?? 'Kein Ort angegeben'}

+
+ + {TERMIN_TYP_LABELS[t.typ]} + +
+ ))} +
+
+
+
+ ) +} diff --git a/innungsapp/apps/admin/app/dashboard/stellen/DeactivateButton.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/stellen/DeactivateButton.tsx similarity index 100% rename from innungsapp/apps/admin/app/dashboard/stellen/DeactivateButton.tsx rename to innungsapp/apps/admin/app/[slug]/dashboard/stellen/DeactivateButton.tsx diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/stellen/neu/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/stellen/neu/page.tsx new file mode 100644 index 0000000..0295e2c --- /dev/null +++ b/innungsapp/apps/admin/app/[slug]/dashboard/stellen/neu/page.tsx @@ -0,0 +1,191 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { trpc } from '@/lib/trpc-client' +import { getTrpcErrorMessage } from '@/lib/trpc-error' +import Link from 'next/link' +import { AIGenerator } from '@/components/ai-generator' + +export default function StelleNeuPage() { + const router = useRouter() + + const { data: members } = trpc.members.list.useQuery({}) + const createMutation = trpc.stellen.createForMember.useMutation({ + onSuccess: () => router.push('/dashboard/stellen'), + }) + + const [form, setForm] = useState({ + memberId: '', + sparte: '', + stellenAnz: 1, + verguetung: '', + lehrjahr: '', + beschreibung: '', + kontaktEmail: '', + kontaktName: '', + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!form.memberId) return + createMutation.mutate({ + ...form, + stellenAnz: Number(form.stellenAnz), + verguetung: form.verguetung || undefined, + lehrjahr: form.lehrjahr || undefined, + beschreibung: form.beschreibung || undefined, + kontaktName: form.kontaktName || undefined, + }) + } + + const inputClass = + 'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent' + + return ( +
+
+ + ← Zurück + + / +

Stelle anlegen

+
+ +
+
+
+ {/* Betrieb */} +
+

Betrieb

+
+ + +
+
+ + {/* Stellendetails */} +
+

Stellendetails

+
+
+ + setForm({ ...form, sparte: e.target.value })} + placeholder="z.B. Elektrotechnik" + className={inputClass} + /> +
+
+ + setForm({ ...form, stellenAnz: Number(e.target.value) })} + className={inputClass} + /> +
+
+ + setForm({ ...form, lehrjahr: e.target.value })} + placeholder="z.B. 1. Lehrjahr" + className={inputClass} + /> +
+
+ + setForm({ ...form, verguetung: e.target.value })} + placeholder="z.B. 650 € / Monat" + className={inputClass} + /> +
+
+ +