fix: final review wave (reminder-400, contact-400, soon-30, cron-claim, edge-session)
This commit is contained in:
1 parent
39f782057c
commit
98387bd2d2
10 files changed
+122
-29
No files matched your search
@@ -0,0 +1,20 @@
|
||||
|
||||
## Final fix wave (5 must-fix) — 2026-10-18
|
||||
Branch: feat/gutscheinverwaltung
|
||||
|
||||
### Changes
|
||||
1. `app/api/vouchers/route.ts`: reminderDays provided-but-all-invalid (e.g. "bald") → 400 `{errors:["reminderDays"]}`; empty/absent still falls back to DEFAULT_REMINDER_DAYS. `app/vouchers/new/page.tsx` shows reminderDays field error.
|
||||
2. `app/api/vouchers/route.ts`: contactIds validated via `contact.findMany` before create; unknown id → 400 `{errors:["contactIds"]}` (statt P2003→500).
|
||||
3. `lib/vouchers.ts`: `VOUCHER_SOON_DAYS` 14→30 (Spec), stale TODO(Task 6) entfernt. API-Route + Page nutzen bereits shared helper `isExpiringSoon`.
|
||||
4. `app/api/cron/check/route.ts`: Idempotenz gehärtet — `claimSlot()` legt atomar Pending-Zeile (`status "versendet"`) vor Send an; Unique-Konflikt (P2002) → `gesendet`/`versendet` = skip, `fehler` = retry (zurück auf pending + senden). `alreadySent` erkennt auch `versendet`.
|
||||
5. `lib/session.ts` neu (nur jose: SESSION_COOKIE/createSession/verifySession); `middleware.ts` importiert nur daraus (Edge-safe, kein bcrypt). `lib/auth.ts` re-exportiert Session-Helper + behält `verifyPassword` (bcrypt) für Login-Route. Kein Behavior-Change.
|
||||
|
||||
### Tests
|
||||
- `tests/reminders.test.ts`: parseReminderDays "30,6,1"→[30,6,1], undefined→Default, "bald"/"abc,!!"→[] (=API 400), "30,bald,1"→[30,1].
|
||||
- `tests/vouchers-validate.test.ts`: VOUCHER_SOON_DAYS=30, isExpiringSoon 20d=true / 40d=false.
|
||||
- Keine Test-Referenz auf 14 vorhanden.
|
||||
|
||||
### Commands & outputs
|
||||
- `npx tsc --noEmit` → exit 0, keine Fehler.
|
||||
- `npx vitest run` → 8 files, 21 tests, alle passed.
|
||||
- `npm run build` → erfolgreich, Middleware 38.9 kB, alle Routen gebaut.
|
||||
@@ -9,7 +9,40 @@ async function alreadySent(db: ReturnType<typeof getDb>, ruleId: number, kanal:
|
||||
const found = await db.notificationLog.findUnique({
|
||||
where: { ruleId_kanal_empfaenger_zielDatum: { ruleId, kanal, empfaenger, zielDatum } },
|
||||
});
|
||||
return found?.status === "gesendet";
|
||||
return found?.status === "gesendet" || found?.status === "versendet";
|
||||
}
|
||||
|
||||
// Atomarer Claim vor dem Versand: erster Gewinner legt eine "versendet"-Zeile an,
|
||||
// ein Konkurrent mit Unique-Konflikt (P2002) wird abgewiesen. "fehler" bleibt retrybar.
|
||||
async function claimSlot(
|
||||
db: ReturnType<typeof getDb>,
|
||||
ruleId: number,
|
||||
kanal: string,
|
||||
empfaenger: string,
|
||||
zielDatum: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await db.notificationLog.create({
|
||||
data: { ruleId, kanal, empfaenger, zielDatum, status: "versendet" },
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "P2002") {
|
||||
const existing = await db.notificationLog.findUnique({
|
||||
where: { ruleId_kanal_empfaenger_zielDatum: { ruleId, kanal, empfaenger, zielDatum } },
|
||||
});
|
||||
// Fehler-Logs erneut versuchen: auf pending zurücksetzen und senden.
|
||||
if (existing?.status === "fehler") {
|
||||
await db.notificationLog.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: "versendet", fehler: null },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function logResult(
|
||||
@@ -75,8 +108,9 @@ export async function GET(req: NextRequest) {
|
||||
if (vc.contact.email) targets.push({ kanal: "mail", empfaenger: vc.contact.email });
|
||||
if (vc.contact.telegramChatId) targets.push({ kanal: "telegram", empfaenger: vc.contact.telegramChatId });
|
||||
for (const t of targets) {
|
||||
// Unique vor Versand prüfen (Idempotenz: kein Doppelversand).
|
||||
// Atomar claimen vor Versand (Idempotenz: kein Doppelversand bei parallelen Läufen).
|
||||
if (await alreadySent(db, ruleId, t.kanal, t.empfaenger, zielDatum)) continue;
|
||||
if (!(await claimSlot(db, ruleId, t.kanal, t.empfaenger, zielDatum))) continue;
|
||||
try {
|
||||
if (t.kanal === "mail") {
|
||||
await sendMail(t.empfaenger, `Gutschein "${v.name}" läuft bald ab`, text);
|
||||
|
||||
@@ -37,6 +37,23 @@ export async function POST(req: NextRequest) {
|
||||
const contactIds: number[] = Array.isArray(body.contactIds)
|
||||
? body.contactIds.map(Number).filter((n: number) => Number.isInteger(n))
|
||||
: [];
|
||||
if (contactIds.length) {
|
||||
const found = await db.contact.findMany({ where: { id: { in: contactIds } }, select: { id: true } });
|
||||
if (found.length !== new Set(contactIds).size) {
|
||||
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const reminderRaw =
|
||||
typeof body.reminderDays === "string"
|
||||
? body.reminderDays
|
||||
: Array.isArray(body.reminderDays)
|
||||
? body.reminderDays.join(",")
|
||||
: undefined;
|
||||
const reminderProvided = reminderRaw !== undefined && reminderRaw.trim() !== "";
|
||||
const reminderDays = parseReminderDays(reminderRaw);
|
||||
if (reminderProvided && reminderDays.length === 0) {
|
||||
return NextResponse.json({ errors: ["reminderDays"] }, { status: 400 });
|
||||
}
|
||||
const created = await db.voucher.create({
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
@@ -49,9 +66,7 @@ export async function POST(req: NextRequest) {
|
||||
notiz: input.notiz,
|
||||
contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined,
|
||||
rules: {
|
||||
create: parseReminderDays(
|
||||
typeof body.reminderDays === "string" ? body.reminderDays : Array.isArray(body.reminderDays) ? body.reminderDays.join(",") : undefined
|
||||
).map((tageVorher) => ({ tageVorher })),
|
||||
create: reminderDays.map((tageVorher) => ({ tageVorher })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function NewVoucherPage() {
|
||||
placeholder="30,6,1"
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
/>
|
||||
{errors.includes("reminderDays") && <p className="mt-1 text-sm text-red-600">Ungültig: reminderDays</p>}
|
||||
</div>
|
||||
<section id="kontakte">
|
||||
<h2 className="text-sm font-medium">Empfänger</h2>
|
||||
|
||||
+3
-18
@@ -1,23 +1,8 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
const COOKIE = "gt_session";
|
||||
export const SESSION_COOKIE = COOKIE;
|
||||
|
||||
export { SESSION_COOKIE, createSession, verifySession } from "./session";
|
||||
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
export const SESSION_COOKIE = "gt_session";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -11,9 +11,8 @@ export function formatEuro(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
}
|
||||
|
||||
// TODO(Task 6): vorläufiges "bald ablaufend"-Fenster. Task 6 ersetzt dies durch
|
||||
// individuelle Reminder-Regeln (Defaults 30/6/1 Tage vorher).
|
||||
export const VOUCHER_SOON_DAYS = 14;
|
||||
// TODO(Task 6) erledigt: "bald"-Fenster = 30 Tage (Spec), Reminder-Regeln (Default 30/6/1) steuern den Versand.
|
||||
export const VOUCHER_SOON_DAYS = 30;
|
||||
export function isExpiringSoon(ablaufdatum: Date | string | null, now: number = Date.now()): boolean {
|
||||
if (!ablaufdatum) return false;
|
||||
const t = new Date(ablaufdatum).getTime();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { verifySession, SESSION_COOKIE } from "./lib/auth";
|
||||
import { verifySession, SESSION_COOKIE } from "./lib/session";
|
||||
export async function middleware(req: NextRequest) {
|
||||
const p = req.nextUrl.pathname;
|
||||
if (p.startsWith("/login") || p.startsWith("/api/auth")) return NextResponse.next();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { dueRulesToday, idempotencyKey } from "../lib/reminders";
|
||||
import { dueRulesToday, idempotencyKey, parseReminderDays } from "../lib/reminders";
|
||||
describe("dueRulesToday", () => {
|
||||
it("30/6/1 trifft am richtigen Tag", () => {
|
||||
const ablauf = new Date("2026-12-01T00:00:00");
|
||||
@@ -14,4 +14,12 @@ describe("dueRulesToday", () => {
|
||||
it("key ist stabil", () => {
|
||||
expect(idempotencyKey(1, "mail", "a@b.de", "2026-12-01")).toBe("1|mail|a@b.de|2026-12-01");
|
||||
});
|
||||
it("parseReminderDays: gültig vs. alles-ungültig (API → 400)", () => {
|
||||
expect(parseReminderDays("30,6,1")).toEqual([30, 6, 1]);
|
||||
expect(parseReminderDays(undefined)).toEqual([30, 6, 1]);
|
||||
// "bald"/"abc" parst zu [] → API antwortet 400 mit errors ["reminderDays"]
|
||||
expect(parseReminderDays("bald")).toEqual([]);
|
||||
expect(parseReminderDays("abc,!!")).toEqual([]);
|
||||
expect(parseReminderDays("30,bald,1")).toEqual([30, 1]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateVoucher, formatEuro } from "../lib/vouchers";
|
||||
import { validateVoucher, formatEuro, VOUCHER_SOON_DAYS, isExpiringSoon } 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 };
|
||||
@@ -9,4 +9,12 @@ describe("validateVoucher", () => {
|
||||
it("formatEuro zeigt 25,50 €", () => {
|
||||
expect(formatEuro(2550)).toContain("25,50");
|
||||
});
|
||||
it("bald-Fenster = 30 Tage (Spec)", () => {
|
||||
expect(VOUCHER_SOON_DAYS).toBe(30);
|
||||
const now = new Date("2026-10-18T00:00:00Z").getTime();
|
||||
const in20 = new Date(now + 20 * 24 * 3600 * 1000).toISOString();
|
||||
const in40 = new Date(now + 40 * 24 * 3600 * 1000).toISOString();
|
||||
expect(isExpiringSoon(in20, now)).toBe(true);
|
||||
expect(isExpiringSoon(in40, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user