feat: Erinnerungs-Checkboxen, Anbieterlogos via Logo.dev, Kontakte in Einstellungen
This commit is contained in:
1 parent
11bef8748f
commit
a1195223e0
18 files changed
+381
-50
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ model Voucher {
|
||||
codeText String?
|
||||
kategorie String?
|
||||
notiz String?
|
||||
providerDomain String?
|
||||
status String @default("aktiv")
|
||||
contacts VoucherContact[]
|
||||
rules ReminderRule[]
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
Reference in new issue
Block a user