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
This commit is contained in:
Lucas Berger
2026-06-09 20:47:44 -04:00
parent 80bbdc1735
commit 73fcdaf075
6 changed files with 1060 additions and 18 deletions
@@ -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`);
@@ -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": {}
}
}
@@ -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
}
]
}
+30
View File
@@ -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.
*