diff --git a/.env.example b/.env.example index 4369b77..0925be1 100644 --- a/.env.example +++ b/.env.example @@ -33,9 +33,9 @@ TZ=Europe/Berlin # Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API) # REBRICKABLE_API_KEY= -# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne -# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere -# funktioniert unveraendert. +# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher +# geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der +# Server hier, hat die .env Vorrang und die App zeigt ihn nur an. # SMTP_HOST=mail.example.de # SMTP_PORT=587 # SMTP_SECURE=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d01742..235859a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,25 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +### Hinzugefügt + +- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver + lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen – + ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert + (AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und + nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang. +- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo, + geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest + Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn + die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt, + BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor, + das Bild wird beim Speichern lokal abgelegt. + ### Geändert +- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob + ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP + schon beim Containerstart in der `.env` stand. - **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link, Lego-Nummer, von Hand). @@ -25,15 +42,6 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag „Verwaltung" stand nur im Menü des Administrators. -### Hinzugefügt - -- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo, - geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest - Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn - die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt, - BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor, - das Bild wird beim Speichern lokal abgelegt. - ### Sicherheit - Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede diff --git a/CLAUDE.md b/CLAUDE.md index 5639be8..2ea86b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat. | `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) | | `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) | | `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan | +| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt | | `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | | `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten | @@ -205,7 +206,7 @@ Session. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. Rate-Limiting auf den Login-Endpunkten. 6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und - keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der + keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der Export. 7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals `/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 1fd0d59..130bb6f 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -538,25 +538,27 @@ schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchsten einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine Erinnerung ohne Handlungsbedarf ist nur Lärm. -Dafür braucht es einen SMTP-Zugang in der `.env`: +Dafür braucht es einen Mailserver (SMTP). Den trägst du direkt in der App ein: +**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port, +Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem +Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht +nötig. -```bash -SMTP_HOST=mail.example.de -SMTP_PORT=587 -SMTP_USER=bricktrack@example.de -SMTP_PASSWORD=… -SMTP_FROM=Bricktrack +Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum +Ändern einfach ein neues eintragen. Es landet auch nicht im Export. -# Optional, Standard ist 14: -REMINDER_DAYS_BEFORE=14 -``` +> Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort +> eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, +> `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur +> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE` +> (Standard 14). -Unter **Mehr → Erinnerungen** kannst du die Verbindung prüfen, dir eine +Unter **Mehr → Erinnerungen** kannst du außerdem die Verbindung prüfen, dir eine Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie viel dort noch offen ist. -Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert +Ohne Mailserver ist die Funktion vollständig aus – alles andere funktioniert unverändert. --- diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png index f4d08e1..8ab8f98 100644 Binary files a/docs/bilder/06-mehrfachauswahl.png and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png index 313ff7c..f64b467 100644 Binary files a/docs/bilder/07-wunschliste-dialog.png and b/docs/bilder/07-wunschliste-dialog.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png index 0bafbd5..1ebb055 100644 Binary files a/docs/bilder/11-wunschliste-bearbeiten.png and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/13-reservieren.png b/docs/bilder/13-reservieren.png index ec62c46..26043cc 100644 Binary files a/docs/bilder/13-reservieren.png and b/docs/bilder/13-reservieren.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png index 87e77bc..383ef49 100644 Binary files a/docs/bilder/15-wunschliste-reserviert.png and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/18-fehlteile.png b/docs/bilder/18-fehlteile.png index e99d02c..4846644 100644 Binary files a/docs/bilder/18-fehlteile.png and b/docs/bilder/18-fehlteile.png differ diff --git a/docs/bilder/19-einladungen.png b/docs/bilder/19-einladungen.png index 7bf49d1..176ae83 100644 Binary files a/docs/bilder/19-einladungen.png and b/docs/bilder/19-einladungen.png differ diff --git a/docs/bilder/24-erinnerungen.png b/docs/bilder/24-erinnerungen.png index efbde46..6fd3d3d 100644 Binary files a/docs/bilder/24-erinnerungen.png and b/docs/bilder/24-erinnerungen.png differ diff --git a/drizzle/0004_modern_agent_brand.sql b/drizzle/0004_modern_agent_brand.sql new file mode 100644 index 0000000..ddb0b9c --- /dev/null +++ b/drizzle/0004_modern_agent_brand.sql @@ -0,0 +1,5 @@ +CREATE TABLE `app_setting` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..9e25cca --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2125 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b34f5e0c-ed89-4b05-9d8e-5f2c7c4b2b81", + "prevId": "f27f67e4-d146-4d63-b7cc-78bf5b0c686d", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invite_created_by_user_id_user_id_fk": { + "name": "invite_created_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_used_by_user_id_user_id_fk": { + "name": "invite_used_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "used_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + "credential_id" + ], + "isUnique": true + }, + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instruction": { + "name": "instruction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instruction_set_idx": { + "name": "instruction_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "instruction_set_id_set_id_fk": { + "name": "instruction_set_id_set_id_fk", + "tableFrom": "instruction", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "instruction_source": { + "name": "instruction_source", + "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)" + } + } + }, + "manufacturer": { + "name": "manufacturer", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "manufacturer_slug_unique": { + "name": "manufacturer_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set": { + "name": "set", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "set_number": { + "name": "set_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "piece_count": { + "name": "piece_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "minifig_count": { + "name": "minifig_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ean": { + "name": "ean", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age_range": { + "name": "age_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_path": { + "name": "image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retail_price_cents": { + "name": "retail_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_ids": { + "name": "external_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "set_manufacturer_number_unique": { + "name": "set_manufacturer_number_unique", + "columns": [ + "manufacturer_id", + "set_number" + ], + "isUnique": true + }, + "set_name_idx": { + "name": "set_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "set_ean_idx": { + "name": "set_ean_idx", + "columns": [ + "ean" + ], + "isUnique": false + }, + "set_theme_idx": { + "name": "set_theme_idx", + "columns": [ + "theme_id" + ], + "isUnique": false + }, + "set_year_idx": { + "name": "set_year_idx", + "columns": [ + "year" + ], + "isUnique": false + } + }, + "foreignKeys": { + "set_manufacturer_id_manufacturer_id_fk": { + "name": "set_manufacturer_id_manufacturer_id_fk", + "tableFrom": "set", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "set_theme_id_theme_id_fk": { + "name": "set_theme_id_theme_id_fk", + "tableFrom": "set", + "tableTo": "theme", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set_tag": { + "name": "set_tag", + "columns": { + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "set_tag_set_id_set_id_fk": { + "name": "set_tag_set_id_set_id_fk", + "tableFrom": "set_tag", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "set_tag_tag_id_tag_id_fk": { + "name": "set_tag_tag_id_tag_id_fk", + "tableFrom": "set_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "set_tag_set_id_tag_id_pk": { + "columns": [ + "set_id", + "tag_id" + ], + "name": "set_tag_set_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tag_slug_unique": { + "name": "tag_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "theme": { + "name": "theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_theme_id": { + "name": "parent_theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "theme_manufacturer_idx": { + "name": "theme_manufacturer_idx", + "columns": [ + "manufacturer_id" + ], + "isUnique": false + }, + "theme_unique_name": { + "name": "theme_unique_name", + "columns": [ + "manufacturer_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "theme_manufacturer_id_manufacturer_id_fk": { + "name": "theme_manufacturer_id_manufacturer_id_fk", + "tableFrom": "theme", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "theme_parent_theme_id_theme_id_fk": { + "name": "theme_parent_theme_id_theme_id_fk", + "tableFrom": "theme", + "tableTo": "theme", + "columnsFrom": [ + "parent_theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collector": { + "name": "collector", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blue'" + }, + "birthday": { + "name": "birthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collector_sort_idx": { + "name": "collector_sort_idx", + "columns": [ + "sort_order", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "price_point": { + "name": "price_point", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "observed_at": { + "name": "observed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "price_point_set_idx": { + "name": "price_point_set_idx", + "columns": [ + "set_id", + "observed_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "price_point_set_id_set_id_fk": { + "name": "price_point_set_id_set_id_fk", + "tableFrom": "price_point", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "price_point_positive": { + "name": "price_point_positive", + "value": "\"price_point\".\"price_cents\" >= 0" + } + } + }, + "collection_item": { + "name": "collection_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'owned'" + }, + "completeness": { + "name": "completeness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_price_cents": { + "name": "acquired_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_from": { + "name": "acquired_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gifted_by": { + "name": "gifted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lent_out_to": { + "name": "lent_out_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collection_item_set_idx": { + "name": "collection_item_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "collection_item_collector_idx": { + "name": "collection_item_collector_idx", + "columns": [ + "collector_id" + ], + "isUnique": false + }, + "collection_item_status_idx": { + "name": "collection_item_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "collection_item_location_idx": { + "name": "collection_item_location_idx", + "columns": [ + "location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_item_set_id_set_id_fk": { + "name": "collection_item_set_id_set_id_fk", + "tableFrom": "collection_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_collector_id_collector_id_fk": { + "name": "collection_item_collector_id_collector_id_fk", + "tableFrom": "collection_item", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_location_id_location_id_fk": { + "name": "collection_item_location_id_location_id_fk", + "tableFrom": "collection_item", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "location": { + "name": "location", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_location_id": { + "name": "parent_location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "location_parent_location_id_location_id_fk": { + "name": "location_parent_location_id_location_id_fk", + "tableFrom": "location", + "tableTo": "location", + "columnsFrom": [ + "parent_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "missing_part": { + "name": "missing_part", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color_name": { + "name": "color_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "missing_part_item_idx": { + "name": "missing_part_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "missing_part_collection_item_id_collection_item_id_fk": { + "name": "missing_part_collection_item_id_collection_item_id_fk", + "tableFrom": "missing_part", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "photo": { + "name": "photo", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "photo_set_idx": { + "name": "photo_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "photo_item_idx": { + "name": "photo_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "photo_set_id_set_id_fk": { + "name": "photo_set_id_set_id_fk", + "tableFrom": "photo", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "photo_collection_item_id_collection_item_id_fk": { + "name": "photo_collection_item_id_collection_item_id_fk", + "tableFrom": "photo", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "photo_single_owner": { + "name": "photo_single_owner", + "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)" + } + } + }, + "wishlist": { + "name": "wishlist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occasion": { + "name": "occasion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "event_date": { + "name": "event_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "surprise_mode": { + "name": "surprise_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_at": { + "name": "last_reminder_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_share_token_unique": { + "name": "wishlist_share_token_unique", + "columns": [ + "share_token" + ], + "isUnique": true + }, + "wishlist_owner_idx": { + "name": "wishlist_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_owner_user_id_user_id_fk": { + "name": "wishlist_owner_user_id_user_id_fk", + "tableFrom": "wishlist", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_collector_id_collector_id_fk": { + "name": "wishlist_collector_id_collector_id_fk", + "tableFrom": "wishlist", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_item": { + "name": "wishlist_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "wishlist_id": { + "name": "wishlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserved_by_name": { + "name": "reserved_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservation_token_hash": { + "name": "reservation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_item_unique": { + "name": "wishlist_item_unique", + "columns": [ + "wishlist_id", + "set_id" + ], + "isUnique": true + }, + "wishlist_item_list_idx": { + "name": "wishlist_item_list_idx", + "columns": [ + "wishlist_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_item_wishlist_id_wishlist_id_fk": { + "name": "wishlist_item_wishlist_id_wishlist_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "wishlist", + "columnsFrom": [ + "wishlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_item_set_id_set_id_fk": { + "name": "wishlist_item_set_id_set_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "wishlist_item_priority": { + "name": "wishlist_item_priority", + "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3" + } + } + }, + "app_setting": { + "name": "app_setting", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f5de118..280fb6d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1788765478965, "tag": "0003_powerful_sally_floyd", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1792963951232, + "tag": "0004_modern_agent_brand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(app)/verwaltung/erinnerungen/actions.ts b/src/app/(app)/verwaltung/erinnerungen/actions.ts index 5d37cb1..ad10b67 100644 --- a/src/app/(app)/verwaltung/erinnerungen/actions.ts +++ b/src/app/(app)/verwaltung/erinnerungen/actions.ts @@ -1,9 +1,23 @@ 'use server'; import { revalidatePath } from 'next/cache'; -import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { + actionData, + actionError, + actionOk, + formToObject, + parseForm, + type ActionResult, +} from '@/lib/action-result'; import { env } from '@/env'; +import { + deleteMailSettings, + getMailConfig, + isMailConfigured, + saveMailSettings, +} from '@/lib/mail-settings'; import { sendMail, verifyMailConnection } from '@/lib/mailer'; +import { mailSettingsSchema } from '@/lib/validation'; import { runReminderJob, type ReminderRun } from '@/lib/reminder-job'; import { requireAdmin } from '@/lib/session'; @@ -17,7 +31,7 @@ export async function testMailConnection(): Promise { export async function sendTestMail(): Promise { const admin = await requireAdmin(); - if (!env.isMailConfigured) { + if (!isMailConfigured()) { return actionError('Es ist kein SMTP-Server konfiguriert.'); } @@ -41,7 +55,7 @@ export async function sendTestMail(): Promise { export async function runRemindersNow(): Promise> { await requireAdmin(); - if (!env.isMailConfigured) { + if (!isMailConfigured()) { return actionError('Es ist kein SMTP-Server konfiguriert.'); } @@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise> { revalidatePath('/verwaltung/erinnerungen'); return actionData(run); } + +/** Stores the SMTP server set up on the reminders page. */ +export async function saveMailSettingsAction(formData: FormData): Promise { + await requireAdmin(); + + if (getMailConfig()?.source === 'env') { + return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.'); + } + + const parsed = parseForm(mailSettingsSchema, formToObject(formData)); + if (!parsed.ok) return parsed; + + saveMailSettings(parsed.data); + revalidatePath('/verwaltung/erinnerungen'); + return actionOk(); +} + +export async function deleteMailSettingsAction(): Promise { + await requireAdmin(); + deleteMailSettings(); + revalidatePath('/verwaltung/erinnerungen'); + return actionOk(); +} diff --git a/src/app/(app)/verwaltung/erinnerungen/page.tsx b/src/app/(app)/verwaltung/erinnerungen/page.tsx index f382a16..9998d1e 100644 --- a/src/app/(app)/verwaltung/erinnerungen/page.tsx +++ b/src/app/(app)/verwaltung/erinnerungen/page.tsx @@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { wishlist } from '@/db/schema'; import { env } from '@/env'; +import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form'; import { ReminderSettings, type ReminderListRow, } from '@/components/verwaltung/reminder-settings'; +import { getMailSettingsView } from '@/lib/mail-settings'; import { daysUntil } from '@/lib/reminders'; import { requireAdmin } from '@/lib/session'; @@ -41,11 +43,16 @@ export default async function ErinnerungenPage() { })) .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); + const mail = getMailSettingsView(); + return ( - +
+ + +
); } diff --git a/src/components/verwaltung/mail-settings-form.tsx b/src/components/verwaltung/mail-settings-form.tsx new file mode 100644 index 0000000..e622092 --- /dev/null +++ b/src/components/verwaltung/mail-settings-form.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2, Save, Server, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + deleteMailSettingsAction, + saveMailSettingsAction, +} from '@/app/(app)/verwaltung/erinnerungen/actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type MailSettingsFormView = { + source: 'env' | 'app' | null; + host: string; + port: number; + security: 'starttls' | 'tls' | 'none'; + user: string; + from: string; + hasPassword: boolean; + passwordUnreadable: boolean; +}; + +const SECURITY_LABELS = { + starttls: 'STARTTLS (meist Port 587)', + tls: 'SSL/TLS (meist Port 465)', + none: 'Unverschlüsselt (nur im eigenen Netz)', +} as const; + +/** The SMTP server for reminders, set up right here instead of in the .env. */ +export function MailSettingsForm({ view }: { view: MailSettingsFormView }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [security, setSecurity] = useState(view.security); + const [port, setPort] = useState(String(view.port)); + + if (view.source === 'env') { + return ( + + + + + Mailserver + + + Kommt aus der .env und hat dort Vorrang. Ändern lässt er sich nur dort, danach den + Container neu starten. + + + +

