feat: favorites (#2103)

* feat: favorites and templates(ee)

* rename migrations

* fix sidebar

* cleanup tabs

* fix

* turn off templates

* cleanup

* uuid validation
This commit is contained in:
Philip Okugbe
2026-04-12 22:06:25 +01:00
committed by GitHub
parent 57efb91bd3
commit d42091ccb1
90 changed files with 4557 additions and 187 deletions
@@ -0,0 +1,216 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils';
export const FavoriteType = {
PAGE: 'page',
SPACE: 'space',
TEMPLATE: 'template',
} as const;
export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType];
@Injectable()
export class FavoriteRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insert(favorite: InsertableFavorite): Promise<Favorite | undefined> {
try {
return await this.db
.insertInto('favorites')
.values(favorite)
.returningAll()
.executeTakeFirst();
} catch (err: any) {
if (err?.code === '23505') return undefined;
throw err;
}
}
async deleteByUserAndPage(userId: string, pageId: string): Promise<void> {
await this.db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.execute();
}
async deleteByUserAndSpace(userId: string, spaceId: string): Promise<void> {
await this.db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.where('type', '=', FavoriteType.SPACE)
.execute();
}
async deleteByUserAndTemplate(
userId: string,
templateId: string,
): Promise<void> {
await this.db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('templateId', '=', templateId)
.execute();
}
async findUserFavorites(
userId: string,
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
) {
let query = this.db
.selectFrom('favorites')
.selectAll('favorites')
.where('favorites.userId', '=', userId)
.where('favorites.workspaceId', '=', workspaceId);
if (type) {
query = query.where('favorites.type', '=', type);
}
if (type === FavoriteType.PAGE || !type) {
query = query.select((eb) => this.withPage(eb));
}
if (type === FavoriteType.PAGE) {
query = query.select((eb) => this.withPageSpace(eb));
} else if (type === FavoriteType.SPACE) {
query = query.select((eb) => this.withSpace(eb));
} else {
query = query.select((eb) => this.withSpaceResolved(eb));
}
if (type === FavoriteType.TEMPLATE || !type) {
query = query.select((eb) => this.withTemplate(eb));
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'favorites.id', direction: 'desc' }],
parseCursor: (cursor) => ({
id: cursor.id,
}),
});
}
async deleteByUsersWithoutSpaceAccess(
userIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
if (userIds.length === 0) return;
const { trx } = opts;
const db = dbOrTx(this.db, trx);
const usersWithAccess = db
.selectFrom('spaceMembers')
.select('userId')
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await db
.deleteFrom('favorites')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('userId', 'not in', usersWithAccess)
.execute();
}
async deleteByUserAndWorkspace(
userId: string,
workspaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('favorites')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.spaceId',
])
.whereRef('pages.id', '=', 'favorites.pageId'),
).as('page');
}
private withSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.whereRef('spaces.id', '=', 'favorites.spaceId'),
).as('space');
}
private withPageSpace(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.innerJoin('pages', 'pages.spaceId', 'spaces.id')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.whereRef('pages.id', '=', 'favorites.pageId'),
).as('space');
}
private withSpaceResolved(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
.where(({ or, ref }) =>
or([
sql<boolean>`${ref('spaces.id')} = ${ref('favorites.spaceId')}`,
sql<boolean>`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`,
]),
),
).as('space');
}
private withTemplate(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
.selectFrom('templates')
.select([
'templates.id',
'templates.title',
'templates.description',
'templates.icon',
'templates.spaceId',
])
.whereRef('templates.id', '=', 'favorites.templateId'),
).as('template');
}
}
@@ -324,6 +324,35 @@ export class PageRepo {
});
}
async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) {
let query = this.db
.selectFrom('pages')
.select(this.baseFields)
.select((eb) => this.withSpace(eb))
.where('creatorId', '=', creatorId)
.where('deletedAt', 'is', null);
if (spaceId) {
query = query.where('spaceId', '=', spaceId);
} else {
query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId));
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'updatedAt', direction: 'desc' },
{ expression: 'id', direction: 'desc' },
],
parseCursor: (cursor) => ({
updatedAt: new Date(cursor.updatedAt),
id: cursor.id,
}),
});
}
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pages')
@@ -290,6 +290,32 @@ export class SpaceMemberRepo {
return membership.map((space) => space.id);
}
async getUserRolesForSpaces(
userId: string,
spaceIds: string[],
): Promise<{ spaceId: string; role: string }[]> {
if (spaceIds.length === 0) return [];
return this.db
.selectFrom('spaceMembers')
.select(['spaceId', 'role'])
.where('userId', '=', userId)
.where('spaceId', 'in', spaceIds)
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['spaceMembers.spaceId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', 'in', spaceIds),
)
.execute();
}
async getUserSpaces(userId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('spaces')
@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableTemplate,
Page,
Template,
UpdatableTemplate,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@Injectable()
export class TemplateRepo {
private baseFields: Array<keyof Template> = [
'id',
'title',
'description',
'icon',
'spaceId',
'workspaceId',
'creatorId',
'lastUpdatedById',
'createdAt',
'updatedAt',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
templateId: string,
workspaceId: string,
opts?: { includeContent?: boolean; trx?: KyselyTransaction },
): Promise<Template> {
const db = dbOrTx(this.db, opts?.trx);
const query = db
.selectFrom('templates')
.select(this.baseFields)
.$if(opts?.includeContent ?? false, (qb) => qb.select('content'))
.select((eb) => [this.withCreator(eb)])
.where('id', '=', templateId)
.where('workspaceId', '=', workspaceId);
return query.executeTakeFirst() as Promise<Template>;
}
async findTemplates(
workspaceId: string,
accessibleSpaceIds: string[],
pagination: PaginationOptions,
opts?: { spaceId?: string },
) {
let query = this.db
.selectFrom('templates')
.select(this.baseFields)
.select((eb) => [this.withCreator(eb)])
.where('workspaceId', '=', workspaceId);
if (opts?.spaceId) {
if (!accessibleSpaceIds.includes(opts.spaceId)) {
query = query.where('spaceId', 'is', null);
} else {
query = query.where((eb) =>
eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
);
}
} else {
query = query.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
...(accessibleSpaceIds.length > 0
? [eb('spaceId', 'in', accessibleSpaceIds)]
: []),
]),
);
}
if (pagination.query) {
const searchTerm = `%${pagination.query}%`;
query = query.where((eb) =>
eb.or([
eb(sql`f_unaccent(title)`, 'ilike', sql`f_unaccent(${searchTerm})`),
eb(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${searchTerm})`,
),
]),
);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'title', direction: 'asc' },
{ expression: 'id', direction: 'asc' },
],
parseCursor: (cursor) => ({
title: cursor.title,
id: cursor.id,
}),
});
}
async insertTemplate(
insertableTemplate: InsertableTemplate,
trx?: KyselyTransaction,
): Promise<{ id: string }> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('templates')
.values(insertableTemplate)
.returning('id')
.executeTakeFirst();
}
async updateTemplate(
updatableTemplate: UpdatableTemplate,
templateId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('templates')
.set({ ...updatableTemplate, updatedAt: new Date() })
.where('id', '=', templateId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async deleteTemplate(
templateId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('templates')
.where('id', '=', templateId)
.where('workspaceId', '=', workspaceId)
.execute();
}
withCreator(eb: ExpressionBuilder<DB, 'templates'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'templates.creatorId'),
).as('creator');
}
}
@@ -230,4 +230,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateTemplateSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
}