feat: notifications (#1947)

* feat: notifications
* feat: watchers

* improvements

* handle page move for watchers

* make watchers non-blocking

* more
This commit is contained in:
Philip Okugbe
2026-02-14 20:00:38 -08:00
committed by GitHub
parent e0ab9d9b5e
commit 05b3c65b0f
80 changed files with 3071 additions and 238 deletions
@@ -0,0 +1,53 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('notifications')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('actor_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade'),
)
.addColumn('comment_id', 'uuid', (col) =>
col.references('comments.id').onDelete('cascade'),
)
.addColumn('data', 'jsonb')
.addColumn('read_at', 'timestamptz')
.addColumn('emailed_at', 'timestamptz')
.addColumn('archived_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_notifications_user_id')
.on('notifications')
.columns(['user_id', 'id desc'])
.execute();
await db.schema
.createIndex('idx_notifications_user_unread')
.on('notifications')
.column('user_id')
.where(sql.ref('read_at'), 'is', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('notifications').execute();
}
@@ -0,0 +1,57 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('watchers')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('muted_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_watchers_user_page')
.on('watchers')
.columns(['user_id', 'page_id'])
.unique()
.where('page_id', 'is not', null)
.execute();
await db.schema
.createIndex('idx_watchers_user_space')
.on('watchers')
.columns(['user_id', 'space_id'])
.unique()
.where(sql.ref('page_id'), 'is', null)
.execute();
// Query index for fetching watchers by page
await db.schema
.createIndex('idx_watchers_page_id')
.on('watchers')
.column('page_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('watchers').execute();
}
@@ -0,0 +1,29 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Backfill watchers from pages.contributorIds and pages.creatorId
// This inserts unique user-page combinations from both sources
await sql`
INSERT INTO watchers (user_id, page_id, space_id, workspace_id, type, added_by_id)
SELECT DISTINCT
u.user_id,
p.id as page_id,
p.space_id,
p.workspace_id,
'page' as type,
u.user_id as added_by_id
FROM pages p
CROSS JOIN LATERAL (
SELECT unnest(p.contributor_ids) as user_id
UNION
SELECT p.creator_id as user_id WHERE p.creator_id IS NOT NULL
) u
WHERE p.deleted_at IS NULL
AND u.user_id IS NOT NULL
ON CONFLICT DO NOTHING
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DELETE FROM watchers WHERE type = 'page'`.execute(db);
}