+ Server: {view.host}:{view.port} ·{' '} + {SECURITY_LABELS[view.security].split(' (')[0]} +

+

+ Absender: {view.from} +

+
+
+ ); + } + + function changeSecurity(next: 'starttls' | 'tls' | 'none') { + setSecurity(next); + // Follow the usual port while the user has not picked a special one. + if (next === 'tls' && port === '587') setPort('465'); + if (next !== 'tls' && port === '465') setPort('587'); + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + + startTransition(async () => { + const result = await saveMailSettingsAction(formData); + if (!result.ok) { + toast.error(result.error); + return; + } + // The password never stays visible, not even as dots. + const password = form.elements.namedItem('password'); + if (password instanceof HTMLInputElement) password.value = ''; + toast.success('Mailserver gespeichert. Prüf ihn gleich mit „Verbindung prüfen“.'); + router.refresh(); + }); + } + + function remove() { + startTransition(async () => { + const result = await deleteMailSettingsAction(); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Mailserver entfernt. Erinnerungen sind aus.'); + router.refresh(); + }); + } + + return ( + + + + + Mailserver + + + Über diesen Server gehen die Erinnerungen raus. Die Angaben findest du bei deinem + Mail-Anbieter unter „SMTP“ oder „Postausgangsserver“. Das Passwort wird verschlüsselt + gespeichert und nie wieder angezeigt. + + + +
+
+ + + + + setPort(event.target.value)} + required + /> + +
+ + + changeSecurity(event.target.value as typeof security)} + > + {Object.entries(SECURITY_LABELS).map(([value, label]) => ( + + ))} + + + +
+ + + + + + +
+ + {view.hasPassword ? ( + + ) : null} + + + + + +
+ + {view.source === 'app' ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/verwaltung/reminder-settings.tsx b/src/components/verwaltung/reminder-settings.tsx index 2aa3346..b63f779 100644 --- a/src/components/verwaltung/reminder-settings.tsx +++ b/src/components/verwaltung/reminder-settings.tsx @@ -98,17 +98,10 @@ export function ReminderSettings({ ) : (

- Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere + Es ist noch kein Mailserver eingerichtet – Erinnerungen sind damit aus, alles andere funktioniert unverändert.

-

Zum Aktivieren in die .env eintragen und den Container neu starten:

-
-{`SMTP_HOST=mail.example.de
-SMTP_PORT=587
-SMTP_USER=bricktrack@example.de
-SMTP_PASSWORD=…
-SMTP_FROM=Bricktrack `}
-              
+

Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.

)} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 554833b..4dc33fe 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -4,4 +4,5 @@ export * from './collector'; export * from './prices'; export * from './collection'; export * from './wishlist'; +export * from './settings'; export * from './relations'; diff --git a/src/db/schema/settings.ts b/src/db/schema/settings.ts new file mode 100644 index 0000000..95b1961 --- /dev/null +++ b/src/db/schema/settings.ts @@ -0,0 +1,14 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * Settings made in the app rather than in the .env - for now the SMTP server + * for reminders. One row per topic, the value as JSON. + * + * Never part of the export: it may hold credentials (the SMTP password is + * stored encrypted, see src/lib/secret-box.ts). + */ +export const appSetting = sqliteTable('app_setting', { + key: text('key').primaryKey(), + value: text('value', { mode: 'json' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); diff --git a/src/lib/mail-settings.ts b/src/lib/mail-settings.ts new file mode 100644 index 0000000..8e3a7e6 --- /dev/null +++ b/src/lib/mail-settings.ts @@ -0,0 +1,129 @@ +import 'server-only'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { appSetting } from '@/db/schema'; +import { env } from '@/env'; +import { openSecret, sealSecret } from '@/lib/secret-box'; +import type { MailSettingsInput } from '@/lib/validation'; + +/** + * Where the SMTP server comes from. + * + * The .env wins when it names a server: an admin who configured it there + * expects exactly that. Otherwise the settings made on the reminders page + * apply. Without either, reminders stay off. + */ + +export type MailSecurity = 'starttls' | 'tls' | 'none'; + +export type MailConfig = { + source: 'env' | 'app'; + host: string; + port: number; + security: MailSecurity; + user: string | null; + password: string | null; + from: string; +}; + +type StoredMail = { + host: string; + port: number; + security: MailSecurity; + user: string | null; + /** Encrypted, see secret-box.ts. */ + password: string | null; + from: string; +}; + +const KEY = 'smtp'; + +function readStored(): StoredMail | null { + const row = db.select().from(appSetting).where(eq(appSetting.key, KEY)).get(); + return (row?.value as StoredMail | undefined) ?? null; +} + +export function getMailConfig(): MailConfig | null { + if (env.isMailConfigured) { + return { + source: 'env', + host: env.SMTP_HOST!, + port: env.SMTP_PORT, + security: env.SMTP_SECURE || env.SMTP_PORT === 465 ? 'tls' : 'starttls', + user: env.SMTP_USER ?? null, + password: env.SMTP_PASSWORD ?? null, + from: env.SMTP_FROM!, + }; + } + + const stored = readStored(); + if (!stored) return null; + return { + source: 'app', + host: stored.host, + port: stored.port, + security: stored.security, + user: stored.user, + password: stored.password ? openSecret(stored.password, env.BETTER_AUTH_SECRET) : null, + from: stored.from, + }; +} + +export function isMailConfigured(): boolean { + return getMailConfig() !== null; +} + +/** What the settings form may show - never the password itself. */ +export type MailSettingsView = { + source: 'env' | 'app' | null; + host: string; + port: number; + security: MailSecurity; + user: string; + from: string; + hasPassword: boolean; + /** A password is stored but cannot be decrypted (BETTER_AUTH_SECRET changed). */ + passwordUnreadable: boolean; +}; + +export function getMailSettingsView(): MailSettingsView { + const config = getMailConfig(); + const stored = config?.source === 'env' ? null : readStored(); + + return { + source: config?.source ?? null, + host: config?.host ?? '', + port: config?.port ?? 587, + security: config?.security ?? 'starttls', + user: config?.user ?? '', + from: config?.from ?? '', + hasPassword: Boolean(config?.password), + passwordUnreadable: Boolean(stored?.password && !config?.password), + }; +} + +export function saveMailSettings(input: MailSettingsInput): void { + const previous = readStored(); + + let password = previous?.password ?? null; + if (input.clearPassword) password = null; + if (input.password) password = sealSecret(input.password, env.BETTER_AUTH_SECRET); + + const value: StoredMail = { + host: input.host, + port: input.port, + security: input.security, + user: input.user, + password, + from: input.from, + }; + + db.insert(appSetting) + .values({ key: KEY, value, updatedAt: new Date() }) + .onConflictDoUpdate({ target: appSetting.key, set: { value, updatedAt: new Date() } }) + .run(); +} + +export function deleteMailSettings(): void { + db.delete(appSetting).where(eq(appSetting.key, KEY)).run(); +} diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts index 553e623..95a3c13 100644 --- a/src/lib/mailer.ts +++ b/src/lib/mailer.ts @@ -1,32 +1,39 @@ import 'server-only'; import nodemailer, { type Transporter } from 'nodemailer'; -import { env } from '@/env'; +import { getMailConfig, type MailConfig } from '@/lib/mail-settings'; /** * Outgoing mail. * - * Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and + * Entirely optional: without a server - in the .env or on the reminders page - * nothing is sent. The app never depends on mail working. */ -let transporter: Transporter | null = null; +let cached: { key: string; transporter: Transporter; from: string } | null = null; -function getTransporter(): Transporter | null { - if (!env.isMailConfigured) return null; - if (transporter) return transporter; +function connectionKey(config: MailConfig): string { + return JSON.stringify([config.host, config.port, config.security, config.user, config.password]); +} - transporter = nodemailer.createTransport({ - host: env.SMTP_HOST, - port: env.SMTP_PORT, +function getTransport(): { transporter: Transporter; from: string } | null { + const config = getMailConfig(); + if (!config) return null; + + // Settings can change at runtime now; a new connection follows them. + const key = connectionKey(config); + if (cached?.key === key) return { transporter: cached.transporter, from: config.from }; + + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, // Implicit TLS on 465, STARTTLS on 587 - the usual pair. - secure: env.SMTP_SECURE || env.SMTP_PORT === 465, - auth: - env.SMTP_USER && env.SMTP_PASSWORD - ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } - : undefined, + secure: config.security === 'tls', + ignoreTLS: config.security === 'none', + auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined, }); - return transporter; + cached = { key, transporter, from: config.from }; + return { transporter, from: config.from }; } export type MailResult = { ok: true } | { ok: false; error: string }; @@ -36,14 +43,14 @@ export async function sendMail(input: { subject: string; text: string; }): Promise { - const transport = getTransporter(); + const transport = getTransport(); if (!transport) { return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; } try { - await transport.sendMail({ - from: env.SMTP_FROM, + await transport.transporter.sendMail({ + from: transport.from, to: input.to, subject: input.subject, text: input.text, @@ -60,13 +67,13 @@ export async function sendMail(input: { /** Checks the connection without sending anything. */ export async function verifyMailConnection(): Promise { - const transport = getTransporter(); + const transport = getTransport(); if (!transport) { return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; } try { - await transport.verify(); + await transport.transporter.verify(); return { ok: true }; } catch (error) { return { diff --git a/src/lib/reminder-job.ts b/src/lib/reminder-job.ts index e19c6fa..d5296f9 100644 --- a/src/lib/reminder-job.ts +++ b/src/lib/reminder-job.ts @@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { user, wishlist } from '@/db/schema'; import { env } from '@/env'; +import { isMailConfigured } from './mail-settings'; import { sendMail } from './mailer'; import { reminderSubject, @@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] { export async function runReminderJob(now = new Date()): Promise { const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] }; - if (!env.isMailConfigured) return result; + if (!isMailConfigured()) return result; const candidates = loadCandidates(); result.checked = candidates.length; @@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void { * cooldown keeps that from turning into repeated mail. */ export function startReminderScheduler(): void { - if (!env.isMailConfigured) { - console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.'); - return; - } - + // Always scheduled: the mail server can be set up in the app at any time, + // and each run checks whether one is configured. console.log( - `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`, + isMailConfigured() + ? `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.` + : '[reminders] Noch kein SMTP-Server eingerichtet - Erinnerungen starten, sobald es einen gibt.', ); const tick = () => { diff --git a/src/lib/secret-box.ts b/src/lib/secret-box.ts new file mode 100644 index 0000000..8938432 --- /dev/null +++ b/src/lib/secret-box.ts @@ -0,0 +1,41 @@ +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'; + +/** + * Encrypts secrets that settings must keep, such as the SMTP password. + * + * AES-256-GCM with a key derived from BETTER_AUTH_SECRET: a copy of the + * database alone does not reveal the password. Changing BETTER_AUTH_SECRET + * makes stored secrets unreadable - `openSecret` then returns null and the + * password has to be entered again. Pure apart from node:crypto, so tested. + */ + +const VERSION = 'v1'; + +function deriveKey(masterSecret: string): Buffer { + return Buffer.from(hkdfSync('sha256', masterSecret, 'bricktrack', 'app-setting-secret', 32)); +} + +export function sealSecret(plain: string, masterSecret: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', deriveKey(masterSecret), iv); + const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [VERSION, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':'); +} + +export function openSecret(sealed: string, masterSecret: string): string | null { + const [version, iv, tag, encrypted] = sealed.split(':'); + if (version !== VERSION || !iv || !tag || encrypted === undefined) return null; + + try { + const decipher = createDecipheriv('aes-256-gcm', deriveKey(masterSecret), Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(encrypted, 'base64')), + decipher.final(), + ]).toString('utf8'); + } catch { + // Wrong key or tampered value: treat as "no password stored". + return null; + } +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 30b7408..1fdf30e 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -219,6 +219,39 @@ export const reservationSchema = z.object({ export type ManufacturerInput = z.infer; export type ThemeInput = z.infer; +// --------------------------------------------------------------------------- +// Mail (reminders) +// --------------------------------------------------------------------------- + +export const mailSecurityEnum = z.enum(['starttls', 'tls', 'none']); + +export const mailSettingsSchema = z.object({ + host: z + .string() + .trim() + .min(1, 'Bitte den SMTP-Server angeben.') + .max(255) + .regex(/^[a-z0-9.-]+$/i, 'Nur der Servername, z. B. mail.example.de – ohne https:// und Port.'), + port: z.coerce.number().int().min(1, 'Ungültiger Port.').max(65535, 'Ungültiger Port.').default(587), + security: mailSecurityEnum.default('starttls'), + user: optionalText(255), + /** Empty keeps the stored password; see clearPassword to remove it. Not trimmed. */ + password: z + .string() + .max(500) + .nullish() + .transform((value) => (value ? value : null)), + clearPassword: checkbox, + from: z + .string() + .trim() + .min(3, 'Bitte einen Absender angeben.') + .max(255) + .refine((value) => /@[^@\s>]+\.[^@\s>]+/.test(value), 'Der Absender braucht eine E-Mail-Adresse.'), +}); + +export type MailSettingsInput = z.infer; + export type LocationInput = z.infer; export type SetInput = z.infer; export type CollectionItemInput = z.infer; diff --git a/tests/unit/mail-settings.test.ts b/tests/unit/mail-settings.test.ts new file mode 100644 index 0000000..7cc3d95 --- /dev/null +++ b/tests/unit/mail-settings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { openSecret, sealSecret } from '@/lib/secret-box'; +import { mailSettingsSchema } from '@/lib/validation'; + +const SECRET = 'test-secret-0123456789-abcdefghijklmnop'; + +describe('Passwort verschluesselt speichern', () => { + it('kommt mit demselben Schluessel unveraendert zurueck', () => { + const sealed = sealSecret('geheim mit Leerzeichen ', SECRET); + expect(sealed).not.toContain('geheim'); + expect(openSecret(sealed, SECRET)).toBe('geheim mit Leerzeichen '); + }); + + it('verschluesselt dasselbe Passwort jedes Mal anders', () => { + expect(sealSecret('x', SECRET)).not.toBe(sealSecret('x', SECRET)); + }); + + it('liefert nichts bei anderem Schluessel oder veraendertem Wert', () => { + const sealed = sealSecret('geheim', SECRET); + expect(openSecret(sealed, 'ein-anderes-secret-0123456789-abcdefgh')).toBeNull(); + + const [version, iv, tag, data] = sealed.split(':'); + const flipped = Buffer.from(data!, 'base64'); + flipped[0] = flipped[0]! ^ 0xff; + expect(openSecret([version, iv, tag, flipped.toString('base64')].join(':'), SECRET)).toBeNull(); + expect(openSecret('kaputt', SECRET)).toBeNull(); + }); +}); + +describe('SMTP-Eingaben pruefen', () => { + const valid = { + host: 'mail.example.de', + port: '587', + security: 'starttls', + user: 'bricktrack@example.de', + password: ' mit Rand ', + from: 'Bricktrack ', + }; + + it('nimmt gueltige Angaben und laesst das Passwort, wie es ist', () => { + const parsed = mailSettingsSchema.parse(valid); + expect(parsed.port).toBe(587); + expect(parsed.password).toBe(' mit Rand '); + expect(parsed.clearPassword).toBe(false); + }); + + it('macht aus einem leeren Passwort "unveraendert"', () => { + expect(mailSettingsSchema.parse({ ...valid, password: '' }).password).toBeNull(); + }); + + it('lehnt URLs, Ports und Absender ohne Adresse ab', () => { + expect(mailSettingsSchema.safeParse({ ...valid, host: 'https://mail.example.de' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, host: 'mail.example.de:587' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, port: '70000' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, from: 'Bricktrack' }).success).toBe(false); + expect(mailSettingsSchema.safeParse({ ...valid, security: 'ssl3' }).success).toBe(false); + }); +});