From 73fcdaf0754bc2d0ab05540020e3115d165c1110 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 20:47:44 -0400 Subject: [PATCH] feat(05-01): add push_subscriptions table + calendar_events.title column; VAPID env wiring - schema.ts: new pushSubscriptions mysqlTable (user_id FK cascade, endpoint unique, p256dh, auth) - schema.ts: add nullable title varchar(500) to calendarEvents after rawVevent (D-02/NOTIF-01) - 0003_same_xavin.sql: CREATE TABLE push_subscriptions + ALTER calendar_events ADD title - migration applied to dev DB via db:generate + db:migrate (NOT db:push per anti-pattern) - docker-compose.yml: inject VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT into api environment block - .env.example: document all three VAPID vars with placeholders + generation instructions --- .env.example | 51 +- .../api/src/db/migrations/0003_same_xavin.sql | 15 + .../src/db/migrations/meta/0003_snapshot.json | 969 ++++++++++++++++++ apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 30 + docker-compose.yml | 6 + 6 files changed, 1060 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/db/migrations/0003_same_xavin.sql create mode 100644 apps/api/src/db/migrations/meta/0003_snapshot.json diff --git a/.env.example b/.env.example index 0a2d291..2e0a027 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,37 @@ -# Database -DB_HOST=mariadb -DB_PORT=3306 -DB_USER=familysync -DB_PASSWORD= -DB_NAME=familysync -DB_ROOT_PASSWORD= +# FamilySync — environment variable reference +# Copy to .env and fill in real values. .env is gitignored and must never be committed. +# +# Deployment: these vars are injected into the Docker Compose `api` service via +# the `environment:` block in docker-compose.yml. All values are resolved at +# container start time from the host .env file. -# OIDC (Authelia) — fill in after registering the client -OIDC_AUTH_SECRET= -OIDC_ISSUER= +# ── MariaDB ─────────────────────────────────────────────────────────────────── +DB_PASSWORD=change_me_strong_password +DB_ROOT_PASSWORD=change_me_root_password + +# ── OIDC / Authelia ─────────────────────────────────────────────────────────── +# Authorization code + PKCE flow (client_secret_basic). See CLAUDE.md §Authelia. +OIDC_AUTH_SECRET=change_me_32_char_secret_minimum +OIDC_ISSUER=https://auth.example.com OIDC_CLIENT_ID=familysync -OIDC_CLIENT_SECRET= -OIDC_REDIRECT_URI=https://familysync.yourdomain.com/callback -OIDC_AUTH_EXTERNAL_URL=https://familysync.yourdomain.com +OIDC_CLIENT_SECRET=change_me_client_secret +OIDC_REDIRECT_URI=https://familysync.example.com/callback +OIDC_AUTH_EXTERNAL_URL=https://auth.example.com +# Scopes granted by the Authelia client definition (must include offline_access for +# refresh-token session persistence). +OIDC_SCOPES=openid profile email offline_access -# CalDAV broker encryption key — generate with: -# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -APP_PASSWORD_ENCRYPTION_KEY= +# ── App-password encryption ─────────────────────────────────────────────────── +# 32-byte hex key used to AES-256-GCM encrypt Fastmail app passwords at rest. +# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +APP_PASSWORD_ENCRYPTION_KEY=change_me_64_hex_chars -# DEV ONLY — injects a fixed dev user, skips Authelia. Hard-disabled when NODE_ENV=production. NEVER set in prod. -# DEV_AUTH_BYPASS=true +# ── VAPID — Web Push notifications (Phase 5) ───────────────────────────────── +# Generate a keypair (one-time, per deployment): +# npx web-push generate-vapid-keys --json +# VAPID_PUBLIC_KEY is served to the PWA at GET /api/push/vapid-public-key (no secret). +# VAPID_PRIVATE_KEY signs push messages — treat as a secret; never commit it. +# VAPID_SUBJECT is a contact URL (mailto: or https:) sent to push services. +VAPID_PUBLIC_KEY=replace_with_url_safe_base64_public_key +VAPID_PRIVATE_KEY=replace_with_url_safe_base64_private_key +VAPID_SUBJECT=mailto:admin@familysync.example.com diff --git a/apps/api/src/db/migrations/0003_same_xavin.sql b/apps/api/src/db/migrations/0003_same_xavin.sql new file mode 100644 index 0000000..15a91e9 --- /dev/null +++ b/apps/api/src/db/migrations/0003_same_xavin.sql @@ -0,0 +1,15 @@ +CREATE TABLE `push_subscriptions` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `endpoint` text NOT NULL, + `p256dh` text NOT NULL, + `auth` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `push_subscriptions_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_push_endpoint` UNIQUE(`endpoint`) +); +--> statement-breakpoint +ALTER TABLE `calendar_events` ADD `title` varchar(500);--> statement-breakpoint +ALTER TABLE `push_subscriptions` ADD CONSTRAINT `push_subscriptions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_push_subscriptions_user_id` ON `push_subscriptions` (`user_id`); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0003_snapshot.json b/apps/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..bc249d9 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,969 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "091236ec-ff3a-4982-8e9d-022a398f24f9", + "prevId": "3a73e735-2d98-4ddb-b68a-fc1a3e6a81e8", + "tables": { + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "object_url": { + "name": "object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_vevent": { + "name": "raw_vevent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_utc": { + "name": "dtstart_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_date": { + "name": "dtstart_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_rrule": { + "name": "has_rrule", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_calendar_events_dtstart_utc": { + "name": "idx_calendar_events_dtstart_utc", + "columns": [ + "dtstart_utc" + ], + "isUnique": false + }, + "idx_calendar_events_dtstart_date": { + "name": "idx_calendar_events_dtstart_date", + "columns": [ + "dtstart_date" + ], + "isUnique": false + }, + "idx_calendar_events_has_rrule": { + "name": "idx_calendar_events_has_rrule", + "columns": [ + "has_rrule" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_calendar_id_calendars_id_fk": { + "name": "calendar_events_calendar_id_calendars_id_fk", + "tableFrom": "calendar_events", + "tableTo": "calendars", + "columnsFrom": [ + "calendar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_events_id": { + "name": "calendar_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_uid": { + "name": "uniq_calendar_uid", + "columns": [ + "calendar_id", + "uid" + ] + } + }, + "checkConstraint": {} + }, + "calendar_outbox": { + "name": "calendar_outbox", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "enum('create','update','delete')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','done','failed','dead')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_url": { + "name": "calendar_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_object_url": { + "name": "calendar_object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_outbox_user_status": { + "name": "idx_outbox_user_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_outbox_next_attempt": { + "name": "idx_outbox_next_attempt", + "columns": [ + "next_attempt_at", + "status" + ], + "isUnique": false + }, + "idx_outbox_uid": { + "name": "idx_outbox_uid", + "columns": [ + "uid" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_outbox_user_id_users_id_fk": { + "name": "calendar_outbox_user_id_users_id_fk", + "tableFrom": "calendar_outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_outbox_id": { + "name": "calendar_outbox_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "calendars": { + "name": "calendars", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ctag": { + "name": "ctag", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_token": { + "name": "sync_token", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_calendars_user_id": { + "name": "idx_calendars_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendars_user_id_users_id_fk": { + "name": "calendars_user_id_users_id_fk", + "tableFrom": "calendars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendars_id": { + "name": "calendars_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_user_url": { + "name": "uniq_calendar_user_url", + "columns": [ + "user_id", + "url" + ] + } + }, + "checkConstraint": {} + }, + "list_items": { + "name": "list_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rank": { + "name": "rank", + "type": "varchar(255) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_list_items_list_id_rank": { + "name": "idx_list_items_list_id_rank", + "columns": [ + "list_id", + "rank" + ], + "isUnique": false + }, + "idx_list_items_list_id_checked": { + "name": "idx_list_items_list_id_checked", + "columns": [ + "list_id", + "checked" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_items_list_id_lists_id_fk": { + "name": "list_items_list_id_lists_id_fk", + "tableFrom": "list_items", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_items_id": { + "name": "list_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "list_shares": { + "name": "list_shares", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "idx_list_shares_user_id": { + "name": "idx_list_shares_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_shares_list_id_lists_id_fk": { + "name": "list_shares_list_id_lists_id_fk", + "tableFrom": "list_shares", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_shares_user_id_users_id_fk": { + "name": "list_shares_user_id_users_id_fk", + "tableFrom": "list_shares", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_shares_id": { + "name": "list_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_list_share": { + "name": "uniq_list_share", + "columns": [ + "list_id", + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_id": { + "name": "owner_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_lists_owner_id": { + "name": "idx_lists_owner_id", + "columns": [ + "owner_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "lists_owner_id_users_id_fk": { + "name": "lists_owner_id_users_id_fk", + "tableFrom": "lists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lists_id": { + "name": "lists_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "member_credentials": { + "name": "member_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fastmail_email": { + "name": "fastmail_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_member_credentials_user_id": { + "name": "idx_member_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_credentials_user_id_users_id_fk": { + "name": "member_credentials_user_id_users_id_fk", + "tableFrom": "member_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_credentials_id": { + "name": "member_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_push_subscriptions_user_id": { + "name": "idx_push_subscriptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_subscriptions_id": { + "name": "push_subscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_push_endpoint": { + "name": "uniq_push_endpoint", + "columns": [ + "endpoint" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "oidc_iss": { + "name": "oidc_iss", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_oidc_identity": { + "name": "uniq_oidc_identity", + "columns": [ + "oidc_iss", + "oidc_sub" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 6598839..fcef7f6 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781029240528, "tag": "0002_yielding_mattie_franklin", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1781052360194, + "tag": "0003_same_xavin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 6ebd148..c923bac 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -119,6 +119,9 @@ export const calendarEvents = mysqlTable( etag: varchar('etag', { length: 256 }), objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated from obj.url by sync.ts (D-08) rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string for ical.js + // Readable event title extracted from VEVENT SUMMARY by sync.ts (D-02/NOTIF-01). + // Nullable: populated by Phase 5 sync update; pre-existing rows remain NULL until resynced. + title: varchar('title', { length: 500 }), dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed allDay: boolean('all_day').default(false).notNull(), @@ -223,6 +226,33 @@ export const listShares = mysqlTable( ], ) +/** + * Push notification subscriptions (Phase 5 — Web Push). + * + * Member-count-agnostic (D-18): one row per browser push subscription endpoint. + * endpoint is globally unique — a single device endpoint belongs to exactly one user. + * Cascade delete on user removal keeps subscriptions clean without orphan cleanup jobs. + */ +export const pushSubscriptions = mysqlTable( + 'push_subscriptions', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + endpoint: text('endpoint').notNull(), + p256dh: text('p256dh').notNull(), + auth: varchar('auth', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + // One endpoint is globally unique — a single device maps to exactly one subscription row + unique('uniq_push_endpoint').on(t.endpoint), + index('idx_push_subscriptions_user_id').on(t.userId), + ], +) + /** * Items within a list. * diff --git a/docker-compose.yml b/docker-compose.yml index ea139c6..83603e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,12 @@ services: # persistence (D-12/AUTH-02) and must also be allowed on the Authelia client. OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email offline_access} APP_PASSWORD_ENCRYPTION_KEY: ${APP_PASSWORD_ENCRYPTION_KEY:-} + # VAPID keys for Web Push notifications (Phase 5). + # Generate with: npx web-push generate-vapid-keys --json + # Private key must only live in the gitignored .env — never commit it. + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_SUBJECT: ${VAPID_SUBJECT} depends_on: mariadb: condition: service_healthy