This commit is contained in:
knuthtimo-lab 2026-03-04 14:13:16 +01:00
parent b7d826e29c
commit 56ea3348d6
41 changed files with 846 additions and 162 deletions

View File

@ -5,6 +5,14 @@
--- ---
## Technisches Setup
Die aktuelle und verbindliche Anleitung fuer Start, Docker-Deployment, Migrationen und Seeding liegt in:
- `innungsapp/README.md`
Dieses Root-README ist eine Produkt- und Strategieuebersicht.
## Was ist InnungsApp? ## Was ist InnungsApp?
InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerkerschaften digitalisiert. Sie löst zwei akute Probleme gleichzeitig: InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerkerschaften digitalisiert. Sie löst zwei akute Probleme gleichzeitig:
@ -56,7 +64,7 @@ InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerke
- **Hosting:** Vercel - **Hosting:** Vercel
- **Analytics:** PostHog - **Analytics:** PostHog
## Quickstart ## Quickstart (Legacy, nicht der aktuelle Betriebsweg)
```bash ```bash
# Repo klonen # Repo klonen
@ -78,3 +86,5 @@ pnpm --filter @innungsapp/admin dev -- --port 3032
npx expo start --clear npx expo start --clear
Demo: admin@demo.de / demo1234 Demo: admin@demo.de / demo1234
Hinweis: Fuer den aktuellen lokalen/produktiven Betrieb bitte `innungsapp/README.md` verwenden.

View File

@ -1,9 +1,10 @@
# ============================================= # =============================================
# DATABASE (SQLite — kein externer DB-Server nötig) # DATABASE (PostgreSQL)
# Dev: file:../../packages/shared/prisma/dev.db
# Prod: file:./prisma/prod.db
# ============================================= # =============================================
DATABASE_URL="file:../../packages/shared/prisma/dev.db" POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="innungsapp"
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public"
# ============================================= # =============================================
# BETTER-AUTH # BETTER-AUTH
@ -28,6 +29,12 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# =============================================
# SUPERADMIN SEED
# =============================================
SUPERADMIN_EMAIL="superadmin@innungsapp.de"
SUPERADMIN_PASSWORD="change-me-strong-password"
# ============================================= # =============================================
# MOBILE APP (Expo) # MOBILE APP (Expo)
# ============================================= # =============================================

View File

@ -3,6 +3,12 @@
# Kopieren als: innungsapp/.env # Kopieren als: innungsapp/.env
# ============================================= # =============================================
# Database (PostgreSQL)
POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="change-this-db-password"
DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public"
# Auth — UNBEDINGT ändern! # Auth — UNBEDINGT ändern!
BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string" BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string"
BETTER_AUTH_URL="https://yourdomain.com" BETTER_AUTH_URL="https://yourdomain.com"
@ -20,5 +26,9 @@ NEXT_PUBLIC_APP_URL="https://yourdomain.com"
NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# Superadmin Seed
SUPERADMIN_EMAIL="superadmin@yourdomain.com"
SUPERADMIN_PASSWORD="change-this-superadmin-password"
# Uploads # Uploads
UPLOAD_MAX_SIZE_MB="10" UPLOAD_MAX_SIZE_MB="10"

View File

@ -21,9 +21,6 @@ out
# Uploads (local file storage) # Uploads (local file storage)
apps/admin/uploads/ apps/admin/uploads/
# Prisma
packages/shared/prisma/migrations/
# Expo # Expo
apps/mobile/.expo apps/mobile/.expo
apps/mobile/android apps/mobile/android

View File

