docs: gutscheinverwaltung spec and plan
This commit is contained in:
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.
|
||||
Reference in new issue
Block a user