diff --git a/docker-compose.yaml b/docker-compose.yaml
index 587d0e6..4566fbe 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -23,6 +23,15 @@ services:
SEED_LEA_PASSWORD: "${SEED_LEA_PASSWORD}"
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL}"
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL}"
+ # E-Mail-Versand. Die Datenbank hat Vorrang; diese Werte greifen, solange
+ # unter Admin → E-Mail nichts eingetragen ist. So läuft der Versand nach
+ # einem frischen Aufsetzen sofort, ohne dass jemand etwas abtippen muss.
+ SMTP_HOST: "${SMTP_HOST}"
+ SMTP_PORT: "${SMTP_PORT}"
+ SMTP_SECURE: "${SMTP_SECURE}"
+ SMTP_USER: "${SMTP_USER}"
+ SMTP_PASSWORD: "${SMTP_PASSWORD}"
+ SMTP_FROM: "${SMTP_FROM}"
depends_on:
- db
- minio
diff --git a/migrations/016_print_history.sql b/migrations/016_print_history.sql
new file mode 100644
index 0000000..dcad541
--- /dev/null
+++ b/migrations/016_print_history.sql
@@ -0,0 +1,40 @@
+-- Druck-Historie: was zuletzt gedruckt wurde ------------------------------
+--
+-- Gespeichert wird **nur die Anleitung**, nicht das Ergebnis: die
+-- Bogen-Einstellung, die Verweise auf die Quellbilder und ein paar Eckdaten.
+-- Ein A3-Druckbogen als PDF ist schnell 20–80 MB; bei ein paar Dutzend
+-- Drucken wäre die Platte voll, und zwar mit Dateien, die sich in Sekunden
+-- neu erzeugen lassen. Der Bogen wird beim erneuten Drucken einfach neu
+-- gerechnet — deterministisch, aus denselben Quellen.
+--
+-- Zeilengröße: rund 1–3 KB je Eintrag. Tausend Drucke sind damit ein paar
+-- Megabyte statt vieler Gigabyte.
+CREATE TABLE IF NOT EXISTS print_runs (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ created_by uuid REFERENCES users(id) ON DELETE SET NULL,
+ -- Woher der Druck kam: 'web' | 'telegram' | 'mcp' | 'api'
+ origin text NOT NULL DEFAULT 'web',
+ -- Sprechende Kurzfassung für die Liste („13 × 18 cm ×1, 35 × 45 mm ×8").
+ summary text,
+ paper text, -- „DIN A4 · 210 × 297 mm"
+ pages integer NOT NULL DEFAULT 1,
+ pieces integer NOT NULL DEFAULT 1,
+ bytes integer, -- Größe des erzeugten PDFs, nur zur Information
+ -- Die vollständige Anfrage, aus der sich der Bogen neu erzeugen lässt.
+ -- Bewusst der Anfragekörper und nicht ein eigenes Format: Was hier steht,
+ -- lässt sich unverändert wieder an /api/print/sheet schicken.
+ config jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS print_runs_user_idx ON print_runs(created_by, created_at DESC);
+CREATE INDEX IF NOT EXISTS print_runs_created_idx ON print_runs(created_at DESC);
+
+-- Welche Bibliotheksbilder in einem Druck steckten. Nur für Bilder aus der
+-- Bibliothek — hochgeladene Einzeldateien haben keine dauerhafte Kennung und
+-- sind nach dem Aufräumen der Quellen ohnehin weg.
+CREATE TABLE IF NOT EXISTS print_run_items (
+ run_id uuid NOT NULL REFERENCES print_runs(id) ON DELETE CASCADE,
+ item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+ PRIMARY KEY (run_id, item_id)
+);
+CREATE INDEX IF NOT EXISTS print_run_items_item_idx ON print_run_items(item_id);
diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx
index 2a9021e..23ef501 100644
--- a/src/components/AdminApp.tsx
+++ b/src/components/AdminApp.tsx
@@ -336,6 +336,21 @@ export default function AdminApp() {
von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für
Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.
+ {s.smtp_quelle === 'umgebung' && s.smtp_wirksam && (
+
+ Der Versand läuft bereits — über die Server-Einstellung
+ ({s.smtp_wirksam.user} auf {s.smtp_wirksam.host}:{s.smtp_wirksam.port}).
+ Die Felder unten sind leer, weil hier nichts eingetragen ist; das ist in Ordnung.
+ Wer hier etwas einträgt und speichert, überschreibt die Server-Einstellung.
+
+ )}
+ {s.smtp_quelle === 'datenbank' && (
+
Der Versand läuft über das, was hier eingetragen ist.
+ )}
+ {!s.smtp_quelle && (
+
Noch kein Versand eingerichtet — Passwort vergessen,
+ Auftragshinweise und der Mailversand von Teilen-Links funktionieren bis dahin nicht.
+ )}
field('smtp_host', e.target.value)} />
@@ -599,6 +614,10 @@ function AdminStyles() {
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
.karte>.fein{padding:0 16px 14px;}
+ .hinweiskasten{padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-sm);
+ background:var(--paper);font-size:12.5px;line-height:1.55;}
+ .hinweiskasten.warn{border-color:var(--warn);background:oklch(72% .15 75 / .08);}
+ .hinweiskasten code{font-family:var(--font-mono);font-size:11.5px;}
/* Am Handy war alles zweispaltig — ein Feld „Postausgangsserver" bekam so
rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */
@media(max-width:560px){
diff --git a/src/components/PrintApp.tsx b/src/components/PrintApp.tsx
index ff1200d..c80699c 100644
--- a/src/components/PrintApp.tsx
+++ b/src/components/PrintApp.tsx
@@ -6,6 +6,7 @@ import {
} from '../lib/printlayout';
import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize';
import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath';
+import PrintRuns from './PrintRuns';
/* --------------------------------------------------------------------------
Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
@@ -127,6 +128,8 @@ export default function PrintApp() {
const [targets, setTargets] = useState([]);
const [editing, setEditing] = useState(null);
const [picker, setPicker] = useState(false);
+ // Der Verlauf lädt erst beim Aufklappen — eine Anfrage weniger auf jeder Seite.
+ const [verlaufOffen, setVerlaufOffen] = useState(false);
const [presets, setPresets] = useState([]);
const [presetSel, setPresetSel] = useState('');
const [busy, setBusy] = useState(null);
@@ -292,6 +295,32 @@ export default function PrintApp() {
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
.then((j) => setPresets(j.presets || [])).catch(() => {});
+ /**
+ * Die Einstellung eines früheren Drucks übernehmen — Papier, Marken, Ränder,
+ * Qualität. Die **Bilder** kommen bewusst nicht mit: Wer „übernehmen" drückt,
+ * will meist dieselbe Einrichtung für ein anderes Motiv. Wer denselben Bogen
+ * will, nimmt „Nochmal drucken".
+ */
+ const configUebernehmen = (cfg: any) => {
+ if (!cfg) return;
+ try {
+ if (cfg.paper?.id) setPaperId(cfg.paper.id);
+ else if (cfg.paper?.size) { setPaperId('custom'); setPaperCustom(String(cfg.paper.size)); }
+ if (typeof cfg.landscape === 'boolean') setPaperLandscape(cfg.landscape);
+ if (cfg.marks?.mode) setMarks(cfg.marks.mode);
+ if (cfg.marks?.lengthMm != null) setMarkLen(Number(cfg.marks.lengthMm));
+ if (cfg.marks?.offsetMm != null) setMarkOff(Number(cfg.marks.offsetMm));
+ if (cfg.marginMm != null) setMarginMm(Number(cfg.marginMm));
+ if (cfg.bleedMm != null) setBleedMm(Number(cfg.bleedMm));
+ if (cfg.gapMm != null) { setAutoGap(false); setGapMm(Number(cfg.gapMm)); }
+ if (cfg.dpi != null) setDpi(Number(cfg.dpi));
+ if (cfg.ext) setExt(cfg.ext === 'png' ? 'png' : 'jpg');
+ if (typeof cfg.center === 'boolean') setCenter(cfg.center);
+ notify('Einstellung übernommen. Die Bilder wählst du selbst — „Nochmal drucken" nimmt auch die.');
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ } catch { notify('Diese Einstellung ließ sich nicht übernehmen.', true); }
+ };
+
const addFromLibrary = (it: any) => {
const [w, h] = String(it.output_px || '').split(/[x×]/).map((n: string) => parseInt(n, 10) || 0);
const id = uid();
@@ -475,6 +504,15 @@ export default function PrintApp() {
Auch mit ⌘V einfügen · bis zu {MAX_BILDER} Stück ·
kein KI-Schritt, nur zuschneiden, skalieren, setzen
+ {/* Der zweite Weg gehört gleichberechtigt hierher: Die meisten
+ Bilder liegen längst in Klarbild und müssen nicht erst
+ exportiert und wieder hochgeladen werden. */}
+
oder
+
{presets.length > 0 && (
Oder mit einer Vorlage beginnen
@@ -500,7 +538,10 @@ export default function PrintApp() {
onDuplicate={() => duplicate(c.id)}
onRemove={() => setCells((p) => p.filter((x) => x.id !== c.id))} />
))}
-
+
+
+
+
)}
Im Druckdialog „Tatsächliche Größe" / 100 % wählen — nicht „an Seitengröße anpassen".>}
+
+ {/* Zuletzt gedruckt — zugeklappt, weil es beim Arbeiten nicht stört,
+ aber zur Hand ist, wenn man denselben Bogen noch einmal braucht. */}
+
+
+ setVerlaufOffen((e.target as HTMLDetailsElement).open)}>
+
+ Liste anzeigen
+ denselben Bogen noch einmal erzeugen
+
+
+ Hier liegen nur die Einstellungen, nicht die PDFs — ein A3-Bogen wären schnell 20 bis 80 MB.
+ Weil das Druckmodul ohne KI rechnet, ergibt derselbe Auftrag wieder exakt dasselbe Blatt.
+ Aufgehoben werden die letzten {100} Drucke je Person.
+
+
+
+
+ );
+}
+
+function PrintRunsStyles() {
+ return ;
+}
diff --git a/src/lib/mail.ts b/src/lib/mail.ts
index 4d8b213..725f986 100644
--- a/src/lib/mail.ts
+++ b/src/lib/mail.ts
@@ -15,21 +15,55 @@ export interface MailCfg {
user: string; password: string; from: string;
}
-/** SMTP-Zugang aus den Einstellungen — `null`, wenn nichts konfiguriert ist. */
-export async function loadMailConfig(): Promise {
+export interface MailQuelle { cfg: MailCfg; quelle: 'datenbank' | 'umgebung' }
+
+/**
+ * SMTP-Zugang — erst aus den Einstellungen, sonst aus der Umgebung.
+ *
+ * Dieselbe Rangfolge wie beim OpenRouter-Schlüssel: Was jemand im Admin
+ * einträgt, gewinnt. Die Umgebungsvariablen sind der Startwert, damit der
+ * Versand nach einem frischen Aufsetzen sofort läuft, ohne dass jemand
+ * Zugangsdaten abtippen muss — und damit das Passwort **nur** dort und in der
+ * verschlüsselten Spalte liegt, nie im Repository.
+ */
+export async function loadMailSource(): Promise {
const s = await one(
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`);
- if (!s?.smtp_host || !s?.smtp_user || !s?.smtp_password) return null;
- let password = '';
- try { password = decrypt(s.smtp_password); } catch { return null; }
- return {
- host: s.smtp_host,
- port: Number(s.smtp_port) || 465,
- secure: s.smtp_secure !== false,
- user: s.smtp_user,
- password,
- from: s.smtp_from || s.smtp_user,
- };
+
+ if (s?.smtp_host && s?.smtp_user && s?.smtp_password) {
+ try {
+ return { quelle: 'datenbank', cfg: {
+ host: s.smtp_host,
+ port: Number(s.smtp_port) || 465,
+ secure: s.smtp_secure !== false,
+ user: s.smtp_user,
+ password: decrypt(s.smtp_password),
+ from: s.smtp_from || s.smtp_user,
+ } };
+ } catch {
+ // Entschlüsselung gescheitert (z. B. nach einem Wechsel des
+ // ENCRYPTION_KEY) — dann lieber die Umgebung als gar nichts.
+ console.error('[mail] Gespeichertes Passwort nicht lesbar, weiche auf die Umgebung aus.');
+ }
+ }
+
+ const e = process.env;
+ if (e.SMTP_HOST && e.SMTP_USER && e.SMTP_PASSWORD) {
+ return { quelle: 'umgebung', cfg: {
+ host: e.SMTP_HOST,
+ port: Number(e.SMTP_PORT) || 465,
+ secure: e.SMTP_SECURE !== 'false',
+ user: e.SMTP_USER,
+ password: e.SMTP_PASSWORD,
+ from: e.SMTP_FROM || e.SMTP_USER,
+ } };
+ }
+ return null;
+}
+
+/** Nur der Zugang, ohne die Herkunft. */
+export async function loadMailConfig(): Promise {
+ return (await loadMailSource())?.cfg ?? null;
}
export const mailReady = async (): Promise => !!(await loadMailConfig());
diff --git a/src/lib/printruns.ts b/src/lib/printruns.ts
new file mode 100644
index 0000000..4f98110
--- /dev/null
+++ b/src/lib/printruns.ts
@@ -0,0 +1,190 @@
+// Druck-Historie — was zuletzt gedruckt wurde, und wie man es wiederholt.
+//
+// Der springende Punkt: Gespeichert wird die **Anleitung**, nicht das Ergebnis.
+// Ein A3-Bogen als PDF ist schnell 20–80 MB; ein paar Dutzend davon füllen die
+// Platte mit Dateien, die sich in Sekunden neu rechnen lassen. Also liegt hier
+// der Anfragekörper, mit dem `/api/print/sheet` denselben Bogen erneut erzeugt
+// — rund 1–3 KB je Eintrag.
+//
+// Das geht nur, weil das Druckmodul **deterministisch** ist: dieselben Quellen
+// und dieselbe Einstellung ergeben dasselbe PDF. Bei den KI-Aufträgen wäre das
+// Unsinn, dort käme jedes Mal etwas anderes heraus.
+import { one, query } from './db.ts';
+
+export const MAX_RUNS_JE_NUTZER = 100;
+
+export interface PrintRun {
+ id: string;
+ created_by: string | null;
+ origin: string;
+ summary: string | null;
+ paper: string | null;
+ pages: number;
+ pieces: number;
+ bytes: number | null;
+ config: any;
+ created_at: string;
+}
+
+export interface RunEingang {
+ userId: string | null;
+ origin?: string;
+ summary: string;
+ paper: string;
+ pages: number;
+ pieces: number;
+ bytes: number;
+ /** Der Anfragekörper, unverändert. */
+ config: any;
+ /** Kennungen der verwendeten Bibliotheksbilder. */
+ itemIds: string[];
+}
+
+/**
+ * Was am Bogen hing, in einem Satz.
+ *
+ * Die Liste kommt zellenweise herein — zwei Zellen mit demselben Maß ergäben
+ * sonst „10 × 15 cm, 10 × 15 cm". Gleiche Maße werden deshalb zusammengezogen
+ * und ihre Stückzahlen addiert; ein vorhandenes „×3" wird dabei mitgerechnet,
+ * damit nicht „×3 ×2" entsteht.
+ */
+export function kurzfassung(kinds: string[]): string {
+ if (!kinds.length) return 'Druckbogen';
+
+ // Nur die **abschließende** Stückzahl zählt: „35 × 45 mm ×8" hat zwei Mal ein
+ // ×, und ein gieriger Ausdruck machte aus „13 × 18 cm" prompt „13 cm ×18".
+ // Deshalb ans Ende verankert, mit optionalem Zusatz in Klammern dahinter.
+ const ZAHL = /^(.*?)\s*×\s*(\d+)(\s*\([^)]*\))?$/;
+
+ const summe = new Map();
+ for (const k of kinds) {
+ const m = ZAHL.exec(k);
+ const grund = m ? `${m[1]}${m[3] || ''}` : k;
+ const anzahl = m ? Number(m[2]) : 1;
+ summe.set(grund, (summe.get(grund) || 0) + anzahl);
+ }
+
+ // „30 × 40 cm (Poster) ×3" läse sich schief — die Stückzahl gehört vor den
+ // Zusatz, dorthin, wo sie hereinkam.
+ const teile = [...summe].map(([grund, n]) => {
+ if (n <= 1) return grund;
+ const z = /^(.*?)(\s*\([^)]*\))$/.exec(grund);
+ return z ? `${z[1]} ×${n}${z[2]}` : `${grund} ×${n}`;
+ });
+ const s = teile.join(', ');
+ return s.length <= 120 ? s : `${s.slice(0, 117)}…`;
+}
+
+/**
+ * Einen Druck festhalten.
+ *
+ * Fehler hier dürfen den Druck **nie** scheitern lassen — das PDF ist zu dem
+ * Zeitpunkt längst fertig und unterwegs zum Nutzer. Deshalb fängt der Aufrufer
+ * ab, und diese Funktion meldet nur ins Log.
+ */
+export async function recordRun(e: RunEingang): Promise {
+ const run = await one<{ id: string }>(
+ `INSERT INTO print_runs (created_by, origin, summary, paper, pages, pieces, bytes, config)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
+ [e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes, JSON.stringify(e.config)]);
+ if (!run) return null;
+
+ const ids = [...new Set(e.itemIds.filter(Boolean))];
+ if (ids.length) {
+ // Bilder können zwischenzeitlich gelöscht worden sein — dann fällt die
+ // Verknüpfung weg, der Eintrag bleibt.
+ await query(
+ `INSERT INTO print_run_items (run_id, item_id)
+ SELECT $1, x FROM unnest($2::uuid[]) AS x
+ WHERE EXISTS (SELECT 1 FROM items WHERE id = x)
+ ON CONFLICT DO NOTHING`, [run.id, ids]).catch((err) =>
+ console.error('[printruns] Bildverweise nicht gespeichert:', err?.message || err));
+ }
+
+ // Alte Einträge desselben Nutzers wegräumen. Ohne das wächst die Liste
+ // unbegrenzt, und niemand scrollt bis zum Druck von vor zwei Jahren.
+ if (e.userId) {
+ await query(
+ `DELETE FROM print_runs
+ WHERE created_by = $1 AND id NOT IN (
+ SELECT id FROM print_runs WHERE created_by = $1
+ ORDER BY created_at DESC LIMIT $2)`,
+ [e.userId, MAX_RUNS_JE_NUTZER]).catch(() => {});
+ }
+ return run.id;
+}
+
+export interface RunListe extends PrintRun {
+ /** Bilder, die es noch gibt — nur damit lässt sich der Druck wiederholen. */
+ bilder: { id: string; filename: string | null }[];
+ /** Wie viele **Bibliotheksbilder** inzwischen gelöscht sind. */
+ fehlend: number;
+ /** Wie viele Zellen aus einer hochgeladenen Datei kamen — die sind weg. */
+ hochgeladen: number;
+ /** Lässt sich der Bogen unverändert neu erzeugen? */
+ wiederholbar: boolean;
+}
+
+/**
+ * Die letzten Drucke. Admins sehen alle, sonst nur die eigenen — dieselbe
+ * Regel wie bei den Teilen-Links.
+ */
+export async function listRuns(userId: string | null, istAdmin: boolean, limit = 30): Promise {
+ const runs = await query(
+ `SELECT * FROM print_runs
+ WHERE $2::boolean OR created_by = $1
+ ORDER BY created_at DESC LIMIT $3`, [userId, istAdmin, Math.min(limit, 100)]);
+ if (!runs.length) return [];
+
+ const bilder = await query<{ run_id: string; id: string; filename: string | null }>(
+ `SELECT ri.run_id, i.id, i.filename
+ FROM print_run_items ri JOIN items i ON i.id = ri.item_id
+ WHERE ri.run_id = ANY($1::uuid[]) AND i.status = 'done' AND i.result_path IS NOT NULL`,
+ [runs.map((r) => r.id)]);
+
+ const nachRun = new Map();
+ for (const b of bilder) {
+ const l = nachRun.get(b.run_id) || [];
+ l.push({ id: b.id, filename: b.filename });
+ nachRun.set(b.run_id, l);
+ }
+
+ return runs.map((r) => {
+ const vorhanden = nachRun.get(r.id) || [];
+ const zellen: any[] = Array.isArray(r.config?.cells) ? r.config.cells : [];
+ // Nur Zellen aus der Bibliothek lassen sich wiederholen. Hochgeladene
+ // Dateien liegen unter `sources/…` und werden je nach Einstellung nach der
+ // Verarbeitung aufgeräumt — dann fehlt die Quelle.
+ const ausBibliothek = zellen.filter((c) => c?.src?.kind === 'item').length;
+ // Verschiedene Gründe, verschieden erklärt: Ein gelöschtes Bibliotheksbild
+ // ist etwas anderes als eine Datei, die nie in der Bibliothek lag. Beides
+ // in einen Zähler zu werfen führte zu „1 Bild ist nicht mehr da" für einen
+ // Bogen, dessen Quelle schlicht ein Upload war.
+ const eindeutige = new Set(zellen.filter((c) => c?.src?.kind === 'item')
+ .map((c) => String(c.src.id))).size;
+ return {
+ ...r,
+ bilder: vorhanden,
+ fehlend: Math.max(0, eindeutige - vorhanden.length),
+ hochgeladen: zellen.length - ausBibliothek,
+ // Wiederholbar nur, wenn **alle** Zellen aus der Bibliothek kommen und
+ // alle Bilder noch da sind. Halb geht nicht: Ein Bogen mit fehlendem
+ // Bild wäre nicht derselbe Bogen.
+ wiederholbar: zellen.length > 0 && ausBibliothek === zellen.length && vorhanden.length === zellen.length,
+ };
+ });
+}
+
+/** Einen einzelnen Druck holen — für das erneute Erzeugen. */
+export async function getRun(id: string, userId: string | null, istAdmin: boolean): Promise {
+ return one(
+ `SELECT * FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2)`,
+ [id, userId, istAdmin]);
+}
+
+export async function deleteRun(id: string, userId: string | null, istAdmin: boolean): Promise {
+ const r = await query<{ id: string }>(
+ `DELETE FROM print_runs WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`,
+ [id, userId, istAdmin]);
+ return r.length > 0;
+}
diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts
index f8735c8..21d057b 100644
--- a/src/pages/api/admin/settings.ts
+++ b/src/pages/api/admin/settings.ts
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
+import { loadMailSource } from '../../../lib/mail';
export const prerender = false;
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
@@ -10,6 +11,7 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async () => {
const s = await one('SELECT * FROM settings WHERE id=1');
+ const mail = await loadMailSource();
if (!s) return json({ settings: null });
let orMask = '';
if (s.openrouter_key_enc) { try { orMask = maskSecret(decrypt(s.openrouter_key_enc)); } catch { orMask = 'gesetzt'; } }
@@ -35,6 +37,9 @@ export const GET: APIRoute = async () => {
// E-Mail-Versand (Passwort wird nie zurückgegeben, nur ob es gesetzt ist)
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
+ // Woher der gerade wirksame Zugang kommt: 'datenbank' | 'umgebung' | null
+ smtp_quelle: mail?.quelle ?? null,
+ smtp_wirksam: mail ? { host: mail.cfg.host, port: mail.cfg.port, user: mail.cfg.user, from: mail.cfg.from } : null,
// Teilen
share_default_days: s.share_default_days,
} });
diff --git a/src/pages/api/print/runs.ts b/src/pages/api/print/runs.ts
new file mode 100644
index 0000000..d40d755
--- /dev/null
+++ b/src/pages/api/print/runs.ts
@@ -0,0 +1,23 @@
+import type { APIRoute } from 'astro';
+import { listRuns, deleteRun } from '../../../lib/printruns';
+
+export const prerender = false;
+const json = (b: unknown, s = 200) =>
+ new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/** Die letzten Drucke — Einstellung und Quellen, nie das PDF selbst. */
+export const GET: APIRoute = async ({ locals, url }) => {
+ if (!locals.user) return new Response('Unauthorized', { status: 401 });
+ const limit = Number(url.searchParams.get('limit')) || 30;
+ const runs = await listRuns(locals.user.uid, locals.user.role === 'admin', limit);
+ return json({ runs });
+};
+
+export const DELETE: APIRoute = async ({ locals, url }) => {
+ if (!locals.user) return new Response('Unauthorized', { status: 401 });
+ const id = url.searchParams.get('id') || '';
+ if (!UUID.test(id)) return json({ error: 'Unbekannter Eintrag.' }, 404);
+ const ok = await deleteRun(id, locals.user.uid, locals.user.role === 'admin');
+ return ok ? json({ ok: true }) : json({ error: 'Unbekannter Eintrag.' }, 404);
+};
diff --git a/src/pages/api/print/sheet.ts b/src/pages/api/print/sheet.ts
index 35f345f..c95b63b 100644
--- a/src/pages/api/print/sheet.ts
+++ b/src/pages/api/print/sheet.ts
@@ -7,6 +7,7 @@ import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
import { cfgForKey } from '../../../lib/delivery';
import { uploadBuffer } from '../../../lib/picdrop';
import { one } from '../../../lib/db';
+import { recordRun, kurzfassung } from '../../../lib/printruns';
export const prerender = false;
@@ -206,6 +207,26 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
}
+ // Den Druck festhalten — nur die Anleitung, nicht das PDF. Fehler dabei
+ // dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus.
+ try {
+ await recordRun({
+ userId: locals.user.uid ?? null,
+ origin: String(body?.origin || 'web'),
+ summary: kurzfassung(kinds),
+ paper: `${labelMm(sheet.wMm, sheet.hMm)}`,
+ pages: pages.length,
+ pieces: totalPieces,
+ bytes: bytes.length,
+ config: body,
+ itemIds: [...cellsIn, ...big.map((b) => b.cell)]
+ .map((c: any) => (c?.src?.kind === 'item' ? String(c.src.id) : null))
+ .filter(Boolean) as string[],
+ });
+ } catch (e: any) {
+ console.error('[print] Historie nicht gespeichert:', e?.message || e);
+ }
+
return new Response(Buffer.from(bytes), {
headers: {
'Content-Type': 'application/pdf',
diff --git a/tests/printruns.test.ts b/tests/printruns.test.ts
new file mode 100644
index 0000000..40fb065
--- /dev/null
+++ b/tests/printruns.test.ts
@@ -0,0 +1,43 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { kurzfassung } from '../src/lib/printruns.ts';
+
+/**
+ * Die Kurzfassung steht in der Verlaufsliste. Sie kommt zellenweise herein —
+ * zwei Zellen mit demselben Maß dürfen dort nicht zweimal auftauchen.
+ */
+
+test('Einzelnes Maß bleibt, wie es ist', () => {
+ assert.equal(kurzfassung(['13 × 18 cm']), '13 × 18 cm');
+});
+
+test('Gleiche Maße werden zusammengezogen', () => {
+ assert.equal(kurzfassung(['10 × 15 cm', '10 × 15 cm']), '10 × 15 cm ×2');
+});
+
+test('Vorhandene Stückzahlen werden addiert, nicht angehängt', () => {
+ assert.equal(kurzfassung(['35 × 45 mm ×8', '35 × 45 mm ×4']), '35 × 45 mm ×12');
+ assert.equal(kurzfassung(['10 × 15 cm ×3', '10 × 15 cm']), '10 × 15 cm ×4');
+});
+
+test('Verschiedene Maße bleiben nebeneinander, in ihrer Reihenfolge', () => {
+ assert.equal(kurzfassung(['13 × 18 cm', '35 × 45 mm ×8']), '13 × 18 cm, 35 × 45 mm ×8');
+});
+
+test('Zusätze wie (Poster) trennen die Gruppen', () => {
+ assert.equal(kurzfassung(['30 × 40 cm ×2 (Poster)', '30 × 40 cm ×1 (Poster)']),
+ '30 × 40 cm ×3 (Poster)');
+ assert.equal(kurzfassung(['30 × 40 cm (Poster)', '30 × 40 cm (überstehend)']),
+ '30 × 40 cm (Poster), 30 × 40 cm (überstehend)');
+});
+
+test('Leere Liste ergibt etwas Lesbares', () => {
+ assert.equal(kurzfassung([]), 'Druckbogen');
+});
+
+test('Sehr lange Aufzählungen werden gekürzt', () => {
+ const viele = Array.from({ length: 40 }, (_, i) => `${i + 10} × ${i + 15} cm`);
+ const s = kurzfassung(viele);
+ assert.ok(s.length <= 118, `zu lang: ${s.length}`);
+ assert.ok(s.endsWith('…'));
+});