diff --git a/innungsapp/.env.example b/innungsapp/.env.example index ed815be..6a9863c 100644 --- a/innungsapp/.env.example +++ b/innungsapp/.env.example @@ -1,47 +1,47 @@ -# ============================================= -# DATABASE (PostgreSQL) -# ============================================= -POSTGRES_DB="innungsapp" -POSTGRES_USER="innungsapp" -POSTGRES_PASSWORD="innungsapp" -DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" - -# ============================================= -# BETTER-AUTH -# ============================================= -BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string" -BETTER_AUTH_URL="http://localhost:3000" - -# ============================================= -# EMAIL (SMTP for magic links & invitations) -# ============================================= -EMAIL_FROM="noreply@innungsapp.de" -SMTP_HOST="smtp.example.com" -SMTP_PORT="587" -SMTP_SECURE="false" -SMTP_USER="" -SMTP_PASS="" - -# ============================================= -# ADMIN APP (Next.js) -# ============================================= -NEXT_PUBLIC_APP_URL="http://localhost:3000" -NEXT_PUBLIC_POSTHOG_KEY="" -NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" - -# ============================================= -# SUPERADMIN SEED -# ============================================= -SUPERADMIN_EMAIL="superadmin@innungsapp.de" -SUPERADMIN_PASSWORD="change-me-strong-password" - -# ============================================= -# MOBILE APP (Expo) -# ============================================= -EXPO_PUBLIC_API_URL="http://localhost:3000" - -# ============================================= -# FILE UPLOADS -# ============================================= -UPLOAD_DIR="./uploads" -UPLOAD_MAX_SIZE_MB="10" +# ============================================= +# DATABASE (PostgreSQL) +# ============================================= +POSTGRES_DB="innungsapp" +POSTGRES_USER="innungsapp" +POSTGRES_PASSWORD="innungsapp" +DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" + +# ============================================= +# BETTER-AUTH +# ============================================= +BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string" +BETTER_AUTH_URL="http://localhost:3000" + +# ============================================= +# EMAIL (SMTP for magic links & invitations) +# ============================================= +EMAIL_FROM="noreply@innungsapp.de" +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_SECURE="false" +SMTP_USER="" +SMTP_PASS="" + +# ============================================= +# ADMIN APP (Next.js) +# ============================================= +NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_POSTHOG_KEY="" +NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" + +# ============================================= +# SUPERADMIN SEED +# ============================================= +SUPERADMIN_EMAIL="superadmin@innungsapp.de" +SUPERADMIN_PASSWORD="change-me-strong-password" + +# ============================================= +# MOBILE APP (Expo) +# ============================================= +EXPO_PUBLIC_API_URL="http://localhost:3000" + +# ============================================= +# FILE UPLOADS +# ============================================= +UPLOAD_DIR="./uploads" +UPLOAD_MAX_SIZE_MB="10" diff --git a/innungsapp/.env.production.example b/innungsapp/.env.production.example index 0eeeb72..b5364a0 100644 --- a/innungsapp/.env.production.example +++ b/innungsapp/.env.production.example @@ -1,34 +1,34 @@ -# ============================================= -# Produktion — .env Vorlage -# 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! -BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string" -BETTER_AUTH_URL="https://yourdomain.com" - -# Email (SMTP) -EMAIL_FROM="noreply@yourdomain.com" -SMTP_HOST="smtp.example.com" -SMTP_PORT="587" -SMTP_SECURE="false" -SMTP_USER="user@example.com" -SMTP_PASS="your-smtp-password" - -# Öffentliche URLs -NEXT_PUBLIC_APP_URL="https://yourdomain.com" -NEXT_PUBLIC_POSTHOG_KEY="" -NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" - -# Superadmin Seed -SUPERADMIN_EMAIL="superadmin@yourdomain.com" -SUPERADMIN_PASSWORD="change-this-superadmin-password" - -# Uploads -UPLOAD_MAX_SIZE_MB="10" +# ============================================= +# Produktion — .env Vorlage +# 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! +BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string" +BETTER_AUTH_URL="https://yourdomain.com" + +# Email (SMTP) +EMAIL_FROM="noreply@yourdomain.com" +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_SECURE="false" +SMTP_USER="user@example.com" +SMTP_PASS="your-smtp-password" + +# Öffentliche URLs +NEXT_PUBLIC_APP_URL="https://yourdomain.com" +NEXT_PUBLIC_POSTHOG_KEY="" +NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" + +# Superadmin Seed +SUPERADMIN_EMAIL="superadmin@yourdomain.com" +SUPERADMIN_PASSWORD="change-this-superadmin-password" + +# Uploads +UPLOAD_MAX_SIZE_MB="10" diff --git a/innungsapp/.gitignore b/innungsapp/.gitignore index b02196f..5f4e0d9 100644 --- a/innungsapp/.gitignore +++ b/innungsapp/.gitignore @@ -1,36 +1,36 @@ -# Dependencies -node_modules -.pnp -.pnp.js - -# Build outputs -.next -dist -build -out - -# Turbo -.turbo - -# Environment -.env -.env.local -.env.production -.env.staging - -# Uploads (local file storage) -apps/admin/uploads/ - -# Expo -apps/mobile/.expo -apps/mobile/android -apps/mobile/ios - -# OS -.DS_Store -Thumbs.db - -# Editor -.vscode -.idea -*.swp +# Dependencies +node_modules +.pnp +.pnp.js + +# Build outputs +.next +dist +build +out + +# Turbo +.turbo + +# Environment +.env +.env.local +.env.production +.env.staging + +# Uploads (local file storage) +apps/admin/uploads/ + +# Expo +apps/mobile/.expo +apps/mobile/android +apps/mobile/ios + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode +.idea +*.swp diff --git a/innungsapp/README.md b/innungsapp/README.md index f38e247..f1a96f8 100644 --- a/innungsapp/README.md +++ b/innungsapp/README.md @@ -1,327 +1,327 @@ -# InnungsApp - -Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo). - -## Stack - -| Layer | Technology | -|---|---| -| Monorepo | pnpm Workspaces + Turborepo | -| Admin Dashboard | Next.js 15 (App Router) | -| Mobile App | Expo + React Native | -| API | tRPC v11 | -| Auth | better-auth (magic links + credential login) | -| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) | -| Styling | Tailwind CSS (admin), NativeWind (mobile) | - -## Projektstruktur - -```text -innungsapp/ -|-- apps/ -| |-- admin/ -| `-- mobile/ -|-- packages/ -| `-- shared/ -| `-- prisma/ -|-- docker-compose.yml -`-- README.md -``` - -## 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 - -- Node.js >= 20 -- pnpm >= 9 -- SMTP-Zugang (fuer Einladungen und Magic Links) - -### 1. Abhaengigkeiten installieren - -```bash -pnpm install -``` - -### 2. Umgebungsvariablen setzen (Admin lokal) - -```bash -cp .env.example .env -``` - -Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte). - -### 3. DB vorbereiten (lokal) - -Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv): - -```bash -docker compose up -d postgres -``` - -Prisma vorbereiten: - -```bash -pnpm db:generate -pnpm db:push -``` - -Optional Demo-Daten: - -```bash -pnpm db:seed -pnpm db:seed-superadmin -``` - -### 4. Entwicklung starten - -```bash -pnpm --filter @innungsapp/admin dev -pnpm --filter @innungsapp/mobile dev -``` - -Oder parallel: - -```bash -pnpm dev -``` - -## Production Deployment (Docker, Admin) - -Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server. - -### Voraussetzungen - -- Linux Server mit Docker + Docker Compose -- DNS-Eintrag auf den Server -- SMTP-Zugangsdaten -- Reverse Proxy (z. B. Nginx) fuer HTTPS - -### 1. Repository klonen - -```bash -git clone -cd innungsapp -``` - -### 2. Production-Env anlegen - -```bash -cp .env.production.example .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_URL` (z. B. `https://app.deine-innung.de`) -- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`) -- `EMAIL_FROM` -- `SMTP_HOST` -- `SMTP_PORT` -- `SMTP_SECURE` -- `SMTP_USER` -- `SMTP_PASS` -- `SUPERADMIN_EMAIL` -- `SUPERADMIN_PASSWORD` - -### 3. Container bauen und starten - -```bash -docker compose up -d --build -``` - -Hinweis zum DB-Start: - -- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt. -- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt. - -### 4. Healthcheck und Logs pruefen - -```bash -docker compose logs -f admin -curl -fsS http://localhost:3010/api/health -``` - -Erwartet: JSON mit `"status":"ok"`, z. B. - -```json -{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"} -``` - -### 5. Superadmin anlegen (nur beim ersten Start) - -```bash -docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js -``` - -Login-Daten kommen aus `.env`: - -- E-Mail: `SUPERADMIN_EMAIL` -- Passwort: `SUPERADMIN_PASSWORD` - -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) - -Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren. -Beispiel: - -```nginx -server { - listen 80; - server_name app.deine-innung.de; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl; - server_name app.deine-innung.de; - - ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem; - - location / { - proxy_pass http://localhost:3010; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -### 7. Updates einspielen - -```bash -git pull -docker compose up -d --build -docker compose logs -f admin -``` - -### 8. Backup und Restore (Docker Volumes) - -Vorher die exakten Volumenamen pruefen: - -```bash -docker volume ls | grep pg_data -docker volume ls | grep uploads_data -``` - -Backup: - -```bash -mkdir -p backups -docker run --rm \ - -v innungsapp_pg_data:/volume \ - -v "$(pwd)/backups:/backup" \ - alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ." -``` - -Restore (nur bei gestoppter App): - -```bash -docker compose down -docker run --rm \ - -v innungsapp_pg_data:/volume \ - -v "$(pwd)/backups:/backup" \ - alpine sh -c "rm -rf /volume/* && tar xzf /backup/.tar.gz -C /volume" -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) - -```bash -cd apps/mobile -eas build --platform all --profile production -eas submit --platform all -``` - -Wichtig: - -- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden. -- Fuer Production darf keine API-URL auf `localhost` zeigen. - -## Troubleshooting - -### `migrate deploy` oder `db push` fehlschlaegt - -- `DATABASE_URL` pruefen -- `postgres` Container Healthcheck pruefen (`docker compose ps`) -- Logs: `docker compose logs -f admin` - -### Healthcheck liefert Fehler - -- Containerstatus: `docker compose ps` -- App-Logs lesen -- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen - -### Login funktioniert nicht nach Seed - -- Seed-Command erneut ausfuehren -- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren - -## Weiterfuehrende Doku - -- Produkt-Roadmap: `../ROADMAP.md` -- Architektur: `../ARCHITECTURE.md` -- API Design: `../API_DESIGN.md` +# InnungsApp + +Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo). + +## Stack + +| Layer | Technology | +|---|---| +| Monorepo | pnpm Workspaces + Turborepo | +| Admin Dashboard | Next.js 15 (App Router) | +| Mobile App | Expo + React Native | +| API | tRPC v11 | +| Auth | better-auth (magic links + credential login) | +| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) | +| Styling | Tailwind CSS (admin), NativeWind (mobile) | + +## Projektstruktur + +```text +innungsapp/ +|-- apps/ +| |-- admin/ +| `-- mobile/ +|-- packages/ +| `-- shared/ +| `-- prisma/ +|-- docker-compose.yml +`-- README.md +``` + +## 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 + +- Node.js >= 20 +- pnpm >= 9 +- SMTP-Zugang (fuer Einladungen und Magic Links) + +### 1. Abhaengigkeiten installieren + +```bash +pnpm install +``` + +### 2. Umgebungsvariablen setzen (Admin lokal) + +```bash +cp .env.example .env +``` + +Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte). + +### 3. DB vorbereiten (lokal) + +Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv): + +```bash +docker compose up -d postgres +``` + +Prisma vorbereiten: + +```bash +pnpm db:generate +pnpm db:push +``` + +Optional Demo-Daten: + +```bash +pnpm db:seed +pnpm db:seed-superadmin +``` + +### 4. Entwicklung starten + +```bash +pnpm --filter @innungsapp/admin dev +pnpm --filter @innungsapp/mobile dev +``` + +Oder parallel: + +```bash +pnpm dev +``` + +## Production Deployment (Docker, Admin) + +Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server. + +### Voraussetzungen + +- Linux Server mit Docker + Docker Compose +- DNS-Eintrag auf den Server +- SMTP-Zugangsdaten +- Reverse Proxy (z. B. Nginx) fuer HTTPS + +### 1. Repository klonen + +```bash +git clone +cd innungsapp +``` + +### 2. Production-Env anlegen + +```bash +cp .env.production.example .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_URL` (z. B. `https://app.deine-innung.de`) +- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`) +- `EMAIL_FROM` +- `SMTP_HOST` +- `SMTP_PORT` +- `SMTP_SECURE` +- `SMTP_USER` +- `SMTP_PASS` +- `SUPERADMIN_EMAIL` +- `SUPERADMIN_PASSWORD` + +### 3. Container bauen und starten + +```bash +docker compose up -d --build +``` + +Hinweis zum DB-Start: + +- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt. +- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt. + +### 4. Healthcheck und Logs pruefen + +```bash +docker compose logs -f admin +curl -fsS http://localhost:3010/api/health +``` + +Erwartet: JSON mit `"status":"ok"`, z. B. + +```json +{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"} +``` + +### 5. Superadmin anlegen (nur beim ersten Start) + +```bash +docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js +``` + +Login-Daten kommen aus `.env`: + +- E-Mail: `SUPERADMIN_EMAIL` +- Passwort: `SUPERADMIN_PASSWORD` + +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) + +Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren. +Beispiel: + +```nginx +server { + listen 80; + server_name app.deine-innung.de; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name app.deine-innung.de; + + ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem; + + location / { + proxy_pass http://localhost:3010; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### 7. Updates einspielen + +```bash +git pull +docker compose up -d --build +docker compose logs -f admin +``` + +### 8. Backup und Restore (Docker Volumes) + +Vorher die exakten Volumenamen pruefen: + +```bash +docker volume ls | grep pg_data +docker volume ls | grep uploads_data +``` + +Backup: + +```bash +mkdir -p backups +docker run --rm \ + -v innungsapp_pg_data:/volume \ + -v "$(pwd)/backups:/backup" \ + alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ." +``` + +Restore (nur bei gestoppter App): + +```bash +docker compose down +docker run --rm \ + -v innungsapp_pg_data:/volume \ + -v "$(pwd)/backups:/backup" \ + alpine sh -c "rm -rf /volume/* && tar xzf /backup/.tar.gz -C /volume" +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) + +```bash +cd apps/mobile +eas build --platform all --profile production +eas submit --platform all +``` + +Wichtig: + +- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden. +- Fuer Production darf keine API-URL auf `localhost` zeigen. + +## Troubleshooting + +### `migrate deploy` oder `db push` fehlschlaegt + +- `DATABASE_URL` pruefen +- `postgres` Container Healthcheck pruefen (`docker compose ps`) +- Logs: `docker compose logs -f admin` + +### Healthcheck liefert Fehler + +- Containerstatus: `docker compose ps` +- App-Logs lesen +- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen + +### Login funktioniert nicht nach Seed + +- Seed-Command erneut ausfuehren +- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren + +## Weiterfuehrende Doku + +- Produkt-Roadmap: `../ROADMAP.md` +- Architektur: `../ARCHITECTURE.md` +- API Design: `../API_DESIGN.md` diff --git a/innungsapp/apps/admin/Dockerfile b/innungsapp/apps/admin/Dockerfile index 2ccc0d0..be312f1 100644 --- a/innungsapp/apps/admin/Dockerfile +++ b/innungsapp/apps/admin/Dockerfile @@ -1,107 +1,110 @@ -# ============================================= -# Stage 1: Dependencies -# ============================================= -FROM node:20-slim AS deps -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate - -# Install OpenSSL for Prisma -RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Copy workspace config files -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ -COPY apps/admin/package.json ./apps/admin/ -COPY packages/shared/package.json ./packages/shared/ - -# Install all dependencies -RUN pnpm install --frozen-lockfile - -# ============================================= -# Stage 2: Build -# ============================================= -FROM node:20-slim AS builder -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate - -# Install OpenSSL for Prisma -RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules -COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules - -COPY . . - -# Generate Prisma client for Alpine Linux -RUN pnpm --filter @innungsapp/shared prisma:generate - -# Accept build arguments for environment variables -ARG BETTER_AUTH_SECRET -ARG BETTER_AUTH_URL -ARG BETTER_AUTH_BASE_URL -ARG NEXT_PUBLIC_APP_URL - -# Build the admin app -ENV NEXT_TELEMETRY_DISABLED=1 -ENV DOCKER_BUILD=1 -# Set environment variables from build args for Next.js build -ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET -ENV BETTER_AUTH_URL=$BETTER_AUTH_URL -ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL -ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL -RUN pnpm --filter @innungsapp/admin build - -# ============================================= -# Stage 3: Production Runner -# ============================================= -FROM node:20-slim AS runner -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate - -# Install OpenSSL for Prisma -RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nextjs - -# Copy built output (standalone includes all necessary node_modules) -COPY --from=builder /app/apps/admin/.next/standalone ./ -COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static -COPY --from=builder /app/apps/admin/public ./apps/admin/public - -# Copy Prisma schema + migrations for runtime migrations -COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma - -# Copy 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 --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/ - -# Install Prisma CLI globally for runtime migrations -RUN npm install -g prisma@5.22.0 - -# Create uploads directory -RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads - -# Copy entrypoint -COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh -RUN chmod +x ./docker-entrypoint.sh - -USER nextjs - -EXPOSE 3000 - -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - -ENTRYPOINT ["./docker-entrypoint.sh"] +# ============================================= +# Stage 1: Dependencies +# ============================================= +FROM node:20-slim AS deps +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +# Install OpenSSL for Prisma +RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy workspace config files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/admin/package.json ./apps/admin/ +COPY packages/shared/package.json ./packages/shared/ + +# Install all dependencies +RUN pnpm install --frozen-lockfile + +# ============================================= +# Stage 2: Build +# ============================================= +FROM node:20-slim AS builder +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +# Install OpenSSL for Prisma +RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules +COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules + +COPY . . + +# Generate Prisma client for Alpine Linux +RUN pnpm --filter @innungsapp/shared prisma:generate + +# Accept build arguments for environment variables +ARG BETTER_AUTH_SECRET +ARG BETTER_AUTH_URL +ARG BETTER_AUTH_BASE_URL +ARG NEXT_PUBLIC_APP_URL + +# Build the admin app +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DOCKER_BUILD=1 +# Set environment variables from build args for Next.js build +ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET +ENV BETTER_AUTH_URL=$BETTER_AUTH_URL +ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +RUN pnpm --filter @innungsapp/admin build + +# ============================================= +# Stage 3: Production Runner +# ============================================= +FROM node:20-slim AS runner +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +# Install OpenSSL for Prisma +RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy built output (standalone includes all necessary node_modules) +COPY --from=builder /app/apps/admin/.next/standalone ./ +COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static +COPY --from=builder /app/apps/admin/public ./apps/admin/public + +# Fix permissions so nextjs user can write to .next/cache at runtime +RUN chown -R nextjs:nodejs /app/apps/admin/.next + +# Copy Prisma schema + migrations for runtime migrations +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 --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/ + +# Install Prisma CLI globally for runtime migrations +RUN npm install -g prisma@5.22.0 + +# Create uploads directory +RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads + +# Copy entrypoint +COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/innungsapp/apps/admin/app/[slug]/actions.ts b/innungsapp/apps/admin/app/[slug]/actions.ts index 34466d4..a8fed8c 100644 --- a/innungsapp/apps/admin/app/[slug]/actions.ts +++ b/innungsapp/apps/admin/app/[slug]/actions.ts @@ -1,70 +1,70 @@ -'use server' - -import { auth, getSanitizedHeaders } from '@/lib/auth' -import { prisma } from '@innungsapp/shared' -// @ts-ignore -import { hashPassword } from 'better-auth/crypto' - -export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) { - const newPassword = formData.get('newPassword') as string - const confirmPassword = formData.get('confirmPassword') as string - - if (newPassword !== confirmPassword) { - return { success: false, error: 'Passwörter stimmen nicht überein.' } - } - - if (newPassword.length < 8) { - return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' } - } - - const sanitizedHeaders = await getSanitizedHeaders() - const session = await auth.api.getSession({ headers: sanitizedHeaders }) - if (!session?.user) { - return { success: false, error: 'Nicht authentifiziert.' } - } - - const userId = session.user.id - - // Hash and save new password directly — user is already authenticated so no old password needed - const newHash = await hashPassword(newPassword) - - const credAccount = await prisma.account.findFirst({ - where: { userId, providerId: 'credential' }, - }) - - if (credAccount) { - await prisma.account.update({ - where: { id: credAccount.id }, - data: { password: newHash }, - }) - } else { - await prisma.account.create({ - data: { - id: crypto.randomUUID(), - accountId: userId, - providerId: 'credential', - userId, - password: newHash, - }, - }) - } - - // Clear mustChangePassword - await prisma.user.update({ - where: { id: userId }, - data: { mustChangePassword: false }, - }) - - // Sign out so the user logs in fresh with the new password - try { - await auth.api.signOut({ headers: sanitizedHeaders }) - } catch { - // ignore - } - - return { - success: true, - error: '', - redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`, - } -} +'use server' + +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { prisma } from '@innungsapp/shared' +// @ts-ignore +import { hashPassword } from 'better-auth/crypto' + +export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) { + const newPassword = formData.get('newPassword') as string + const confirmPassword = formData.get('confirmPassword') as string + + if (newPassword !== confirmPassword) { + return { success: false, error: 'Passwörter stimmen nicht überein.' } + } + + if (newPassword.length < 8) { + return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' } + } + + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) { + return { success: false, error: 'Nicht authentifiziert.' } + } + + const userId = session.user.id + + // Hash and save new password directly — user is already authenticated so no old password needed + const newHash = await hashPassword(newPassword) + + const credAccount = await prisma.account.findFirst({ + where: { userId, providerId: 'credential' }, + }) + + if (credAccount) { + await prisma.account.update({ + where: { id: credAccount.id }, + data: { password: newHash }, + }) + } else { + await prisma.account.create({ + data: { + id: crypto.randomUUID(), + accountId: userId, + providerId: 'credential', + userId, + password: newHash, + }, + }) + } + + // Clear mustChangePassword + await prisma.user.update({ + where: { id: userId }, + data: { mustChangePassword: false }, + }) + + // Sign out so the user logs in fresh with the new password + try { + await auth.api.signOut({ headers: sanitizedHeaders }) + } catch { + // ignore + } + + return { + success: true, + error: '', + redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`, + } +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx index fe4384f..570ee43 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx @@ -1,66 +1,66 @@ -'use client' - -import { useEffect } from 'react' -import { useActionState } from 'react' -import { changePasswordAndDisableMustChange } from '../actions' - -export function ForcePasswordChange({ slug }: { slug: string }) { - 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 ( -
-
-

