feat: Erinnerungs-Checkboxen, Anbieterlogos via Logo.dev, Kontakte in Einstellungen

This commit is contained in:
opencode committed 2026-10-18 12:25:56 +02:00
1 parent 11bef8748f
commit a1195223e0
18 files changed
+381 -50

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ SMTP_USER=
SMTP_PASS=
MAIL_FROM=gutscheine@example.de
TELEGRAM_BOT_TOKEN=
LOGO_DEV_SECRET_KEY=
LOGO_DEV_PUBLISHABLE_KEY=
CRON_SECRET=REPLACE_ME
APP_URL=http://localhost:3000
TZ=Europe/Berlin
+1 -1
View File
@@ -5,7 +5,7 @@ Private Gutscheinverwaltung (Next.js + SQLite + Prisma). Ein Shared-Login für d
## Start (Docker Compose)
```bash
cp .env.example .env # Werte ausfüllen (v. a. APP_PASSWORD_HASH, SESSION_SECRET, CRON_SECRET, APP_URL)
cp .env.example .env # Werte ausfüllen (v. a. APP_PASSWORD_HASH, SESSION_SECRET, CRON_SECRET, APP_URL, LOGO_DEV_* für Logos)
docker compose up --build -d
```
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { searchLogos } from "@/lib/logo";
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get("q") ?? "";
if (!q.trim()) return NextResponse.json([]);
try {
const candidates = await searchLogos(q);
return NextResponse.json(candidates);
} catch (err) {
const message = err instanceof Error ? err.message : "Logosuche fehlgeschlagen";
const status = message.includes("LOGO_DEV_SECRET_KEY") ? 503 : 502;
return NextResponse.json({ error: message }, { status });
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
import { withLogoUrl } from "@/lib/logo";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -8,7 +9,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
const db = getDb();
const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } } } });
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(v);
return NextResponse.json(withLogoUrl(v));
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+12 -4
View File
@@ -3,6 +3,7 @@ import { getDb } from "@/lib/db";
import { getVoucherStatus } from "@/lib/voucher-status";
import { validateVoucher, isExpiringSoon } from "@/lib/vouchers";
import { parseReminderDays } from "@/lib/reminders";
import { normalizeDomain, withLogoUrl } from "@/lib/logo";
export async function GET(req: NextRequest) {
const filter = req.nextUrl.searchParams.get("filter");
@@ -16,7 +17,7 @@ export async function GET(req: NextRequest) {
if (filter === "aktiv") return s === "aktiv";
return true;
});
return NextResponse.json(filtered);
return NextResponse.json(filtered.map(withLogoUrl));
}
export async function POST(req: NextRequest) {
@@ -43,15 +44,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
}
}
const providerDomain = normalizeDomain(
body.providerDomain === undefined || body.providerDomain === null ? null : String(body.providerDomain)
);
if (body.providerDomain !== undefined && body.providerDomain !== null && String(body.providerDomain).trim() !== "" && !providerDomain) {
return NextResponse.json({ errors: ["providerDomain"] }, { status: 400 });
}
const reminderRaw =
typeof body.reminderDays === "string"
? body.reminderDays
: Array.isArray(body.reminderDays)
? body.reminderDays.join(",")
? body.reminderDays
: undefined;
const reminderProvided = reminderRaw !== undefined && reminderRaw.trim() !== "";
const explicitNone = Array.isArray(body.reminderDays) && body.reminderDays.length === 0;
const reminderDays = parseReminderDays(reminderRaw);
if (reminderProvided && reminderDays.length === 0) {
if (!explicitNone && reminderRaw !== undefined && (typeof reminderRaw !== "string" || reminderRaw.trim() !== "") && reminderDays.length === 0) {
return NextResponse.json({ errors: ["reminderDays"] }, { status: 400 });
}
const created = await db.voucher.create({
@@ -64,6 +71,7 @@ export async function POST(req: NextRequest) {
codeText: input.codeText,
kategorie: input.kategorie,
notiz: input.notiz,
providerDomain,
contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined,
rules: {
create: reminderDays.map((tageVorher) => ({ tageVorher })),
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { monogram } from "@/lib/logo";
export default function LogoImg({
logoUrl,
name,
size = 40,
}: {
logoUrl: string | null;
name: string;
size?: number;
}) {
const [failed, setFailed] = useState(false);
if (!logoUrl || failed) {
return (
<span
aria-hidden="true"
style={{ width: size, height: size, fontSize: Math.max(11, size * 0.36) }}
className="grid shrink-0 place-items-center rounded-[10px] bg-brand-100 font-bold text-brand-800"
>
{monogram(name)}
</span>
);
}
return (
<img
src={logoUrl}
alt=""
width={size}
height={size}
loading="lazy"
onError={() => setFailed(true)}
style={{ width: size, height: size }}
className="shrink-0 rounded-[10px] border border-line bg-white object-contain"
/>
);
}
+1 -2
View File
@@ -7,7 +7,6 @@ import type { ReactNode } from "react";
const NAV = [
{ href: "/", label: "Gutscheine" },
{ href: "/vouchers/new", label: "Neu" },
{ href: "/contacts", label: "Kontakte" },
{ href: "/settings", label: "Einstellungen" },
] as const;
@@ -89,7 +88,7 @@ export default function Shell({ children }: { children: ReactNode }) {
<nav
aria-label="Hauptmenü"
className="fixed inset-x-0 bottom-0 grid grid-cols-4 border-t border-line bg-white/95 backdrop-blur md:hidden"
className="fixed inset-x-0 bottom-0 grid grid-cols-3 border-t border-line bg-white/95 backdrop-blur md:hidden"
>
{NAV.map((item) => (
<Link
+19 -12
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import { Suspense } from "react";
import Shell from "./components/shell";
import LogoImg from "./components/logo-img";
import { withLogoUrl } from "@/lib/logo";
import { getDb } from "@/lib/db";
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
import { formatEuro, isExpiringSoon } from "@/lib/vouchers";
@@ -64,22 +66,27 @@ async function VoucherList({ filter }: { filter: string }) {
}
return (
<ul className="grid gap-3">
{filtered.map((v) => {
{filtered.map(withLogoUrl).map((v) => {
const s = getVoucherStatus(v);
return (
<li key={v.id} className="rounded-card border border-line bg-white p-5 transition-shadow hover:shadow-[0_8px_24px_rgba(32,55,39,0.08)]">
<div className="flex items-start justify-between gap-3">
<Link href={`/vouchers/${v.id}`} className="text-[15px] font-semibold tracking-tight hover:text-brand-700">
{v.name}
</Link>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${STATUS_STYLE[s]}`}>
{statusLabel(s)}
</span>
<div className="flex items-center gap-3">
<LogoImg logoUrl={v.logoUrl ?? null} name={v.anbieter} />
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
<div className="min-w-0">
<Link href={`/vouchers/${v.id}`} className="text-[15px] font-semibold tracking-tight hover:text-brand-700">
{v.name}
</Link>
<p className="mt-1 text-sm text-muted">
{v.anbieter} · {formatEuro(v.restCents)} von {formatEuro(v.wertCents)}
{v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""}
</p>
</div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${STATUS_STYLE[s]}`}>
{statusLabel(s)}
</span>
</div>
</div>
<p className="mt-1.5 text-sm text-muted">
{v.anbieter} · {formatEuro(v.restCents)} von {formatEuro(v.wertCents)}
{v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""}
</p>
</li>
);
})}
+12 -2
View File
@@ -24,8 +24,18 @@ export default async function SettingsPage() {
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
<p className="mt-1 text-sm text-muted">Erinnerungen und Benachrichtigungen prüfen.</p>
<p className="mt-1 text-sm text-muted">Erinnerungen, Benachrichtigungen und Empfänger.</p>
<div className="mt-5 grid gap-4">
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Kontakte</h2>
<p className="mt-1 text-sm text-muted">Wer soll an eure Gutscheine erinnert werden?</p>
<Link
href="/contacts"
className="mt-3 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Kontakte verwalten
</Link>
</section>
<Card title="Standard-Erinnerungstage">
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
</Card>
@@ -41,7 +51,7 @@ export default async function SettingsPage() {
)}
</Card>
<Card title="Konfiguration und Backup">
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, CRON_SECRET, APP_URL.</p>
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, LOGO_DEV_SECRET_KEY, LOGO_DEV_PUBLISHABLE_KEY, CRON_SECRET, APP_URL.</p>
<p className="mt-2">SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
</Card>
</div>
+7 -1
View File
@@ -2,6 +2,7 @@
import { use, useEffect, useState } from "react";
import Link from "next/link";
import Shell from "../../components/shell";
import LogoImg from "../../components/logo-img";
import { formatEuro } from "@/lib/vouchers";
import { getVoucherStatus } from "@/lib/voucher-status";
@@ -16,6 +17,8 @@ type Voucher = {
kategorie: string | null;
notiz: string | null;
status: string;
providerDomain?: string | null;
logoUrl?: string | null;
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
};
@@ -82,7 +85,10 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{v.name}</h1>
<div className="mt-2 flex items-center gap-3">
<LogoImg logoUrl={v.logoUrl ?? null} name={v.anbieter} size={52} />
<h1 className="text-2xl font-semibold tracking-tight">{v.name}</h1>
</div>
<section aria-label="Details" className="mt-4 divide-y divide-line rounded-card border border-line bg-white px-5 py-2">
<Row label="Anbieter">{v.anbieter}</Row>
<Row label="Wert">{formatEuro(v.wertCents)}</Row>
+121 -19
View File
@@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Shell from "../../components/shell";
import LogoImg from "../../components/logo-img";
import { validateVoucher } from "@/lib/vouchers";
import { ALLOWED_REMINDER_DAYS, DEFAULT_REMINDER_DAYS } from "@/lib/reminders";
import type { LogoCandidate } from "@/lib/logo";
// Erweiterbar für Task 5 (Kontakte): zusätzliches Feld contactIds + <ContactSelect />
// ohne bestehende Felder anzufassen — Felder als Konfiguration belassen.
@@ -28,7 +31,12 @@ export default function NewVoucherPage() {
const [serverError, setServerError] = useState("");
const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]);
const [contactIds, setContactIds] = useState<number[]>([]);
const [reminderDays, setReminderDays] = useState("30,6,1");
const [reminderDays, setReminderDays] = useState<number[]>([...DEFAULT_REMINDER_DAYS]);
const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
const [logoSearching, setLogoSearching] = useState(false);
const [logoError, setLogoError] = useState("");
const [logoSearched, setLogoSearched] = useState(false);
const [selectedLogo, setSelectedLogo] = useState<LogoCandidate | null>(null);
useEffect(() => {
fetch("/api/contacts")
@@ -41,6 +49,38 @@ export default function NewVoucherPage() {
setValues((v) => ({ ...v, [name]: value }));
}
function toggleDay(day: number) {
setReminderDays((days) => (days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort((a, b) => b - a)));
}
async function searchLogos() {
const q = (values.anbieter ?? "").trim();
if (!q) {
setLogoError("Bitte zuerst einen Anbieter eintragen.");
return;
}
setLogoSearching(true);
setLogoError("");
setLogoSearched(false);
try {
const res = await fetch(`/api/logos/search?q=${encodeURIComponent(q)}`);
const data = await res.json().catch(() => null);
if (!res.ok) {
setLogoError(data?.error ?? "Suche fehlgeschlagen");
setCandidates([]);
} else {
setCandidates(Array.isArray(data) ? data : []);
if (!Array.isArray(data) || data.length === 0) setLogoError("Kein Logo gefunden.");
}
} catch {
setLogoError("Suche fehlgeschlagen");
setCandidates([]);
} finally {
setLogoSearching(false);
setLogoSearched(true);
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const wertCents = Number(values.wertCents);
@@ -61,7 +101,7 @@ export default function NewVoucherPage() {
const res = await fetch("/api/vouchers", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...input, contactIds, reminderDays }),
body: JSON.stringify({ ...input, contactIds, reminderDays, providerDomain: selectedLogo?.domain ?? null }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -103,25 +143,87 @@ export default function NewVoucherPage() {
</div>
))}
</div>
<div className="mt-4">
<label htmlFor="reminderDays" className="block text-[13px] font-semibold">
Erinnerung (Tage vorher, Kommaliste)
</label>
<input
id="reminderDays"
name="reminderDays"
type="text"
value={reminderDays}
onChange={(e) => setReminderDays(e.target.value)}
placeholder="30,6,1"
aria-invalid={errors.includes("reminderDays") || errors.includes("reminder")}
className={`${inputClass} ${errors.includes("reminderDays") || errors.includes("reminder") ? "border-danger" : ""}`}
/>
{(errors.includes("reminderDays") || errors.includes("reminder")) && (
<p className="mt-1 text-[13px] font-medium text-danger">Mindestens ein gültiger Tag nötig.</p>
<div className="mt-4 rounded-field bg-wash p-4">
<h2 className="text-[13px] font-semibold">Anbieterlogo</h2>
{selectedLogo ? (
<div className="mt-2 flex items-center gap-3">
<LogoImg logoUrl={selectedLogo.logoUrl} name={selectedLogo.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{selectedLogo.name}</p>
<p className="truncate text-[13px] text-muted">{selectedLogo.domain}</p>
</div>
<button
type="button"
onClick={() => setSelectedLogo(null)}
className="shrink-0 rounded-field border border-line bg-white px-3 py-1.5 text-[13px] font-medium hover:text-danger"
>
Entfernen
</button>
</div>
) : (
<>
<p className="mt-0.5 text-[13px] text-muted">Logo anhand des Anbieternamens suchen und bestätigen.</p>
<button
type="button"
onClick={searchLogos}
disabled={logoSearching}
className="mt-2 h-10 rounded-field border border-line bg-white px-4 text-sm font-semibold transition-colors hover:border-brand-600 hover:text-brand-700 disabled:opacity-60"
>
{logoSearching ? "Sucht …" : "Logo suchen"}
</button>
{logoError && <p role="alert" className="mt-2 text-[13px] font-medium text-danger">{logoError}</p>}
{logoSearched && !logoError && candidates.length > 0 && (
<ul className="mt-2 grid gap-1.5">
{candidates.map((c) => (
<li key={c.domain}>
<button
type="button"
onClick={() => {
setSelectedLogo(c);
setCandidates([]);
setLogoSearched(false);
}}
className="flex w-full items-center gap-3 rounded-[10px] px-2 py-1.5 text-left transition-colors hover:bg-white"
>
<LogoImg logoUrl={c.logoUrl} name={c.name} size={36} />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{c.name}</span>
<span className="block truncate text-[13px] text-muted">{c.domain}</span>
</span>
</button>
</li>
))}
</ul>
)}
</>
)}
</div>
<section id="kontakte" aria-label="Empfänger" className="mt-5 rounded-field bg-wash p-4">
<fieldset className="mt-4">
<legend className="text-[13px] font-semibold">Erinnerung (Tage vorher)</legend>
<p className="mt-0.5 text-[13px] text-muted">Nichts ausgewählt bedeutet keine Erinnerung.</p>
<div className="mt-2 flex flex-wrap gap-2">
{ALLOWED_REMINDER_DAYS.map((day) => {
const checked = reminderDays.includes(day);
return (
<label
key={day}
className={`flex cursor-pointer items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-medium transition-colors ${
checked ? "border-brand-700 bg-brand-600 text-white" : "border-line bg-white hover:border-brand-600"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleDay(day)}
className="sr-only"
/>
{day} {day === 1 ? "Tag" : "Tage"}
</label>
);
})}
</div>
</fieldset>
<section id="kontakte" aria-label="Empfänger" className="mt-4 rounded-field bg-wash p-4">
<h2 className="text-[13px] font-semibold">Empfänger</h2>
<p className="mt-0.5 text-[13px] text-muted">Wer soll an diesen Gutschein erinnert werden?</p>
{contacts.length === 0 ? (
+2
View File
@@ -1,5 +1,7 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { ensureSchema } = await import("./lib/db-migrate");
await ensureSchema();
const { ensureSchedulerStarted } = await import("./lib/scheduler");
ensureSchedulerStarted();
}
+21
View File
@@ -0,0 +1,21 @@
import { getDb } from "./db";
/**
* Stellt additive Schema-Änderungen sicher, ohne dass auf dem Server ein
* separater `prisma migrate`-Lauf nötig ist. Idempotent: fehlende Spalten
* werden genau einmal ergänzt, alles andere bleibt unangetastet.
* Best-effort: Fehler werden geloggt, der Start läuft trotzdem weiter.
*/
export async function ensureSchema(): Promise<void> {
try {
const db = getDb();
const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map(
(c) => c.name
);
if (!cols.includes("providerDomain")) {
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "providerDomain" TEXT`;
}
} catch (err) {
console.error("ensureSchema fehlgeschlagen (Start läuft weiter):", err);
}
}
+56
View File
@@ -0,0 +1,56 @@
export type LogoCandidate = { name: string; domain: string; logoUrl: string | null };
const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
export function normalizeDomain(domain: string | null | undefined): string | null {
if (!domain) return null;
const d = domain.trim().toLowerCase();
if (!DOMAIN_RE.test(d)) return null;
return d;
}
/** Baut die öffentliche Logo-URL für eine gespeicherte Domain. Key bleibt serverseitig. */
export function logoImageUrl(domain: string | null | undefined): string | null {
const d = normalizeDomain(domain);
const token = process.env.LOGO_DEV_PUBLISHABLE_KEY;
if (!d || !token) return null;
return `https://img.logo.dev/${d}?token=${token}&size=128&format=png`;
}
/** Kürzel aus dem Anbieternamen als Fallback, z. B. "Therme Erding" → "TE". */
export function monogram(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
/** Reichert ein Voucher-Objekt um die öffentliche logoUrl an (serverseitig). */
export function withLogoUrl<T extends { providerDomain: string | null }>(v: T): T & { logoUrl: string | null } {
return { ...v, logoUrl: logoImageUrl(v.providerDomain) };
}
/** Sucht Anbieter über Logo.dev (serverseitig, Secret-Key). Gibt max. 8 Treffer zurück. */
export async function searchLogos(query: string): Promise<LogoCandidate[]> {
const key = process.env.LOGO_DEV_SECRET_KEY;
if (!key) throw new Error("LOGO_DEV_SECRET_KEY fehlt");
const q = query.trim();
if (!q) return [];
const res = await fetch(`https://api.logo.dev/search?q=${encodeURIComponent(q)}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!res.ok) throw new Error(`Logosuche ${res.status}`);
const data: unknown = await res.json();
if (!Array.isArray(data)) return [];
const out: LogoCandidate[] = [];
for (const item of data) {
if (typeof item !== "object" || item === null) continue;
const rec = item as Record<string, unknown>;
const domain = normalizeDomain(typeof rec.domain === "string" ? rec.domain : null);
if (!domain) continue;
const name = typeof rec.name === "string" && rec.name.trim() ? rec.name.trim() : domain;
out.push({ name, domain, logoUrl: logoImageUrl(domain) });
if (out.length >= 8) break;
}
return out;
}
+19 -7
View File
@@ -1,3 +1,4 @@
export const ALLOWED_REMINDER_DAYS = [30, 14, 7, 6, 3, 1];
export const DEFAULT_REMINDER_DAYS = [30, 6, 1];
export function toDateKey(d: Date): string {
@@ -23,11 +24,22 @@ export function idempotencyKey(ruleId: number, kanal: string, empfaenger: string
return `${ruleId}|${kanal}|${empfaenger}|${zielDatum}`;
}
export function parseReminderDays(raw: string | null | undefined): number[] {
if (raw == null || !raw.trim()) return [...DEFAULT_REMINDER_DAYS];
const days = raw
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n >= 0);
return [...new Set(days)].sort((a, b) => b - a);
/**
* Parst die Erinnerungsauswahl. Akzeptiert die festen Checkbox-Werte
* (30, 14, 7, 6, 3, 1) als Kommaliste oder Array.
* - undefined/leere Zeichenkette: Standard [30, 6, 1] (Feld weggelassen)
* - leeres Array: bewusst keine Erinnerung (alle abgewählt)
* - sonst: nur erlaubte Tage, Duplikate raus, absteigend sortiert
*/
export function parseReminderDays(raw: string | number[] | null | undefined): number[] {
if (raw == null) return [...DEFAULT_REMINDER_DAYS];
if (!Array.isArray(raw) && String(raw).trim() === "") return [...DEFAULT_REMINDER_DAYS];
const nums: number[] = (Array.isArray(raw) ? raw : String(raw).split(","))
.map((s) => Number(typeof s === "string" ? s.trim() : s))
.filter(Number.isInteger);
return [...new Set(nums.filter((n) => ALLOWED_REMINDER_DAYS.includes(n)))].sort((a, b) => b - a);
}
export function isValidReminderDay(n: number): boolean {
return ALLOWED_REMINDER_DAYS.includes(n);
}
+1
View File
@@ -15,6 +15,7 @@ model Voucher {
codeText String?
kategorie String?
notiz String?
providerDomain String?
status String @default("aktiv")
contacts VoucherContact[]
rules ReminderRule[]
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { logoImageUrl, monogram, normalizeDomain, searchLogos } from "../lib/logo";
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.LOGO_DEV_SECRET_KEY;
delete process.env.LOGO_DEV_PUBLISHABLE_KEY;
});
describe("logo", () => {
it("normalizeDomain akzeptiert nur gültige Domains", () => {
expect(normalizeDomain("Therme-Erding.de")).toBe("therme-erding.de");
expect(normalizeDomain(" keinedomain ")).toBeNull();
expect(normalizeDomain(null)).toBeNull();
});
it("logoImageUrl braucht Domain und öffentlichen Key", () => {
process.env.LOGO_DEV_PUBLISHABLE_KEY = "pk_test";
expect(logoImageUrl("stripe.com")).toBe("https://img.logo.dev/stripe.com?token=pk_test&size=128&format=png");
expect(logoImageUrl(null)).toBeNull();
delete process.env.LOGO_DEV_PUBLISHABLE_KEY;
expect(logoImageUrl("stripe.com")).toBeNull();
});
it("monogram bildet Kürzel aus dem Namen", () => {
expect(monogram("Therme Erding")).toBe("TE");
expect(monogram("dm")).toBe("DM");
expect(monogram(" ")).toBe("?");
});
it("searchLogos mappt Treffer und begrenzt auf 8", async () => {
process.env.LOGO_DEV_SECRET_KEY = "sk_test";
process.env.LOGO_DEV_PUBLISHABLE_KEY = "pk_test";
const items = Array.from({ length: 10 }, (_, i) => ({ name: `Shop ${i}`, domain: `shop${i}.de` }));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => items }));
const res = await searchLogos("shop");
expect(res).toHaveLength(8);
expect(res[0]).toEqual({ name: "Shop 0", domain: "shop0.de", logoUrl: expect.stringContaining("shop0.de") });
});
it("searchLogos wirft ohne Key und bei Fehlerstatus", async () => {
await expect(searchLogos("x")).rejects.toThrow("LOGO_DEV_SECRET_KEY fehlt");
process.env.LOGO_DEV_SECRET_KEY = "sk_test";
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 }));
await expect(searchLogos("x")).rejects.toThrow("Logosuche 401");
});
});
+8 -1
View File
@@ -14,9 +14,16 @@ 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)", () => {
it("parseReminderDays: feste Auswahl 30/14/7/6/3/1", () => {
expect(parseReminderDays("30,6,1")).toEqual([30, 6, 1]);
expect(parseReminderDays(undefined)).toEqual([30, 6, 1]);
expect(parseReminderDays("")).toEqual([30, 6, 1]);
expect(parseReminderDays("30,14,7,6,3,1")).toEqual([30, 14, 7, 6, 3, 1]);
expect(parseReminderDays("30,99,abc")).toEqual([30]);
expect(parseReminderDays([30, 6, 1])).toEqual([30, 6, 1]);
expect(parseReminderDays([1])).toEqual([1]);
// bewusst keine Erinnerung: leeres Array
expect(parseReminderDays([])).toEqual([]);
// "bald"/"abc" parst zu [] → API antwortet 400 mit errors ["reminderDays"]
expect(parseReminderDays("bald")).toEqual([]);
expect(parseReminderDays("abc,!!")).toEqual([]);