From 0d08df0bffee5d72a7a0e121e2bcc08c7136abe3 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Sep 2026 09:22:29 +0200 Subject: [PATCH] feat(erinnerungen): e-mail, wenn ein wunschlisten-termin naeher rueckt Ruecken Termin und offene Wuensche zusammen, geht eine Nachricht an den Besitzer der Liste - standardmaessig 14 Tage vorher, hoechstens einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine Erinnerung ohne Handlungsbedarf ist nur Laerm. Der Zeitplan haengt an Next' instrumentation-Hook und prueft stuendlich, nicht taeglich - so kann ein Neustart keinen Tag ueberspringen, und die Sperrfrist je Liste verhindert trotzdem Mehrfachversand. Der Zeitstempel wird erst nach erfolgreichem Versand gesetzt, damit ein Fehlversuch wiederholt wird. Ohne SMTP_HOST und SMTP_FROM ist die Funktion komplett aus: kein Zeitplan, kein Versand, und die Verwaltungsseite erklaert stattdessen, was in die .env gehoert. Mit Konfiguration gibt es dort Knoepfe fuer Verbindungstest, Testnachricht und sofortige Pruefung. 12 Tests fuer die Entscheidungsregeln. Einer davon hat einen echten Fehler gefunden: die Sperrfrist rechnete mit umgedrehtem Vorzeichen und haette jede Erinnerung dauerhaft unterdrueckt. Refs Phase 3 Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 13 + drizzle/0003_powerful_sally_floyd.sql | 1 + drizzle/meta/0003_snapshot.json | 2094 +++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 2 + pnpm-lock.yaml | 19 + .../(app)/verwaltung/erinnerungen/actions.ts | 51 + .../(app)/verwaltung/erinnerungen/page.tsx | 51 + .../verwaltung/reminder-settings.tsx | 166 ++ src/components/verwaltung/verwaltung-nav.tsx | 1 + src/db/schema/wishlist.ts | 2 + src/env.ts | 23 +- src/instrumentation.ts | 15 + src/lib/mailer.ts | 77 + src/lib/reminder-job.ts | 141 ++ src/lib/reminders.ts | 91 + tests/unit/reminders.test.ts | 131 ++ 17 files changed, 2884 insertions(+), 1 deletion(-) create mode 100644 drizzle/0003_powerful_sally_floyd.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/app/(app)/verwaltung/erinnerungen/actions.ts create mode 100644 src/app/(app)/verwaltung/erinnerungen/page.tsx create mode 100644 src/components/verwaltung/reminder-settings.tsx create mode 100644 src/instrumentation.ts create mode 100644 src/lib/mailer.ts create mode 100644 src/lib/reminder-job.ts create mode 100644 src/lib/reminders.ts create mode 100644 tests/unit/reminders.test.ts diff --git a/.env.example b/.env.example index 75817ac..4369b77 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,16 @@ TZ=Europe/Berlin # und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter. # 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. +# SMTP_HOST=mail.example.de +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER=bricktrack@example.de +# SMTP_PASSWORD= +# SMTP_FROM=Bricktrack + +# Wie viele Tage vor dem Termin erinnert wird (Standard: 14). +# REMINDER_DAYS_BEFORE=14 diff --git a/drizzle/0003_powerful_sally_floyd.sql b/drizzle/0003_powerful_sally_floyd.sql new file mode 100644 index 0000000..6ef4793 --- /dev/null +++ b/drizzle/0003_powerful_sally_floyd.sql @@ -0,0 +1 @@ +ALTER TABLE `wishlist` ADD `last_reminder_at` integer; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..d93cc42 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2094 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f27f67e4-d146-4d63-b7cc-78bf5b0c686d", + "prevId": "36e88ca3-098f-40f0-9253-abe30ff27c47", + "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" + } + } + } + }, + "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 00d2600..f5de118 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1788765229376, "tag": "0002_aspiring_whiplash", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1788765478965, + "tag": "0003_powerful_sally_floyd", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 06c3cb8..e593f1a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "lucide-react": "^1.41.0", "next": "15.5.25", "next-themes": "^0.4.6", + "nodemailer": "^10.0.0", "pdfjs-dist": "^6.3.289", "radix-ui": "^1.6.7", "react": "19.1.0", @@ -52,6 +53,7 @@ "@tailwindcss/postcss": "^4", "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fef224..8784d08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nodemailer: + specifier: ^10.0.0 + version: 10.0.0 pdfjs-dist: specifier: ^6.3.289 version: 6.3.289 @@ -90,6 +93,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.43 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/react': specifier: ^19 version: 19.2.18 @@ -2262,6 +2268,9 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/react-dom@19.2.7': resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: @@ -4135,6 +4144,10 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + nodemailer@10.0.0: + resolution: {integrity: sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==} + engines: {node: '>=20.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -6950,6 +6963,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 20.19.43 + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -8805,6 +8822,8 @@ snapshots: node-releases@2.0.54: {} + nodemailer@10.0.0: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 diff --git a/src/app/(app)/verwaltung/erinnerungen/actions.ts b/src/app/(app)/verwaltung/erinnerungen/actions.ts new file mode 100644 index 0000000..5d37cb1 --- /dev/null +++ b/src/app/(app)/verwaltung/erinnerungen/actions.ts @@ -0,0 +1,51 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { env } from '@/env'; +import { sendMail, verifyMailConnection } from '@/lib/mailer'; +import { runReminderJob, type ReminderRun } from '@/lib/reminder-job'; +import { requireAdmin } from '@/lib/session'; + +export async function testMailConnection(): Promise { + await requireAdmin(); + + const result = await verifyMailConnection(); + return result.ok ? { ok: true } : actionError(result.error); +} + +export async function sendTestMail(): Promise { + const admin = await requireAdmin(); + + if (!env.isMailConfigured) { + return actionError('Es ist kein SMTP-Server konfiguriert.'); + } + + const result = await sendMail({ + to: admin.email, + subject: 'Bricktrack: Testnachricht', + text: [ + 'Das hat geklappt.', + '', + 'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu', + 'Wunschlisten-Terminen.', + '', + env.BASE_URL, + ].join('\n'), + }); + + return result.ok ? { ok: true } : actionError(result.error); +} + +/** Runs the check immediately instead of waiting for the next hour. */ +export async function runRemindersNow(): Promise> { + await requireAdmin(); + + if (!env.isMailConfigured) { + return actionError('Es ist kein SMTP-Server konfiguriert.'); + } + + const run = await runReminderJob(); + revalidatePath('/verwaltung/erinnerungen'); + return actionData(run); +} diff --git a/src/app/(app)/verwaltung/erinnerungen/page.tsx b/src/app/(app)/verwaltung/erinnerungen/page.tsx new file mode 100644 index 0000000..f382a16 --- /dev/null +++ b/src/app/(app)/verwaltung/erinnerungen/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next'; +import { eq, sql } from 'drizzle-orm'; +import { db } from '@/db'; +import { wishlist } from '@/db/schema'; +import { env } from '@/env'; +import { + ReminderSettings, + type ReminderListRow, +} from '@/components/verwaltung/reminder-settings'; +import { daysUntil } from '@/lib/reminders'; +import { requireAdmin } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Erinnerungen' }; + +export default async function ErinnerungenPage() { + const admin = await requireAdmin(); + + const openItems = sql`( + select count(*) from wishlist_item wi + where wi.wishlist_id = ${wishlist.id} + and wi.reserved_at is null + and wi.fulfilled_at is null + )`.mapWith(Number); + + const now = new Date(); + const lists: ReminderListRow[] = db + .select({ + id: wishlist.id, + title: wishlist.title, + eventDate: wishlist.eventDate, + lastReminderAt: wishlist.lastReminderAt, + openItemCount: openItems, + }) + .from(wishlist) + .where(eq(wishlist.ownerUserId, admin.id)) + .all() + .filter((row) => row.eventDate !== null) + .map((row) => ({ + ...row, + daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null, + })) + .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); + + return ( + + ); +} diff --git a/src/components/verwaltung/reminder-settings.tsx b/src/components/verwaltung/reminder-settings.tsx new file mode 100644 index 0000000..2aa3346 --- /dev/null +++ b/src/components/verwaltung/reminder-settings.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { BellRing, Loader2, Mail, PlugZap } from 'lucide-react'; +import { toast } from 'sonner'; +import { + runRemindersNow, + sendTestMail, + testMailConnection, +} from '@/app/(app)/verwaltung/erinnerungen/actions'; +import { formatDate } from '@/lib/format'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +export type ReminderListRow = { + id: string; + title: string; + eventDate: Date | null; + openItemCount: number; + lastReminderAt: Date | null; + daysLeft: number | null; +}; + +export function ReminderSettings({ + configured, + daysBefore, + lists, +}: { + configured: boolean; + daysBefore: number; + lists: ReminderListRow[]; +}) { + const [pending, startTransition] = useTransition(); + const [busy, setBusy] = useState<'verify' | 'test' | 'run' | null>(null); + + function run(kind: 'verify' | 'test' | 'run') { + setBusy(kind); + startTransition(async () => { + if (kind === 'verify') { + const result = await testMailConnection(); + if (result.ok) toast.success('Der SMTP-Server antwortet.'); + else toast.error(result.error); + } + + if (kind === 'test') { + const result = await sendTestMail(); + if (result.ok) toast.success('Testnachricht verschickt. Schau in dein Postfach.'); + else toast.error(result.error); + } + + if (kind === 'run') { + const result = await runRemindersNow(); + if (!result.ok) { + toast.error(result.error); + } else if (result.data.sent === 0 && result.data.failures.length === 0) { + toast.info('Nichts zu tun – gerade steht keine Erinnerung an.'); + } else { + toast.success( + `${result.data.sent} Erinnerung(en) verschickt, ${result.data.failures.length} fehlgeschlagen.`, + ); + } + } + + setBusy(null); + }); + } + + return ( +
+ + + + + Erinnerungen + + + Rückt der Termin einer Wunschliste näher und stehen noch Wünsche offen, geht eine + Nachricht an dich raus – {daysBefore} Tage vorher, höchstens einmal pro Woche je Liste. + + + + {configured ? ( +
+ + + +
+ ) : ( +
+

+ Es ist kein Mailversand 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 `}
+              
+
+ )} +
+
+ + + + Wunschlisten mit Termin + + Nur Listen mit Termin können erinnert werden. Ist alles reserviert, bleibt es still. + + + + {lists.length === 0 ? ( +

+ Keine Wunschliste hat bisher einen Termin. +

+ ) : ( +
    + {lists.map((list) => ( +
  • +
    +

    {list.title}

    +

    + {list.eventDate ? formatDate(list.eventDate) : 'ohne Termin'} + {list.lastReminderAt + ? ` · zuletzt erinnert am ${formatDate(list.lastReminderAt)}` + : ''} +

    +
    + + {list.openItemCount > 0 ? ( + {list.openItemCount} offen + ) : ( + + Alles vergeben + + )} + + {list.daysLeft !== null && list.daysLeft >= 0 ? ( + + {list.daysLeft === 0 ? 'heute' : `in ${list.daysLeft} Tagen`} + + ) : ( + vorbei + )} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx index c74f3e8..12c229a 100644 --- a/src/components/verwaltung/verwaltung-nav.tsx +++ b/src/components/verwaltung/verwaltung-nav.tsx @@ -10,6 +10,7 @@ const tabs = [ { href: '/verwaltung/themen', label: 'Themen', adminOnly: false }, { href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false }, { href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true }, + { href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true }, { href: '/verwaltung/daten', label: 'Daten', adminOnly: true }, ] as const; diff --git a/src/db/schema/wishlist.ts b/src/db/schema/wishlist.ts index 781282e..e5091ec 100644 --- a/src/db/schema/wishlist.ts +++ b/src/db/schema/wishlist.ts @@ -29,6 +29,8 @@ export const wishlist = sqliteTable( surpriseMode: integer('surprise_mode', { mode: 'boolean' }).notNull().default(false), /** Message shown to the gift givers, e.g. "Bitte keine Doppelungen". */ note: text('note'), + /** When the owner was last reminded - keeps a nag from becoming a flood. */ + lastReminderAt: integer('last_reminder_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, diff --git a/src/env.ts b/src/env.ts index ffb1f45..ac17f83 100644 --- a/src/env.ts +++ b/src/env.ts @@ -27,8 +27,26 @@ const schema = z.object({ /** Upload size limit per file, in megabytes. */ MAX_UPLOAD_MB: z.coerce.number().int().positive().max(200).default(25), - /** Optional, phase 2: Rebrickable set import. Import stays disabled without it. */ + /** Optional: Rebrickable set import. Import stays disabled without it. */ REBRICKABLE_API_KEY: z.string().min(1).optional(), + + /** + * Optional: outgoing mail for wishlist reminders. Without SMTP_HOST the + * whole reminder feature stays switched off - nothing is scheduled, nothing + * is sent. + */ + SMTP_HOST: z.string().min(1).optional(), + SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587), + SMTP_SECURE: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + SMTP_USER: z.string().min(1).optional(), + SMTP_PASSWORD: z.string().min(1).optional(), + SMTP_FROM: z.string().min(1).optional(), + + /** How many days before the event a reminder goes out. */ + REMINDER_DAYS_BEFORE: z.coerce.number().int().min(1).max(90).default(14), }); /** @@ -82,6 +100,9 @@ export const env = { MAX_UPLOAD_BYTES: raw.MAX_UPLOAD_MB * 1024 * 1024, isProduction: raw.NODE_ENV === 'production', + + /** Reminders need a host and a sender; everything else has a default. */ + isMailConfigured: Boolean(raw.SMTP_HOST && raw.SMTP_FROM), } as const; export type Env = typeof env; diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..9e5332a --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,15 @@ +/** + * Runs once when the server starts. + * + * Next calls this before handling the first request, which is the one place a + * long-lived background task can be started from. + */ +export async function register() { + // Only in the Node runtime - the edge runtime has no timers that survive, + // and the dev server would start a second scheduler on every reload. + if (process.env.NEXT_RUNTIME !== 'nodejs') return; + if (process.env.NODE_ENV !== 'production') return; + + const { startReminderScheduler } = await import('@/lib/reminder-job'); + startReminderScheduler(); +} diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts new file mode 100644 index 0000000..553e623 --- /dev/null +++ b/src/lib/mailer.ts @@ -0,0 +1,77 @@ +import 'server-only'; +import nodemailer, { type Transporter } from 'nodemailer'; +import { env } from '@/env'; + +/** + * Outgoing mail. + * + * Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and + * nothing is sent. The app never depends on mail working. + */ + +let transporter: Transporter | null = null; + +function getTransporter(): Transporter | null { + if (!env.isMailConfigured) return null; + if (transporter) return transporter; + + transporter = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: env.SMTP_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, + }); + + return transporter; +} + +export type MailResult = { ok: true } | { ok: false; error: string }; + +export async function sendMail(input: { + to: string; + subject: string; + text: string; +}): Promise { + const transport = getTransporter(); + if (!transport) { + return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; + } + + try { + await transport.sendMail({ + from: env.SMTP_FROM, + to: input.to, + subject: input.subject, + text: input.text, + }); + return { ok: true }; + } catch (error) { + console.error('[mail] Versand fehlgeschlagen', error); + return { + ok: false, + error: error instanceof Error ? error.message : 'Der Versand ist fehlgeschlagen.', + }; + } +} + +/** Checks the connection without sending anything. */ +export async function verifyMailConnection(): Promise { + const transport = getTransporter(); + if (!transport) { + return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; + } + + try { + await transport.verify(); + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Der Server antwortet nicht.', + }; + } +} diff --git a/src/lib/reminder-job.ts b/src/lib/reminder-job.ts new file mode 100644 index 0000000..b105778 --- /dev/null +++ b/src/lib/reminder-job.ts @@ -0,0 +1,141 @@ +import 'server-only'; +import { eq, sql } from 'drizzle-orm'; +import { db } from '@/db'; +import { user, wishlist } from '@/db/schema'; +import { env } from '@/env'; +import { sendMail } from './mailer'; +import { + reminderSubject, + reminderText, + shouldRemind, + type RemindableWishlist, +} from './reminders'; + +/** + * Sends the due wishlist reminders. + * + * Run from the hourly scheduler and from the "jetzt prüfen" button. Sending is + * recorded on the list, so a second run within the week stays quiet. + */ + +export type ReminderRun = { + checked: number; + sent: number; + skipped: number; + failures: Array<{ title: string; error: string }>; +}; + +type Candidate = RemindableWishlist & { + ownerEmail: string; + ownerName: string; + shareToken: string; + isPublic: boolean; +}; + +function loadCandidates(): Candidate[] { + const openItems = sql`( + select count(*) from wishlist_item wi + where wi.wishlist_id = ${wishlist.id} + and wi.reserved_at is null + and wi.fulfilled_at is null + )`.mapWith(Number); + + return db + .select({ + id: wishlist.id, + title: wishlist.title, + eventDate: wishlist.eventDate, + lastReminderAt: wishlist.lastReminderAt, + shareToken: wishlist.shareToken, + isPublic: wishlist.isPublic, + ownerEmail: user.email, + ownerName: user.name, + openItemCount: openItems, + }) + .from(wishlist) + .innerJoin(user, eq(user.id, wishlist.ownerUserId)) + .all(); +} + +export async function runReminderJob(now = new Date()): Promise { + const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] }; + if (!env.isMailConfigured) return result; + + const candidates = loadCandidates(); + result.checked = candidates.length; + + for (const candidate of candidates) { + const decision = shouldRemind(candidate, { + now, + daysBefore: env.REMINDER_DAYS_BEFORE, + }); + + if (!decision.remind) { + result.skipped += 1; + continue; + } + + const mail = await sendMail({ + to: candidate.ownerEmail, + subject: reminderSubject(candidate, decision.daysLeft), + text: reminderText({ + title: candidate.title, + daysLeft: decision.daysLeft, + openItemCount: candidate.openItemCount, + shareUrl: candidate.isPublic + ? `${env.BASE_URL}/w/${candidate.shareToken}` + : '(Teilen ist für diese Liste ausgeschaltet)', + appUrl: `${env.BASE_URL}/wunschlisten/${candidate.id}`, + }), + }); + + if (!mail.ok) { + result.failures.push({ title: candidate.title, error: mail.error }); + continue; + } + + // Only after a successful send - a failed attempt must be retried. + db.update(wishlist) + .set({ lastReminderAt: now }) + .where(eq(wishlist.id, candidate.id)) + .run(); + result.sent += 1; + } + + return result; +} + +const HOUR_MS = 60 * 60 * 1000; + +/** + * Starts the hourly check. + * + * Hourly rather than daily so a restart cannot skip a day, and the per-list + * 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; + } + + console.log( + `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`, + ); + + const tick = () => { + void runReminderJob() + .then((run) => { + if (run.sent > 0 || run.failures.length > 0) { + console.log( + `[reminders] ${run.sent} verschickt, ${run.failures.length} fehlgeschlagen.`, + ); + } + }) + .catch((error) => console.error('[reminders] Durchlauf fehlgeschlagen', error)); + }; + + // A short delay so the first check does not compete with the app's start-up. + setTimeout(tick, 60_000).unref?.(); + setInterval(tick, HOUR_MS).unref?.(); +} diff --git a/src/lib/reminders.ts b/src/lib/reminders.ts new file mode 100644 index 0000000..2ceaa04 --- /dev/null +++ b/src/lib/reminders.ts @@ -0,0 +1,91 @@ +/** + * When to remind someone about a wishlist. + * + * Pure rules, no database and no mail: a reminder that arrives at the wrong + * moment is worse than none, so this is the part that gets tested. + */ + +export type RemindableWishlist = { + id: string; + title: string; + eventDate: Date | null; + openItemCount: number; + lastReminderAt: Date | null; +}; + +export type ReminderDecision = + | { remind: true; daysLeft: number } + | { remind: false; reason: 'no-date' | 'too-early' | 'past' | 'nothing-open' | 'already-sent' }; + +/** Whole days between two points, ignoring the time of day. */ +export function daysUntil(target: Date, now: Date): number { + const startOfTarget = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()); + const startOfNow = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.round((startOfTarget - startOfNow) / 86_400_000); +} + +/** + * A reminder goes out when the date is close, something is still unclaimed, + * and we have not just written about it. + */ +export function shouldRemind( + list: RemindableWishlist, + options: { now: Date; daysBefore: number; minDaysBetween?: number }, +): ReminderDecision { + const minDaysBetween = options.minDaysBetween ?? 7; + + if (!list.eventDate) return { remind: false, reason: 'no-date' }; + + const daysLeft = daysUntil(list.eventDate, options.now); + // The day itself still counts - a morning reminder can save the afternoon. + if (daysLeft < 0) return { remind: false, reason: 'past' }; + if (daysLeft > options.daysBefore) return { remind: false, reason: 'too-early' }; + + // Nothing open means everything is taken care of; silence is the right answer. + if (list.openItemCount <= 0) return { remind: false, reason: 'nothing-open' }; + + if (list.lastReminderAt) { + // daysUntil(now, lastReminderAt) reads as "days from the last reminder + // until now", which is exactly the age of that reminder. + const daysSinceLast = daysUntil(options.now, list.lastReminderAt); + if (daysSinceLast < minDaysBetween) return { remind: false, reason: 'already-sent' }; + } + + return { remind: true, daysLeft }; +} + +/** German subject line, because that is what lands in the inbox. */ +export function reminderSubject(list: RemindableWishlist, daysLeft: number): string { + if (daysLeft === 0) return `Heute: ${list.title}`; + if (daysLeft === 1) return `Morgen: ${list.title}`; + return `In ${daysLeft} Tagen: ${list.title}`; +} + +export function reminderText(input: { + title: string; + daysLeft: number; + openItemCount: number; + shareUrl: string; + appUrl: string; +}): string { + const when = + input.daysLeft === 0 + ? 'ist heute' + : input.daysLeft === 1 + ? 'ist morgen' + : `ist in ${input.daysLeft} Tagen`; + + const open = + input.openItemCount === 1 + ? 'steht noch 1 Wunsch offen' + : `stehen noch ${input.openItemCount} Wünsche offen`; + + return [ + `Der Termin für „${input.title}" ${when}, und auf der Liste ${open}.`, + '', + `Liste ansehen: ${input.appUrl}`, + `Zum Teilen: ${input.shareUrl}`, + '', + 'Diese Erinnerung kommt von deinem eigenen Bricktrack.', + ].join('\n'); +} diff --git a/tests/unit/reminders.test.ts b/tests/unit/reminders.test.ts new file mode 100644 index 0000000..c4eee05 --- /dev/null +++ b/tests/unit/reminders.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { + daysUntil, + reminderSubject, + reminderText, + shouldRemind, + type RemindableWishlist, +} from '@/lib/reminders'; + +const now = new Date('2026-12-10T09:00:00'); +const on = (iso: string) => new Date(`${iso}T00:00:00`); + +function list(overrides: Partial = {}): RemindableWishlist { + return { + id: 'w-1', + title: 'Weihnachten 2026', + eventDate: on('2026-12-24'), + openItemCount: 3, + lastReminderAt: null, + ...overrides, + }; +} + +describe('Tage bis zum Termin', () => { + it('zaehlt ganze Tage, unabhaengig von der Uhrzeit', () => { + expect(daysUntil(on('2026-12-24'), now)).toBe(14); + expect(daysUntil(on('2026-12-10'), now)).toBe(0); + expect(daysUntil(on('2026-12-09'), now)).toBe(-1); + }); +}); + +describe('Wann erinnert wird', () => { + it('erinnert, wenn der Termin im Fenster liegt und etwas offen ist', () => { + const decision = shouldRemind(list(), { now, daysBefore: 14 }); + + expect(decision).toEqual({ remind: true, daysLeft: 14 }); + }); + + it('schweigt, solange der Termin weit weg ist', () => { + const decision = shouldRemind(list({ eventDate: on('2027-06-01') }), { + now, + daysBefore: 14, + }); + + expect(decision).toEqual({ remind: false, reason: 'too-early' }); + }); + + it('erinnert am Tag selbst noch', () => { + const decision = shouldRemind(list({ eventDate: on('2026-12-10') }), { + now, + daysBefore: 14, + }); + + expect(decision).toEqual({ remind: true, daysLeft: 0 }); + }); + + it('schweigt, wenn der Termin vorbei ist', () => { + const decision = shouldRemind(list({ eventDate: on('2026-12-01') }), { + now, + daysBefore: 14, + }); + + expect(decision).toEqual({ remind: false, reason: 'past' }); + }); + + it('schweigt ohne Termin', () => { + const decision = shouldRemind(list({ eventDate: null }), { now, daysBefore: 14 }); + + expect(decision).toEqual({ remind: false, reason: 'no-date' }); + }); + + it('schweigt, wenn alles reserviert ist', () => { + const decision = shouldRemind(list({ openItemCount: 0 }), { now, daysBefore: 14 }); + + expect(decision).toEqual({ remind: false, reason: 'nothing-open' }); + }); + + it('wiederholt sich nicht innerhalb einer Woche', () => { + const decision = shouldRemind(list({ lastReminderAt: on('2026-12-08') }), { + now, + daysBefore: 14, + }); + + expect(decision).toEqual({ remind: false, reason: 'already-sent' }); + }); + + it('erinnert wieder, wenn die Sperrfrist vorbei ist', () => { + const decision = shouldRemind(list({ lastReminderAt: on('2026-12-01') }), { + now, + daysBefore: 14, + }); + + expect(decision).toMatchObject({ remind: true }); + }); +}); + +describe('Betreff und Text', () => { + it('formuliert die Naehe im Betreff', () => { + expect(reminderSubject(list(), 0)).toBe('Heute: Weihnachten 2026'); + expect(reminderSubject(list(), 1)).toBe('Morgen: Weihnachten 2026'); + expect(reminderSubject(list(), 5)).toBe('In 5 Tagen: Weihnachten 2026'); + }); + + it('nennt Termin, offene Wuensche und beide Links', () => { + const text = reminderText({ + title: 'Weihnachten 2026', + daysLeft: 3, + openItemCount: 2, + shareUrl: 'https://bricktrack.example.de/w/abc', + appUrl: 'https://bricktrack.example.de/wunschlisten/w-1', + }); + + expect(text).toContain('ist in 3 Tagen'); + expect(text).toContain('stehen noch 2 Wünsche offen'); + expect(text).toContain('https://bricktrack.example.de/w/abc'); + expect(text).toContain('https://bricktrack.example.de/wunschlisten/w-1'); + }); + + it('bleibt im Singular, wenn nur ein Wunsch offen ist', () => { + const text = reminderText({ + title: 'Geburtstag', + daysLeft: 1, + openItemCount: 1, + shareUrl: 'x', + appUrl: 'y', + }); + + expect(text).toContain('ist morgen'); + expect(text).toContain('steht noch 1 Wunsch offen'); + }); +});