Passwort festlegen

-

- Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account. -

-
- -
- - -
- - -
- -
- - -
- - {state?.error && ( -

{state?.error}

- )} - - -
-
- ) -} +'use client' + +import { useEffect } from 'react' +import { useActionState } from 'react' +import { changePasswordAndDisableMustChange } from '../actions' + +export function ForcePasswordChange({ slug }: { slug: string }) { + 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 ( +
+
+

Passwort festlegen

+

+ Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account. +

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

{state?.error}

+ )} + + +
+
+ ) +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/page.tsx index 3031908..ccc6c34 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/page.tsx @@ -1,213 +1,213 @@ -import { prisma } from '@innungsapp/shared' -import { auth, getSanitizedHeaders } from '@/lib/auth' -import { headers } from 'next/headers' -import { redirect } from 'next/navigation' -import Link from 'next/link' -import { MEMBER_STATUS_LABELS } from '@innungsapp/shared' -import { format } from 'date-fns' -import { de } from 'date-fns/locale' - -const STATUS_COLORS: Record = { - aktiv: 'bg-green-100 text-green-700', - ruhend: 'bg-yellow-100 text-yellow-700', - ausgetreten: 'bg-red-100 text-red-700', -} - -export default async function MitgliederPage(props: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }> -}) { - const searchParams = await props.searchParams - const search = typeof searchParams.q === 'string' ? searchParams.q : '' - const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined - - const sanitizedHeaders = await getSanitizedHeaders() - const session = await auth.api.getSession({ headers: sanitizedHeaders }) - if (!session?.user) redirect('/login') - - const userRole = await prisma.userRole.findFirst({ - where: { userId: session.user.id }, - orderBy: { createdAt: 'asc' }, - }) - if (!userRole || userRole.role !== 'admin') redirect('/dashboard') - - const members = await prisma.member.findMany({ - where: { - orgId: userRole.orgId, - ...(statusFilter && { status: statusFilter as never }), - ...(search && { - OR: [ - { name: { contains: search } }, - { betrieb: { contains: search } }, - { ort: { contains: search } }, - ], - }), - }, - orderBy: { name: 'asc' }, - }) - - // Also fetch admins to display them in the list if no status filter or status matches "aktiv" - const admins = await prisma.userRole.findMany({ - where: { - orgId: userRole.orgId, - role: 'admin', - ...(search && { - user: { - OR: [ - { name: { contains: search } }, - { email: { contains: search } }, - ] - } - }) - }, - include: { - user: true - } - }) - - const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId)) - // Map userId → member record so admin entries show real member data - const memberByUserId = new Map(members.filter((m: typeof members[number]) => m.userId).map((m: typeof members[number]) => [m.userId!, m])) - - const combinedList = [ - // Include admins only if there's no status filter, or if filtering for 'aktiv' - ...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => { - const m = memberByUserId.get(a.user.id) - return { - id: m ? m.id : `admin-${a.user.id}`, - name: m?.name ?? a.user.name, - betrieb: m?.betrieb ?? a.user.email, - sparte: m?.sparte ?? 'Sonderfunktion', - ort: m?.ort ?? '—', - seit: m?.seit ?? null as number | null, - status: m?.status ?? 'aktiv', - userId: a.user.id, - isAdmin: true, - realId: m ? m.id : a.user.id, - role: 'Administrator', - } - }) : []), - ...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({ - id: m.id, - name: m.name, - betrieb: m.betrieb, - sparte: m.sparte, - ort: m.ort, - seit: m.seit, - status: m.status, - userId: m.userId, - isAdmin: false, - realId: m.id, - role: 'Mitglied', - })) - ] - - combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name)) - - return ( -
-
-
-

Mitglieder

-

{combinedList.length} Einträge

-
- - + Mitglied anlegen - -
- - {/* Filters */} -
-
- - - -
-
- - {/* Table */} -
- - - - - - - - - - - - - - {combinedList.map((m) => ( - - - - - - - - - - ))} - -
Name / BetriebRolleOrtMitglied seitStatusEingeladen
-
-

{m.name}

-

{m.betrieb}

-
-
- - {m.role} - - {m.ort}{m.seit ?? '—'} - - {MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'} - - - {m.userId ? ( - Aktiv - ) : ( - - )} - - - Bearbeiten - -
- {combinedList.length === 0 && ( -
- Keine Mitglieder gefunden -
- )} -
-
- ) -} +import { prisma } from '@innungsapp/shared' +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import Link from 'next/link' +import { MEMBER_STATUS_LABELS } from '@innungsapp/shared' +import { format } from 'date-fns' +import { de } from 'date-fns/locale' + +const STATUS_COLORS: Record = { + aktiv: 'bg-green-100 text-green-700', + ruhend: 'bg-yellow-100 text-yellow-700', + ausgetreten: 'bg-red-100 text-red-700', +} + +export default async function MitgliederPage(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}) { + const searchParams = await props.searchParams + const search = typeof searchParams.q === 'string' ? searchParams.q : '' + const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined + + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) redirect('/login') + + const userRole = await prisma.userRole.findFirst({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'asc' }, + }) + if (!userRole || userRole.role !== 'admin') redirect('/dashboard') + + const members = await prisma.member.findMany({ + where: { + orgId: userRole.orgId, + ...(statusFilter && { status: statusFilter as never }), + ...(search && { + OR: [ + { name: { contains: search } }, + { betrieb: { contains: search } }, + { ort: { contains: search } }, + ], + }), + }, + orderBy: { name: 'asc' }, + }) + + // Also fetch admins to display them in the list if no status filter or status matches "aktiv" + const admins = await prisma.userRole.findMany({ + where: { + orgId: userRole.orgId, + role: 'admin', + ...(search && { + user: { + OR: [ + { name: { contains: search } }, + { email: { contains: search } }, + ] + } + }) + }, + include: { + user: true + } + }) + + const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId)) + // Map userId → member record so admin entries show real member data + const memberByUserId = new Map(members.filter((m: typeof members[number]) => m.userId).map((m: typeof members[number]) => [m.userId!, m])) + + const combinedList = [ + // Include admins only if there's no status filter, or if filtering for 'aktiv' + ...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => { + const m = memberByUserId.get(a.user.id) + return { + id: m ? m.id : `admin-${a.user.id}`, + name: m?.name ?? a.user.name, + betrieb: m?.betrieb ?? a.user.email, + sparte: m?.sparte ?? 'Sonderfunktion', + ort: m?.ort ?? '—', + seit: m?.seit ?? null as number | null, + status: m?.status ?? 'aktiv', + userId: a.user.id, + isAdmin: true, + realId: m ? m.id : a.user.id, + role: 'Administrator', + } + }) : []), + ...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({ + id: m.id, + name: m.name, + betrieb: m.betrieb, + sparte: m.sparte, + ort: m.ort, + seit: m.seit, + status: m.status, + userId: m.userId, + isAdmin: false, + realId: m.id, + role: 'Mitglied', + })) + ] + + combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name)) + + return ( +
+
+
+