@ -11,7 +11,7 @@ Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (E
| Mobile App | Expo + React Native | | Mobile App | Expo + React Native |
| API | tRPC v11 | | API | tRPC v11 |
| Auth | better-auth (magic links + credential login) | | Auth | better-auth (magic links + credential login) |
| Database | SQLite + Prisma ORM | | Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) |
| Styling | Tailwind CSS (admin), NativeWind (mobile) | | Styling | Tailwind CSS (admin), NativeWind (mobile) |
## Projektstruktur ## Projektstruktur
@ -30,6 +30,11 @@ innungsapp/
## Local Setup ## Local Setup
Port-Hinweis:
- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000`
- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`)
### Voraussetzungen ### Voraussetzungen
- Node.js >= 20 - Node.js >= 20
@ -45,13 +50,21 @@ pnpm install
### 2. Umgebungsvariablen setzen (Admin lokal) ### 2. Umgebungsvariablen setzen (Admin lokal)
```bash ```bash
cp .env.example apps/admin/.env.local cp .env.example .env
``` ```
Danach `apps/admin/.env.local` anpassen (mindestens `BETTER_AUTH_SECRET`, SMTP-Werte). Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte).
### 3. DB vorbereiten (lokal) ### 3. DB vorbereiten (lokal)
Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv):
```bash
docker compose up -d postgres
```
Prisma vorbereiten:
```bash ```bash
pnpm db:generate pnpm db:generate
pnpm db:push pnpm db:push
@ -61,6 +74,7 @@ Optional Demo-Daten:
```bash ```bash
pnpm db:seed pnpm db:seed
pnpm db:seed-superadmin
``` ```
### 4. Entwicklung starten ### 4. Entwicklung starten
@ -102,6 +116,10 @@ cp .env.production.example .env
Pflichtwerte in `.env`: Pflichtwerte in `.env`:
- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`)
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `BETTER_AUTH_SECRET` (mindestens 32 Zeichen) - `BETTER_AUTH_SECRET` (mindestens 32 Zeichen)
- `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`) - `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`)
- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`) - `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`)
@ -111,6 +129,8 @@ Pflichtwerte in `.env`:
- `SMTP_SECURE` - `SMTP_SECURE`
- `SMTP_USER` - `SMTP_USER`
- `SMTP_PASS` - `SMTP_PASS`
- `SUPERADMIN_EMAIL`
- `SUPERADMIN_PASSWORD`
### 3. Container bauen und starten ### 3. Container bauen und starten
@ -127,10 +147,14 @@ Hinweis zum DB-Start:
```bash ```bash
docker compose logs -f admin docker compose logs -f admin
curl -fsS http://localhost:3000/api/health curl -fsS http://localhost:3010/api/health
``` ```
Erwartet: JSON mit `status: "ok"`. Erwartet: JSON mit `"status":"ok"`, z. B.
```json
{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"}
```
### 5. Superadmin anlegen (nur beim ersten Start) ### 5. Superadmin anlegen (nur beim ersten Start)
@ -138,16 +162,20 @@ Erwartet: JSON mit `status: "ok"`.
docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js
``` ```
Default Login: Login-Daten kommen aus `.env`:
- E-Mail: `superadmin@innungsapp.de` - E-Mail: `SUPERADMIN_EMAIL`
- Passwort: `demo1234` - Passwort: `SUPERADMIN_PASSWORD`
Passwort direkt nach dem ersten Login aendern. Hinweis:
- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt.
- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt.
- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden.
### 6. HTTPS (Reverse Proxy) ### 6. HTTPS (Reverse Proxy)
Nginx sollte auf `localhost:3000` weiterleiten und TLS terminieren. Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren.
Beispiel: Beispiel:
```nginx ```nginx
@ -165,7 +193,7 @@ server {
ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3010;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -188,7 +216,7 @@ docker compose logs -f admin
Vorher die exakten Volumenamen pruefen: Vorher die exakten Volumenamen pruefen:
```bash ```bash
docker volume ls | grep db_data docker volume ls | grep pg_data
docker volume ls | grep uploads_data docker volume ls | grep uploads_data
``` ```
@ -197,9 +225,9 @@ Backup:
```bash ```bash
mkdir -p backups mkdir -p backups
docker run --rm \ docker run --rm \
-v innungsapp_db_data:/volume \ -v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \ -v "$(pwd)/backups:/backup" \
alpine sh -c "tar czf /backup/db_data_$(date +%F_%H%M).tar.gz -C /volume ." alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ."
``` ```
Restore (nur bei gestoppter App): Restore (nur bei gestoppter App):
@ -207,12 +235,59 @@ Restore (nur bei gestoppter App):
```bash ```bash
docker compose down docker compose down
docker run --rm \ docker run --rm \
-v innungsapp_db_data:/volume \ -v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \ -v "$(pwd)/backups:/backup" \
alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume" alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume"
docker compose up -d docker compose up -d
``` ```
### 9. Verifizierte Kommandos (Stand 4. Maerz 2026)
Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt:
```bash
# 1) Postgres starten (falls noch nicht aktiv)
docker compose up -d postgres
# 2) Prisma Client generieren
(cd packages/shared && npx prisma generate)
# 3) Initiale PostgreSQL-Migration erstellen (einmalig)
(cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only)
# 4) Migration anwenden
(cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate deploy --schema=prisma/schema.prisma)
# 5) Gesamtes Setup bauen und starten
docker compose up -d --build
# 6) Superadmin seeden (mit ENV-Werten)
docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \
-e SUPERADMIN_PASSWORD='demo1234' \
-w /app admin node packages/shared/prisma/seed-superadmin.js
# 7) Laufzeitstatus pruefen
docker compose ps
docker compose logs --tail 80 admin
curl -fsS http://localhost:3010/api/health
```
Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet):
```bash
# JSONB-Spalten pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'organizations' AND column_name IN ('landing_page_features','landing_page_footer') ORDER BY column_name;"
# Seeded Superadmin pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT u.email, u.role, u.email_verified, a.provider_id, (a.password IS NOT NULL) AS has_password FROM \"user\" u LEFT JOIN account a ON a.user_id = u.id AND a.provider_id = 'credential' WHERE u.email = 'superadmin@innungsapp.de';"
```
## Mobile Release (EAS) ## Mobile Release (EAS)
```bash ```bash
@ -231,14 +306,14 @@ Wichtig:
### `migrate deploy` oder `db push` fehlschlaegt ### `migrate deploy` oder `db push` fehlschlaegt
- `DATABASE_URL` pruefen - `DATABASE_URL` pruefen
- Rechte auf `/app/data` pruefen - `postgres` Container Healthcheck pruefen (`docker compose ps`)
- Logs: `docker compose logs -f admin` - Logs: `docker compose logs -f admin`
### Healthcheck liefert Fehler ### Healthcheck liefert Fehler
- Containerstatus: `docker compose ps` - Containerstatus: `docker compose ps`
- App-Logs lesen - App-Logs lesen
- Reverse Proxy testweise umgehen und direkt `localhost:3000` pruefen - Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen
### Login funktioniert nicht nach Seed ### Login funktioniert nicht nach Seed

View File

@ -79,6 +79,10 @@ COPY --from=builder /app/apps/admin/public ./apps/admin/public
# Copy Prisma schema + migrations for runtime migrations # Copy Prisma schema + migrations for runtime migrations
COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma
# Copy Prisma Client package for runtime seed scripts.
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma ./node_modules/.prisma
# Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them) # Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them)
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/
@ -89,9 +93,6 @@ RUN npm install -g prisma@5.22.0
# Create uploads directory # Create uploads directory
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads 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 entrypoint
COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh RUN chmod +x ./docker-entrypoint.sh

View File

