Merge branch 'main' into base

This commit is contained in:
Philipinho
2026-04-17 13:48:49 +01:00
435 changed files with 30705 additions and 7427 deletions
@@ -17,10 +17,13 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
@@ -78,12 +81,15 @@ import { normalizePostgresUrl } from '../common/helpers';
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
FavoriteRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
WatcherRepo,
TemplateRepo,
PageListener,
BaseRepo,
BasePropertyRepo,
@@ -101,12 +107,15 @@ import { normalizePostgresUrl } from '../common/helpers';
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
FavoriteRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
WatcherRepo,
TemplateRepo,
BaseRepo,
BasePropertyRepo,
BaseRowRepo,
@@ -0,0 +1,45 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_sessions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('device_name', 'varchar')
.addColumn('user_agent', 'text')
.addColumn('ip_address', sql`inet`)
.addColumn('geo_location', 'varchar')
.addColumn('last_active_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('metadata', 'jsonb')
.addColumn('revoked_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await sql`
CREATE INDEX idx_user_sessions_active
ON user_sessions (user_id, workspace_id, last_active_at DESC)
WHERE revoked_at IS NULL
`.execute(db);
await sql`
CREATE INDEX idx_user_sessions_revoked
ON user_sessions (expires_at)
WHERE revoked_at IS NOT NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_sessions').execute();
}
@@ -0,0 +1,333 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_group_users_user_id')
.ifNotExists()
.on('group_users')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_user_id')
.ifNotExists()
.on('space_members')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_group_id')
.ifNotExists()
.on('space_members')
.column('group_id')
.execute();
// Page tree
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_parent_position
ON pages (space_id, parent_page_id, position COLLATE "C")
WHERE deleted_at IS NULL
`.execute(db);
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id
ON pages (parent_page_id)
WHERE deleted_at IS NULL
`.execute(db);
// Recent pages query
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_updated
ON pages (space_id, updated_at DESC)
WHERE deleted_at IS NULL
`.execute(db);
// Trash view
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_deleted
ON pages (space_id, deleted_at DESC)
WHERE deleted_at IS NOT NULL
`.execute(db);
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_hostname_lower
ON workspaces (LOWER(hostname))
`.execute(db);
await db.schema
.createIndex('idx_workspaces_created_at')
.ifNotExists()
.on('workspaces')
.column('created_at')
.execute();
await db.schema
.createIndex('idx_users_workspace_deleted')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'deleted_at'])
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_slug_lower_workspace
ON spaces (LOWER(slug), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_spaces_workspace_id')
.ifNotExists()
.on('spaces')
.column('workspace_id')
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_lower_workspace
ON groups (LOWER(name), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_groups_workspace_id')
.ifNotExists()
.on('groups')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_shares_page_id')
.ifNotExists()
.on('shares')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_page_id')
.ifNotExists()
.on('attachments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_space_id')
.ifNotExists()
.on('attachments')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_comments_page_id')
.ifNotExists()
.on('comments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_comments_parent_comment_id')
.ifNotExists()
.on('comments')
.column('parent_comment_id')
.execute();
await sql`
CREATE INDEX IF NOT EXISTS idx_page_history_page_created
ON page_history (page_id, created_at DESC)
`.execute(db);
await db.schema
.createIndex('idx_attachments_workspace_id')
.ifNotExists()
.on('attachments')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_backlinks_target_page_id')
.ifNotExists()
.on('backlinks')
.column('target_page_id')
.execute();
await db.schema
.createIndex('idx_pages_workspace_id')
.ifNotExists()
.on('pages')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_pages_creator_id')
.ifNotExists()
.on('pages')
.column('creator_id')
.execute();
// Notifications: FK cascade from pages, spaces, comments
await db.schema
.createIndex('idx_notifications_page_id')
.ifNotExists()
.on('notifications')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_notifications_space_id')
.ifNotExists()
.on('notifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_notifications_comment_id')
.ifNotExists()
.on('notifications')
.column('comment_id')
.execute();
// Watchers: cleanup queries and FK cascade
await db.schema
.createIndex('idx_watchers_user_workspace')
.ifNotExists()
.on('watchers')
.columns(['user_id', 'workspace_id'])
.execute();
await db.schema
.createIndex('idx_watchers_space_id')
.ifNotExists()
.on('watchers')
.column('space_id')
.execute();
// Auth providers: all queries filter by workspaceId
await db.schema
.createIndex('idx_auth_providers_workspace_id')
.ifNotExists()
.on('auth_providers')
.column('workspace_id')
.execute();
// Auth accounts: SSO login lookup by provider user
await db.schema
.createIndex('idx_auth_accounts_provider_user_id')
.ifNotExists()
.on('auth_accounts')
.columns(['provider_user_id', 'auth_provider_id'])
.execute();
// Workspace invitations: listing and SSO lookup
await db.schema
.createIndex('idx_workspace_invitations_workspace_id')
.ifNotExists()
.on('workspace_invitations')
.column('workspace_id')
.execute();
// API keys: query and FK cascade
await db.schema
.createIndex('idx_api_keys_workspace_id')
.ifNotExists()
.on('api_keys')
.column('workspace_id')
.execute();
// User sessions: delete queries and FK cascade on all session states
await db.schema
.createIndex('idx_user_sessions_user_workspace')
.ifNotExists()
.on('user_sessions')
.columns(['user_id', 'workspace_id'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_group_users_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_group_id').ifExists().execute();
await db.schema
.dropIndex('idx_pages_space_parent_position')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_parent_page_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_updated').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_deleted').ifExists().execute();
await db.schema
.dropIndex('idx_workspaces_hostname_lower')
.ifExists()
.execute();
await db.schema.dropIndex('idx_workspaces_created_at').ifExists().execute();
await db.schema
.dropIndex('idx_users_workspace_deleted')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_slug_lower_workspace')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_groups_name_lower_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_groups_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_shares_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_space_id').ifExists().execute();
await db.schema.dropIndex('idx_comments_page_id').ifExists().execute();
await db.schema
.dropIndex('idx_comments_parent_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_page_history_page_created')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_attachments_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_backlinks_target_page_id')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_creator_id').ifExists().execute();
await db.schema
.dropIndex('idx_notifications_page_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_space_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_watchers_user_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_watchers_space_id').ifExists().execute();
await db.schema
.dropIndex('idx_auth_providers_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_auth_accounts_provider_user_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_workspace_invitations_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_api_keys_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_user_sessions_user_workspace')
.ifExists()
.execute();
}
@@ -0,0 +1,118 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('ai_chats')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').notNull(),
)
.addColumn('title', 'varchar', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chats_workspace_creator')
.ifNotExists()
.on('ai_chats')
.columns(['workspace_id', 'creator_id', 'id'])
.execute();
await db.schema
.createTable('ai_chat_messages')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('chat_id', 'uuid', (col) =>
col.references('ai_chats.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('content', 'text', (col) => col)
.addColumn('tool_calls', 'jsonb', (col) => col)
.addColumn('metadata', 'jsonb', (col) => col)
.addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chat_messages_chat_id')
.ifNotExists()
.on('ai_chat_messages')
.columns(['chat_id', 'id'])
.execute();
await db.schema
.createIndex('idx_ai_chat_messages_tsv')
.ifNotExists()
.on('ai_chat_messages')
.using('GIN')
.column('tsv')
.execute();
//ts-vector
await sql`
CREATE OR REPLACE FUNCTION ai_chat_messages_tsvector_trigger() RETURNS trigger AS $$
BEGIN
NEW.tsv := to_tsvector('english', f_unaccent(substring(coalesce(NEW.content, ''), 1, 100000)));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER ai_chat_messages_tsvector_update
BEFORE INSERT OR UPDATE ON ai_chat_messages
FOR EACH ROW EXECUTE FUNCTION ai_chat_messages_tsvector_trigger();
`.execute(db);
await db.schema
.alterTable('attachments')
.addColumn('ai_chat_id', 'uuid', (col) => col)
.execute();
await db.schema
.createIndex('idx_attachments_ai_chat_id')
.ifNotExists()
.on('attachments')
.column('ai_chat_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_attachments_ai_chat_id').execute();
await db.schema.alterTable('attachments').dropColumn('ai_chat_id').execute();
await sql`DROP TRIGGER IF EXISTS ai_chat_messages_tsvector_update ON ai_chat_messages`.execute(
db,
);
await sql`DROP FUNCTION IF EXISTS ai_chat_messages_tsvector_trigger`.execute(
db,
);
await db.schema.dropTable('ai_chat_messages').execute();
await db.schema.dropTable('ai_chats').execute();
}
@@ -0,0 +1,76 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('templates')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('title', 'varchar')
.addColumn('description', 'text')
.addColumn('content', 'jsonb')
.addColumn('ydoc', 'bytea')
.addColumn('icon', 'varchar')
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('last_updated_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('collaborator_ids', sql`uuid[]`)
.addColumn('text_content', 'text', (col) => col)
.addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz')
.execute();
await db.schema
.createIndex('idx_templates_workspace_id')
.on('templates')
.columns(['workspace_id'])
.execute();
await db.schema
.createIndex('idx_templates_space_id')
.on('templates')
.columns(['space_id'])
.execute();
await db.schema
.createIndex('templates_tsv_idx')
.on('templates')
.using('GIN')
.column('tsv')
.execute();
await sql`
CREATE OR REPLACE FUNCTION templates_tsvector_trigger() RETURNS trigger AS $$
begin
new.tsv :=
setweight(to_tsvector('english', f_unaccent(coalesce(new.title, ''))), 'A') ||
setweight(to_tsvector('english', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B');
return new;
end;
$$ LANGUAGE plpgsql;
`.execute(db);
await sql`CREATE OR REPLACE TRIGGER templates_tsvector_update BEFORE INSERT OR UPDATE
ON templates FOR EACH ROW EXECUTE FUNCTION templates_tsvector_trigger();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER IF EXISTS templates_tsvector_update ON templates`.execute(db);
await sql`DROP FUNCTION IF EXISTS templates_tsvector_trigger`.execute(db);
await db.schema.dropTable('templates').execute();
}
@@ -0,0 +1,63 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('favorites')
.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'),
)
.addColumn('template_id', 'uuid', (col) =>
col.references('templates.id').onDelete('cascade'),
)
.addColumn('type', 'varchar', (col) => col.notNull())
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.defaultTo(sql`now()`).notNull(),
)
.execute();
await db.schema
.createIndex('idx_favorites_user_page')
.on('favorites')
.columns(['user_id', 'page_id'])
.unique()
.where('page_id', 'is not', null)
.execute();
await db.schema
.createIndex('idx_favorites_user_space')
.on('favorites')
.columns(['user_id', 'space_id'])
.unique()
.where('space_id', 'is not', null)
.execute();
await db.schema
.createIndex('idx_favorites_user_template')
.on('favorites')
.columns(['user_id', 'template_id'])
.unique()
.where('template_id', 'is not', null)
.execute();
await db.schema
.createIndex('idx_favorites_user_workspace_type')
.on('favorites')
.columns(['user_id', 'workspace_id', 'type'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('favorites').execute();
}
@@ -0,0 +1,117 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_verifications')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().unique().references('pages.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.notNull().references('spaces.id').onDelete('cascade'),
)
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring'))
.addColumn('status', 'varchar')
.addColumn('mode', 'varchar')
.addColumn('period_amount', 'integer')
.addColumn('period_unit', 'varchar')
.addColumn('verified_at', 'timestamptz')
.addColumn('verified_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('expires_at', 'timestamptz')
.addColumn('requested_at', 'timestamptz')
.addColumn('requested_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('rejected_at', 'timestamptz')
.addColumn('rejected_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('rejection_comment', 'text')
.addColumn('data', 'jsonb')
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createTable('page_verifiers')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_verification_id', 'uuid', (col) =>
col.notNull().references('page_verifications.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_verifiers_verification_user_unique', [
'page_verification_id',
'user_id',
])
.execute();
await db.schema
.createIndex('idx_page_verifications_expires_at')
.ifNotExists()
.on('page_verifications')
.column('expires_at')
.where('expires_at', 'is not', null)
.execute();
await db.schema
.createIndex('idx_page_verifications_workspace_id_id')
.ifNotExists()
.on('page_verifications')
.columns(['workspace_id', 'id desc'])
.execute();
await db.schema
.createIndex('idx_page_verifications_space_id')
.ifNotExists()
.on('page_verifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_page_verifiers_user_id')
.ifNotExists()
.on('page_verifiers')
.column('user_id')
.execute();
await db.schema
.alterTable('notifications')
.addColumn('page_verification_id', 'uuid', (col) =>
col.references('page_verifications.id').onDelete('cascade'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('notifications')
.dropColumn('page_verification_id')
.execute();
await db.schema.dropTable('page_verifiers').ifExists().execute();
await db.schema.dropTable('page_verifications').ifExists().execute();
}
@@ -0,0 +1,32 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('file_tasks')
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null').ifNotExists(),
)
.execute();
await db.schema
.alterTable('file_tasks')
.addColumn('metadata', 'jsonb', (col) => col.ifNotExists())
.execute();
await db.schema
.createIndex('idx_file_tasks_page_export')
.ifNotExists()
.on('file_tasks')
.columns(['page_id', 'workspace_id'])
.where(sql.ref('type'), '=', 'export')
.where(sql.ref('deleted_at'), 'is', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_file_tasks_page_export').execute();
await db.schema.alterTable('file_tasks').dropColumn('page_id').execute();
await db.schema.alterTable('file_tasks').dropColumn('metadata').execute();
}
@@ -7,6 +7,7 @@ import {
InsertableAttachment,
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
import { AttachmentType } from '../../../core/attachment/attachment.constants';
@Injectable()
export class AttachmentRepo {
@@ -23,6 +24,7 @@ export class AttachmentRepo {
'creatorId',
'pageId',
'spaceId',
'aiChatId',
'workspaceId',
'createdAt',
'updatedAt',
@@ -44,6 +46,21 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async findByIdWithContent(
attachmentId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments')
.select([...this.baseFields, 'textContent'])
.where('id', '=', attachmentId)
.executeTakeFirst();
}
async insertAttachment(
insertableAttachment: InsertableAttachment,
trx?: KyselyTransaction,
@@ -72,6 +89,21 @@ export class AttachmentRepo {
.execute();
}
async findByAiChatId(
aiChatId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment[]> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments')
.select(this.baseFields)
.where('aiChatId', '=', aiChatId)
.execute();
}
updateAttachmentsByPageId(
updatableAttachment: UpdatableAttachment,
pageIds: string[],
@@ -97,6 +129,25 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async claimAttachmentsForChat(
attachmentIds: string[],
aiChatId: string,
creatorId: string,
workspaceId: string,
): Promise<void> {
if (attachmentIds.length === 0) return;
await this.db
.updateTable('attachments')
.set({ aiChatId })
.where('id', 'in', attachmentIds)
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('type', '=', AttachmentType.Chat)
.where('aiChatId', 'is', null)
.execute();
}
async deleteAttachmentById(attachmentId: string): Promise<void> {
await this.db
.deleteFrom('attachments')
@@ -0,0 +1,292 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils';
export const FavoriteType = {
PAGE: 'page',
SPACE: 'space',
TEMPLATE: 'template',
} as const;
export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType];
@Injectable()
export class FavoriteRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insert(favorite: InsertableFavorite): Promise<Favorite | undefined> {
try {
return await this.db
.insertInto('favorites')
.values(favorite)
.returningAll()
.executeTakeFirst();
} catch (err: any) {
if (err?.code === '23505') return undefined;
throw err;
}
}
async deleteByUserAndPage(userId: string, pageId: string): Promise<void> {
await this.db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.execute();
}
async deleteByUserAndSpace(userId: string, spaceId: string): Promise<void> {
await this.db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.where('type', '=', FavoriteType.SPACE)
.execute();
}
async deleteByUserAndTemplate(
userId: string,
templateId: string,
): Promise<void> {
await this.db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('templateId', '=', templateId)
.execute();
}
async getFavoriteIds(
userId: string,
workspaceId: string,
type: FavoriteType,
spaceId?: string,
): Promise<{ items: string[]; meta: any }> {
const idColumn =
type === FavoriteType.PAGE
? 'pageId'
: type === FavoriteType.SPACE
? 'spaceId'
: 'templateId';
let query = this.db
.selectFrom('favorites')
.select(['favorites.id', `favorites.${idColumn} as entityId`])
.where('favorites.userId', '=', userId)
.where('favorites.workspaceId', '=', workspaceId)
.where('favorites.type', '=', type);
if (spaceId) {
query = this.applySpaceFilter(query, type, spaceId);
}
const result = await executeWithCursorPagination(query, {
perPage: 250,
fields: [{ expression: 'favorites.id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return {
items: result.items
.map((r) => (r as any).entityId as string)
.filter(Boolean),
meta: result.meta,
};
}
async findUserFavorites(
userId: string,
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
spaceId?: string,
) {
let query = this.db
.selectFrom('favorites')
.selectAll('favorites')
.where('favorites.userId', '=', userId)
.where('favorites.workspaceId', '=', workspaceId);
if (type) {
query = query.where('favorites.type', '=', type);
}
if (spaceId) {
query = this.applySpaceFilter(query, type, spaceId);
}
if (type === FavoriteType.PAGE || !type) {
query = query.select((eb) => this.withPage(eb));
}
if (type === FavoriteType.PAGE) {
query = query.select((eb) => this.withPageSpace(eb));
} else if (type === FavoriteType.SPACE) {
query = query.select((eb) => this.withSpace(eb));
} else {
query = query.select((eb) => this.withSpaceResolved(eb));
}
if (type === FavoriteType.TEMPLATE || !type) {
query = query.select((eb) => this.withTemplate(eb));
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'favorites.id', direction: 'desc' }],
parseCursor: (cursor) => ({
id: cursor.id,
}),
});
}
async deleteByUsersWithoutSpaceAccess(
userIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
if (userIds.length === 0) return;
const { trx } = opts;
const db = dbOrTx(this.db, trx);
const usersWithAccess = db
.selectFrom('spaceMembers')
.select('userId')
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await db
.deleteFrom('favorites')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('userId', 'not in', usersWithAccess)
.execute();
}
async deleteByUserAndWorkspace(
userId: string,
workspaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
private applySpaceFilter<Q extends SelectQueryBuilder<any, any, any>>(
query: Q,
type: FavoriteType | undefined,
spaceId: string,
): Q {
if (type === FavoriteType.PAGE) {
return query.where((eb: any) =>
eb.exists(
eb
.selectFrom('pages')
.select(sql`1`.as('one'))
.whereRef('pages.id', '=', 'favorites.pageId')
.where('pages.spaceId', '=', spaceId),
),
) as Q;
}
if (type === FavoriteType.SPACE) {
return query.where('favorites.spaceId' as any, '=', spaceId) as Q;
}
if (type === FavoriteType.TEMPLATE) {
return query.where((eb: any) =>
eb.exists(
eb
.selectFrom('templates')
.select(sql`1`.as('one'))
.whereRef('templates.id', '=', 'favorites.templateId')
.where('templates.spaceId', '=', spaceId),
),
) as Q;
}
return query;
}
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
])
.whereRef('pages.id', '=', 'favorites.pageId'),
).as('page');
}
private withSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.whereRef('spaces.id', '=', 'favorites.spaceId'),
).as('space');
}
private withPageSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.innerJoin('pages', 'pages.spaceId', 'spaces.id')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.whereRef('pages.id', '=', 'favorites.pageId'),
).as('space');
}
private withSpaceResolved(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.where(({ or, ref }) =>
or([
sql<boolean>`${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
sql<boolean>`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
]),
),
).as('space');
}
private withTemplate(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('templates')
.select([
'templates.id',
'templates.title',
'templates.description',
'templates.icon',
'templates.spaceId',
])
.whereRef('templates.id', '=', 'favorites.templateId'),
).as('template');
}
}
@@ -11,6 +11,10 @@ import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
NotificationTab,
NotificationType,
} from '../../../core/notification/notification.constants';
@Injectable()
export class NotificationRepo {
@@ -27,8 +31,12 @@ export class NotificationRepo {
.executeTakeFirst();
}
async findByUserId(userId: string, pagination: PaginationOptions) {
const query = this.db
async findByUserId(
userId: string,
pagination: PaginationOptions,
type: NotificationTab = 'all',
) {
let query = this.db
.selectFrom('notifications')
.selectAll('notifications')
.select((eb) => this.withActor(eb))
@@ -38,10 +46,20 @@ export class NotificationRepo {
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
);
if (type === 'direct') {
query = query.where('type', '!=', NotificationType.PAGE_UPDATED);
} else if (type === 'updates') {
query = query.where('type', '=', NotificationType.PAGE_UPDATED);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
@@ -51,6 +69,14 @@ export class NotificationRepo {
});
}
async insert(notification: InsertableNotification): Promise<Notification> {
return this.db
.insertInto('notifications')
.values(notification)
.returningAll()
.executeTakeFirst();
}
async getUnreadCount(userId: string): Promise<number> {
const result = await this.db
.selectFrom('notifications')
@@ -60,7 +86,11 @@ export class NotificationRepo {
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
)
.executeTakeFirst();
@@ -68,14 +98,6 @@ export class NotificationRepo {
return Number(result?.count ?? 0);
}
async insert(notification: InsertableNotification): Promise<Notification> {
return this.db
.insertInto('notifications')
.values(notification)
.returningAll()
.executeTakeFirst();
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
await this.db
.updateTable('notifications')
@@ -83,12 +105,6 @@ export class NotificationRepo {
.where('id', '=', notificationId)
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
@@ -105,12 +121,15 @@ export class NotificationRepo {
.where('id', 'in', notificationIds)
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
.execute();
}
async markAllAsRead(userId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ readAt: new Date() })
.where('userId', '=', userId)
.where('readAt', 'is', null)
.execute();
}
@@ -123,19 +142,27 @@ export class NotificationRepo {
.execute();
}
async markAllAsRead(userId: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({ readAt: new Date() })
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
]),
)
async getRecentlyNotifiedUserIds(
userIds: string[],
pageId: string,
type: string,
withinHours: number,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const cutoff = new Date(Date.now() - withinHours * 60 * 60 * 1000);
const rows = await this.db
.selectFrom('notifications')
.select('userId')
.where('userId', 'in', userIds)
.where('pageId', '=', pageId)
.where('type', '=', type)
.where('createdAt', '>', cutoff)
.groupBy('userId')
.execute();
return new Set(rows.map((r) => r.userId));
}
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
@@ -324,6 +324,35 @@ export class PageRepo {
});
}
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
let query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('creatorId', '=', creatorId)
.where('deletedAt', 'is', null);
if (spaceId) {
query = query.where('spaceId', '=', spaceId);
} else {
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
@@ -0,0 +1,162 @@
import {
InsertableUserSession,
UserSession,
} from '@docmost/db/types/entity.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
@Injectable()
export class UserSessionRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertSession(
session: InsertableUserSession,
trx?: KyselyTransaction,
): Promise<UserSession> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('userSessions')
.values(session)
.returningAll()
.executeTakeFirstOrThrow();
}
async findActiveById(id: string): Promise<UserSession | undefined> {
return this.db
.selectFrom('userSessions')
.selectAll()
.where('id', '=', id)
.where('expiresAt', '>', new Date())
.where('revokedAt', 'is', null)
.executeTakeFirst();
}
async findActiveByUser(
userId: string,
workspaceId: string,
): Promise<UserSession[]> {
return this.db
.selectFrom('userSessions')
.selectAll()
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('expiresAt', '>', new Date())
.where('revokedAt', 'is', null)
.orderBy('lastActiveAt', 'desc')
.execute();
}
async updateLastActiveAt(id: string): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ lastActiveAt: new Date() })
.where('id', '=', id)
.execute();
}
async revokeById(
id: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('id', '=', id)
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('revokedAt', 'is', null)
.execute();
}
async revokeAllExceptCurrent(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', currentSessionId)
.where('revokedAt', 'is', null)
.execute();
}
async revokeByUserId(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('revokedAt', 'is', null)
.execute();
}
async deleteByUserId(
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('userSessions')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async deleteAllExceptCurrent(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('userSessions')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', currentSessionId)
.execute();
}
async deleteStale(retentionDays: number): Promise<void> {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
await this.db
.deleteFrom('userSessions')
.where((eb) =>
eb.or([
eb('revokedAt', '<', cutoff),
eb('expiresAt', '<', cutoff),
]),
)
.execute();
}
async trimExcessSessions(maxPerUser: number): Promise<void> {
const overflowed = await this.db
.selectFrom('userSessions')
.select(['userId', 'workspaceId'])
.groupBy(['userId', 'workspaceId'])
.having(sql`COUNT(*)`, '>', maxPerUser)
.execute();
for (const { userId, workspaceId } of overflowed) {
await sql`
DELETE FROM user_sessions
WHERE id IN (
SELECT id FROM user_sessions
WHERE user_id = ${userId} AND workspace_id = ${workspaceId}
ORDER BY last_active_at DESC
OFFSET ${maxPerUser}
)
`.execute(this.db);
}
}
}
@@ -290,6 +290,32 @@ export class SpaceMemberRepo {
return membership.map((space) => space.id);
}
async getUserRolesForSpaces(
userId: string,
spaceIds: string[],
): Promise<{ spaceId: string; role: string }[]> {
if (spaceIds.length === 0) return [];
return this.db
.selectFrom('spaceMembers')
.select(['spaceId', 'role'])
.where('userId', '=', userId)
.where('spaceId', 'in', spaceIds)
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['spaceMembers.spaceId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', 'in', spaceIds),
)
.execute();
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('spaces')
@@ -111,6 +111,28 @@ export class SpaceRepo {
.executeTakeFirst();
}
async updateCommentSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('comments', COALESCE(settings->'comments', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableTemplate,
Page,
Template,
UpdatableTemplate,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@Injectable()
export class TemplateRepo {
private baseFields: Array<keyof Template> = [
'id',
'title',
'description',
'icon',
'spaceId',
'workspaceId',
'creatorId',
'lastUpdatedById',
'createdAt',
'updatedAt',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
templateId: string,
workspaceId: string,
opts?: { includeContent?: boolean; trx?: KyselyTransaction },
): Promise<Template> {
const db = dbOrTx(this.db, opts?.trx);
const query = db
.selectFrom('templates')
.select(this.baseFields)
.$if(opts?.includeContent ?? false, (qb) => qb.select('content'))
.select((eb) => [this.withCreator(eb)])
.where('id', '=', templateId)
.where('workspaceId', '=', workspaceId);
return query.executeTakeFirst() as Promise<Template>;
}
async findTemplates(
workspaceId: string,
accessibleSpaceIds: string[],
pagination: PaginationOptions,
opts?: { spaceId?: string },
) {
let query = this.db
.selectFrom('templates')
.select(this.baseFields)
.select((eb) => [this.withCreator(eb)])
.where('workspaceId', '=', workspaceId);
if (opts?.spaceId) {
if (!accessibleSpaceIds.includes(opts.spaceId)) {
query = query.where('spaceId', 'is', null);
} else {
query = query.where((eb) =>
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
);
}
} else {
query = query.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
...(accessibleSpaceIds.length > 0
? [eb('spaceId', 'in', accessibleSpaceIds)]
: []),
]),
);
}
if (pagination.query) {
const searchTerm = `%${pagination.query}%`;
query = query.where((eb) =>
eb.or([
eb(sql`f_unaccent(title)`, 'ilike', sql`f_unaccent(${searchTerm})`),
eb(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${searchTerm})`,
),
]),
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'title', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
title: cursor.title,
id: cursor.id,
}),
});
}
async insertTemplate(
insertableTemplate: InsertableTemplate,
trx?: KyselyTransaction,
): Promise<{ id: string }> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('templates')
.values(insertableTemplate)
.returning('id')
.executeTakeFirst();
}
async updateTemplate(
updatableTemplate: UpdatableTemplate,
templateId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('templates')
.set({ ...updatableTemplate, updatedAt: new Date() })
.where('id', '=', templateId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async deleteTemplate(
templateId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('templates')
.where('id', '=', templateId)
.where('workspaceId', '=', workspaceId)
.execute();
}
withCreator(eb: ExpressionBuilder<DB, 'templates'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'templates.creatorId'),
).as('creator');
}
}
@@ -13,6 +13,7 @@ import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { NotificationSettingKey } from '../../../core/notification/notification.constants';
@Injectable()
export class UserRepo {
@@ -191,6 +192,24 @@ export class UserRepo {
.executeTakeFirst();
}
async updateNotificationSetting(
userId: string,
settingKey: NotificationSettingKey,
settingValue: boolean,
) {
return await this.db
.updateTable('users')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('notifications', COALESCE(settings->'notifications', '{}'::jsonb)
|| jsonb_build_object(${sql.lit(settingKey)}, ${sql.lit(settingValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', userId)
.returning(this.baseFields)
.executeTakeFirst();
}
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
return jsonObjectFrom(
eb
@@ -20,18 +20,6 @@ export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
export class WatcherRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findByUserAndPage(
userId: string,
pageId: string,
): Promise<Watcher | undefined> {
return this.db
.selectFrom('watchers')
.selectAll()
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('watchers')
@@ -66,6 +54,53 @@ export class WatcherRepo {
return watchers.map((w) => w.userId);
}
/**
* Recipients for a `page.updated` notification, combining:
* - Active page watchers on this page, AND
* - Active space watchers on this space, EXCLUDING any user who has a
* muted page watcher row for this page (per-page mute always wins).
*
* Deduplicated at the SQL level — a user watching both the page and the
* containing space appears once.
*/
async getPageUpdateRecipientIds(
pageId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const db = dbOrTx(this.db, trx);
const pageWatchers = db
.selectFrom('watchers')
.select('userId')
.where('pageId', '=', pageId)
.where('type', '=', WatcherType.PAGE)
.where('mutedAt', 'is', null);
const spaceWatchers = db
.selectFrom('watchers as sw')
.select('sw.userId')
.where('sw.spaceId', '=', spaceId)
.where('sw.pageId', 'is', null)
.where('sw.type', '=', WatcherType.SPACE)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('watchers as pw')
.select('pw.id')
.whereRef('pw.userId', '=', 'sw.userId')
.where('pw.pageId', '=', pageId)
.where('pw.type', '=', WatcherType.PAGE)
.where('pw.mutedAt', 'is not', null),
),
),
);
const rows = await pageWatchers.union(spaceWatchers).execute();
return [...new Set(rows.map((r) => r.userId))];
}
async insert(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
@@ -110,20 +145,97 @@ export class WatcherRepo {
.executeTakeFirst();
}
async upsertSpace(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
): Promise<Watcher | undefined> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('watchers')
.values(watcher)
.onConflict((oc) =>
oc
.columns(['userId', 'spaceId'])
.where('pageId', 'is', null)
.doNothing(),
)
.returningAll()
.executeTakeFirst();
}
async mute(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
const mutedAt = new Date();
await db
.insertInto('watchers')
.values({
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
mutedAt,
})
.onConflict((oc) =>
oc
.columns(['userId', 'pageId'])
.where('pageId', 'is not', null)
.doUpdateSet({ mutedAt }),
)
.execute();
}
async deleteSpaceWatch(
userId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('watchers')
.set({ mutedAt: new Date() })
.deleteFrom('watchers')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.where('spaceId', '=', spaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE)
.execute();
}
async getWatchedSpaceIds(userId: string, workspaceId: string) {
const query = this.db
.selectFrom('watchers')
.select(['watchers.id', 'watchers.spaceId'])
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE);
return executeWithCursorPagination(query, {
perPage: 250,
fields: [{ expression: 'watchers.id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
}
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
.select('id')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE)
.executeTakeFirst();
return !!watcher;
}
async isWatching(userId: string, pageId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
@@ -164,14 +276,14 @@ export class WatcherRepo {
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
this.db
db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await this.db
await db
.deleteFrom('watchers')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
@@ -230,4 +230,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateTemplateSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
}
+114
View File
@@ -43,6 +43,7 @@ export interface ApiKeys {
}
export interface Attachments {
aiChatId: string | null;
createdAt: Generated<Timestamp>;
creatorId: string;
deletedAt: Timestamp | null;
@@ -174,6 +175,17 @@ export interface Comments {
workspaceId: string;
}
export interface Favorites {
id: Generated<string>;
userId: string;
pageId: string | null;
spaceId: string | null;
templateId: string | null;
type: string;
workspaceId: string;
createdAt: Generated<Timestamp>;
}
export interface FileTasks {
createdAt: Generated<Timestamp>;
creatorId: string | null;
@@ -184,6 +196,8 @@ export interface FileTasks {
filePath: string;
fileSize: Int8 | null;
id: Generated<string>;
metadata: Json | null;
pageId: string | null;
source: string | null;
spaceId: string | null;
status: string | null;
@@ -388,6 +402,7 @@ export interface Notifications {
pageId: string | null;
spaceId: string | null;
commentId: string | null;
pageVerificationId: string | null;
data: Json | null;
readAt: Timestamp | null;
emailedAt: Timestamp | null;
@@ -482,7 +497,101 @@ export interface PagePermissions {
updatedAt: Generated<Timestamp>;
}
export interface PageVerifications {
id: Generated<string>;
pageId: string;
workspaceId: string;
spaceId: string;
type: Generated<string>;
status: string | null;
mode: string | null;
periodAmount: number | null;
periodUnit: string | null;
verifiedAt: Timestamp | null;
verifiedById: string | null;
expiresAt: Timestamp | null;
requestedAt: Timestamp | null;
requestedById: string | null;
rejectedAt: Timestamp | null;
rejectedById: string | null;
rejectionComment: string | null;
data: Json | null;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageVerifiers {
id: Generated<string>;
pageVerificationId: string;
userId: string;
isPrimary: Generated<boolean>;
addedById: string | null;
createdAt: Generated<Timestamp>;
}
export interface Templates {
id: Generated<string>;
title: string | null;
description: string | null;
content: Json | null;
ydoc: Buffer | null;
icon: string | null;
spaceId: string | null;
workspaceId: string;
creatorId: string | null;
lastUpdatedById: string | null;
collaboratorIds: string[] | null;
textContent: string | null;
tsv: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface AiChats {
id: Generated<string>;
workspaceId: string;
creatorId: string;
title: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface AiChatMessages {
id: Generated<string>;
chatId: string;
workspaceId: string;
userId: string | null;
role: string;
content: string | null;
toolCalls: Json | null;
metadata: Json | null;
tsv: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface UserSessions {
id: Generated<string>;
userId: string;
workspaceId: string;
deviceName: string | null;
userAgent: string | null;
ipAddress: string | null;
geoLocation: string | null;
metadata: Json | null;
lastActiveAt: Generated<Timestamp>;
expiresAt: Timestamp;
revokedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
}
export interface DB {
aiChats: AiChats;
aiChatMessages: AiChatMessages;
apiKeys: ApiKeys;
attachments: Attachments;
audit: Audit;
@@ -495,6 +604,7 @@ export interface DB {
backlinks: Backlinks;
billing: Billing;
comments: Comments;
favorites: Favorites;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
@@ -502,12 +612,16 @@ export interface DB {
pageAccess: PageAccess;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
templates: Templates;
userMfa: UserMfa;
users: Users;
userSessions: UserSessions;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
+61 -16
View File
@@ -1,5 +1,7 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
AiChats,
AiChatMessages,
Attachments,
BaseProperties,
BaseRows,
@@ -10,6 +12,8 @@ import {
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
PageVerifications as _PageVerifications,
PageVerifiers as _PageVerifiers,
Pages,
Spaces,
Users,
@@ -24,14 +28,32 @@ import {
AuthProviders,
AuthAccounts,
Shares,
Favorites,
FileTasks,
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
Watchers,
Audit as _Audit,
Templates,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
// AI Chat
export type AiChat = Selectable<AiChats>;
export type InsertableAiChat = Insertable<AiChats>;
export type UpdatableAiChat = Updateable<Omit<AiChats, 'id'>>;
// AI Chat Message
// `tsv` is an internal tsvector column maintained by a trigger for
// full-text search. It is omitted from the public type so it never leaks
// into HTTP responses or the chat history fed to the language model.
export type AiChatMessage = Omit<Selectable<AiChatMessages>, 'tsv'>;
export type InsertableAiChatMessage = Omit<
Insertable<AiChatMessages>,
'tsv'
>;
// Workspace
export type Workspace = Selectable<Workspaces>;
export type InsertableWorkspace = Insertable<Workspaces>;
@@ -121,6 +143,11 @@ export type Share = Selectable<Shares>;
export type InsertableShare = Insertable<Shares>;
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
// Favorite
export type Favorite = Selectable<Favorites>;
export type InsertableFavorite = Insertable<Favorites>;
export type UpdatableFavorite = Updateable<Omit<Favorites, 'id'>>;
// File Task
export type FileTask = Selectable<FileTasks>;
export type InsertableFileTask = Insertable<FileTasks>;
@@ -151,6 +178,40 @@ export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
// Page Verification
export type PageVerification = Selectable<_PageVerifications>;
export type InsertablePageVerification = Insertable<_PageVerifications>;
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
// Page Verifier
export type PageVerifier = Selectable<_PageVerifiers>;
export type InsertablePageVerifier = Insertable<_PageVerifiers>;
// User Session
export type UserSession = Selectable<UserSessions>;
export type InsertableUserSession = Insertable<UserSessions>;
export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
// Template
export type Template = Selectable<Templates>;
export type InsertableTemplate = Insertable<Templates>;
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
// Base
export type Base = Selectable<Bases>;
export type InsertableBase = Insertable<Bases>;
@@ -170,19 +231,3 @@ export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
export type BaseView = Selectable<BaseViews>;
export type InsertableBaseView = Insertable<BaseViews>;
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;