feat: öffentlich bereitstellbar — Caddy mit Auto-HTTPS, interner Cron-Zugriff, Boot-Migration inkl. Basistabellen und /data-Rechten, Backup-Test

This commit is contained in:
opencode committed 2026-10-18 21:36:46 +02:00
1 parent 780b735dfa
commit 7ae291030a
9 files changed
+231 -25

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@ node_modules
.worktrees
.superpowers
Dockerfile
docker-compose.yml
docker-compose*.yml
Caddyfile
.dockerignore
.env
.env*.local
+7 -1
View File
@@ -1,4 +1,7 @@
DATABASE_URL=file:/data/gutscheine.db
# Öffentliche Domain (Pflicht, z. B. gutscheine.example.de). Für lokalen
# Testbetrieb ohne TLS geht auch: DOMAIN=localhost
DOMAIN=gutscheine.example.de
APP_USER=haushalt
APP_PASSWORD_HASH=$2b$10$REPLACE_ME
ADMIN_EMAIL=admin@beispiel.de
@@ -13,7 +16,10 @@ TELEGRAM_BOT_TOKEN=
LOGO_DEV_SECRET_KEY=
LOGO_DEV_PUBLISHABLE_KEY=
CRON_SECRET=REPLACE_ME
APP_URL=http://localhost:3000
# Öffentliche Adresse (Pflicht für OIDC-Redirects und Cron-Aufrufe von außen)
APP_URL=https://gutscheine.example.de
# Interne App-Adresse für den Scheduler (Standard passt zu docker-compose.yml)
APP_INTERNAL_URL=http://app:3000
ALLOW_PASSWORD_LOGIN=true
TZ=Europe/Berlin
# Optional: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET (stattdessen UI-Einstellungen nutzen)
+6
View File
@@ -0,0 +1,6 @@
# Öffentliche Domain aus .env (DOMAIN). Caddy holt und erneuert das
# Let's-Encrypt-Zertifikat automatisch und leitet HTTP auf HTTPS um.
# Lokal funktioniert dieselbe Datei mit DOMAIN=localhost (dann ohne TLS).
{$DOMAIN} {
reverse_proxy app:3000
}
+1 -1
View File
@@ -10,7 +10,7 @@ RUN npx prisma generate && npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S app && adduser -S app -G app
RUN addgroup -S app && adduser -S app -G app && mkdir -p /data && chown app:app /data
COPY --from=builder --chown=app:app /app/.next/standalone ./
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
COPY --from=builder --chown=app:app /app/prisma ./prisma
+22 -19
View File
@@ -4,16 +4,33 @@ Private Gutscheinverwaltung für einen Haushalt (Next.js + SQLite + Prisma). Gem
Rollen: Der erste Nutzer ist Admin (wird beim Start aus `APP_USER`/`APP_PASSWORD_HASH` angelegt). Admins verwalten Nutzer, Einladungen und Einstellungen; alle Mitglieder verwalten gemeinsam Gutscheine.
## Start (Docker Compose)
## Öffentlich betreiben (Docker + Caddy + HTTPS)
Voraussetzungen auf dem Server: Docker mit Compose-Plugin, eine Domain mit A/AAAA-Record auf die Server-IP, offene Ports 80/443 (plus SSH für dich). Port 3000 wird **nicht** geöffnet — die App ist nur im internen Compose-Netz erreichbar.
```bash
cp .env.example .env # Werte ausfüllen (v. a. APP_PASSWORD_HASH bzw. ADMIN_EMAIL, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, CRON_SECRET, APP_URL)
docker compose up --build -d
cp .env.example .env # Pflicht: DOMAIN (z. B. gutscheine.example.de), APP_URL=https://<DOMAIN>, APP_USER, APP_PASSWORD_HASH, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, CRON_SECRET
docker compose up -d --build
```
App: http://localhost:3000 — Anmeldung mit Benutzername oder E-Mail plus Passwort, oder per SSO (falls in den Einstellungen konfiguriert).
Caddy holt und erneuert das Let's-Encrypt-Zertifikat automatisch und leitet HTTP auf HTTPS um. `APP_URL` muss exakt die öffentliche Adresse sein (wichtig für OIDC-Redirects: in Authentik als Redirect-URI `https://<DOMAIN>/api/auth/oidc/callback` eintragen; der Reminder-Scheduler nutzt intern `APP_INTERNAL_URL`).
Der Erinnerungs-Scheduler startet automatisch im Production-Prozess (`instrumentation.ts` → `lib/scheduler.ts`, alle 15 Minuten, `APP_URL` als Basis).
Prüfen:
- `https://<DOMAIN>/login` lädt mit gültigem Zertifikat, `http://` leitet auf HTTPS um.
- Vom Server aus: Port 3000 ist geschlossen (kein `curl http://localhost:3000` möglich).
- Einladungs-Links und Cron-Läufe funktionieren (Test-Mail in den Einstellungen).
Lokal testen geht mit derselben Datei: `DOMAIN=localhost` in `.env` (dann ohne TLS, Caddy macht automatisch HTTP).
## Entwicklung
```bash
npm ci
npx prisma generate
npm run dev # http://localhost:3001
npm test
npm run build
```
## Mitglieder einladen
@@ -35,17 +52,3 @@ docker cp gutscheintracker-app-1:/data/gutscheine.db ./gutscheine-backup.db
# oder Volume direkt kopieren
docker run --rm -v gutschein-data:/data -v "$PWD":/backup alpine cp /data/gutscheine.db /backup/gutscheine-backup.db
```
## Reverse Proxy
Für HTTPS einen Reverse Proxy (z. B. Caddy/Nginx) vor Port 3000 schalten und `APP_URL` auf die öffentliche URL setzen, damit der Scheduler die Cron-Route über die richtige Basis-URL aufruft.
## Entwicklung
```bash
npm ci
npx prisma generate
npm run dev
npm test
npm run build
```
+32 -2
View File
@@ -1,9 +1,39 @@
services:
app:
build: .
ports: ["3000:3000"]
volumes: ["gutschein-data:/data"]
# Kein ports:-Mapping: Die App ist nur im internen Compose-Netz erreichbar.
# Nach außen geht es ausschließlich über den Caddy-Reverse-Proxy (443/HTTPS).
expose:
- "3000"
environment:
# Interner Direktzugriff für den Erinnerungs-Scheduler (kein Hairpin über den Proxy nötig).
- APP_INTERNAL_URL=http://app:3000
volumes:
- gutschein-data:/data
env_file: [.env]
restart: unless-stopped
security_opt:
- no-new-privileges:true
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (optional, schadet nicht)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
DOMAIN: ${DOMAIN:?DOMAIN muss in .env gesetzt sein (z. B. gutscheine.example.de)}
depends_on:
- app
security_opt:
- no-new-privileges:true
volumes:
gutschein-data: {}
caddy-data: {}
caddy-config: {}
+17
View File
@@ -50,6 +50,23 @@ export async function ensureSchema(): Promise<void> {
await db.$executeRaw`ALTER TABLE "Invite" ADD COLUMN "householdId" INTEGER REFERENCES "Household"("id") ON DELETE CASCADE`;
}
}
if (!tables.includes("Voucher")) {
await db.$executeRaw`CREATE TABLE "Voucher" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "anbieter" TEXT NOT NULL, "wertCents" INTEGER NOT NULL, "restCents" INTEGER NOT NULL, "ablaufdatum" DATETIME, "codeText" TEXT, "kategorie" TEXT, "notiz" TEXT, "providerDomain" TEXT, "createdBy" TEXT, "shopUrl" TEXT, "wertTyp" TEXT NOT NULL DEFAULT 'euro', "prozentWert" INTEGER, "mindestbestellwertCents" INTEGER, "ownerId" INTEGER NOT NULL DEFAULT 0, "householdId" INTEGER, "status" TEXT NOT NULL DEFAULT 'aktiv', "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, CONSTRAINT "Voucher_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Voucher_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE SET NULL ON UPDATE CASCADE)`;
}
if (!tables.includes("Contact")) {
await db.$executeRaw`CREATE TABLE "Contact" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "email" TEXT, "telegramChatId" TEXT, "ownerId" INTEGER NOT NULL DEFAULT 0, "householdId" INTEGER, CONSTRAINT "Contact_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "Contact_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE SET NULL ON UPDATE CASCADE)`;
}
if (!tables.includes("VoucherContact")) {
await db.$executeRaw`CREATE TABLE "VoucherContact" ("voucherId" INTEGER NOT NULL, "contactId" INTEGER NOT NULL, PRIMARY KEY ("voucherId", "contactId"), CONSTRAINT "VoucherContact_voucherId_fkey" FOREIGN KEY ("voucherId") REFERENCES "Voucher"("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "VoucherContact_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact"("id") ON DELETE CASCADE ON UPDATE CASCADE)`;
}
if (!tables.includes("ReminderRule")) {
await db.$executeRaw`CREATE TABLE "ReminderRule" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "voucherId" INTEGER NOT NULL, "tageVorher" INTEGER NOT NULL, CONSTRAINT "ReminderRule_voucherId_fkey" FOREIGN KEY ("voucherId") REFERENCES "Voucher"("id") ON DELETE CASCADE ON UPDATE CASCADE)`;
await db.$executeRaw`CREATE UNIQUE INDEX IF NOT EXISTS "ReminderRule_voucherId_tageVorher_key" ON "ReminderRule"("voucherId", "tageVorher")`;
}
if (!tables.includes("NotificationLog")) {
await db.$executeRaw`CREATE TABLE "NotificationLog" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "ruleId" INTEGER NOT NULL, "kanal" TEXT NOT NULL, "empfaenger" TEXT NOT NULL, "zielDatum" TEXT NOT NULL, "status" TEXT NOT NULL, "fehler" TEXT, "sentAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "NotificationLog_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "ReminderRule"("id") ON DELETE CASCADE ON UPDATE CASCADE)`;
await db.$executeRaw`CREATE UNIQUE INDEX IF NOT EXISTS "NotificationLog_ruleId_kanal_empfaenger_zielDatum_key" ON "NotificationLog"("ruleId", "kanal", "empfaenger", "zielDatum")`;
}
const vcols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map(
(c) => c.name
);
+6 -1
View File
@@ -30,6 +30,11 @@ export function stopScheduler(): void {
export function ensureSchedulerStarted(): void {
if (process.env.NODE_ENV !== "production") return;
if (timer) return;
const baseUrl = process.env.APP_URL ?? `http://localhost:${process.env.PORT ?? 3000}`;
// Bevorzugt internen Direktzugriff (kein Hairpin über den öffentlichen Reverse-Proxy nötig),
// fällt zurück auf die öffentliche APP_URL (z. B. für Cron von außen).
const baseUrl =
process.env.APP_INTERNAL_URL?.replace(/\/$/, "") ||
process.env.APP_URL?.replace(/\/$/, "") ||
`http://localhost:${process.env.PORT ?? 3000}`;
startScheduler(baseUrl);
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
let dir: string;
let dbUrl: string;
let prevDatabaseUrl: string | undefined;
let prevAppUser: string | undefined;
let prevAppHash: string | undefined;
let prevAdminEmail: string | undefined;
// Import erst nach Env-Setup (lazy via dynamic import, da getDb() beim
// ersten Aufruf instanziiert und DATABASE_URL dann einliest).
let migrate!: typeof import("../lib/db-migrate");
let ensureAdmin!: typeof import("../lib/ensure-admin");
type Db = ReturnType<typeof import("../lib/db")["getDb"]>;
let db: Db;
function freshDbUrl(name: string) {
return `file:${join(dir, name)}`;
}
async function tables(): Promise<string[]> {
const rows = await db.$queryRaw<{ name: string }[]>`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`;
return rows.map((r) => r.name);
}
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), "gutscheine-migrate-test-"));
prevDatabaseUrl = process.env.DATABASE_URL;
prevAppUser = process.env.APP_USER;
prevAppHash = process.env.APP_PASSWORD_HASH;
prevAdminEmail = process.env.ADMIN_EMAIL;
dbUrl = freshDbUrl("fresh.db");
process.env.DATABASE_URL = dbUrl;
process.env.APP_USER = "haushalt";
// bcrypt-Hash für "gutschein123" (Testwert, kein echtes Geheimnis)
process.env.APP_PASSWORD_HASH = "$2a$10$Dev5QNpvAHJfEmd26lTGJ.JuBhU.ag8G.q13Q.ihTI3FZQhWpxLUe";
process.env.ADMIN_EMAIL = "admin@beispiel.de";
const g = globalThis as unknown as { prisma?: unknown };
delete g.prisma;
migrate = await import("../lib/db-migrate");
ensureAdmin = await import("../lib/ensure-admin");
const { getDb } = await import("../lib/db");
db = getDb();
});
afterAll(async () => {
await db.$disconnect().catch(() => {});
const g = globalThis as unknown as { prisma?: unknown };
delete g.prisma;
if (prevDatabaseUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = prevDatabaseUrl;
if (prevAppUser === undefined) delete process.env.APP_USER;
else process.env.APP_USER = prevAppUser;
if (prevAppHash === undefined) delete process.env.APP_PASSWORD_HASH;
else process.env.APP_PASSWORD_HASH = prevAppHash;
if (prevAdminEmail === undefined) delete process.env.ADMIN_EMAIL;
else process.env.ADMIN_EMAIL = prevAdminEmail;
rmSync(dir, { recursive: true, force: true });
});
describe("Erst-Boot auf leerer Datenbank", () => {
it("legt komplettes Schema, Admin und Standard-Haushalt an", async () => {
await migrate.ensureSchema();
await ensureAdmin.ensureFirstAdmin();
await migrate.ensureMultitenancy();
const t = await tables();
for (const expected of ["Voucher", "Contact", "VoucherContact", "ReminderRule", "NotificationLog", "AppSetting", "Household", "HouseholdMember", "User", "Invite"]) {
expect(t).toContain(expected);
}
const households = await db.household.findMany();
expect(households).toHaveLength(1);
expect(households[0].name).toBe("Haushalt");
const admin = await db.user.findFirst({ where: { name: "haushalt" }, include: { membership: true } });
expect(admin).toBeTruthy();
expect(admin!.role).toBe("admin");
expect(admin!.membership?.householdId).toBe(households[0].id);
expect(admin!.membership?.role).toBe("admin");
});
it("ist bei erneutem Lauf idempotent (keine Duplikate)", async () => {
await migrate.ensureSchema();
await ensureAdmin.ensureFirstAdmin();
await migrate.ensureMultitenancy();
expect(await db.household.count()).toBe(1);
expect(await db.user.count()).toBe(1);
});
});
describe("Altdaten-Übernahme", () => {
it("ordnet Bestand ohne Zuordnung dem Standard-Haushalt und erstem Admin zu", async () => {
// Legacy-Zeilen simulieren: explizite id 0 umgeht AUTOINCREMENT, FKs bleiben erfüllt
await db.$executeRawUnsafe(`INSERT INTO "User" ("id", "name", "role", "tokenVersion", "createdAt", "updatedAt") VALUES (0, 'Alt-System', 'member', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`);
const vRes = (await db.$queryRaw<{ id: number }[]>`INSERT INTO "Voucher" ("name", "anbieter", "wertCents", "restCents", "wertTyp", "status", "ownerId", "householdId", "updatedAt") VALUES ('Alt', 'Laden', 100, 100, 'euro', 'aktiv', 0, NULL, CURRENT_TIMESTAMP) RETURNING id`) as unknown as { id: number }[];
const cRes = (await db.$queryRaw<{ id: number }[]>`INSERT INTO "Contact" ("name", "ownerId", "householdId") VALUES ('Alt-Kontakt', 0, NULL) RETURNING id`) as unknown as { id: number }[];
const vId = Number(vRes[0].id);
const cId = Number(cRes[0].id);
await migrate.ensureMultitenancy();
const voucher = await db.voucher.findUnique({ where: { id: vId } });
const contact = await db.contact.findUnique({ where: { id: cId } });
const admin = await db.user.findFirst({ where: { name: "haushalt" } });
expect(voucher?.householdId).toBe(1);
expect(voucher?.ownerId).toBe(admin!.id);
expect(contact?.householdId).toBe(1);
expect(contact?.ownerId).toBe(admin!.id);
await db.voucher.delete({ where: { id: vId } });
await db.contact.delete({ where: { id: cId } });
await db.user.deleteMany({ where: { id: 0 } });
});
it("benennt veraltete Namensliste um statt sie zu löschen", async () => {
const dbUrl2 = freshDbUrl("legacy.db");
process.env.DATABASE_URL = dbUrl2;
const g = globalThis as unknown as { prisma?: unknown };
delete g.prisma;
const { getDb: freshGetDb } = await import("../lib/db");
const db2 = freshGetDb();
try {
await db2.$executeRawUnsafe(`CREATE TABLE "HouseholdMember" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`);
await db2.$executeRawUnsafe(`INSERT INTO "HouseholdMember" ("name") VALUES ('Familie')`);
await migrate.ensureSchema();
const t = (await db2.$queryRaw<{ name: string }[]>`SELECT name FROM sqlite_master WHERE type='table'`).map((r) => r.name);
expect(t).toContain("HouseholdMemberLegacy");
expect(t).toContain("HouseholdMember");
const legacy = (await db2.$queryRaw<{ name: string }[]>`SELECT name FROM "HouseholdMemberLegacy"`) as unknown[];
expect(legacy).toHaveLength(1);
} finally {
await db2.$disconnect().catch(() => {});
delete (globalThis as unknown as { prisma?: unknown }).prisma;
process.env.DATABASE_URL = dbUrl;
const { getDb: origGetDb } = await import("../lib/db");
db = origGetDb();
}
});
});