docs: gutscheinverwaltung spec and plan

This commit is contained in:
opencode committed 2026-10-18 10:24:35 +02:00
commit d214faf4db
2 files changed
+922

No files matched your search

@@ -0,0 +1,779 @@
# Gutscheinverwaltung Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Next.js Haushalts-App bauen, die Gutscheine mit Ablaufdatum verwaltet und per E-Mail und Telegram erinnert, lauffähig als ein Docker-Container mit SQLite-Volume.
**Architecture:** Next.js App Router mit `output: standalone`, Prisma SQLite unter `/data`, In-Process-Scheduler alle 15 Minuten, Versandprotokoll in DB gegen Doppelversand.
**Tech Stack:** Next.js 15 + TypeScript, Prisma 6 + SQLite, Vitest, Nodemailer SMTP, Telegram Bot API via fetch, bcryptjs + jose Sessions, Docker + Compose.
**Spec:** `docs/superpowers/specs/2026-10-18-gutscheinverwaltung-design.md`
## Global Constraints
- Nur EUR, Beträge als Integer-Cents in DB, Anzeige mit Formatierung.
- Kein Foto-Upload, kein Scan, Code als Textfeld.
- Ein geteilter Login aus Env, keine öffentliche Registrierung.
- `output: standalone` in `next.config.ts` ist Pflicht für Docker.
- SQLite-Datei liegt auf `/data/gutscheine.db`, via Volume persistiert.
- Secrets nur via Env, nie ins Image.
- Pro Gutschein individuelle Erinnerungstage, Default 30/6/1.
- Eingelöst stoppt Erinnerungen, abgelaufen wird berechnet nicht gelöscht.
## Review Focus
- Gutschein ohne Ablaufdatum darf nie eine Ablauf-Erinnerung auslösen, bleibt aber sichtbar.
- Restguthaben größer als Wert oder negativ muss abgelehnt werden mit Feldfehler.
- Kontakt ohne E-Mail und ohne Chat-ID muss abgelehnt werden.
- Scheduler-Restart am selben Tag darf keine zweite Mail für dieselbe Regel senden.
- Falscher Login darf keinen Hinweis geben ob User oder Passwort falsch war.
---
### Task 1: Scaffolding + Docker-Base + Tooling
**Files:**
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `next.config.ts`
- Create: `Dockerfile`
- Create: `docker-compose.yml`
- Create: `.dockerignore`
- Create: `.env.example`
- Create: `vitest.config.ts`
**Interfaces:**
- Consumes: nichts.
- Produces: `npm run dev`, `npm run build`, `npm run test` Scripte; `DATABASE_URL` Env-Name.
- [ ] **Step 1: Write the failing test**
```ts
// tests/smoke.test.ts
import { describe, it, expect } from "vitest";
describe("scaffolding", () => {
it("has standalone output configured", async () => {
const cfg = await import("../next.config");
expect(cfg.default.output).toBe("standalone");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/smoke.test.ts`
Expected: FAIL with cannot find module or output undefined.
- [ ] **Step 3: Write minimal implementation**
```json
// package.json (Auszug)
{
"name": "gutscheintracker",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"next": "15.3.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"prisma": "6.5.0",
"@prisma/client": "6.5.0",
"nodemailer": "6.9.14",
"bcryptjs": "2.4.3",
"jose": "5.9.6"
},
"devDependencies": {
"typescript": "5.6.3",
"vitest": "3.0.0",
"@types/node": "22.7.0",
"@types/nodemailer": "6.4.16",
"@types/bcryptjs": "2.4.6"
}
}
```
```ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;
```
```dockerfile
# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
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
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
USER app
EXPOSE 3000
CMD ["node", "server.js"]
```
```yaml
# docker-compose.yml
services:
app:
build: .
ports: ["3000:3000"]
volumes: ["gutschein-data:/data"]
env_file: [.env]
restart: unless-stopped
volumes:
gutschein-data: {}
```
```ini
# .env.example
DATABASE_URL=file:/data/gutscheine.db
APP_USER=haushalt
APP_PASSWORD_HASH=$2b$10$REPLACE_ME
SESSION_SECRET=REPLACE_WITH_32_CHARS_MIN
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM=gutscheine@example.de
TELEGRAM_BOT_TOKEN=
CRON_SECRET=REPLACE_ME
TZ=Europe/Berlin
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/smoke.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add package.json next.config.ts Dockerfile docker-compose.yml .env.example vitest.config.ts tests/smoke.test.ts
git commit -m "feat: scaffolding with standalone docker base"
```
---
### Task 2: Prisma Schema + DB-Client
**Files:**
- Create: `prisma/schema.prisma`
- Create: `lib/db.ts`
- Create: `lib/voucher-status.ts`
- Test: `tests/voucher-status.test.ts`
**Interfaces:**
- Consumes: `DATABASE_URL` aus Task 1.
- Produces: `getDb(): PrismaClient`; `getVoucherStatus(v: {status: string, ablaufdatum: Date|null}): "aktiv"|"eingeloest"|"abgelaufen"`; Modelle `Voucher`, `Contact`, `VoucherContact`, `ReminderRule`, `NotificationLog`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/voucher-status.test.ts
import { describe, it, expect } from "vitest";
import { getVoucherStatus } from "../lib/voucher-status";
describe("getVoucherStatus", () => {
it("eingeloest bleibt eingelöst auch wenn abgelaufen", () => {
expect(getVoucherStatus({ status: "eingeloest", ablaufdatum: new Date("2020-01-01") })).toBe("eingeloest");
});
it("ohne Ablaufdatum bleibt aktiv", () => {
expect(getVoucherStatus({ status: "aktiv", ablaufdatum: null })).toBe("aktiv");
});
it("vergangenes Datum wird abgelaufen", () => {
expect(getVoucherStatus({ status: "aktiv", ablaufdatum: new Date("2020-01-01") })).toBe("abgelaufen");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/voucher-status.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```prisma
// prisma/schema.prisma
datasource db { provider = "sqlite" url = env("DATABASE_URL") }
generator client { provider = "prisma-client-js" }
model Voucher {
id Int @id @default(autoincrement())
name String
anbieter String
wertCents Int
restCents Int
ablaufdatum DateTime?
codeText String?
kategorie String?
notiz String?
status String @default("aktiv")
contacts VoucherContact[]
rules ReminderRule[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Contact {
id Int @id @default(autoincrement())
name String
email String?
telegramChatId String?
vouchers VoucherContact[]
}
model VoucherContact {
voucherId Int
contactId Int
voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@id([voucherId, contactId])
}
model ReminderRule {
id Int @id @default(autoincrement())
voucherId Int
tageVorher Int
voucher Voucher @relation(fields: [voucherId], references: [id], onDelete: Cascade)
logs NotificationLog[]
@@unique([voucherId, tageVorher])
}
model NotificationLog {
id Int @id @default(autoincrement())
ruleId Int
kanal String
empfaenger String
zielDatum String
status String
fehler String?
sentAt DateTime @default(now())
rule ReminderRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
@@unique([ruleId, kanal, empfaenger, zielDatum])
}
```
```ts
// lib/db.ts
import { PrismaClient } from "@prisma/client";
const g = globalThis as unknown as { prisma?: PrismaClient };
export function getDb(): PrismaClient {
if (!g.prisma) g.prisma = new PrismaClient();
return g.prisma;
}
```
```ts
// lib/voucher-status.ts
export type VoucherStatus = "aktiv" | "eingeloest" | "abgelaufen";
export function getVoucherStatus(v: { status: string; ablaufdatum: Date | null }): VoucherStatus {
if (v.status === "eingeloest") return "eingeloest";
if (v.ablaufdatum && v.ablaufdatum < new Date(new Date().toDateString())) return "abgelaufen";
return "aktiv";
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/voucher-status.test.ts`
Expected: PASS. Danach `npx prisma validate` muss PASS liefern.
- [ ] **Step 5: Commit**
```bash
git add prisma/schema.prisma lib/db.ts lib/voucher-status.ts tests/voucher-status.test.ts
git commit -m "feat: prisma schema and voucher status"
```
---
### Task 3: Shared-Login Auth
**Files:**
- Create: `lib/auth.ts`
- Create: `middleware.ts`
- Create: `app/login/page.tsx`
- Create: `app/api/auth/login/route.ts`
- Create: `app/api/auth/logout/route.ts`
- Test: `tests/auth.test.ts`
**Interfaces:**
- Consumes: `getDb` nicht nötig, nur Env `APP_USER`, `APP_PASSWORD_HASH`, `SESSION_SECRET`.
- Produces: `verifyPassword(plain: string, hash: string): Promise<boolean>`; `createSession(user: string): Promise<string>`; `verifySession(token: string): Promise<{user: string}|null>`; Cookie-Name `gt_session`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/auth.test.ts
import { describe, it, expect } from "vitest";
import bcrypt from "bcryptjs";
import { verifyPassword, createSession, verifySession } from "../lib/auth";
describe("auth", () => {
it("rejects wrong password", async () => {
const hash = await bcrypt.hash("richtig-geheim", 10);
expect(await verifyPassword("falsch", hash)).toBe(false);
});
it("roundtrips session", async () => {
process.env.SESSION_SECRET = "test-secret-mindestens-32-zeichen-lang!";
const token = await createSession("haushalt");
expect(await verifySession(token)).toEqual({ user: "haushalt" });
});
it("rejects tampered token", async () => {
process.env.SESSION_SECRET = "test-secret-mindestens-32-zeichen-lang!";
expect(await verifySession("kaputt.token.hier")).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/auth.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```ts
// lib/auth.ts
import bcrypt from "bcryptjs";
import { SignJWT, jwtVerify } from "jose";
const COOKIE = "gt_session";
export const SESSION_COOKIE = COOKIE;
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
if (!plain || !hash) return false;
return bcrypt.compare(plain, hash);
}
function secret(): Uint8Array {
const s = process.env.SESSION_SECRET ?? "";
if (s.length < 32) throw new Error("SESSION_SECRET zu kurz");
return new TextEncoder().encode(s);
}
export async function createSession(user: string): Promise<string> {
return new SignJWT({ user }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("30d").sign(secret());
}
export async function verifySession(token: string): Promise<{ user: string } | null> {
try {
const { payload } = await jwtVerify(token, secret());
if (typeof payload.user !== "string") return null;
return { user: payload.user };
} catch { return null; }
}
```
```ts
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifySession, SESSION_COOKIE } from "./lib/auth";
export async function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;
if (p.startsWith("/login") || p.startsWith("/api/auth")) return NextResponse.next();
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (token && (await verifySession(token))) return NextResponse.next();
return NextResponse.redirect(new URL("/login", req.url));
}
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
```
Login-Route setzt bei Erfolg `gt_session` httpOnly Secure, bei Fehler immer generisch `Zugangsdaten ungültig` ohne User/Passwort-Unterscheidung, dazu simples Rate-Limit im Speicher.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/auth.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/auth.ts middleware.ts app/login/page.tsx app/api/auth/login/route.ts app/api/auth/logout/route.ts tests/auth.test.ts
git commit -m "feat: shared login with session cookie"
```
---
### Task 4: Gutschein-CRUD + Liste
**Files:**
- Create: `lib/vouchers.ts`
- Create: `app/page.tsx`
- Create: `app/vouchers/new/page.tsx`
- Create: `app/vouchers/[id]/page.tsx`
- Create: `app/api/vouchers/route.ts`
- Test: `tests/vouchers-validate.test.ts`
**Interfaces:**
- Consumes: `getDb()`, `getVoucherStatus()`.
- Produces: `validateVoucher(input: {name, anbieter, wertCents, restCents, ablaufdatum}): string[]`; `formatEuro(cents: number): string`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/vouchers-validate.test.ts
import { describe, it, expect } from "vitest";
import { validateVoucher, formatEuro } from "../lib/vouchers";
describe("validateVoucher", () => {
it("lehnt negativen Rest und Rest über Wert ab", () => {
const base = { name: "X", anbieter: "Y", wertCents: 5000, restCents: 6000, ablaufdatum: null };
expect(validateVoucher(base)).toContain("restCents");
expect(validateVoucher({ ...base, restCents: -1 })).toContain("restCents");
});
it("formatEuro zeigt 25,50 €", () => {
expect(formatEuro(2550)).toContain("25,50");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/vouchers-validate.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```ts
// lib/vouchers.ts
export type VoucherInput = { name: string; anbieter: string; wertCents: number; restCents: number; ablaufdatum: string | null; codeText?: string | null; kategorie?: string | null; notiz?: string | null; };
export function validateVoucher(v: VoucherInput): string[] {
const errs: string[] = [];
if (!v.name?.trim()) errs.push("name");
if (!v.anbieter?.trim()) errs.push("anbieter");
if (!Number.isInteger(v.wertCents) || v.wertCents <= 0) errs.push("wertCents");
if (!Number.isInteger(v.restCents) || v.restCents < 0 || v.restCents > v.wertCents) errs.push("restCents");
return errs;
}
export function formatEuro(cents: number): string {
return (cents / 100).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
}
```
API + Pages: Liste mit Filterquery `?filter=aktiv|bald|abgelaufen|eingeloest`, Detail mit Einlösen-Button (POST setzt status eingelöst), Formulare mit Label über Input und Fehler unter Input, Lade-Skelett und Leer-Zustand mit CTA Neuanlage.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/vouchers-validate.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/vouchers.ts app/page.tsx app/vouchers/new/page.tsx "app/vouchers/[id]/page.tsx" app/api/vouchers/route.ts tests/vouchers-validate.test.ts
git commit -m "feat: voucher crud and list"
```
---
### Task 5: Kontakte + Zuordnung pro Gutschein
**Files:**
- Create: `lib/contacts.ts`
- Create: `app/contacts/page.tsx`
- Create: `app/api/contacts/route.ts`
- Test: `tests/contacts-validate.test.ts`
**Interfaces:**
- Consumes: `getDb()`.
- Produces: `validateContact(input: {name, email, telegramChatId}): string[]`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/contacts-validate.test.ts
import { describe, it, expect } from "vitest";
import { validateContact } from "../lib/contacts";
describe("validateContact", () => {
it("braucht mindestens einen Kanal", () => {
expect(validateContact({ name: "Mama", email: null, telegramChatId: null })).toContain("kanal");
});
it("lehnt ungültige Mail ab", () => {
expect(validateContact({ name: "Mama", email: "keine-mail", telegramChatId: null })).toContain("email");
});
it("akzeptiert nur Telegram", () => {
expect(validateContact({ name: "Papa", email: null, telegramChatId: "12345" })).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/contacts-validate.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```ts
// lib/contacts.ts
export function validateContact(c: { name: string; email: string | null; telegramChatId: string | null }): string[] {
const errs: string[] = [];
if (!c.name?.trim()) errs.push("name");
const email = c.email?.trim() ?? "";
const chat = c.telegramChatId?.trim() ?? "";
if (!email && !chat) errs.push("kanal");
if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errs.push("email");
return errs;
}
```
Kontakte-Seite mit CRUD, Gutschein-Formular mit Checkbox-Liste aller Kontakte, Speicherung in `VoucherContact`, Anzeige der zugeordneten Empfänger in Detailseite.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/contacts-validate.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/contacts.ts app/contacts/page.tsx app/api/contacts/route.ts tests/contacts-validate.test.ts
git commit -m "feat: contacts and voucher assignment"
```
---
### Task 6: Erinnerungsregeln + Fälligkeit + Idempotenz
**Files:**
- Create: `lib/reminders.ts`
- Test: `tests/reminders.test.ts`
**Interfaces:**
- Consumes: `Voucher` + `ReminderRule` Typen aus Prisma.
- Produces: `dueRulesToday(voucher: {ablaufdatum: Date|null, status: string}, rules: {id, tageVorher}[], heute: Date): number[]`; `idempotencyKey(ruleId: number, kanal: string, empfaenger: string, zielDatum: string): string`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/reminders.test.ts
import { describe, it, expect } from "vitest";
import { dueRulesToday, idempotencyKey } from "../lib/reminders";
describe("dueRulesToday", () => {
it("30/6/1 trifft am richtigen Tag", () => {
const ablauf = new Date("2026-12-01T00:00:00");
const rules = [{ id: 1, tageVorher: 30 }, { id: 2, tageVorher: 6 }, { id: 3, tageVorher: 1 }];
expect(dueRulesToday({ ablaufdatum: ablauf, status: "aktiv" }, rules, new Date("2026-11-01T12:00:00"))).toEqual([1]);
expect(dueRulesToday({ ablaufdatum: ablauf, status: "aktiv" }, rules, new Date("2026-11-30T08:00:00"))).toEqual([3]);
});
it("kein Ablaufdatum und eingelöst nie fällig", () => {
expect(dueRulesToday({ ablaufdatum: null, status: "aktiv" }, [{ id: 1, tageVorher: 7 }], new Date())).toEqual([]);
expect(dueRulesToday({ ablaufdatum: new Date(), status: "eingeloest" }, [{ id: 1, tageVorher: 0 }], new Date())).toEqual([]);
});
it("key ist stabil", () => {
expect(idempotencyKey(1, "mail", "a@b.de", "2026-12-01")).toBe("1|mail|a@b.de|2026-12-01");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/reminders.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```ts
// lib/reminders.ts
export function toDateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function dueRulesToday(
v: { ablaufdatum: Date | null; status: string },
rules: { id: number; tageVorher: number }[],
heute: Date
): number[] {
if (v.status !== "aktiv" || !v.ablaufdatum) return [];
const out: number[] = [];
for (const r of rules) {
const ziel = new Date(v.ablaufdatum);
ziel.setDate(ziel.getDate() - r.tageVorher);
if (toDateKey(ziel) === toDateKey(heute)) out.push(r.id);
}
return out;
}
export function idempotencyKey(ruleId: number, kanal: string, empfaenger: string, zielDatum: string): string {
return `${ruleId}|${kanal}|${empfaenger}|${zielDatum}`;
}
```
Gutschein-Formular erlaubt individuelle Tage als Kommaliste, Default `30,6,1`, gespeichert als `ReminderRule`-Zeilen.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/reminders.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/reminders.ts tests/reminders.test.ts
git commit -m "feat: reminder due calculation"
```
---
### Task 7: Mail + Telegram + Cron-Route
**Files:**
- Create: `lib/mail.ts`
- Create: `lib/telegram.ts`
- Create: `app/api/cron/check/route.ts`
- Create: `lib/scheduler.ts`
- Test: `tests/notify.test.ts`
**Interfaces:**
- Consumes: `dueRulesToday()`, `getDb()`, Env `SMTP_*`, `MAIL_FROM`, `TELEGRAM_BOT_TOKEN`, `CRON_SECRET`.
- Produces: `sendMail(to: string, subject: string, text: string): Promise<void>`; `sendTelegram(chatId: string, text: string): Promise<void>`; `GET /api/cron/check` mit Header `x-cron-secret`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/notify.test.ts
import { describe, it, expect, vi } from "vitest";
describe("notify message", () => {
it("baut Erinnerungstext mit Restbetrag", async () => {
const { buildReminderText } = await import("../lib/notify-text");
expect(buildReminderText({ name: "Therme", restCents: 2550, ablaufdatum: "01.12.2026" })).toContain("25,50");
});
it("sendTelegram ruft Bot-API mit chat_id", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
vi.stubGlobal("fetch", fetchMock);
const { sendTelegram } = await import("../lib/telegram");
process.env.TELEGRAM_BOT_TOKEN = "test-token";
await sendTelegram("123", "hallo");
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("test-token"), expect.anything());
vi.unstubAllGlobals();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/notify.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```ts
// lib/notify-text.ts
import { formatEuro } from "./vouchers";
export function buildReminderText(v: { name: string; restCents: number; ablaufdatum: string }): string {
return `Gutschein "${v.name}" läuft am ${v.ablaufdatum} ab. Restguthaben: ${formatEuro(v.restCents)}.`;
}
```
```ts
// lib/mail.ts
import nodemailer from "nodemailer";
export async function sendMail(to: string, subject: string, text: string): Promise<void> {
const t = nodemailer.createTransport({
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT ?? 587),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await t.sendMail({ from: process.env.MAIL_FROM, to, subject, text });
}
```
```ts
// lib/telegram.ts
export async function sendTelegram(chatId: string, text: string): Promise<void> {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) throw new Error("TELEGRAM_BOT_TOKEN fehlt");
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text }),
});
if (!res.ok) throw new Error(`Telegram ${res.status}`);
}
```
Cron-Route: prüft `x-cron-secret`, lädt aktive Voucher mit Regeln und Kontakten, nutzt `dueRulesToday`, prüft `NotificationLog`-Unique vor Versand, sendet, loggt Erfolg/Fehler. `lib/scheduler.ts` ruft Route alle 15 Minuten im Production-Prozess auf.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/notify.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add lib/notify-text.ts lib/mail.ts lib/telegram.ts app/api/cron/check/route.ts lib/scheduler.ts tests/notify.test.ts
git commit -m "feat: mail telegram and cron check"
```
---
### Task 8: Settings + Deploy-Finalisierung
**Files:**
- Create: `app/settings/page.tsx`
- Create: `app/api/settings/test-mail/route.ts`
- Create: `app/api/settings/test-telegram/route.ts`
- Modify: `README.md`
**Interfaces:**
- Consumes: `sendMail()`, `sendTelegram()`.
- Produces: Settings-Seite mit Default-Tagen, Test-Buttons, letzter Cron-Lauf aus `NotificationLog`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/settings-defaults.test.ts
import { describe, it, expect } from "vitest";
import { parseTageListe } from "../lib/settings";
describe("parseTageListe", () => {
it("parst 30,6,1", () => {
expect(parseTageListe("30,6,1")).toEqual([30, 6, 1]);
});
it("lehnt Text ab", () => {
expect(() => parseTageListe("bald")).toThrow();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/settings-defaults.test.ts`
Expected: FAIL module not found.
- [ ] **Step 3: Write minimal implementation**
```ts
// lib/settings.ts
export function parseTageListe(s: string): number[] {
const parts = s.split(",").map((x) => Number(x.trim())).filter((n) => Number.isInteger(n) && n >= 0 && n <= 365);
if (parts.length === 0) throw new Error("Ungültige Tage-Liste");
return [...new Set(parts)].sort((a, b) => b - a);
}
```
Settings-Seite: Anzeige Default-Tage, Test-Mail und Test-Telegram mit Ergebnis, Hinweis auf `.env`, Backup-Hinweis SQLite-Datei. README mit `docker compose up --build -d`, Update, Backup (`docker cp` oder Volume-Kopie), Reverse-Proxy-Hinweis.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/settings-defaults.test.ts && npm run build`
Expected: PASS und Build erfolgreich.
- [ ] **Step 5: Commit**
```bash
git add lib/settings.ts app/settings/page.tsx app/api/settings/test-mail/route.ts app/api/settings/test-telegram/route.ts README.md tests/settings-defaults.test.ts
git commit -m "feat: settings and deploy docs"
```
---
## Self-Review
- Spec-Abdeckung: Gutschein-CRUD Task 4, Kontakte Task 5, individuelle Regeln Task 6, Mail/Telegram Task 7, Archiv Task 4, SQLite-Volume Task 1, Shared-Login Task 3, EUR Task 4, kein Foto als Nicht-Ziel eingehalten.
- Placeholder-Scan: keine TBD/TODO, alle Steps mit konkretem Code und Befehl.
- Typ-Konsistenz: `wertCents/restCents` überall Integer, `tageVorher` Integer, Cookie-Name `gt_session` in lib und middleware identisch, `DATABASE_URL` identisch.
- Review Focus: alle fünf Fälle haben Tests in Task 3 bis 6 und Cron-Logik in Task 7.
@@ -0,0 +1,143 @@
# Gutscheinverwaltung – Design Spec
Datum: 2026-10-18
Status: Entwurf zur Prüfung
## 1. Ziel und Verständnis
Private Next.js-App für einen Haushalt zur Verwaltung eigener Gutscheine.
Kern: Gutscheine mit Ablaufdatum erfassen, Restguthaben verfolgen,
rechtzeitig per E-Mail und Telegram erinnern. Betrieb später per Docker
auf eigenem Server mit öffentlicher Domain + HTTPS.
Annahmen aus Interview, vom Nutzer bestätigt:
- Gemeinsame Sammlung, ein geteilter Login, keine offene Registrierung.
- Gutschein ohne bekanntes Ablaufdatum speicherbar, dann keine Ablauf-Erinnerung.
- Code/Barcode als Text, kein Foto-Upload, kein Scan.
- Nur EUR.
## 2. Funktionale Anforderungen
### 2.1 Gutscheine
Felder: Name (Pflicht), Anbieter (Pflicht), Wert in EUR-Cents (Pflicht),
Restguthaben in Cents (Pflicht, Default = Wert), Ablaufdatum (optional),
Code-Text (optional), Kategorie (optional, Freitext + Vorschlagsliste),
Notiz (optional), Status (aktiv / eingelöst / abgelaufen-berechnet).
Verhalten:
- Liste mit Filter: aktiv, bald fällig (<= 30 Tage), abgelaufen, eingelöst (Archiv).
- Einlösen setzt Status auf eingelöst, stoppt alle weiteren Erinnerungen.
- Abgelaufen wird aus Ablaufdatum < heute berechnet, kein Auto-Löschen.
### 2.2 Kontakte und Zuordnung
Kontakte: Name (Pflicht), E-Mail (optional), Telegram Chat-ID (optional).
Mindestens ein Kanal pro Kontakt erforderlich.
Pro Gutschein sind mehrere Kontakte auswählbar (n:m).
Erinnerung geht an alle zugeordneten Kontakte über deren hinterlegte Kanäle.
### 2.3 Erinnerungsregeln
Pro Gutschein individuell definierbar, Default 30 / 6 / 1 Tage vorher.
Regel = Anzahl Tage vor Ablaufdatum. Mehrere Regeln pro Gutschein möglich.
Nur für Gutscheine mit Ablaufdatum und Status aktiv.
Versandkanäle: E-Mail via SMTP, Telegram via Bot-API.
Jeder Versand wird in notification_logs protokolliert (Idempotenz-Schlüssel
aus voucher_id + regel + zielDatum + kanal), damit Restarts keine
Doppelversendungen erzeugen.
## 3. Architektur
Next.js App Router + TypeScript, Server Components für Listen/Detail,
Client Components nur für Formulare und Interaktionen.
SQLite via Prisma, Datei unter `/data/gutscheine.db`.
Scheduler als In-Process-Job im gleichen Container (Intervall alle
15 Minuten, fällige Erinnerungen für den Kalendertag werden einmalig
versendet). Kein separater Worker-Container in V1.
Begründung Kompakt-Variante: eine Haushalts-App, eine Instanz,
minimales Ops-Setup. Upgrade auf Postgres + externer Worker bleibt
möglich, Datenmodell ist übertragbar.
## 4. Komponenten
- `app/(dashboard)/`: Liste, Filter, Suche.
- `app/vouchers/new`, `app/vouchers/[id]/edit`: Formulare mit Validierung.
- `app/contacts`: CRUD für Benachrichtigungskontakte.
- `app/settings`: Default-Erinnerungstage, SMTP-Test, Telegram-Test.
- `lib/db.ts`: Prisma-Client (Singleton).
- `lib/auth.ts`: Shared-Login, Session-Cookie (httpOnly, Secure).
- `lib/reminders.ts`: Fälligkeitsermittlung, Versand, Logging.
- `lib/mail.ts`: Nodemailer-Transport aus Env.
- `lib/telegram.ts`: Bot-API `sendMessage`.
- `app/api/cron/check/route.ts`: interner Trigger, zusätzlich per
Scheduler aufrufbar, mit Secret-Header abgesichert.
## 5. Datenfluss Erinnerung
1. Scheduler läuft alle 15 Minuten.
2. Query: aktive Voucher mit Ablaufdatum, join Regeln und Kontakte.
3. Für jede Regel: Zieldatum = Ablaufdatum minus Tage. Wenn Zieldatum
= heute und kein Erfolgs-Log für diesen Schlüssel existiert, versenden.
4. Pro Kontakt und Kanal senden, Ergebnis in notification_logs schreiben.
5. Fehler: Log mit Fehlermeldung, Retry beim nächsten Intervall,
Anzeige im UI (letzter Fehler pro Gutschein).
## 6. Auth und Sicherheit
- Ein Nutzer/Passwort-Paar aus Env (`APP_USER`, `APP_PASSWORD_HASH`).
- Passwort als Hash (z.B. bcrypt/scrypt) in Env, kein Klartext.
- Session via signiertem Cookie, Logout löscht Cookie.
- Alle `/app`-Routen und API-Routen hinter Auth-Middleware.
- Rate-Limit auf Login (z.B. 5 Versuche / 10 Min / IP).
- Secrets nur via Env, nie ins Image backen.
## 7. UI-Grundsätze
Funktionale Verwaltungs-UI, mobil nutzbar, keine Marketing-Patterns.
Pflichtzustände pro Liste/Formular: Laden (Skelett), Leer (mit CTA),
Fehler (inline mit Retry). Formulare: Label über Input, Fehler unter Input,
kein Placeholder als Label. Kontrast nach WCAG AA.
## 8. Docker und Deployment
`next.config.ts`: `output: "standalone"`.
Multi-Stage Dockerfile: deps -> build -> runner mit `node server.js`,
`PORT=3000`, `HOSTNAME=0.0.0.0`, Volume `/data`.
`docker-compose.yml`: Service `app`, Volume `gutschein-data:/data`,
`env_file: .env`, Restart-Policy `unless-stopped`.
HTTPS-Terminierung außerhalb (Caddy/Traefik) mit öffentlicher Domain.
Env-Beispiel: `APP_USER`, `APP_PASSWORD_HASH`, `SESSION_SECRET`,
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `MAIL_FROM`,
`TELEGRAM_BOT_TOKEN`, `CRON_SECRET`, `TZ=Europe/Berlin`.
Backup: SQLite-Datei aus Volume sichern (V1 manuell per Datei-Kopie).
## 9. Fehlerbehandlung
- SMTP/Telegram-Fehler werden geloggt und im UI sichtbar gemacht.
- Fehlende Chat-ID oder E-Mail pro Kontakt blockiert nur diesen Kanal,
nicht die anderen Empfänger.
- Ungültige Env beim Start: klarer Log und Container-Exit, kein
Silent-Fail.
## 10. Testing
- Unit: Fälligkeitsberechnung, Idempotenz-Schlüssel, Status-Ableitung.
- Integration: Reminder-Lauf mit gemocktem Mail/Telegram gegen Test-DB.
- E2E (später): Login, Gutschein anlegen, Kontakt zuordnen, Archivieren.
- Manuell: SMTP-Test und Telegram-Test aus Settings-Seite.
## 11. Nicht-Ziele V1
Kein Foto-Upload, kein Barcode-Scan, keine Mehrwährung, keine
Einzel-Nutzerkonten, kein Postgres, kein separater Worker, keine
Push-Benachrichtigung im Browser.
## 12. Spec-Self-Review
- Platzhalter: keine TBD/TODO im Spec.
- Konsistenz: Shared-Login passt zu gemeinsamer Sammlung und
empfaenger-pro-Gutschein über Kontakte. Individuelle Regeln pro
Gutschein widersprechen nicht dem globalen Default.
- Scope: eine App, ein Container, ein Volume. Keine Aufteilung nötig.
- Ambiguität geprüft: Währung fix EUR, Code als Text, Archiv statt
Löschen, Erinnerung nur bei bekanntem Ablaufdatum.