mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 06:44:05 +08:00
Merge branch 'main'
This commit is contained in:
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -79,6 +80,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
FavoriteRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
@@ -100,6 +102,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
FavoriteRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 +28,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))
|
||||
@@ -42,6 +47,12 @@ export class NotificationRepo {
|
||||
]),
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -138,6 +149,29 @@ export class NotificationRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
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'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,81 @@ 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 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 +260,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)
|
||||
|
||||
+44
@@ -43,6 +43,7 @@ export interface ApiKeys {
|
||||
}
|
||||
|
||||
export interface Attachments {
|
||||
aiChatId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string;
|
||||
deletedAt: Timestamp | null;
|
||||
@@ -459,7 +460,49 @@ export interface Templates {
|
||||
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;
|
||||
@@ -483,6 +526,7 @@ export interface DB {
|
||||
templates: Templates;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userSessions: UserSessions;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import {
|
||||
AiChats,
|
||||
AiChatMessages,
|
||||
Attachments,
|
||||
Comments,
|
||||
Groups,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
@@ -30,6 +33,21 @@ import {
|
||||
} 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>;
|
||||
@@ -164,6 +182,11 @@ export type PagePermission = Selectable<_PagePermissions>;
|
||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||
|
||||
// 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>;
|
||||
|
||||
Reference in New Issue
Block a user