@ -2,7 +2,6 @@
import { auth, getSanitizedHeaders } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { redirect } from 'next/navigation'
// @ts-ignore // @ts-ignore
import { hashPassword } from 'better-auth/crypto' import { hashPassword } from 'better-auth/crypto'
@ -25,7 +24,6 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat
} }
const userId = session.user.id const userId = session.user.id
const slug = formData.get('slug') as string
// Hash and save new password directly — user is already authenticated so no old password needed // Hash and save new password directly — user is already authenticated so no old password needed
const newHash = await hashPassword(newPassword) const newHash = await hashPassword(newPassword)
@ -64,5 +62,9 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat
// ignore // ignore
} }
redirect(`/login?message=password_changed&callbackUrl=/${slug}/dashboard`) return {
success: true,
error: '',
redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`,
}
} }

View File

@ -1,10 +1,17 @@
'use client' 'use client'
import { useEffect } from 'react'
import { useActionState } from 'react' import { useActionState } from 'react'
import { changePasswordAndDisableMustChange } from '../actions' import { changePasswordAndDisableMustChange } from '../actions'
export function ForcePasswordChange({ slug }: { slug: string }) { export function ForcePasswordChange({ slug }: { slug: string }) {
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '' }) const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' })
useEffect(() => {
if (state?.success && state?.redirectTo) {
window.location.href = state.redirectTo
}
}, [state?.success, state?.redirectTo])
return ( return (
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm"> <div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">

View File

@ -2,6 +2,24 @@ import { prisma } from '@innungsapp/shared'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
function jsonToText(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
}
return JSON.stringify(value)
}
export default async function TenantLandingPage({ export default async function TenantLandingPage({
params, params,
}: { }: {
@ -26,8 +44,8 @@ export default async function TenantLandingPage({
const secondaryColor = org.secondaryColor || undefined const secondaryColor = org.secondaryColor || undefined
const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk' const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk'
const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.' const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
const features = org.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung' const features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = org.landingPageFooter || `© ${new Date().getFullYear()} ${org.name}` const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}`
const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk` const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`
const buttonText = org.landingPageButtonText || 'Jetzt App laden' const buttonText = org.landingPageButtonText || 'Jetzt App laden'

View File

@ -39,9 +39,32 @@ function getModel(provider: LlmProvider): string {
return process.env.OPENAI_MODEL || 'gpt-4o-mini' return process.env.OPENAI_MODEL || 'gpt-4o-mini'
} }
function hasApiKey(provider: LlmProvider): boolean {
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
return !!process.env.OPENAI_API_KEY
}
function buildFallbackLandingContent(orgName: string, context: string) {
const cleanOrg = orgName.trim()
const cleanContext = context.trim().replace(/\s+/g, ' ')
const shortContext = cleanContext.slice(0, 180)
const detailSentence = shortContext
? `Dabei stehen insbesondere ${shortContext}.`
: 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.'
return {
title: `${cleanOrg} - Stark im Handwerk`,
text: `${cleanOrg} verbindet Betriebe, stärkt die Gemeinschaft und setzt sich für die Interessen des Handwerks vor Ort ein. ${detailSentence}`,
fallbackUsed: true,
}
}
export async function POST(req: Request) { export async function POST(req: Request) {
let parsedBody: any = null
try { try {
const body = await req.json() const body = await req.json()
parsedBody = body
const { orgName, context } = body const { orgName, context } = body
if (!orgName || !context) { if (!orgName || !context) {
@ -49,9 +72,14 @@ export async function POST(req: Request) {
} }
const provider = getProvider() const provider = getProvider()
const client = createClient(provider)
const model = getModel(provider) const model = getModel(provider)
if (!hasApiKey(provider)) {
return NextResponse.json(buildFallbackLandingContent(orgName, context))
}
const client = createClient(provider)
const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft. const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft.
Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage. Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage.
@ -89,6 +117,10 @@ WICHTIG: Geben Sie AUSSCHLIESSLICH ein valides JSON-Objekt zurück, komplett ohn
} catch (error: any) { } catch (error: any) {
console.error('Error generating AI landing page content:', error) console.error('Error generating AI landing page content:', error)
if (parsedBody?.orgName && parsedBody?.context) {
return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context))
}
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 }) return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
} }
} }

View File

@ -1,10 +1,9 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
export async function POST() { export async function POST() {
const session = await auth.api.getSession({ headers: await headers() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
} }

View File

@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
// @ts-ignore // @ts-ignore
import { hashPassword } from 'better-auth/crypto' import { hashPassword } from 'better-auth/crypto'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
} }

View File

@ -1,9 +1,9 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: req.headers }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) { if (!session?.user) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }

View File

@ -1,6 +1,6 @@
/** /**
* DEV-ONLY: Sets a password for the demo admin user via better-auth. * DEV-ONLY: Sets a password for the demo admin user via better-auth.
* Call once after seeding: GET http://localhost:3032/api/setup * Call once after seeding: GET http://localhost:3010/api/setup
* Remove this file before going to production. * Remove this file before going to production.
*/ */
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'

View File

@ -2,14 +2,21 @@ import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises' import { writeFile, mkdir } from 'fs/promises'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads' const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024 const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
// Auth check // Auth check
const session = await auth.api.getSession({ headers: req.headers }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
@ -39,7 +46,7 @@ export async function POST(req: NextRequest) {
const ext = path.extname(file.name) const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}` const fileName = `${randomUUID()}${ext}`
const uploadPath = path.join(process.cwd(), UPLOAD_DIR) const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true }) await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())

View File

@ -2,21 +2,28 @@ import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import path from 'path' import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads' const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
// Added comment to force recompile after ENOSPC
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
try { try {
const { path: filePathParams } = await params; const { path: filePathParams } = await params
const filePath = path.join(process.cwd(), UPLOAD_DIR, ...filePathParams) const uploadRoot = getUploadRoot()
const filePath = path.join(uploadRoot, ...filePathParams)
// Security: prevent path traversal // Security: prevent path traversal
const resolved = path.resolve(filePath) const resolved = path.resolve(filePath)
const uploadDir = path.resolve(path.join(process.cwd(), UPLOAD_DIR)) const uploadDir = path.resolve(uploadRoot)
if (!resolved.startsWith(uploadDir)) { if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
return new NextResponse('Forbidden', { status: 403 }) return new NextResponse('Forbidden', { status: 403 })
} }

View File

@ -1,4 +1,4 @@
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Link from 'next/link' import Link from 'next/link'
@ -7,7 +7,7 @@ import { redirect } from 'next/navigation'
export default async function GlobalDashboardRedirect() { export default async function GlobalDashboardRedirect() {
const headerList = await headers() const headerList = await headers()
const host = headerList.get('host') || '' const host = headerList.get('host') || ''
const session = await auth.api.getSession({ headers: headerList }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
if (!session?.user) { if (!session?.user) {
redirect('/login') redirect('/login')
@ -93,9 +93,8 @@ export default async function GlobalDashboardRedirect() {
<form action={async () => { <form action={async () => {
'use server' 'use server'
const { auth } = await import('@/lib/auth') const { auth, getSanitizedHeaders } = await import('@/lib/auth')
const { headers } = await import('next/headers') await auth.api.signOut({ headers: await getSanitizedHeaders() })
await auth.api.signOut({ headers: await headers() })
redirect('/login') redirect('/login')
}}> }}>
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700"> <button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">

View File

@ -6,7 +6,7 @@ import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({ const authClient = createAuthClient({
baseURL: typeof window !== 'undefined' baseURL: typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032'), : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
}) })
export default function PasswortAendernPage() { export default function PasswortAendernPage() {

View File

@ -86,6 +86,9 @@ export function CreateOrgForm() {
} }
const [isHeroUploading, setIsHeroUploading] = useState(false) const [isHeroUploading, setIsHeroUploading] = useState(false)
const appBaseUrl = (typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '')
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@ -182,7 +185,7 @@ export function CreateOrgForm() {
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label>
<input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" /> <input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
<p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${formData.slug}.localhost:3032` : 'ihr-slug.localhost:3032'}</span></p> <p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${appBaseUrl}/${formData.slug}` : `${appBaseUrl}/ihr-slug`}</span></p>
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label> <label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label>
@ -407,8 +410,8 @@ export function CreateOrgForm() {
<div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8"> <div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8">
<p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p> <p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p>
<a href={`http://${formData.slug}.localhost:3032`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all"> <a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
{formData.slug}.localhost:3032 {appBaseUrl}/{formData.slug}
</a> </a>
</div> </div>

View File

