feat: settings and deploy docs
This commit is contained in:
1 parent
8b33242db1
commit
39f782057c
10 files changed
+194
No files matched your search
@@ -9,4 +9,5 @@ SMTP_PASS=
|
||||
MAIL_FROM=gutscheine@example.de
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
CRON_SECRET=REPLACE_ME
|
||||
APP_URL=http://localhost:3000
|
||||
TZ=Europe/Berlin
|
||||
@@ -0,0 +1,45 @@
|
||||
# Gutscheintracker
|
||||
|
||||
Private Gutscheinverwaltung (Next.js + SQLite + Prisma). Ein Shared-Login für den Haushalt, Erinnerungen per Mail/Telegram.
|
||||
|
||||
## Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # Werte ausfüllen (v. a. APP_PASSWORD_HASH, SESSION_SECRET, CRON_SECRET, APP_URL)
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
App: http://localhost:3000 — Login mit `APP_USER` und dem Passwort zu `APP_PASSWORD_HASH`.
|
||||
|
||||
Der Erinnerungs-Scheduler startet automatisch im Production-Prozess (`instrumentation.ts` → `lib/scheduler.ts`, alle 15 Minuten, `APP_URL` als Basis).
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
Die SQLite-Datei liegt im Volume `gutschein-data` unter `/data/gutscheine.db`:
|
||||
|
||||
```bash
|
||||
docker cp gutscheintracker-app-1:/data/gutscheine.db ./gutscheine-backup.db
|
||||
# oder Volume direkt kopieren
|
||||
docker run --rm -v gutschein-data:/data -v "$PWD":/backup alpine cp /data/gutscheine.db /backup/gutscheine-backup.db
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Für HTTPS einen Reverse Proxy (z. B. Caddy/Nginx) vor Port 3000 schalten und `APP_URL` auf die öffentliche URL setzen, damit der Scheduler die Cron-Route über die richtige Basis-URL aufruft.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const to = String(body.to ?? process.env.MAIL_FROM ?? "");
|
||||
if (!to) return NextResponse.json({ error: "Empfänger fehlt" }, { status: 400 });
|
||||
try {
|
||||
await sendMail(to, "Gutschein-Tracker Test-Mail", "Test-Mail vom Gutschein-Tracker: Versand funktioniert.");
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sendTelegram } from "@/lib/telegram";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const chatId = String(body.chatId ?? "");
|
||||
if (!chatId) return NextResponse.json({ error: "Chat-ID fehlt" }, { status: 400 });
|
||||
try {
|
||||
await sendTelegram(chatId, "Test-Nachricht vom Gutschein-Tracker: Versand funktioniert.");
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = { title: "Gutscheintracker" };
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
||||
import { TestButtons } from "./test-buttons";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const db = getDb();
|
||||
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
|
||||
return (
|
||||
<main className="mx-auto max-w-xl space-y-4 p-6">
|
||||
<Link href="/" className="text-sm underline">← Zurück</Link>
|
||||
<h1 className="text-xl font-bold">Einstellungen</h1>
|
||||
<section className="rounded border p-4">
|
||||
<h2 className="font-medium">Standard-Erinnerungstage</h2>
|
||||
<p className="text-sm text-gray-600">Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
|
||||
</section>
|
||||
<TestButtons />
|
||||
<section className="rounded border p-4 text-sm">
|
||||
<h2 className="font-medium">Letzter Cron-Lauf</h2>
|
||||
{last ? (
|
||||
<p>
|
||||
{last.sentAt.toLocaleString("de-DE")} · {last.kanal} an {last.empfaenger} · {last.status}
|
||||
{last.fehler ? ` · Fehler: ${last.fehler}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Noch keine Benachrichtigung versendet.</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="rounded border p-4 text-sm text-gray-600">
|
||||
<h2 className="font-medium text-black">Konfiguration (.env)</h2>
|
||||
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, CRON_SECRET, APP_URL.</p>
|
||||
<h2 className="mt-2 font-medium text-black">Backup</h2>
|
||||
<p>SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export function TestButtons() {
|
||||
const [mail, setMail] = useState("test@example.de");
|
||||
const [chatId, setChatId] = useState("");
|
||||
const [result, setResult] = useState("");
|
||||
|
||||
async function testMail() {
|
||||
setResult("…");
|
||||
const res = await fetch("/api/settings/test-mail", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ to: mail }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setResult(res.ok ? "Test-Mail versendet." : `Fehler: ${data.error ?? res.status}`);
|
||||
}
|
||||
|
||||
async function testTelegram() {
|
||||
setResult("…");
|
||||
const res = await fetch("/api/settings/test-telegram", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ chatId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setResult(res.ok ? "Test-Telegram versendet." : `Fehler: ${data.error ?? res.status}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded border p-4">
|
||||
<h2 className="font-medium">Versand testen</h2>
|
||||
<div>
|
||||
<label htmlFor="mail" className="block text-sm font-medium">E-Mail-Empfänger</label>
|
||||
<input id="mail" value={mail} onChange={(e) => setMail(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
|
||||
</div>
|
||||
<button type="button" onClick={testMail} className="rounded bg-black px-4 py-2 text-white">Test-Mail senden</button>
|
||||
<div>
|
||||
<label htmlFor="chatId" className="block text-sm font-medium">Telegram-Chat-ID</label>
|
||||
<input id="chatId" value={chatId} onChange={(e) => setChatId(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
|
||||
</div>
|
||||
<button type="button" onClick={testTelegram} className="rounded bg-black px-4 py-2 text-white">Test-Telegram senden</button>
|
||||
{result && <p role="status" className="text-sm">{result}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { ensureSchedulerStarted } = await import("./lib/scheduler");
|
||||
ensureSchedulerStarted();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const DEFAULT_TAGE_VORHER = [30, 6, 1];
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user