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'); + }); +});