Mitglieder

+

{combinedList.length} Einträge

+
+ + + Mitglied anlegen + +
+ + {/* Filters */} +
+
+ + + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + + {combinedList.map((m) => ( + + + + + + + + + + ))} + +
Name / BetriebRolleOrtMitglied seitStatusEingeladen
+
+

{m.name}

+

{m.betrieb}

+
+
+ + {m.role} + + {m.ort}{m.seit ?? '—'} + + {MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'} + + + {m.userId ? ( + Aktiv + ) : ( + + )} + + + Bearbeiten + +
+ {combinedList.length === 0 && ( +
+ Keine Mitglieder gefunden +
+ )} +
+
+ ) +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx index 1e0dba8..66476bf 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/news/[id]/page.tsx @@ -1,235 +1,235 @@ -'use client' - -import { use, useState, useEffect } from 'react' -import { useRouter } from 'next/navigation' -import { trpc } from '@/lib/trpc-client' -import { getTrpcErrorMessage } from '@/lib/trpc-error' -import Link from 'next/link' -import dynamic from 'next/dynamic' - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }) - -const KATEGORIEN = [ - { value: 'Wichtig', label: 'Wichtig' }, - { value: 'Pruefung', label: 'Prüfung' }, - { value: 'Foerderung', label: 'Förderung' }, - { value: 'Veranstaltung', label: 'Veranstaltung' }, - { value: 'Allgemein', label: 'Allgemein' }, -] - -export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const router = useRouter() - - const { data: news, isLoading } = trpc.news.byId.useQuery({ id }) - const updateMutation = trpc.news.update.useMutation({ - onSuccess: () => router.push('/dashboard/news'), - }) - const deleteMutation = trpc.news.delete.useMutation({ - onSuccess: () => router.push('/dashboard/news'), - }) - - const [title, setTitle] = useState('') - const [body, setBody] = useState('') - const [kategorie, setKategorie] = useState('Allgemein') - const [uploading, setUploading] = useState(false) - const [attachments, setAttachments] = useState< - Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }> - >([]) - - useEffect(() => { - if (news) { - setTitle(news.title) - setBody(news.body) - setKategorie(news.kategorie) - if (news.attachments) { - setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 }))) - } - } - }, [news]) - - if (isLoading) return
Wird geladen...
- if (!news) return
Beitrag nicht gefunden.
- - async function handleFileUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return - setUploading(true) - const formData = new FormData() - formData.append('file', file) - try { - const res = await fetch('/api/upload', { method: 'POST', body: formData }) - const data = await res.json() - setAttachments((prev) => [...prev, data]) - } catch { - alert('Upload fehlgeschlagen') - } finally { - setUploading(false) - } - } - - function handleSave(publishNow: boolean) { - if (!title.trim() || !body.trim()) return - updateMutation.mutate({ - id, - data: { - title, - body, - kategorie: kategorie as never, - publishedAt: publishNow ? new Date().toISOString() : undefined, - attachments: attachments.map((a) => ({ - name: a.name, - storagePath: a.storagePath, - sizeBytes: a.sizeBytes, - mimeType: a.mimeType || 'application/pdf', - })), - }, - }) - } - - function handleUnpublish() { - updateMutation.mutate({ id, data: { publishedAt: null } }) - } - - const isPublished = !!news.publishedAt - - return ( -
-
- - ← Zurück - - / -

Beitrag bearbeiten

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

- {getTrpcErrorMessage(updateMutation.error)} -

- )} - -
-
- {!isPublished && ( - - )} - - {isPublished && ( - - )} -
- -
-
-
- ) -} +'use client' + +import { use, useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { trpc } from '@/lib/trpc-client' +import { getTrpcErrorMessage } from '@/lib/trpc-error' +import Link from 'next/link' +import dynamic from 'next/dynamic' + +const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }) + +const KATEGORIEN = [ + { value: 'Wichtig', label: 'Wichtig' }, + { value: 'Pruefung', label: 'Prüfung' }, + { value: 'Foerderung', label: 'Förderung' }, + { value: 'Veranstaltung', label: 'Veranstaltung' }, + { value: 'Allgemein', label: 'Allgemein' }, +] + +export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const router = useRouter() + + const { data: news, isLoading } = trpc.news.byId.useQuery({ id }) + const updateMutation = trpc.news.update.useMutation({ + onSuccess: () => router.push('/dashboard/news'), + }) + const deleteMutation = trpc.news.delete.useMutation({ + onSuccess: () => router.push('/dashboard/news'), + }) + + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [kategorie, setKategorie] = useState('Allgemein') + const [uploading, setUploading] = useState(false) + const [attachments, setAttachments] = useState< + Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }> + >([]) + + useEffect(() => { + if (news) { + setTitle(news.title) + setBody(news.body) + setKategorie(news.kategorie) + if (news.attachments) { + setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 }))) + } + } + }, [news]) + + if (isLoading) return
Wird geladen...
+ if (!news) return
Beitrag nicht gefunden.
+ + async function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch('/api/upload', { method: 'POST', body: formData }) + const data = await res.json() + setAttachments((prev) => [...prev, data]) + } catch { + alert('Upload fehlgeschlagen') + } finally { + setUploading(false) + } + } + + function handleSave(publishNow: boolean) { + if (!title.trim() || !body.trim()) return + updateMutation.mutate({ + id, + data: { + title, + body, + kategorie: kategorie as never, + publishedAt: publishNow ? new Date().toISOString() : undefined, + attachments: attachments.map((a) => ({ + name: a.name, + storagePath: a.storagePath, + sizeBytes: a.sizeBytes, + mimeType: a.mimeType || 'application/pdf', + })), + }, + }) + } + + function handleUnpublish() { + updateMutation.mutate({ id, data: { publishedAt: null } }) + } + + const isPublished = !!news.publishedAt + + return ( +
+
+ + ← Zurück + + / +

Beitrag bearbeiten

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

+ {getTrpcErrorMessage(updateMutation.error)} +

+ )} + +
+
+ {!isPublished && ( + + )} + + {isPublished && ( + + )} +
+ +
+
+
+ ) +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx index 62b0d05..09e1546 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/news/page.tsx @@ -1,124 +1,124 @@ -import { prisma } from '@innungsapp/shared' -import { auth, getSanitizedHeaders } from '@/lib/auth' -import { headers } from 'next/headers' -import { redirect } from 'next/navigation' -import Link from 'next/link' -import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared' -import { format } from 'date-fns' -import { de } from 'date-fns/locale' - -const KATEGORIE_COLORS: Record = { - Wichtig: 'bg-red-100 text-red-700', - Pruefung: 'bg-blue-100 text-blue-700', - Foerderung: 'bg-green-100 text-green-700', - Veranstaltung: 'bg-purple-100 text-purple-700', - Allgemein: 'bg-gray-100 text-gray-700', -} - -export default async function NewsPage() { - const sanitizedHeaders = await getSanitizedHeaders() - const session = await auth.api.getSession({ headers: sanitizedHeaders }) - if (!session?.user) redirect('/login') - const userRole = await prisma.userRole.findFirst({ - where: { userId: session.user.id, role: 'admin' }, - }) - if (!userRole) redirect('/dashboard') - - const news = await prisma.news.findMany({ - where: { orgId: userRole.orgId }, - include: { author: { select: { name: true } } }, - orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }], - }) - - const published = news.filter((n: typeof news[number]) => n.publishedAt) - const drafts = news.filter((n: typeof news[number]) => !n.publishedAt) - - return ( -
-
-
-