@ -1,10 +1,9 @@
'use server' 'use server'
import { prisma } from '@innungsapp/shared' import { prisma, Prisma } from '@innungsapp/shared'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import { z } from 'zod' import { z } from 'zod'
import { sendAdminCredentialsEmail } from '@/lib/email' import { sendAdminCredentialsEmail } from '@/lib/email'
// @ts-ignore // @ts-ignore
@ -14,6 +13,14 @@ function normalizeEmail(email: string | null | undefined): string {
return (email ?? '').trim().toLowerCase() return (email ?? '').trim().toLowerCase()
} }
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (!value) {
return Prisma.DbNull
}
return value
}
/** /**
* Sets a credential (email+password) account for a user. * Sets a credential (email+password) account for a user.
* Uses direct DB write with better-auth's hashPassword for compatibility. * Uses direct DB write with better-auth's hashPassword for compatibility.
@ -39,7 +46,7 @@ async function setCredentialPassword(userId: string, password: string) {
async function requireSuperAdmin() { async function requireSuperAdmin() {
const session = await auth.api.getSession({ headers: await headers() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de' const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
// An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin // An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin
@ -165,8 +172,8 @@ export async function createOrganization(prevState: any, formData: FormData) {
landingPageHeroImage: validatedData.landingPageHeroImage || null, landingPageHeroImage: validatedData.landingPageHeroImage || null,
// @ts-ignore // @ts-ignore
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity, landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
landingPageFeatures: validatedData.landingPageFeatures || null, landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: validatedData.landingPageFooter || null, landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null, landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null, landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null, appStoreUrl: validatedData.appStoreUrl || null,
@ -221,7 +228,7 @@ export async function createOrganization(prevState: any, formData: FormData) {
adminName: user.name || validatedData.adminEmail.split('@')[0], adminName: user.name || validatedData.adminEmail.split('@')[0],
orgName: org.name, orgName: org.name,
password: validatedData.adminPassword, password: validatedData.adminPassword,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032', loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
}) })
} catch (emailError) { } catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden:', emailError) console.error('E-Mail konnte nicht gesendet werden:', emailError)
@ -276,8 +283,8 @@ export async function updateOrganization(id: string, prevState: any, formData: F
landingPageTitle: validatedData.landingPageTitle || null, landingPageTitle: validatedData.landingPageTitle || null,
landingPageText: validatedData.landingPageText || null, landingPageText: validatedData.landingPageText || null,
landingPageHeroImage: validatedData.landingPageHeroImage || null, landingPageHeroImage: validatedData.landingPageHeroImage || null,
landingPageFeatures: validatedData.landingPageFeatures || null, landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: validatedData.landingPageFooter || null, landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null, landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null, landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null, appStoreUrl: validatedData.appStoreUrl || null,
@ -383,7 +390,7 @@ export async function createAdmin(prevState: any, formData: FormData) {
adminName: validatedData.name, adminName: validatedData.name,
orgName: org?.name || 'Ihre Innung', orgName: org?.name || 'Ihre Innung',
password: validatedData.password, password: validatedData.password,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3032', loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
}) })
} catch (emailError) { } catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError) console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)

View File

@ -1,5 +1,4 @@
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
@ -8,7 +7,7 @@ export default async function SuperAdminLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await auth.api.getSession({ headers: await headers() }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user) { if (!session?.user) {
redirect('/login') redirect('/login')

View File

@ -3,6 +3,24 @@
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { updateOrganization } from '../../actions' import { updateOrganization } from '../../actions'
function jsonToText(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
}
return JSON.stringify(value)
}
interface Props { interface Props {
org: { org: {
id: string id: string
@ -18,8 +36,8 @@ interface Props {
landingPageButtonText: string | null landingPageButtonText: string | null
landingPageHeroImage: string | null landingPageHeroImage: string | null
landingPageHeroOverlayOpacity: number | null landingPageHeroOverlayOpacity: number | null
landingPageFeatures: string | null landingPageFeatures: unknown
landingPageFooter: string | null landingPageFooter: unknown
appStoreUrl: string | null appStoreUrl: string | null
playStoreUrl: string | null playStoreUrl: string | null
} }
@ -36,19 +54,8 @@ export function EditOrgForm({ org }: Props) {
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946') const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF') const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
let initialFeatures = '' const initialFeatures = jsonToText(org.landingPageFeatures)
try { const initialFooter = jsonToText(org.landingPageFooter)
if (org.landingPageFeatures) {
const parsed = JSON.parse(org.landingPageFeatures)
if (Array.isArray(parsed)) {
initialFeatures = parsed.join('\n')
} else {
initialFeatures = org.landingPageFeatures
}
}
} catch {
initialFeatures = org.landingPageFeatures || ''
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@ -321,7 +328,7 @@ export function EditOrgForm({ org }: Props) {
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label> <label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
<textarea <textarea
name="landingPageFooter" name="landingPageFooter"
defaultValue={org.landingPageFooter ?? ''} defaultValue={initialFooter}
rows={2} rows={2}
placeholder="© 2024 Innung. Alle Rechte vorbehalten." placeholder="© 2024 Innung. Alle Rechte vorbehalten."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500" className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"

View File

@ -7,7 +7,7 @@ const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains). // Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined' baseURL: typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032'), : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
}) })
interface LoginFormProps { interface LoginFormProps {
@ -52,7 +52,25 @@ export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
// mustChangePassword is handled by the dashboard ForcePasswordChange component // mustChangePassword is handled by the dashboard ForcePasswordChange component
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const callbackUrl = params.get('callbackUrl') const callbackUrl = params.get('callbackUrl')
window.location.href = callbackUrl || '/dashboard'
let target = '/dashboard'
if (callbackUrl?.startsWith('/')) {
target = callbackUrl
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
// when already on the tenant subdomain test.localhost.
const hostname = window.location.hostname
const parts = hostname.split('.')
const isTenantSubdomain =
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
const tenantSlug = isTenantSubdomain ? parts[0] : null
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
target = target.slice(tenantSlug.length + 1) || '/dashboard'
}
}
window.location.href = target
} }
return ( return (

View File

@ -8,7 +8,7 @@ const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains). // Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined' baseURL: typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032'), : (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
}) })
const PAGE_TITLES: Record<string, string> = { const PAGE_TITLES: Record<string, string> = {

View File

@ -2,7 +2,7 @@
set -e set -e
# Keep DATABASE_URL consistent for every Prisma command # Keep DATABASE_URL consistent for every Prisma command
export DATABASE_URL="${DATABASE_URL:-file:/app/data/prod.db}" export DATABASE_URL="${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
MIGRATIONS_DIR="./packages/shared/prisma/migrations" MIGRATIONS_DIR="./packages/shared/prisma/migrations"
# Debug: Check environment variables # Debug: Check environment variables
@ -22,14 +22,34 @@ echo "NODE_ENV: ${NODE_ENV:-[not set]}"
echo "========================================" echo "========================================"
echo "" echo ""
run_with_retries() {
attempt=1
max_attempts=20
while [ "$attempt" -le "$max_attempts" ]; do
if "$@"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "Command failed after ${max_attempts} attempts."
return 1
fi
echo "Database not ready yet. Retry ${attempt}/${max_attempts} in 3s..."
attempt=$((attempt + 1))
sleep 3
done
}
# Prefer migration-based deploys. Fall back to db push when no migrations exist yet. # Prefer migration-based deploys. Fall back to db push when no migrations exist yet.
set -- "$MIGRATIONS_DIR"/*/migration.sql set -- "$MIGRATIONS_DIR"/*/migration.sql
if [ -f "$1" ]; then if [ -f "$1" ]; then
echo "Applying Prisma migrations..." echo "Applying Prisma migrations..."
npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma run_with_retries npx prisma migrate deploy --schema=./packages/shared/prisma/schema.prisma
else else
echo "No Prisma migrations found. Syncing schema with db push..." echo "No Prisma migrations found. Syncing schema with db push..."
npx prisma db push --skip-generate --schema=./packages/shared/prisma/schema.prisma run_with_retries npx prisma db push --skip-generate --schema=./packages/shared/prisma/schema.prisma
fi fi
echo "Starting Next.js server..." echo "Starting Next.js server..."

View File

@ -9,7 +9,7 @@ import { headers } from 'next/headers'
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: 'sqlite', provider: 'postgresql',
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
@ -17,13 +17,13 @@ export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET!, secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!, baseURL: process.env.BETTER_AUTH_URL!,
trustedOrigins: [ trustedOrigins: [
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3032', process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010',
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3032', process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3010',
'http://localhost:3000', 'http://localhost:3000',
'http://localhost:3001', 'http://localhost:3001',
'http://localhost:3032', 'http://localhost:3010',
'http://localhost:8081', 'http://localhost:8081',
'http://*.localhost:3032', 'http://*.localhost:3010',
'http://*.localhost:3000', 'http://*.localhost:3000',
'https://*.innungsapp.de', 'https://*.innungsapp.de',
'https://*.innungsapp.com', 'https://*.innungsapp.com',
@ -55,17 +55,17 @@ export const auth = betterAuth({
export type Auth = typeof auth export type Auth = typeof auth
export async function getSanitizedHeaders() { export async function getSanitizedHeaders(sourceHeaders?: HeadersInit) {
const allHeaders = await headers() const baseHeaders = sourceHeaders ? new Headers(sourceHeaders) : new Headers(await headers())
const sanitizedHeaders = new Headers(allHeaders) const sanitizedHeaders = new Headers(baseHeaders)
// Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches // Avoid ENOTFOUND by forcing host to localhost for internal better-auth fetches
// We use the host defined in BETTER_AUTH_URL // We use the host defined in BETTER_AUTH_URL
try { try {
const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3032') const betterAuthUrl = new URL(process.env.BETTER_AUTH_URL || 'http://localhost:3010')
sanitizedHeaders.set('host', betterAuthUrl.host) sanitizedHeaders.set('host', betterAuthUrl.host)
} catch (e) { } catch (e) {
sanitizedHeaders.set('host', 'localhost:3032') sanitizedHeaders.set('host', 'localhost:3010')
} }
return sanitizedHeaders return sanitizedHeaders

View File

@ -1,7 +1,10 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim()
const SMTP_HOST_IS_PLACEHOLDER = SMTP_HOST === '' || SMTP_HOST.toLowerCase() === 'smtp.example.com'
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587, port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true', secure: process.env.SMTP_SECURE === 'true',
auth: auth:
@ -10,6 +13,16 @@ const transporter = nodemailer.createTransport({
: undefined, : undefined,
}) })
async function sendMailOrSkip(mailOptions: any, emailType: string) {
if (SMTP_HOST_IS_PLACEHOLDER) {
const target = typeof mailOptions?.to === 'string' ? mailOptions.to : 'unknown-recipient'
console.warn(`[email] SMTP not configured. Skipping ${emailType} email to ${target}.`)
return
}
await transporter.sendMail(mailOptions)
}
export async function sendMagicLinkEmail({ export async function sendMagicLinkEmail({
to, to,
magicUrl, magicUrl,
@ -17,7 +30,7 @@ export async function sendMagicLinkEmail({
to: string to: string
magicUrl: string magicUrl: string
}) { }) {
await transporter.sendMail({ await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de', from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to, to,
subject: 'Ihr Login-Link für InnungsApp', subject: 'Ihr Login-Link für InnungsApp',
@ -44,7 +57,7 @@ export async function sendMagicLinkEmail({
</div> </div>
</div> </div>
`, `,
}) }, 'magic link')
} }
export async function sendInviteEmail({ export async function sendInviteEmail({
@ -61,7 +74,7 @@ export async function sendInviteEmail({
// Generate magic link for the invite // Generate magic link for the invite
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true` const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
await transporter.sendMail({ await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de', from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to, to,
subject: `Einladung zur InnungsApp — ${orgName}`, subject: `Einladung zur InnungsApp — ${orgName}`,
@ -86,7 +99,7 @@ export async function sendInviteEmail({
</div> </div>
</div> </div>
`, `,
}) }, 'invite')
} }
export async function sendAdminCredentialsEmail({ export async function sendAdminCredentialsEmail({
@ -102,7 +115,7 @@ export async function sendAdminCredentialsEmail({
password: string password: string
loginUrl: string loginUrl: string
}) { }) {
await transporter.sendMail({ await sendMailOrSkip({
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de', from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
to, to,
subject: `Admin-Zugang für — ${orgName}`, subject: `Admin-Zugang für — ${orgName}`,
@ -134,5 +147,5 @@ export async function sendAdminCredentialsEmail({
</div> </div>
</div> </div>
`, `,
}) }, 'admin credentials')
} }

View File

@ -4,6 +4,7 @@ import type { NextRequest } from 'next/server'
const PUBLIC_PREFIXES = [ const PUBLIC_PREFIXES = [
'/login', '/login',
'/api/auth', '/api/auth',
'/api/health',
'/api/trpc/stellen.listPublic', '/api/trpc/stellen.listPublic',
'/api/setup', '/api/setup',
'/registrierung', '/registrierung',
@ -11,6 +12,7 @@ const PUBLIC_PREFIXES = [
'/datenschutz', '/datenschutz',
] ]
const PUBLIC_EXACT_PATHS = ['/'] const PUBLIC_EXACT_PATHS = ['/']
const TENANT_SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern']
// Reserved subdomains that shouldn't be treated as tenant slugs // Reserved subdomains that shouldn't be treated as tenant slugs
const RESERVED_SUBDOMAINS = [ const RESERVED_SUBDOMAINS = [
@ -40,6 +42,19 @@ export function middleware(request: NextRequest) {
} }
} }
// Normalize stale tenant-prefixed shared paths like /test/login to /login
// before auth checks, otherwise callbackUrl can get stuck on /test/login.
if (slug) {
for (const sharedPath of TENANT_SHARED_PATHS) {
const prefixedPath = `/${slug}${sharedPath}`
if (pathname === prefixedPath || pathname.startsWith(`${prefixedPath}/`)) {
const canonicalUrl = request.nextUrl.clone()
canonicalUrl.pathname = pathname.replace(prefixedPath, sharedPath)
return NextResponse.redirect(canonicalUrl)
}
}
}
// Allow static files from /public // Allow static files from /public
const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api') const isStaticFile = pathname.includes('.') && !pathname.startsWith('/api')
const isPublic = const isPublic =
@ -62,8 +77,7 @@ export function middleware(request: NextRequest) {
if (slug) { if (slug) {
// Paths that should not be rewritten into the slug folder // Paths that should not be rewritten into the slug folder
// because they are shared across the entire app // because they are shared across the entire app
const SHARED_PATHS = ['/login', '/api', '/superadmin', '/registrierung', '/impressum', '/datenschutz', '/passwort-aendern'] const isSharedPath = TENANT_SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
const isSharedPath = SHARED_PATHS.some((p) => pathname.startsWith(p)) ||
pathname.startsWith('/_next') || pathname.startsWith('/_next') ||
/\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname) /\.(png|jpg|jpeg|gif|svg|webp|ico|txt|xml)$/i.test(pathname)

View File

@ -1,9 +1,9 @@
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import { auth } from '@/lib/auth' import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
export async function createContext({ req }: FetchCreateContextFnOptions) { export async function createContext({ req }: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: req.headers }) const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
return { return {
req, req,
session, session,

View File

@ -1,6 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { router, memberProcedure, adminProcedure } from '../trpc' import { router, memberProcedure, adminProcedure } from '../trpc'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { sendInviteEmail, sendAdminCredentialsEmail } from '@/lib/email' import { sendInviteEmail, sendAdminCredentialsEmail } from '@/lib/email'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { prisma } from '@innungsapp/shared' import { prisma } from '@innungsapp/shared'
@ -51,7 +50,8 @@ const nonEmptyString = (min = 2) =>
const MemberInput = z.object({ const MemberInput = z.object({
name: z.string().min(2), name: z.string().min(2),
betrieb: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()), betrieb: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()), // Member.sparte is required in Prisma; map "not selected" to a safe default.
sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional().default('Sonstiges')),
ort: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()), ort: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
telefon: z.string().optional(), telefon: z.string().optional(),
email: z.string().email(), email: z.string().email(),
@ -216,14 +216,17 @@ export const membersRouter = router({
// 1. Create the member record // 1. Create the member record
const member = await ctx.prisma.member.create({ const member = await ctx.prisma.member.create({
data: { ...rest, orgId: ctx.orgId } as any, data: {
...rest,
sparte: rest.sparte || 'Sonstiges',
orgId: ctx.orgId,
} as any,
}) })
// 2. Create a User account if a password was provided OR role is 'admin', // 2. Create a User account if a password was provided OR role is 'admin',
// so the role is always persisted (no email sent here). // so the role is always persisted (no email sent here).
if (password || role === 'admin') { if (password || role === 'admin') {
try { try {
const authHeaders = await getSanitizedHeaders()
const existing = await ctx.prisma.user.findUnique({ where: { email: input.email } }) const existing = await ctx.prisma.user.findUnique({ where: { email: input.email } })
let userId: string | undefined = existing?.id let userId: string | undefined = existing?.id
const effectivePassword = password || crypto.randomBytes(8).toString('hex') const effectivePassword = password || crypto.randomBytes(8).toString('hex')
@ -279,11 +282,14 @@ export const membersRouter = router({
.input(MemberInput) .input(MemberInput)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { role, password, ...memberData } = input const { role, password, ...memberData } = input
const authHeaders = await getSanitizedHeaders()
// 1. Create member record // 1. Create member record
const member = await ctx.prisma.member.create({ const member = await ctx.prisma.member.create({
data: { ...memberData, orgId: ctx.orgId } as any, data: {
...memberData,
sparte: memberData.sparte || 'Sonstiges',
orgId: ctx.orgId,
} as any,
}) })
const org = await ctx.prisma.organization.findUniqueOrThrow({ const org = await ctx.prisma.organization.findUniqueOrThrow({

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,23 @@
services: services:
postgres:
image: postgres:16-alpine
container_name: innungsapp-postgres
restart: unless-stopped
environment:
POSTGRES_DB: "${POSTGRES_DB:-innungsapp}"
POSTGRES_USER: "${POSTGRES_USER:-innungsapp}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-innungsapp}"
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-innungsapp} -d ${POSTGRES_DB:-innungsapp}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
admin: admin:
build: build:
context: . context: .
@ -10,11 +29,14 @@ services:
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://innungsapp.com}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://innungsapp.com}"
container_name: innungsapp-admin container_name: innungsapp-admin
restart: unless-stopped restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports: ports:
- "3010:3000" - "3010:3000"
environment: environment:
# Database — SQLite file inside the named volume # Database — PostgreSQL
DATABASE_URL: "file:/app/data/prod.db" DATABASE_URL: "${DATABASE_URL:-postgresql://innungsapp:innungsapp@postgres:5432/innungsapp?schema=public}"
# Auth — CHANGE THESE in production! # Auth — CHANGE THESE in production!
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
@ -29,6 +51,10 @@ services:
SMTP_USER: "${SMTP_USER}" SMTP_USER: "${SMTP_USER}"
SMTP_PASS: "${SMTP_PASS}" SMTP_PASS: "${SMTP_PASS}"
# Superadmin seed defaults (override in .env)
SUPERADMIN_EMAIL: "${SUPERADMIN_EMAIL:-superadmin@innungsapp.de}"
SUPERADMIN_PASSWORD: "${SUPERADMIN_PASSWORD:-}"
# Public URLs # Public URLs
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://yourdomain.com}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-https://yourdomain.com}"
NEXT_PUBLIC_POSTHOG_KEY: "${NEXT_PUBLIC_POSTHOG_KEY:-}" NEXT_PUBLIC_POSTHOG_KEY: "${NEXT_PUBLIC_POSTHOG_KEY:-}"
@ -41,17 +67,15 @@ services:
# Node # Node
NODE_ENV: "production" NODE_ENV: "production"
volumes: volumes:
# SQLite database — persists across restarts
- db_data:/app/data
# Uploaded files — persists across restarts # Uploaded files — persists across restarts
- uploads_data:/app/uploads - uploads_data:/app/uploads
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q '\"status\":\"ok\"'"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
volumes: volumes:
db_data: pg_data:
uploads_data: uploads_data:

View File

@ -11,6 +11,7 @@
"db:push": "pnpm --filter @innungsapp/shared prisma:push", "db:push": "pnpm --filter @innungsapp/shared prisma:push",
"db:studio": "pnpm --filter @innungsapp/shared prisma:studio", "db:studio": "pnpm --filter @innungsapp/shared prisma:studio",
"db:seed": "pnpm --filter @innungsapp/shared prisma:seed", "db:seed": "pnpm --filter @innungsapp/shared prisma:seed",
"db:seed-superadmin": "pnpm --filter @innungsapp/shared prisma:seed-superadmin",
"db:reset": "pnpm --filter @innungsapp/shared prisma:migrate -- --reset" "db:reset": "pnpm --filter @innungsapp/shared prisma:migrate -- --reset"
}, },
"devDependencies": { "devDependencies": {

View File

@ -14,6 +14,7 @@
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"prisma:seed-superadmin": "tsx prisma/seed-superadmin.ts",
"prisma:seed-admin": "tsx prisma/seed-admin-password.ts", "prisma:seed-admin": "tsx prisma/seed-admin-password.ts",
"prisma:seed-demo-members": "tsx prisma/seed-demo-members.ts" "prisma:seed-demo-members": "tsx prisma/seed-demo-members.ts"
}, },

View File

@ -0,0 +1,350 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"email_verified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"role" TEXT,
"banned" BOOLEAN DEFAULT false,
"ban_reason" TEXT,
"ban_expires" TIMESTAMP(3),
"must_change_password" BOOLEAN DEFAULT false,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"ip_address" TEXT,
"user_agent" TEXT,
"user_id" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"access_token" TEXT,
"refresh_token" TEXT,
"id_token" TEXT,
"access_token_expires_at" TIMESTAMP(3),
"refresh_token_expires_at" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'pilot',
"logo_url" TEXT,
"primary_color" TEXT NOT NULL DEFAULT '#E63946',
"secondary_color" TEXT,
"contact_email" TEXT,
"avv_accepted" BOOLEAN NOT NULL DEFAULT false,
"avv_accepted_at" TIMESTAMP(3),
"landing_page_title" TEXT,
"landing_page_text" TEXT,
"landing_page_section_title" TEXT,
"landing_page_button_text" TEXT,
"landing_page_hero_image" TEXT,
"landing_page_hero_overlay_opacity" INTEGER DEFAULT 50,
"landing_page_features" JSONB,
"landing_page_footer" JSONB,
"app_store_url" TEXT,
"play_store_url" TEXT,
"ai_enabled" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "members" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"user_id" TEXT,
"name" TEXT NOT NULL,
"betrieb" TEXT NOT NULL,
"sparte" TEXT NOT NULL,
"ort" TEXT NOT NULL,
"telefon" TEXT,
"email" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'aktiv',
"ist_ausbildungsbetrieb" BOOLEAN NOT NULL DEFAULT false,
"seit" INTEGER,
"avatar_url" TEXT,
"push_token" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"role" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "news" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"author_id" TEXT,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"kategorie" TEXT NOT NULL,
"published_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "news_reads" (
"id" TEXT NOT NULL,
"news_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_reads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "news_attachments" (
"id" TEXT NOT NULL,
"news_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"storage_path" TEXT NOT NULL,
"mime_type" TEXT,
"size_bytes" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "news_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stellen" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"member_id" TEXT NOT NULL,
"sparte" TEXT NOT NULL,
"stellen_anz" INTEGER NOT NULL DEFAULT 1,
"verguetung" TEXT,
"lehrjahr" TEXT,
"beschreibung" TEXT,
"kontakt_email" TEXT NOT NULL,
"kontakt_name" TEXT,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "stellen_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "termine" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"datum" TIMESTAMP(3) NOT NULL,
"uhrzeit" TEXT,
"ende_datum" TIMESTAMP(3),
"ende_uhrzeit" TEXT,
"ort" TEXT,
"adresse" TEXT,
"typ" TEXT NOT NULL,
"beschreibung" TEXT,
"max_teilnehmer" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "termine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "termin_anmeldungen" (
"id" TEXT NOT NULL,
"termin_id" TEXT NOT NULL,
"member_id" TEXT NOT NULL,
"angemeldet_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "termin_anmeldungen_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "conversations" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "conversations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "conversation_members" (
"id" TEXT NOT NULL,
"conversation_id" TEXT NOT NULL,
"member_id" TEXT NOT NULL,
"last_read_at" TIMESTAMP(3),
CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "messages" (
"id" TEXT NOT NULL,
"conversation_id" TEXT NOT NULL,
"sender_id" TEXT NOT NULL,
"body" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "members_user_id_key" ON "members"("user_id");
-- CreateIndex
CREATE INDEX "members_org_id_idx" ON "members"("org_id");
-- CreateIndex
CREATE INDEX "members_status_idx" ON "members"("status");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_org_id_user_id_key" ON "user_roles"("org_id", "user_id");
-- CreateIndex
CREATE INDEX "news_org_id_idx" ON "news"("org_id");
-- CreateIndex
CREATE INDEX "news_published_at_idx" ON "news"("published_at");
-- CreateIndex
CREATE UNIQUE INDEX "news_reads_news_id_user_id_key" ON "news_reads"("news_id", "user_id");
-- CreateIndex
CREATE INDEX "stellen_org_id_idx" ON "stellen"("org_id");
-- CreateIndex
CREATE INDEX "stellen_aktiv_idx" ON "stellen"("aktiv");
-- CreateIndex
CREATE INDEX "termine_org_id_idx" ON "termine"("org_id");
-- CreateIndex
CREATE INDEX "termine_datum_idx" ON "termine"("datum");
-- CreateIndex
CREATE UNIQUE INDEX "termin_anmeldungen_termin_id_member_id_key" ON "termin_anmeldungen"("termin_id", "member_id");
-- CreateIndex
CREATE INDEX "conversations_org_id_idx" ON "conversations"("org_id");
-- CreateIndex
CREATE UNIQUE INDEX "conversation_members_conversation_id_member_id_key" ON "conversation_members"("conversation_id", "member_id");
-- CreateIndex
CREATE INDEX "messages_conversation_id_idx" ON "messages"("conversation_id");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "news" ADD CONSTRAINT "news_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "news" ADD CONSTRAINT "news_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "news_reads" ADD CONSTRAINT "news_reads_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "news"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "news_attachments" ADD CONSTRAINT "news_attachments_news_id_fkey" FOREIGN KEY ("news_id") REFERENCES "news"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stellen" ADD CONSTRAINT "stellen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "termine" ADD CONSTRAINT "termine_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "termin_anmeldungen" ADD CONSTRAINT "termin_anmeldungen_termin_id_fkey" FOREIGN KEY ("termin_id") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "termin_anmeldungen" ADD CONSTRAINT "termin_anmeldungen_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -1,14 +1,12 @@
// InnungsApp — Prisma Schema // InnungsApp — Prisma Schema
// Stack: SQLite + Prisma ORM + better-auth // Stack: PostgreSQL + Prisma ORM + better-auth
// Note: SQLite has no native enum support — enum fields are stored as String.
// Valid values are enforced at the application layer (Zod).
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "sqlite" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
@ -108,8 +106,8 @@ model Organization {
landingPageButtonText String? @map("landing_page_button_text") landingPageButtonText String? @map("landing_page_button_text")
landingPageHeroImage String? @map("landing_page_hero_image") landingPageHeroImage String? @map("landing_page_hero_image")
landingPageHeroOverlayOpacity Int? @default(50) @map("landing_page_hero_overlay_opacity") landingPageHeroOverlayOpacity Int? @default(50) @map("landing_page_hero_overlay_opacity")
landingPageFeatures String? @map("landing_page_features") landingPageFeatures Json? @map("landing_page_features") @db.JsonB
landingPageFooter String? @map("landing_page_footer") landingPageFooter Json? @map("landing_page_footer") @db.JsonB
appStoreUrl String? @map("app_store_url") appStoreUrl String? @map("app_store_url")
playStoreUrl String? @map("play_store_url") playStoreUrl String? @map("play_store_url")
aiEnabled Boolean @default(false) @map("ai_enabled") aiEnabled Boolean @default(false) @map("ai_enabled")

View File

@ -16,32 +16,53 @@ async function hashPassword(password) {
return `${salt}:${key.toString('hex')}` return `${salt}:${key.toString('hex')}`
} }
async function main() { function getEnv(name) {
const email = 'superadmin@innungsapp.de' return (process.env[name] || '').trim()
const password = 'demo1234' }
const userId = 'superadmin-user-id'
const accountId = 'superadmin-account-id'
console.log('Seeding superadmin...') async function main() {
const email = (getEnv('SUPERADMIN_EMAIL') || 'superadmin@innungsapp.de').toLowerCase()
const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
let password = getEnv('SUPERADMIN_PASSWORD')
if (!password) {
if (process.env.NODE_ENV === 'production') {
throw new Error('SUPERADMIN_PASSWORD must be set in production.')
}
password = 'demo1234'
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
}
console.log(`Seeding superadmin user for ${email}...`)
const hash = await hashPassword(password) const hash = await hashPassword(password)
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: { update: {
name: 'Super Admin', name,
emailVerified: true, emailVerified: true,
role: 'admin',
}, },
create: { create: {
id: userId, id: userId,
name: 'Super Admin', name,
email, email,
emailVerified: true, emailVerified: true,
role: 'admin',
}, },
}) })
await prisma.account.upsert({ await prisma.account.upsert({
where: { id: accountId }, where: { id: accountId },
update: { password: hash }, update: {
accountId: user.id,
providerId: 'credential',
userId: user.id,
password: hash,
},
create: { create: {
id: accountId, id: accountId,
accountId: user.id, accountId: user.id,

View File

@ -13,27 +13,55 @@ async function hashPassword(password: string): Promise<string> {
return `${salt}:${key.toString('hex')}` return `${salt}:${key.toString('hex')}`
} }
function getEnv(name: string): string {
return (process.env[name] ?? '').trim()
}
async function main() { async function main() {
console.log('Seeding superadmin...') const email = getEnv('SUPERADMIN_EMAIL').toLowerCase() || 'superadmin@innungsapp.de'
const hash = await hashPassword('demo1234') const name = getEnv('SUPERADMIN_NAME') || 'Super Admin'
console.log('Hash generated.') const userId = getEnv('SUPERADMIN_USER_ID') || 'superadmin-user-id'
const accountId = getEnv('SUPERADMIN_ACCOUNT_ID') || 'superadmin-account-id'
let password = getEnv('SUPERADMIN_PASSWORD')
if (!password) {
if (process.env.NODE_ENV === 'production') {
throw new Error('SUPERADMIN_PASSWORD must be set in production.')
}
password = 'demo1234'
console.warn('SUPERADMIN_PASSWORD not set. Using development fallback password.')
}
console.log(`Seeding superadmin user for ${email}...`)
const hash = await hashPassword(password)
const superAdminUser = await prisma.user.upsert({ const superAdminUser = await prisma.user.upsert({
where: { email: 'superadmin@innungsapp.de' }, where: { email },
update: {}, update: {
create: { name,
id: 'superadmin-user-id',
name: 'Super Admin',
email: 'superadmin@innungsapp.de',
emailVerified: true, emailVerified: true,
role: 'admin',
},
create: {
id: userId,
name,
email,
emailVerified: true,
role: 'admin',
}, },
}) })
await prisma.account.upsert({ await prisma.account.upsert({
where: { id: 'superadmin-account-id' }, where: { id: accountId },
update: { password: hash }, update: {
accountId: superAdminUser.id,
userId: superAdminUser.id,
providerId: 'credential',
password: hash,
},
create: { create: {
id: 'superadmin-account-id', id: accountId,
accountId: superAdminUser.id, accountId: superAdminUser.id,
providerId: 'credential', providerId: 'credential',
userId: superAdminUser.id, userId: superAdminUser.id,
@ -41,7 +69,7 @@ async function main() {
}, },
}) })
console.log('Done! Login: superadmin@innungsapp.de / demo1234') console.log(`Done. Login: ${email} / ${password}`)
} }
main() main()

View File

@ -1,2 +1,3 @@
export { prisma } from './lib/prisma' export { prisma } from './lib/prisma'
export { Prisma } from '@prisma/client'
export * from './types/index' export * from './types/index'