diff --git a/src/lib/components/ActivitySheet.svelte b/src/lib/components/ActivitySheet.svelte
index 64078fa..c72a5e5 100644
--- a/src/lib/components/ActivitySheet.svelte
+++ b/src/lib/components/ActivitySheet.svelte
@@ -10,7 +10,7 @@
interface Notification {
id: string;
- type: 'reaction' | 'comment';
+ type: 'reaction' | 'comment' | 'mention';
clipId: string;
emoji: string | null;
commentPreview: string | null;
@@ -129,6 +129,9 @@
if (n.type === 'reaction') {
return `reacted ${n.emoji} to your clip`;
}
+ if (n.type === 'mention') {
+ return 'mentioned you';
+ }
return 'commented on your clip';
}
@@ -183,7 +186,7 @@
{n.actorUsername}
{description(n)}
- {#if n.type === 'comment' && n.commentPreview}
+ {#if (n.type === 'comment' || n.type === 'mention') && n.commentPreview}
{/if}
Daily reminder
diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts
index 07a8c1a..8eaf6be 100644
--- a/src/lib/server/db/index.ts
+++ b/src/lib/server/db/index.ts
@@ -3,7 +3,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import * as schema from './schema';
import { resolve } from 'path';
-import { copyFileSync, existsSync, mkdirSync } from 'fs';
+import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
+import { createHash } from 'node:crypto';
import { createLogger } from '$lib/server/logger';
const dataDir = resolve(process.env.DATA_DIR || 'data');
@@ -44,8 +45,79 @@ if (existsSync(dbPath)) {
}
/* eslint-enable security/detect-non-literal-fs-filename */
+function isAlreadyAppliedError(err: unknown): boolean {
+ const msg = err instanceof Error ? err.message : '';
+ return (
+ msg.includes('duplicate column name') ||
+ msg.includes('no such column') ||
+ msg.includes('already exists')
+ );
+}
+
+function execIdempotent(sql: string) {
+ for (const stmt of sql.split('--> statement-breakpoint')) {
+ const trimmed = stmt.trim();
+ if (!trimmed) continue;
+ try {
+ sqlite.exec(trimmed);
+ } catch (err: unknown) {
+ if (isAlreadyAppliedError(err)) {
+ log.warn({ statement: trimmed.slice(0, 80) }, 'Statement already applied — skipping');
+ } else {
+ throw err;
+ }
+ }
+ }
+}
+
+/* eslint-disable security/detect-non-literal-fs-filename */
+function recoverPendingMigrations(folder: string) {
+ const journalPath = resolve(folder, 'meta/_journal.json');
+ const journal = JSON.parse(readFileSync(journalPath, 'utf-8')) as {
+ entries: { when: number; tag: string }[];
+ };
+
+ const lastRow = sqlite
+ .prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1')
+ .get() as { created_at: number } | undefined;
+ const lastAppliedAt = lastRow?.created_at ?? 0;
+
+ for (const entry of journal.entries) {
+ if (entry.when <= lastAppliedAt) continue;
+
+ const sqlFile = readFileSync(resolve(folder, `${entry.tag}.sql`), 'utf-8');
+ const hash = createHash('sha256').update(sqlFile).digest('hex');
+
+ execIdempotent(sqlFile);
+
+ sqlite
+ .prepare('INSERT INTO __drizzle_migrations ("hash", "created_at") VALUES (?, ?)')
+ .run(hash, entry.when);
+ log.info({ tag: entry.tag }, 'Migration recovered');
+ }
+}
+/* eslint-enable security/detect-non-literal-fs-filename */
+
const startMs = Date.now();
-migrate(db, { migrationsFolder });
+try {
+ migrate(db, { migrationsFolder });
+} catch (err) {
+ // If a migration file was regenerated after being applied (e.g. schema change + drizzle-kit generate),
+ // the journal timestamp changes but the DB already has the schema changes from the old version.
+ // Drizzle sees the new timestamp as unapplied and tries to re-run it, hitting "duplicate column" etc.
+ // Recovery: run each pending statement idempotently and record the migration.
+ const cause = err instanceof Error ? (err as { cause?: Error }).cause : undefined;
+ const isSchemaConflict =
+ cause instanceof Error &&
+ 'code' in cause &&
+ cause.code === 'SQLITE_ERROR' &&
+ isAlreadyAppliedError(cause);
+
+ if (!isSchemaConflict) throw err;
+
+ log.warn({ error: cause?.message }, 'Migration conflict — schema already matches, recovering');
+ recoverPendingMigrations(migrationsFolder);
+}
// Backfill shortcut tokens for existing groups that don't have one
import { isNull, eq } from 'drizzle-orm';
diff --git a/src/lib/server/db/migrations/0020_concerned_toad_men.sql b/src/lib/server/db/migrations/0020_concerned_toad_men.sql
new file mode 100644
index 0000000..9251222
--- /dev/null
+++ b/src/lib/server/db/migrations/0020_concerned_toad_men.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `notification_preferences` ADD `mentions` integer DEFAULT true NOT NULL;--> statement-breakpoint
+ALTER TABLE `groups` DROP COLUMN `max_duration_seconds`;
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/0020_snapshot.json b/src/lib/server/db/migrations/meta/0020_snapshot.json
new file mode 100644
index 0000000..bb5d0c3
--- /dev/null
+++ b/src/lib/server/db/migrations/meta/0020_snapshot.json
@@ -0,0 +1,1211 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "9beecb53-a092-49a5-b7bf-caddbb66474c",
+ "prevId": "56623836-95ff-4543-9b4b-c6687d4d9d24",
+ "tables": {
+ "clips": {
+ "name": "clips",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "original_url": {
+ "name": "original_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "video_path": {
+ "name": "video_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'downloading'"
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'video'"
+ },
+ "audio_path": {
+ "name": "audio_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "artist": {
+ "name": "artist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "album_art": {
+ "name": "album_art",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spotify_url": {
+ "name": "spotify_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "apple_music_url": {
+ "name": "apple_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "youtube_music_url": {
+ "name": "youtube_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "clips_group_url": {
+ "name": "clips_group_url",
+ "columns": [
+ "group_id",
+ "original_url"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "clips_group_id_groups_id_fk": {
+ "name": "clips_group_id_groups_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clips_added_by_users_id_fk": {
+ "name": "clips_added_by_users_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_hearts": {
+ "name": "comment_hearts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_hearts_unique": {
+ "name": "comment_hearts_unique",
+ "columns": [
+ "comment_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_hearts_comment_id_comments_id_fk": {
+ "name": "comment_hearts_comment_id_comments_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_hearts_user_id_users_id_fk": {
+ "name": "comment_hearts_user_id_users_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_views": {
+ "name": "comment_views",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_views_unique": {
+ "name": "comment_views_unique",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_views_clip_id_clips_id_fk": {
+ "name": "comment_views_clip_id_clips_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_views_user_id_users_id_fk": {
+ "name": "comment_views_user_id_users_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "gif_url": {
+ "name": "gif_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "comments_clip_id_clips_id_fk": {
+ "name": "comments_clip_id_clips_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comments_user_id_users_id_fk": {
+ "name": "comments_user_id_users_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "favorites": {
+ "name": "favorites",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "favorites_clip_id_clips_id_fk": {
+ "name": "favorites_clip_id_clips_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "favorites_user_id_users_id_fk": {
+ "name": "favorites_user_id_users_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groups": {
+ "name": "groups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invite_code": {
+ "name": "invite_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retention_days": {
+ "name": "retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_storage_mb": {
+ "name": "max_storage_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_file_size_mb": {
+ "name": "max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 500
+ },
+ "accent_color": {
+ "name": "accent_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'coral'"
+ },
+ "download_provider": {
+ "name": "download_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform_filter_mode": {
+ "name": "platform_filter_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "platform_filter_list": {
+ "name": "platform_filter_list",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shortcut_token": {
+ "name": "shortcut_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shortcut_url": {
+ "name": "shortcut_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "groups_invite_code_unique": {
+ "name": "groups_invite_code_unique",
+ "columns": [
+ "invite_code"
+ ],
+ "isUnique": true
+ },
+ "groups_shortcut_token_unique": {
+ "name": "groups_shortcut_token_unique",
+ "columns": [
+ "shortcut_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "new_adds": {
+ "name": "new_adds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reactions": {
+ "name": "reactions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "comments": {
+ "name": "comments",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "mentions": {
+ "name": "mentions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "daily_reminder": {
+ "name": "daily_reminder",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_preferences_user_id_users_id_fk": {
+ "name": "notification_preferences_user_id_users_id_fk",
+ "tableFrom": "notification_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "comment_preview": {
+ "name": "comment_preview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "notifications_user_created": {
+ "name": "notifications_user_created",
+ "columns": [
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_clip_id_clips_id_fk": {
+ "name": "notifications_clip_id_clips_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_id_users_id_fk": {
+ "name": "notifications_actor_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscriptions": {
+ "name": "push_subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_p256dh": {
+ "name": "keys_p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_auth": {
+ "name": "keys_auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "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": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reactions": {
+ "name": "reactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "reactions_unique": {
+ "name": "reactions_unique",
+ "columns": [
+ "clip_id",
+ "user_id",
+ "emoji"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "reactions_clip_id_clips_id_fk": {
+ "name": "reactions_clip_id_clips_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "reactions_user_id_users_id_fk": {
+ "name": "reactions_user_id_users_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_preference": {
+ "name": "theme_preference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "auto_scroll": {
+ "name": "auto_scroll",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "muted_by_default": {
+ "name": "muted_by_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "avatar_path": {
+ "name": "avatar_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_phone_unique": {
+ "name": "users_phone_unique",
+ "columns": [
+ "phone"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "users_group_id_groups_id_fk": {
+ "name": "users_group_id_groups_id_fk",
+ "tableFrom": "users",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification_codes": {
+ "name": "verification_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "verification_codes_user_id_users_id_fk": {
+ "name": "verification_codes_user_id_users_id_fk",
+ "tableFrom": "verification_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "watched": {
+ "name": "watched",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "watch_percent": {
+ "name": "watch_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "watched_at": {
+ "name": "watched_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "watched_clip_user": {
+ "name": "watched_clip_user",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "watched_clip_id_clips_id_fk": {
+ "name": "watched_clip_id_clips_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "watched_user_id_users_id_fk": {
+ "name": "watched_user_id_users_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json
index 56490aa..ed74af1 100644
--- a/src/lib/server/db/migrations/meta/_journal.json
+++ b/src/lib/server/db/migrations/meta/_journal.json
@@ -141,6 +141,13 @@
"when": 1772318561461,
"tag": "0019_small_edwin_jarvis",
"breakpoints": true
+ },
+ {
+ "idx": 20,
+ "version": "6",
+ "when": 1772347099686,
+ "tag": "0020_concerned_toad_men",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 2bb2c00..1cbfe98 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -199,5 +199,6 @@ export const notificationPreferences = sqliteTable('notification_preferences', {
newAdds: integer('new_adds', { mode: 'boolean' }).notNull().default(true),
reactions: integer('reactions', { mode: 'boolean' }).notNull().default(true),
comments: integer('comments', { mode: 'boolean' }).notNull().default(true),
+ mentions: integer('mentions', { mode: 'boolean' }).notNull().default(true),
dailyReminder: integer('daily_reminder', { mode: 'boolean' }).notNull().default(false)
});
diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts
new file mode 100644
index 0000000..dfad705
--- /dev/null
+++ b/src/lib/server/mentions.ts
@@ -0,0 +1,80 @@
+import { db } from '$lib/server/db';
+import { users, notificationPreferences, notifications } from '$lib/server/db/schema';
+import { eq, and, isNull } from 'drizzle-orm';
+import { sendNotification } from '$lib/server/push';
+import { v4 as uuid } from 'uuid';
+import { createLogger } from '$lib/server/logger';
+
+const log = createLogger('mentions');
+
+/**
+ * Extract @username mentions from text.
+ * Returns an array of unique lowercase usernames.
+ */
+export function extractMentions(text: string): string[] {
+ const regex = /@(\w+)/g;
+ const mentions = new Set();
+ let match;
+ while ((match = regex.exec(text)) !== null) {
+ mentions.add(match[1].toLowerCase());
+ }
+ return [...mentions];
+}
+
+/**
+ * Send mention notifications to all mentioned users.
+ * Checks preferences, skips self-mentions and excluded users, sends push + in-app.
+ */
+export async function notifyMentions(opts: {
+ mentionedUsernames: string[];
+ actorId: string;
+ actorUsername: string;
+ clipId: string;
+ groupId: string;
+ commentPreview: string;
+ excludeUserIds?: string[];
+}): Promise {
+ if (opts.mentionedUsernames.length === 0) return;
+
+ const excludeSet = new Set(opts.excludeUserIds ?? []);
+ excludeSet.add(opts.actorId); // never notify self
+
+ // Look up all active members in the group
+ const groupMembers = await db.query.users.findMany({
+ where: and(eq(users.groupId, opts.groupId), isNull(users.removedAt))
+ });
+
+ // Match mentioned usernames to actual users (case-insensitive)
+ const mentionedSet = new Set(opts.mentionedUsernames);
+ const matchedUsers = groupMembers.filter(
+ (m) => mentionedSet.has(m.username.toLowerCase()) && !excludeSet.has(m.id)
+ );
+
+ for (const recipient of matchedUsers) {
+ // Check notification preferences
+ const prefs = await db.query.notificationPreferences.findFirst({
+ where: eq(notificationPreferences.userId, recipient.id)
+ });
+
+ // Send push if preference allows (default true if no prefs row)
+ if (!prefs || prefs.mentions) {
+ sendNotification(recipient.id, {
+ title: `${opts.actorUsername} mentioned you`,
+ body: opts.commentPreview,
+ url: '/',
+ tag: `mention-${opts.clipId}`
+ }).catch((err) => log.error({ err }, 'mention push notification failed'));
+ }
+
+ // Insert in-app notification
+ await db.insert(notifications).values({
+ id: uuid(),
+ userId: recipient.id,
+ type: 'mention',
+ clipId: opts.clipId,
+ actorId: opts.actorId,
+ commentPreview: opts.commentPreview,
+ createdAt: new Date()
+ });
+ }
+}
diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts
index 8979675..3cd8ec0 100644
--- a/src/lib/server/push.ts
+++ b/src/lib/server/push.ts
@@ -95,7 +95,7 @@ export async function notifyNewClip(clipId: string): Promise {
export async function sendGroupNotification(
groupId: string,
payload: NotificationPayload,
- preferenceKey: 'newAdds' | 'reactions' | 'comments' | 'dailyReminder',
+ preferenceKey: 'newAdds' | 'reactions' | 'comments' | 'mentions' | 'dailyReminder',
excludeUserId?: string
): Promise {
const groupUsers = await db.query.users.findMany({
diff --git a/src/lib/settingsApi.ts b/src/lib/settingsApi.ts
index 123a533..bfa2aef 100644
--- a/src/lib/settingsApi.ts
+++ b/src/lib/settingsApi.ts
@@ -4,6 +4,7 @@ export type NotificationPrefs = {
newAdds: boolean;
reactions: boolean;
comments: boolean;
+ mentions: boolean;
dailyReminder: boolean;
};
@@ -66,7 +67,7 @@ export async function saveAccentColor(key: AccentColorKey): Promise {
export async function fetchNotificationPrefs(): Promise {
const res = await fetch('/api/notifications/preferences');
if (res.ok) return res.json();
- return { newAdds: true, reactions: true, comments: true, dailyReminder: false };
+ return { newAdds: true, reactions: true, comments: true, mentions: true, dailyReminder: false };
}
export async function updateNotificationPref(key: string, value: boolean): Promise {
diff --git a/src/lib/stores/members.ts b/src/lib/stores/members.ts
new file mode 100644
index 0000000..56429c3
--- /dev/null
+++ b/src/lib/stores/members.ts
@@ -0,0 +1,18 @@
+import { writable } from 'svelte/store';
+import type { GroupMember } from '$lib/types';
+
+export const groupMembers = writable([]);
+
+export async function fetchGroupMembers(): Promise {
+ try {
+ const res = await fetch('/api/group/members');
+ if (res.ok) {
+ const data: { id: string; username: string; avatarPath: string | null }[] = await res.json();
+ groupMembers.set(
+ data.map((m) => ({ id: m.id, username: m.username, avatarPath: m.avatarPath }))
+ );
+ }
+ } catch {
+ // silently fail — members list is optional for core functionality
+ }
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 68ffcad..5460e2e 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -27,6 +27,12 @@ export interface FeedClip {
createdAt: string;
}
+export interface GroupMember {
+ id: string;
+ username: string;
+ avatarPath: string | null;
+}
+
export interface ClipSummary {
id: string;
title: string | null;
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index a82d1ce..89bf61a 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -9,6 +9,7 @@
import { globalMuted } from '$lib/stores/mute';
import { initAudioContext } from '$lib/audio/normalizer';
import { feedUiHidden } from '$lib/stores/uiHidden';
+ import { fetchGroupMembers } from '$lib/stores/members';
import ActivitySheet from '$lib/components/ActivitySheet.svelte';
import BellIcon from 'phosphor-svelte/lib/BellIcon';
import HouseIcon from 'phosphor-svelte/lib/HouseIcon';
@@ -26,6 +27,7 @@
onMount(() => {
startPolling();
+ fetchGroupMembers();
// Initialize mute state from user preference
const user = page.data?.user;
if (user) {
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte
index 17d4fb3..c19f815 100644
--- a/src/routes/(app)/settings/+page.svelte
+++ b/src/routes/(app)/settings/+page.svelte
@@ -92,6 +92,7 @@
newAdds: true,
reactions: true,
comments: true,
+ mentions: true,
dailyReminder: false
});
diff --git a/src/routes/api/__tests__/notifications.test.ts b/src/routes/api/__tests__/notifications.test.ts
index 4d63726..9fe0fce 100644
--- a/src/routes/api/__tests__/notifications.test.ts
+++ b/src/routes/api/__tests__/notifications.test.ts
@@ -130,6 +130,7 @@ describe('GET /api/notifications/preferences', () => {
newAdds: true,
reactions: true,
comments: true,
+ mentions: true,
dailyReminder: false
});
});
diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts
index 99c6e4c..6a5c5d3 100644
--- a/src/routes/api/clips/+server.ts
+++ b/src/routes/api/clips/+server.ts
@@ -31,6 +31,7 @@ import {
mapUsersByIds,
safeInt
} from '$lib/server/api-utils';
+import { extractMentions, notifyMentions } from '$lib/server/mentions';
import { createLogger } from '$lib/server/logger';
const log = createLogger('clips');
@@ -179,11 +180,13 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group }
);
}
- const body = await parseBody<{ url?: string; title?: string }>(request);
+ const body = await parseBody<{ url?: string; title?: string; message?: string }>(request);
if (isResponse(body)) return body;
const { url: videoUrl } = body;
const title = typeof body.title === 'string' ? body.title.trim().slice(0, 500) || null : null;
+ const message =
+ typeof body.message === 'string' ? body.message.trim().slice(0, 500) || null : null;
if (!videoUrl) return json({ error: 'URL required' }, { status: 400 });
@@ -266,5 +269,32 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group }
// Push notification is sent after download succeeds (see video/download.ts, music/download.ts)
+ // Auto-post message as the first comment on the clip
+ if (message) {
+ const commentId = uuid();
+ await db.insert(comments).values({
+ id: commentId,
+ clipId,
+ userId: user.id,
+ parentId: null,
+ text: message,
+ gifUrl: null,
+ createdAt: now
+ });
+
+ // Notify @mentioned users
+ const mentionedUsernames = extractMentions(message);
+ if (mentionedUsernames.length > 0) {
+ notifyMentions({
+ mentionedUsernames,
+ actorId: user.id,
+ actorUsername: user.username,
+ clipId,
+ groupId: user.groupId,
+ commentPreview: message.slice(0, 80)
+ }).catch((err) => log.error({ err, clipId }, 'mention notifications failed'));
+ }
+ }
+
return json({ clip: { id: clipId, status: 'downloading', contentType } }, { status: 201 });
});
diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts
index 111a594..e4a9792 100644
--- a/src/routes/api/clips/[id]/comments/+server.ts
+++ b/src/routes/api/clips/[id]/comments/+server.ts
@@ -11,6 +11,7 @@ import {
mapUsersByIds,
notifyClipOwner
} from '$lib/server/api-utils';
+import { extractMentions, notifyMentions } from '$lib/server/mentions';
export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => {
const clipId = params.id;
@@ -87,13 +88,13 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) =>
return json({ comments: formatted });
});
-/** Determine the notification recipient and dispatch. */
+/** Determine the notification recipient and dispatch. Returns recipient ID or null. */
async function dispatchCommentNotification(
clipId: string,
parentId: string | null,
actor: { id: string; username: string },
preview: string
-) {
+): Promise {
if (parentId) {
const parentComment = await db.query.comments.findFirst({
where: eq(comments.id, parentId)
@@ -111,6 +112,7 @@ async function dispatchCommentNotification(
pushTag: `reply-${clipId}`,
commentPreview: preview
});
+ return parentComment.userId;
}
} else {
const clip = await db.query.clips.findFirst({ where: eq(clips.id, clipId) });
@@ -127,8 +129,10 @@ async function dispatchCommentNotification(
pushTag: `comment-${clipId}`,
commentPreview: preview
});
+ return clip.addedBy;
}
}
+ return null;
}
function isValidGiphyUrl(url: string): boolean {
@@ -188,7 +192,27 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u
});
const preview = hasText ? trimmed.slice(0, 80) : '[GIF]';
- await dispatchCommentNotification(clipId, body.parentId || null, user, preview);
+ const commentRecipientId = await dispatchCommentNotification(
+ clipId,
+ body.parentId || null,
+ user,
+ preview
+ );
+
+ // Dispatch @mention notifications (exclude user who already got comment/reply notification)
+ const mentionedUsernames = extractMentions(trimmed);
+ if (mentionedUsernames.length > 0) {
+ const excludeUserIds = commentRecipientId ? [commentRecipientId] : [];
+ notifyMentions({
+ mentionedUsernames,
+ actorId: user.id,
+ actorUsername: user.username,
+ clipId,
+ groupId: user.groupId,
+ commentPreview: preview,
+ excludeUserIds
+ }).catch(() => {});
+ }
return json(
{
diff --git a/src/routes/api/notifications/preferences/+server.ts b/src/routes/api/notifications/preferences/+server.ts
index 84e8fbd..6990d26 100644
--- a/src/routes/api/notifications/preferences/+server.ts
+++ b/src/routes/api/notifications/preferences/+server.ts
@@ -11,13 +11,20 @@ export const GET: RequestHandler = withAuth(async (_event, { user }) => {
});
if (!prefs) {
- return json({ newAdds: true, reactions: true, comments: true, dailyReminder: false });
+ return json({
+ newAdds: true,
+ reactions: true,
+ comments: true,
+ mentions: true,
+ dailyReminder: false
+ });
}
return json({
newAdds: prefs.newAdds,
reactions: prefs.reactions,
comments: prefs.comments,
+ mentions: prefs.mentions,
dailyReminder: prefs.dailyReminder
});
});
@@ -26,7 +33,7 @@ export const PATCH: RequestHandler = withAuth(async ({ request }, { user }) => {
const body = await parseBody>(request);
if (isResponse(body)) return body;
- const allowed = ['newAdds', 'reactions', 'comments', 'dailyReminder'] as const;
+ const allowed = ['newAdds', 'reactions', 'comments', 'mentions', 'dailyReminder'] as const;
const updates: Record = {};
for (const key of allowed) {
@@ -52,6 +59,7 @@ export const PATCH: RequestHandler = withAuth(async ({ request }, { user }) => {
newAdds: updated!.newAdds,
reactions: updated!.reactions,
comments: updated!.comments,
+ mentions: updated!.mentions,
dailyReminder: updated!.dailyReminder
});
});