mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 22:53:08 +08:00
page property
This commit is contained in:
@@ -9,6 +9,7 @@ import { BasePropertyService } from './services/base-property.service';
|
||||
import { BaseRowService } from './services/base-row.service';
|
||||
import { BaseViewService } from './services/base-view.service';
|
||||
import { BaseCsvExportService } from './services/base-csv-export.service';
|
||||
import { BasePageResolverService } from './services/base-page-resolver.service';
|
||||
import { BaseQueueProcessor } from './processors/base-queue.processor';
|
||||
import { BaseWsService } from './realtime/base-ws.service';
|
||||
import { BaseWsConsumers } from './realtime/base-ws-consumers';
|
||||
@@ -29,6 +30,7 @@ import { QueueName } from '../../integrations/queue/constants';
|
||||
BaseRowService,
|
||||
BaseViewService,
|
||||
BaseCsvExportService,
|
||||
BasePageResolverService,
|
||||
BaseQueueProcessor,
|
||||
BasePresenceService,
|
||||
BaseWsService,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const BasePropertyType = {
|
||||
DATE: 'date',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
PAGE: 'page',
|
||||
CHECKBOX: 'checkbox',
|
||||
URL: 'url',
|
||||
EMAIL: 'email',
|
||||
@@ -114,6 +115,7 @@ const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
||||
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
||||
[BasePropertyType.PERSON]: personTypeOptionsSchema,
|
||||
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
||||
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
||||
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
|
||||
@@ -159,6 +161,7 @@ const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
||||
fileSize: z.number().optional(),
|
||||
filePath: z.string().optional(),
|
||||
})),
|
||||
[BasePropertyType.PAGE]: z.uuid(),
|
||||
[BasePropertyType.CHECKBOX]: z.boolean(),
|
||||
[BasePropertyType.URL]: z.url(),
|
||||
[BasePropertyType.EMAIL]: z.email(),
|
||||
@@ -192,6 +195,7 @@ export type CellConversionContext = {
|
||||
fromTypeOptions?: unknown;
|
||||
userNames?: Map<string, string>;
|
||||
attachmentNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>;
|
||||
};
|
||||
|
||||
function resolveChoiceName(
|
||||
@@ -256,6 +260,16 @@ export function attemptCellConversion(
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return { converted: true, value: parts.join(', ') };
|
||||
}
|
||||
if (fromType === BasePropertyType.PAGE && typeof value === 'string') {
|
||||
const title = ctx.pageTitles?.get(value);
|
||||
return { converted: true, value: title ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
// Page cells only accept a page UUID. Free text / other IDs can't be
|
||||
// coerced into a valid page reference — drop to null.
|
||||
if (toType === BasePropertyType.PAGE && fromType !== BasePropertyType.PAGE) {
|
||||
return { converted: true, value: null };
|
||||
}
|
||||
|
||||
const targetSchema = cellValueSchemaMap[toType];
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { BaseService } from '../services/base.service';
|
||||
import { BaseCsvExportService } from '../services/base-csv-export.service';
|
||||
import { BasePageResolverService } from '../services/base-page-resolver.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateBaseDto } from '../dto/create-base.dto';
|
||||
import { UpdateBaseDto } from '../dto/update-base.dto';
|
||||
import { BaseIdDto } from '../dto/base.dto';
|
||||
import { ExportBaseCsvDto } from '../dto/export-base.dto';
|
||||
import { ResolvePagesDto } from '../dto/resolve-pages.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
@@ -35,6 +37,7 @@ export class BaseController {
|
||||
constructor(
|
||||
private readonly baseService: BaseService,
|
||||
private readonly baseCsvExportService: BaseCsvExportService,
|
||||
private readonly basePageResolverService: BasePageResolverService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
@@ -138,4 +141,19 @@ export class BaseController {
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/resolve')
|
||||
async resolvePages(
|
||||
@Body() dto: ResolvePagesDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const items = await this.basePageResolverService.resolvePages(
|
||||
dto.pageIds,
|
||||
workspace.id,
|
||||
user.id,
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ResolvePagesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
@IsUUID('all', { each: true })
|
||||
pageIds: string[];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const PropertyKind = {
|
||||
MULTI: 'multi',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
PAGE: 'page',
|
||||
SYS_USER: 'sys_user',
|
||||
} as const;
|
||||
|
||||
@@ -37,6 +38,8 @@ export function propertyKind(type: string): PropertyKindValue | null {
|
||||
return PropertyKind.PERSON;
|
||||
case BasePropertyType.FILE:
|
||||
return PropertyKind.FILE;
|
||||
case BasePropertyType.PAGE:
|
||||
return PropertyKind.PAGE;
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return PropertyKind.SYS_USER;
|
||||
default:
|
||||
|
||||
@@ -66,6 +66,8 @@ function buildCondition(
|
||||
return personCondition(eb, cond, prop);
|
||||
case PropertyKind.FILE:
|
||||
return arrayOfIdsCondition(eb, cond);
|
||||
case PropertyKind.PAGE:
|
||||
return pageCondition(eb, cond);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
@@ -292,6 +294,48 @@ function personCondition(
|
||||
}
|
||||
}
|
||||
|
||||
function pageCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
// Page cells store a single page uuid as text. Shape matches selectCondition.
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(expr as any, 'in', arr);
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, 'not in', arr),
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = arrayCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
|
||||
@@ -87,4 +87,16 @@ describe('serializeCellForCsv', () => {
|
||||
expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob');
|
||||
expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe('');
|
||||
});
|
||||
|
||||
it('page resolves via pageTitles', () => {
|
||||
const pageTitles = new Map([
|
||||
['p1', 'Launch plan'],
|
||||
['p2', 'Retro notes'],
|
||||
]);
|
||||
const prop = p(BasePropertyType.PAGE);
|
||||
expect(serializeCellForCsv(prop, 'p1', { pageTitles })).toBe('Launch plan');
|
||||
expect(serializeCellForCsv(prop, 'missing', { pageTitles })).toBe('');
|
||||
expect(serializeCellForCsv(prop, 'p1', {})).toBe('');
|
||||
expect(serializeCellForCsv(prop, 123, { pageTitles })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas';
|
||||
|
||||
export type CellCsvContext = {
|
||||
userNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>;
|
||||
};
|
||||
|
||||
type PropertyLike = {
|
||||
@@ -81,6 +82,10 @@ export function serializeCellForCsv(
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return resolveUser(value, ctx);
|
||||
|
||||
case BasePropertyType.PAGE:
|
||||
if (typeof value !== 'string') return '';
|
||||
return ctx.pageTitles?.get(value) ?? '';
|
||||
|
||||
default:
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
@@ -138,40 +138,68 @@ export class BaseCsvExportService {
|
||||
chunk: Array<{ cells: unknown; lastUpdatedById: string | null }>,
|
||||
properties: Array<{ id: string; type: string }>,
|
||||
): Promise<CellCsvContext> {
|
||||
const ctx: CellCsvContext = {};
|
||||
|
||||
const needsUsers = properties.some(
|
||||
(p) =>
|
||||
p.type === BasePropertyType.PERSON ||
|
||||
p.type === BasePropertyType.LAST_EDITED_BY,
|
||||
);
|
||||
if (!needsUsers) return {};
|
||||
|
||||
const userIds = new Set<string>();
|
||||
const personPropIds = properties
|
||||
.filter((p) => p.type === BasePropertyType.PERSON)
|
||||
.map((p) => p.id);
|
||||
if (needsUsers) {
|
||||
const userIds = new Set<string>();
|
||||
const personPropIds = properties
|
||||
.filter((p) => p.type === BasePropertyType.PERSON)
|
||||
.map((p) => p.id);
|
||||
|
||||
for (const row of chunk) {
|
||||
if (row.lastUpdatedById) userIds.add(row.lastUpdatedById);
|
||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||
for (const pid of personPropIds) {
|
||||
const v = cells[pid];
|
||||
if (typeof v === 'string') userIds.add(v);
|
||||
else if (Array.isArray(v)) {
|
||||
for (const id of v) if (typeof id === 'string') userIds.add(id);
|
||||
for (const row of chunk) {
|
||||
if (row.lastUpdatedById) userIds.add(row.lastUpdatedById);
|
||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||
for (const pid of personPropIds) {
|
||||
const v = cells[pid];
|
||||
if (typeof v === 'string') userIds.add(v);
|
||||
else if (Array.isArray(v)) {
|
||||
for (const id of v) if (typeof id === 'string') userIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size > 0) {
|
||||
const rows = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email'])
|
||||
.where('id', 'in', Array.from(userIds))
|
||||
.execute();
|
||||
ctx.userNames = new Map(
|
||||
rows.map((u) => [u.id, u.name || u.email || '']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size === 0) return {};
|
||||
const pagePropIds = properties
|
||||
.filter((p) => p.type === BasePropertyType.PAGE)
|
||||
.map((p) => p.id);
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email'])
|
||||
.where('id', 'in', Array.from(userIds))
|
||||
.execute();
|
||||
if (pagePropIds.length > 0) {
|
||||
const pageIds = new Set<string>();
|
||||
for (const row of chunk) {
|
||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||
for (const pid of pagePropIds) {
|
||||
const v = cells[pid];
|
||||
if (typeof v === 'string' && v.length > 0) pageIds.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userNames: new Map(rows.map((u) => [u.id, u.name || u.email || ''])),
|
||||
};
|
||||
if (pageIds.size > 0) {
|
||||
const rows = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title'])
|
||||
.where('id', 'in', Array.from(pageIds))
|
||||
.execute();
|
||||
ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? '']));
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
export type ResolvedPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BasePageResolverService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async resolvePages(
|
||||
pageIds: string[],
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<ResolvedPage[]> {
|
||||
const unique = Array.from(new Set(pageIds));
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
)
|
||||
.where('pages.id', 'in', unique)
|
||||
.where('pages.workspaceId', '=', workspaceId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const accessible = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: rows.map((r) => r.id),
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessible);
|
||||
|
||||
return rows.filter((r) => accessibleSet.has(r.id));
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,16 @@ async function buildCtx(
|
||||
.execute();
|
||||
ctx.attachmentNames = new Map(rows.map((a) => [a.id, a.fileName]));
|
||||
}
|
||||
} else if (fromType === BasePropertyType.PAGE) {
|
||||
const ids = collectIds(chunk, propertyId);
|
||||
if (ids.size > 0) {
|
||||
const rows = await db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title'])
|
||||
.where('id', 'in', Array.from(ids))
|
||||
.execute();
|
||||
ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? '']));
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
|
||||
Reference in New Issue
Block a user