Postgres
This commit is contained in:
parent
b7d826e29c
commit
56ea3348d6
12
README.md
12
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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> = {
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue