mirror of
https://github.com/docmost/docmost.git
synced 2026-07-22 01:32:48 +10:00
Merge branch 'main' into base-formula
This commit is contained in:
@@ -11,6 +11,8 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PageRepo } from './repos/page/page.repo';
|
||||
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
||||
import { CommentRepo } from './repos/comment/comment.repo';
|
||||
import { PageTransclusionsRepo } from './repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from './repos/page-transclusions/page-transclusion-references.repo';
|
||||
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
||||
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
@@ -22,6 +24,7 @@ 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 { LabelRepo } from '@docmost/db/repos/label/label.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';
|
||||
@@ -79,6 +82,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
SpaceMemberRepo,
|
||||
PageRepo,
|
||||
PagePermissionRepo,
|
||||
PageTransclusionsRepo,
|
||||
PageTransclusionReferencesRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
@@ -89,6 +94,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
TemplateRepo,
|
||||
PageListener,
|
||||
BaseRepo,
|
||||
@@ -105,6 +111,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
SpaceMemberRepo,
|
||||
PageRepo,
|
||||
PagePermissionRepo,
|
||||
PageTransclusionsRepo,
|
||||
PageTransclusionReferencesRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
@@ -115,6 +123,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
TemplateRepo,
|
||||
BaseRepo,
|
||||
BasePropertyRepo,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('scim_tokens')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('token_hash', 'varchar', (col) => col.notNull())
|
||||
.addColumn('token_last_four', 'varchar(4)', (col) => col.notNull())
|
||||
.addColumn('last_used_at', 'timestamptz')
|
||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.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_scim_tokens_token_hash')
|
||||
.ifNotExists()
|
||||
.on('scim_tokens')
|
||||
.column('token_hash')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_scim_tokens_workspace_id')
|
||||
.ifNotExists()
|
||||
.on('scim_tokens')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('users')
|
||||
.addColumn('scim_external_id', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_users_workspace_scim_external_id')
|
||||
.ifNotExists()
|
||||
.on('users')
|
||||
.columns(['workspace_id', 'scim_external_id'])
|
||||
.where('scim_external_id', 'is not', null)
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('groups')
|
||||
.addColumn('scim_external_id', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_groups_workspace_scim_external_id')
|
||||
.ifNotExists()
|
||||
.on('groups')
|
||||
.columns(['workspace_id', 'scim_external_id'])
|
||||
.where('scim_external_id', 'is not', null)
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('groups')
|
||||
.addColumn('is_external', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Backfill: mark all non-default groups as external in workspaces with SSO group sync enabled
|
||||
await sql`
|
||||
UPDATE groups SET is_external = true
|
||||
WHERE is_default = false
|
||||
AND workspace_id IN (
|
||||
SELECT workspace_id FROM auth_providers WHERE group_sync = true
|
||||
)
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addColumn('is_scim_enabled', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('scim_tokens').execute();
|
||||
|
||||
await db.schema.dropIndex('idx_users_workspace_scim_external_id').execute();
|
||||
await db.schema.alterTable('users').dropColumn('scim_external_id').execute();
|
||||
|
||||
await db.schema.dropIndex('idx_groups_workspace_scim_external_id').execute();
|
||||
await db.schema.alterTable('groups').dropColumn('scim_external_id').execute();
|
||||
|
||||
await db.schema.alterTable('groups').dropColumn('is_external').execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.dropColumn('is_scim_enabled')
|
||||
.execute();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('page_transclusions')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.notNull().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('transclusion_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('content', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_transclusions_page_transclusion_unique', [
|
||||
'page_id',
|
||||
'transclusion_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_transclusions_workspace')
|
||||
.on('page_transclusions')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('page_transclusion_references')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('reference_page_id', 'uuid', (col) =>
|
||||
col.notNull().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('source_page_id', 'uuid', (col) =>
|
||||
col.notNull().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('transclusion_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_transclusion_references_unique', [
|
||||
'reference_page_id',
|
||||
'source_page_id',
|
||||
'transclusion_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_transclusion_references_source')
|
||||
.on('page_transclusion_references')
|
||||
.columns(['source_page_id', 'transclusion_id'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_transclusion_references_workspace')
|
||||
.on('page_transclusion_references')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_transclusion_references').execute();
|
||||
await db.schema.dropTable('page_transclusions').execute();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('labels')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('page'))
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('labels_workspace_id_type_name_unique')
|
||||
.on('labels')
|
||||
.columns(['workspace_id', 'type', 'name'])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('page_labels')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('label_id', 'uuid', (col) =>
|
||||
col.references('labels.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_labels_page_id_label_id_unique', [
|
||||
'page_id',
|
||||
'label_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('page_labels_label_id_idx')
|
||||
.on('page_labels')
|
||||
.column('label_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_labels').execute();
|
||||
await db.schema.dropTable('labels').execute();
|
||||
}
|
||||
@@ -89,6 +89,22 @@ export class AttachmentRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByIds(
|
||||
ids: string[],
|
||||
opts?: {
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Attachment[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
return db
|
||||
.selectFrom('attachments')
|
||||
.select(this.baseFields)
|
||||
.where('id', 'in', ids)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByAiChatId(
|
||||
aiChatId: string,
|
||||
opts?: {
|
||||
|
||||
@@ -7,10 +7,20 @@ 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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
executeWithCursorPagination,
|
||||
emptyCursorPaginationResult,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
backlinkId: string,
|
||||
@@ -69,4 +79,84 @@ export class BacklinkRepo {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
|
||||
}
|
||||
|
||||
async findRelatedPageIds(
|
||||
pageId: string,
|
||||
direction: 'incoming' | 'outgoing',
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const userSpaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
|
||||
|
||||
if (direction === 'incoming') {
|
||||
const rows = await this.db
|
||||
.selectFrom('backlinks')
|
||||
.innerJoin('pages', 'pages.id', 'backlinks.sourcePageId')
|
||||
.select('backlinks.sourcePageId as relatedId')
|
||||
.where('backlinks.targetPageId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
return rows.map((r) => r.relatedId);
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('backlinks')
|
||||
.innerJoin('pages', 'pages.id', 'backlinks.targetPageId')
|
||||
.select('backlinks.targetPageId as relatedId')
|
||||
.where('backlinks.sourcePageId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
return rows.map((r) => r.relatedId);
|
||||
}
|
||||
|
||||
async findPagesByIdsPaginated(
|
||||
pageIds: string[],
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
if (pageIds.length === 0) {
|
||||
return emptyCursorPaginationResult<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
updatedAt: Date;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
}>(pagination.limit);
|
||||
}
|
||||
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.slug', 'spaces.name'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
])
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.id', 'in', pageIds);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { DB, Groups } from '@docmost/db/types/db';
|
||||
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
||||
export class GroupRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof Groups> = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'isDefault',
|
||||
'isExternal',
|
||||
'creatorId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
];
|
||||
|
||||
async findById(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Group> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where('id', '=', groupId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -35,13 +53,18 @@ export class GroupRepo {
|
||||
async findByName(
|
||||
groupName: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Group> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -51,8 +74,11 @@ export class GroupRepo {
|
||||
updatableGroup: UpdatableGroup,
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.updateTable('groups')
|
||||
.set({ ...updatableGroup, updatedAt: new Date() })
|
||||
.where('id', '=', groupId)
|
||||
@@ -68,7 +94,7 @@ export class GroupRepo {
|
||||
return db
|
||||
.insertInto('groups')
|
||||
.values(insertableGroup)
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -80,7 +106,7 @@ export class GroupRepo {
|
||||
return (
|
||||
db
|
||||
.selectFrom('groups')
|
||||
.selectAll()
|
||||
.select(this.baseFields)
|
||||
// .select((eb) => this.withMemberCount(eb))
|
||||
.where('isDefault', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
@@ -106,7 +132,7 @@ export class GroupRepo {
|
||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||
let baseQuery = this.db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withMemberCount(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { normalizeLabelName } from '../../../core/label/utils';
|
||||
|
||||
export const LabelType = {
|
||||
PAGE: 'page',
|
||||
SPACE: 'space',
|
||||
} as const;
|
||||
|
||||
export type LabelType = (typeof LabelType)[keyof typeof LabelType];
|
||||
|
||||
@Injectable()
|
||||
export class LabelRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
labelId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('id', '=', labelId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByNameAndWorkspace(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
type: LabelType,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('name', '=', normalizeLabelName(name))
|
||||
.where('type', '=', type)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findOrCreate(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
type: LabelType,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const normalizedName = normalizeLabelName(name);
|
||||
|
||||
// DO UPDATE (rather than DO NOTHING) so RETURNING always emits a row,
|
||||
// even on conflict. Avoids a race where a follow-up SELECT could miss a
|
||||
// row inserted by a concurrent transaction. The set is a no-op write.
|
||||
return db
|
||||
.insertInto('labels')
|
||||
.values({ name: normalizedName, type, workspaceId })
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['name', 'type', 'workspaceId'])
|
||||
.doUpdateSet({ name: normalizedName }),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
|
||||
.select([
|
||||
'labels.id',
|
||||
'labels.name',
|
||||
'labels.type',
|
||||
'labels.createdAt',
|
||||
'labels.updatedAt',
|
||||
'labels.workspaceId',
|
||||
'pageLabels.id as joinId',
|
||||
])
|
||||
.where('pageLabels.pageId', '=', pageId)
|
||||
.where('labels.type', '=', LabelType.PAGE);
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pageLabels.id', direction: 'asc', key: 'joinId' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
joinId: cursor.joinId,
|
||||
}),
|
||||
});
|
||||
|
||||
// joinId is an internal pagination cursor; don't leak it to callers.
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(({ joinId: _joinId, ...rest }) => rest),
|
||||
};
|
||||
}
|
||||
|
||||
async findLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
// Label visibility is scoped to space membership: a label surfaces if it
|
||||
// is attached to any non-deleted page in a space the user belongs to.
|
||||
// Per-page permission restrictions intentionally do not narrow this
|
||||
// further — labels are a space-level concept, not a page-level one.
|
||||
let query = this.db
|
||||
.selectFrom('labels')
|
||||
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', type)
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select('pageLabels.labelId')
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where(
|
||||
'name',
|
||||
'like',
|
||||
`%${pagination.query.toLowerCase()}%`,
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'name', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
name: cursor.name,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async addLabelToPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.insertInto('pageLabels')
|
||||
.values({ pageId, labelId })
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('pageLabels')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('labelId', '=', labelId)
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.select('id')
|
||||
.whereRef('labels.id', '=', 'pageLabels.labelId')
|
||||
.where('labels.workspaceId', '=', workspaceId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getPageLabelCount(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('pageLabels')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.where('pageId', '=', pageId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async getLabelPageCount(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('labels', 'labels.id', 'pageLabels.labelId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('labels.workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async deleteLabel(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('labels')
|
||||
.where('id', '=', labelId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPagesByLabelId(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
'pages.createdAt',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'pages.creatorId'),
|
||||
).as('creator'),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels as pl', 'pl.labelId', 'labels.id')
|
||||
.select(['labels.id', 'labels.name'])
|
||||
.whereRef('pl.pageId', '=', 'pages.id')
|
||||
.where('labels.type', '=', LabelType.PAGE)
|
||||
.orderBy('pl.id', 'asc'),
|
||||
).as('labels'),
|
||||
])
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (opts.spaceId) {
|
||||
query = query.where('pages.spaceId', '=', opts.spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.query) {
|
||||
query = query.where('pages.title', 'ilike', `%${opts.query}%`);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getLabelPageCountForUser(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<number> {
|
||||
let query = this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (spaceId) {
|
||||
query = query.where('pages.spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await query.executeTakeFirst();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
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 {
|
||||
InsertablePageTransclusionReference,
|
||||
PageTransclusionReference,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
|
||||
export type TransclusionReferenceKey = {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PageTransclusionReferencesRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findByReferencePageId(
|
||||
referencePageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusionReference[]> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusionReferences')
|
||||
.selectAll()
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findReferencePageIdsByTransclusion(
|
||||
sourcePageId: string,
|
||||
transclusionId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string[]> {
|
||||
const rows = await dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusionReferences')
|
||||
.select('referencePageId')
|
||||
.distinct()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('sourcePageId', '=', sourcePageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.execute();
|
||||
return rows.map((r) => r.referencePageId);
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
rows: InsertablePageTransclusionReference[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.insertInto('pageTransclusionReferences')
|
||||
.values(rows)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['referencePageId', 'sourcePageId', 'transclusionId'])
|
||||
.doNothing(),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByReferenceAndKeys(
|
||||
referencePageId: string,
|
||||
keys: TransclusionReferenceKey[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTransclusionReferences')
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
keys.map((k) =>
|
||||
eb.and([
|
||||
eb('sourcePageId', '=', k.sourcePageId),
|
||||
eb('transclusionId', '=', k.transclusionId),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteOne(
|
||||
referencePageId: string,
|
||||
sourcePageId: string,
|
||||
transclusionId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTransclusionReferences')
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.where('sourcePageId', '=', sourcePageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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 {
|
||||
InsertablePageTransclusion,
|
||||
PageTransclusion,
|
||||
UpdatablePageTransclusion,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class PageTransclusionsRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion[]> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusions')
|
||||
.selectAll()
|
||||
.where('pageId', '=', pageId)
|
||||
.orderBy('createdAt', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByPageAndTransclusion(
|
||||
pageId: string,
|
||||
transclusionId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusions')
|
||||
.selectAll()
|
||||
.where('pageId', '=', pageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findManyByPageAndTransclusion(
|
||||
keys: Array<{ pageId: string; transclusionId: string }>,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion[]> {
|
||||
if (keys.length === 0) return [];
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusions')
|
||||
.selectAll()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
keys.map((k) =>
|
||||
eb.and([
|
||||
eb('pageId', '=', k.pageId),
|
||||
eb('transclusionId', '=', k.transclusionId),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async insert(
|
||||
data: InsertablePageTransclusion,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.insertInto('pageTransclusions')
|
||||
.values(data)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
data: InsertablePageTransclusion[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (data.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.insertInto('pageTransclusions')
|
||||
.values(data)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async update(
|
||||
pageId: string,
|
||||
transclusionId: string,
|
||||
data: UpdatablePageTransclusion,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await dbOrTx(this.db, trx)
|
||||
.updateTable('pageTransclusions')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('pageId', '=', pageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByPageAndTransclusionIds(
|
||||
pageId: string,
|
||||
transclusionIds: string[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (transclusionIds.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTransclusions')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('transclusionId', 'in', transclusionIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
@@ -17,6 +19,11 @@ import {
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { PagePermissionMember } from './types/page-permission.types';
|
||||
import { withCache } from '../../../common/helpers/with-cache';
|
||||
import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
|
||||
export { PagePermissionMember } from './types/page-permission.types';
|
||||
|
||||
@@ -25,6 +32,7 @@ export class PagePermissionRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly groupRepo: GroupRepo,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
async findPageAccessByPageId(
|
||||
@@ -361,40 +369,8 @@ export class PagePermissionRepo {
|
||||
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
|
||||
*/
|
||||
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
|
||||
const deniedAncestor = await this.db
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId']),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.leftJoin('pagePermissions', (join) =>
|
||||
join
|
||||
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
||||
.on((eb) =>
|
||||
eb.or([
|
||||
eb('pagePermissions.userId', '=', userId),
|
||||
eb(
|
||||
'pagePermissions.groupId',
|
||||
'in',
|
||||
this.userGroupIdsSubquery(eb, userId),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select('pageAccess.pageId')
|
||||
.where('pagePermissions.id', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return !deniedAncestor;
|
||||
const { canAccess } = await this.canUserEditPage(userId, pageId);
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,43 +388,50 @@ export class PagePermissionRepo {
|
||||
canAccess: boolean;
|
||||
canEdit: boolean;
|
||||
}> {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
)
|
||||
SELECT
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
|
||||
AND (
|
||||
pp.user_id = ${userId}::uuid
|
||||
OR pp.group_id IN (
|
||||
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
|
||||
return withCache(
|
||||
this.cacheManager,
|
||||
CacheKey.PAGE_CAN_EDIT(userId, pageId),
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
async () => {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
)
|
||||
)
|
||||
`.execute(this.db);
|
||||
SELECT
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
|
||||
AND (
|
||||
pp.user_id = ${userId}::uuid
|
||||
OR pp.group_id IN (
|
||||
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
|
||||
)
|
||||
)
|
||||
`.execute(this.db);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: row.canAccess,
|
||||
canEdit: row.canAccess && (row.canEdit ?? false),
|
||||
};
|
||||
const row = result.rows[0];
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: row.canAccess,
|
||||
canEdit: row.canAccess && (row.canEdit ?? false),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,7 @@ export class PageRepo {
|
||||
includeCreator?: boolean;
|
||||
includeLastUpdatedBy?: boolean;
|
||||
includeContributors?: boolean;
|
||||
includeDeletedBy?: boolean;
|
||||
includeHasChildren?: boolean;
|
||||
withLock?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
@@ -84,6 +85,10 @@ export class PageRepo {
|
||||
query = query.select((eb) => this.withContributors(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeDeletedBy) {
|
||||
query = query.select((eb) => this.withDeletedBy(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeSpace) {
|
||||
query = query.select((eb) => this.withSpace(eb));
|
||||
}
|
||||
@@ -101,6 +106,30 @@ export class PageRepo {
|
||||
return query.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findManyByIds(
|
||||
pageIds: string[],
|
||||
opts?: {
|
||||
trx?: KyselyTransaction;
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<Page[]> {
|
||||
if (pageIds.length === 0) return [];
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
let query = db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.where('id', 'in', pageIds);
|
||||
|
||||
if (opts?.workspaceId) {
|
||||
query = query
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null);
|
||||
}
|
||||
|
||||
return query.execute();
|
||||
}
|
||||
|
||||
async updatePage(
|
||||
updatablePage: UpdatablePage,
|
||||
pageId: string,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
@@ -13,6 +15,11 @@ import { MemberInfo, UserSpaceRole } from './types';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { withCache } from '../../../common/helpers/with-cache';
|
||||
import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceMemberRepo {
|
||||
@@ -20,6 +27,7 @@ export class SpaceMemberRepo {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly groupRepo: GroupRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
async insertSpaceMember(
|
||||
@@ -214,25 +222,36 @@ export class SpaceMemberRepo {
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
): Promise<UserSpaceRole[]> {
|
||||
const roles = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select(['userId', 'role'])
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.unionAll(
|
||||
this.db
|
||||
return withCache(
|
||||
this.cacheManager,
|
||||
CacheKey.SPACE_ROLES(userId, spaceId),
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
async () => {
|
||||
const roles = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select(['groupUsers.userId', 'spaceMembers.role'])
|
||||
.where('groupUsers.userId', '=', userId)
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
)
|
||||
.execute();
|
||||
.select(['userId', 'role'])
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.unionAll(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin(
|
||||
'groupUsers',
|
||||
'groupUsers.groupId',
|
||||
'spaceMembers.groupId',
|
||||
)
|
||||
.select(['groupUsers.userId', 'spaceMembers.role'])
|
||||
.where('groupUsers.userId', '=', userId)
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (!roles || roles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return roles;
|
||||
if (!roles || roles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return roles;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getUserIdsWithSpaceAccess(
|
||||
|
||||
@@ -63,11 +63,9 @@ export class TemplateRepo {
|
||||
|
||||
if (opts?.spaceId) {
|
||||
if (!accessibleSpaceIds.includes(opts.spaceId)) {
|
||||
query = query.where('spaceId', 'is', null);
|
||||
query = query.where(sql<boolean>`false`);
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
|
||||
);
|
||||
query = query.where('spaceId', '=', opts.spaceId);
|
||||
}
|
||||
} else {
|
||||
query = query.where((eb) =>
|
||||
|
||||
@@ -44,6 +44,7 @@ export class UserRepo {
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -53,6 +54,7 @@ export class UserRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where('id', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -64,6 +66,7 @@ export class UserRepo {
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -73,6 +76,7 @@ export class UserRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
'trashRetentionDays',
|
||||
'isScimEnabled',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
|
||||
+58
@@ -258,7 +258,9 @@ export interface Groups {
|
||||
description: string | null;
|
||||
id: Generated<string>;
|
||||
isDefault: boolean;
|
||||
isExternal: Generated<boolean>;
|
||||
name: string;
|
||||
scimExternalId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
@@ -271,6 +273,25 @@ export interface GroupUsers {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface PageTransclusionReferences {
|
||||
createdAt: Generated<Timestamp>;
|
||||
transclusionId: string;
|
||||
referencePageId: string;
|
||||
id: Generated<string>;
|
||||
sourcePageId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface PageTransclusions {
|
||||
content: Json;
|
||||
createdAt: Generated<Timestamp>;
|
||||
transclusionId: string;
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface PageHistory {
|
||||
content: Json | null;
|
||||
contributorIds: Generated<string[] | null>;
|
||||
@@ -385,6 +406,7 @@ export interface Users {
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
role: string | null;
|
||||
scimExternalId: string | null;
|
||||
settings: Json | null;
|
||||
timezone: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
@@ -428,6 +450,7 @@ export interface Workspaces {
|
||||
enforceMfa: Generated<boolean | null>;
|
||||
enforceSso: Generated<boolean>;
|
||||
hostname: string | null;
|
||||
isScimEnabled: Generated<boolean>;
|
||||
id: Generated<string>;
|
||||
licenseKey: string | null;
|
||||
logo: string | null;
|
||||
@@ -457,6 +480,20 @@ export interface Notifications {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface ScimTokens {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isEnabled: Generated<boolean>;
|
||||
lastUsedAt: Timestamp | null;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenLastFour: string;
|
||||
creatorId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Watchers {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
@@ -469,6 +506,15 @@ export interface Watchers {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Labels {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
type: Generated<string>;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageAccess {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
@@ -480,6 +526,13 @@ export interface PageAccess {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageLabels {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
labelId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PagePermissions {
|
||||
id: Generated<string>;
|
||||
pageAccessId: string;
|
||||
@@ -601,13 +654,18 @@ export interface DB {
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
labels: Labels;
|
||||
notifications: Notifications;
|
||||
pageAccess: PageAccess;
|
||||
pageTransclusionReferences: PageTransclusionReferences;
|
||||
pageTransclusions: PageTransclusions;
|
||||
pagePermissions: PagePermissions;
|
||||
pageHistory: PageHistory;
|
||||
pageLabels: PageLabels;
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
BaseViews,
|
||||
Comments,
|
||||
Groups,
|
||||
Labels,
|
||||
Notifications,
|
||||
PageLabels,
|
||||
PageAccess as _PageAccess,
|
||||
PageTransclusions,
|
||||
PageTransclusionReferences,
|
||||
PagePermissions as _PagePermissions,
|
||||
PageVerifications as _PageVerifications,
|
||||
PageVerifiers as _PageVerifiers,
|
||||
@@ -32,6 +36,7 @@ import {
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
ScimTokens,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
@@ -147,6 +152,18 @@ export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
export type UpdatableFavorite = Updateable<Omit<Favorites, 'id'>>;
|
||||
|
||||
// Page Transclusion
|
||||
export type PageTransclusion = Selectable<PageTransclusions>;
|
||||
export type InsertablePageTransclusion = Insertable<PageTransclusions>;
|
||||
export type UpdatablePageTransclusion = Updateable<Omit<PageTransclusions, 'id'>>;
|
||||
|
||||
// Page Transclusion Reference
|
||||
export type PageTransclusionReference = Selectable<PageTransclusionReferences>;
|
||||
export type InsertablePageTransclusionReference = Insertable<PageTransclusionReferences>;
|
||||
export type UpdatablePageTransclusionReference = Updateable<
|
||||
Omit<PageTransclusionReferences, 'id'>
|
||||
>;
|
||||
|
||||
// File Task
|
||||
export type FileTask = Selectable<FileTasks>;
|
||||
export type InsertableFileTask = Insertable<FileTasks>;
|
||||
@@ -162,6 +179,11 @@ export type ApiKey = Selectable<ApiKeys>;
|
||||
export type InsertableApiKey = Insertable<ApiKeys>;
|
||||
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
||||
|
||||
// Scim Tokens
|
||||
export type ScimToken = Selectable<ScimTokens>;
|
||||
export type InsertableScimToken = Insertable<ScimTokens>;
|
||||
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
|
||||
|
||||
// Page Embedding
|
||||
export type PageEmbedding = Selectable<PageEmbeddings>;
|
||||
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
|
||||
@@ -177,6 +199,15 @@ export type Watcher = Selectable<Watchers>;
|
||||
export type InsertableWatcher = Insertable<Watchers>;
|
||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||
|
||||
// Label
|
||||
export type Label = Selectable<Labels>;
|
||||
export type InsertableLabel = Insertable<Labels>;
|
||||
export type UpdatableLabel = Updateable<Omit<Labels, 'id'>>;
|
||||
|
||||
// PageLabel
|
||||
export type PageLabel = Selectable<PageLabels>;
|
||||
export type InsertablePageLabel = Insertable<PageLabels>;
|
||||
|
||||
// Page Access
|
||||
export type PageAccess = Selectable<_PageAccess>;
|
||||
export type InsertablePageAccess = Insertable<_PageAccess>;
|
||||
|
||||
Reference in New Issue
Block a user