News

-

{published.length} publiziert · {drafts.length} Entwürfe

-
- - + Beitrag erstellen - -
- - {drafts.length > 0 && ( -
-

- Entwürfe -

-
- - - {drafts.map((n: typeof drafts[number]) => ( - - - - - - ))} - -
-

{n.title}

-

Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}

-
- - {NEWS_KATEGORIE_LABELS[n.kategorie]} - - - - Bearbeiten - -
-
-
- )} - -
-

- Publiziert -

-
- - - - - - - - - - - - {published.map((n: typeof published[number]) => ( - - - - - - - - ))} - -
TitelKategorieAutorDatum
{n.title} - - {NEWS_KATEGORIE_LABELS[n.kategorie]} - - {n.author?.name ?? '—'} - {n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'} - - - Bearbeiten - -
-
-
-
- ) -} +import { prisma } from '@innungsapp/shared' +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import Link from 'next/link' +import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared' +import { format } from 'date-fns' +import { de } from 'date-fns/locale' + +const KATEGORIE_COLORS: Record = { + Wichtig: 'bg-red-100 text-red-700', + Pruefung: 'bg-blue-100 text-blue-700', + Foerderung: 'bg-green-100 text-green-700', + Veranstaltung: 'bg-purple-100 text-purple-700', + Allgemein: 'bg-gray-100 text-gray-700', +} + +export default async function NewsPage() { + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) redirect('/login') + const userRole = await prisma.userRole.findFirst({ + where: { userId: session.user.id, role: 'admin' }, + }) + if (!userRole) redirect('/dashboard') + + const news = await prisma.news.findMany({ + where: { orgId: userRole.orgId }, + include: { author: { select: { name: true } } }, + orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }], + }) + + const published = news.filter((n: typeof news[number]) => n.publishedAt) + const drafts = news.filter((n: typeof news[number]) => !n.publishedAt) + + return ( +
+
+
+

News

+

{published.length} publiziert · {drafts.length} Entwürfe

+
+ + + Beitrag erstellen + +
+ + {drafts.length > 0 && ( +
+

+ Entwürfe +

+
+ + + {drafts.map((n: typeof drafts[number]) => ( + + + + + + ))} + +
+

{n.title}

+

Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}

+
+ + {NEWS_KATEGORIE_LABELS[n.kategorie]} + + + + Bearbeiten + +
+
+
+ )} + +
+

+ Publiziert +

+
+ + + + + + + + + + + + {published.map((n: typeof published[number]) => ( + + + + + + + + ))} + +
TitelKategorieAutorDatum
{n.title} + + {NEWS_KATEGORIE_LABELS[n.kategorie]} + + {n.author?.name ?? '—'} + {n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'} + + + Bearbeiten + +
+
+
+
+ ) +} diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx index f9ddf86..5b251f0 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/page.tsx @@ -1,126 +1,126 @@ -import { prisma } from '@innungsapp/shared' -import { auth, getSanitizedHeaders } from '@/lib/auth' -import { headers } from 'next/headers' -import { redirect } from 'next/navigation' -import { StatsCards } from '@/components/stats/StatsCards' -import Link from 'next/link' -import { format } from 'date-fns' -import { de } from 'date-fns/locale' -import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared' - -export default async function DashboardPage() { - const sanitizedHeaders = await getSanitizedHeaders() - const session = await auth.api.getSession({ headers: sanitizedHeaders }) - if (!session?.user) redirect('/login') - - const userRole = await prisma.userRole.findFirst({ - where: { userId: session.user.id }, - include: { org: true }, - }) - if (!userRole) redirect('/login') - - const orgId = userRole.orgId - const now = new Date() - const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) - - const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] = - await Promise.all([ - prisma.member.count({ where: { orgId, status: 'aktiv' } }), - prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }), - prisma.termin.count({ where: { orgId, datum: { gte: now } } }), - prisma.stelle.count({ where: { orgId, aktiv: true } }), - prisma.news.findMany({ - where: { orgId, publishedAt: { not: null } }, - orderBy: { publishedAt: 'desc' }, - take: 5, - include: { author: { select: { name: true } } }, - }), - prisma.termin.findMany({ - where: { orgId, datum: { gte: now } }, - orderBy: { datum: 'asc' }, - take: 3, - }), - ]) - - return ( -
-
-

Übersicht

-

{userRole.org.name}

-
- - - -
- {/* Recent News */} -
-
-

Neueste Beiträge

- - Alle anzeigen - -
-
- {recentNews.map((n: typeof recentNews[number]) => ( -
-
-

{n.title}

-

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

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

Nächste Termine

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

Keine bevorstehenden Termine

- )} - {nextTermine.map((t: typeof nextTermine[number]) => ( -
-
-

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

-

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

-
-
-

{t.titel}

-

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

-
- - {TERMIN_TYP_LABELS[t.typ]} - -
- ))} -
-
-
-
- ) -} +import { prisma } from '@innungsapp/shared' +import { auth, getSanitizedHeaders } from '@/lib/auth' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { StatsCards } from '@/components/stats/StatsCards' +import Link from 'next/link' +import { format } from 'date-fns' +import { de } from 'date-fns/locale' +import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared' + +export default async function DashboardPage() { + const sanitizedHeaders = await getSanitizedHeaders() + const session = await auth.api.getSession({ headers: sanitizedHeaders }) + if (!session?.user) redirect('/login') + + const userRole = await prisma.userRole.findFirst({ + where: { userId: session.user.id }, + include: { org: true }, + }) + if (!userRole) redirect('/login') + + const orgId = userRole.orgId + const now = new Date() + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] = + await Promise.all([ + prisma.member.count({ where: { orgId, status: 'aktiv' } }), + prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }), + prisma.termin.count({ where: { orgId, datum: { gte: now } } }), + prisma.stelle.count({ where: { orgId, aktiv: true } }), + prisma.news.findMany({ + where: { orgId, publishedAt: { not: null } }, + orderBy: { publishedAt: 'desc' }, + take: 5, + include: { author: { select: { name: true } } }, + }), + prisma.termin.findMany({ + where: { orgId, datum: { gte: now } }, + orderBy: { datum: 'asc' }, + take: 3, + }), + ]) + + return ( +
+
+

Übersicht

+

{userRole.org.name}

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

Neueste Beiträge

+ + Alle anzeigen + +
+
+ {recentNews.map((n: typeof recentNews[number]) => ( +
+
+

{n.title}

+

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

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

Nächste Termine

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

Keine bevorstehenden Termine

+ )} + {nextTermine.map((t: typeof nextTermine[number]) => ( +
+
+

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

+

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

+
+
+

{t.titel}

+

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

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

Stelle anlegen

-
- -
-
-
- {/* Betrieb */} -
-

Betrieb

-
- - -
-
- - {/* Stellendetails */} -
-

Stellendetails

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