filter/sort, file, person

This commit is contained in:
Philipinho
2026-03-08 03:15:49 +00:00
parent ac03a54ae6
commit 674b0ec64a
12 changed files with 982 additions and 59 deletions
@@ -47,6 +47,7 @@ import {
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service';
@@ -71,6 +72,7 @@ export class AttachmentController {
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly baseRepo: BaseRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
@@ -163,6 +165,87 @@ export class AttachmentController {
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('bases/files/upload')
@UseInterceptors(FileInterceptor)
async uploadBaseFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const baseId = file.fields?.baseId?.value;
if (!baseId) {
throw new BadRequestException('baseId is required');
}
if (!isValidUUID(baseId)) {
throw new BadRequestException('Invalid baseId');
}
const base = await this.baseRepo.findById(baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const spaceId = base.spaceId;
const spaceAbilityCheck = await this.spaceAbility.createForUser(
user,
spaceId,
);
if (
spaceAbilityCheck.cannot(
SpaceCaslAction.Edit,
SpaceCaslSubject.Base,
)
) {
throw new ForbiddenException();
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@@ -43,7 +43,7 @@ export class AttachmentService {
async uploadFile(opts: {
filePromise: Promise<MultipartFile>;
pageId: string;
pageId?: string;
userId: string;
spaceId: string;
workspaceId: string;
+11 -5
View File
@@ -119,10 +119,10 @@ const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
export function validateTypeOptions(
type: BasePropertyTypeValue,
typeOptions: unknown,
): z.SafeParseReturnType<unknown, unknown> {
): z.ZodSafeParseResult<unknown> {
const schema = typeOptionsSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.SafeParseError<unknown>;
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.ZodSafeParseError<unknown>;
}
return schema.safeParse(typeOptions ?? {});
}
@@ -146,7 +146,13 @@ const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
[BasePropertyType.DATE]: z.string(),
[BasePropertyType.PERSON]: z.array(z.string().uuid()),
[BasePropertyType.FILE]: z.array(z.string().uuid()),
[BasePropertyType.FILE]: z.array(z.object({
id: z.string().uuid(),
fileName: z.string(),
mimeType: z.string().optional(),
fileSize: z.number().optional(),
filePath: z.string().optional(),
})),
[BasePropertyType.CHECKBOX]: z.boolean(),
[BasePropertyType.URL]: z.string().url(),
[BasePropertyType.EMAIL]: z.string().email(),
@@ -161,10 +167,10 @@ export function getCellValueSchema(
export function validateCellValue(
type: BasePropertyTypeValue,
value: unknown,
): z.SafeParseReturnType<unknown, unknown> {
): z.ZodSafeParseResult<unknown> {
const schema = cellValueSchemaMap[type];
if (!schema) {
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError<unknown>;
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.ZodSafeParseError<unknown>;
}
return schema.safeParse(value);
}
@@ -1,4 +1,5 @@
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateRowDto {
@IsUUID()
@@ -27,6 +28,27 @@ export class RowIdDto {
baseId: string;
}
class FilterDto {
@IsUUID()
propertyId: string;
@IsString()
@IsNotEmpty()
operator: string;
@IsOptional()
value?: unknown;
}
class SortDto {
@IsUUID()
propertyId: string;
@IsString()
@IsNotEmpty()
direction: string;
}
export class ListRowsDto {
@IsUUID()
baseId: string;
@@ -34,6 +56,18 @@ export class ListRowsDto {
@IsOptional()
@IsUUID()
viewId?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FilterDto)
filters?: FilterDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SortDto)
sorts?: SortDto[];
}
export class ReorderRowDto {
@@ -93,7 +93,23 @@ export class BaseRowService {
}
async list(dto: ListRowsDto, pagination: PaginationOptions) {
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
const hasFilters = dto.filters && dto.filters.length > 0;
const hasSorts = dto.sorts && dto.sorts.length > 0;
if (!hasFilters && !hasSorts) {
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
}
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
const propertyTypeMap = new Map(properties.map((p) => [p.id, p.type]));
return this.baseRowRepo.findByBaseIdFiltered(
dto.baseId,
dto.filters ?? [],
dto.sorts ?? [],
propertyTypeMap,
pagination,
);
}
async reorder(dto: ReorderRowDto) {
@@ -8,7 +8,20 @@ import {
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { sql } from 'kysely';
import { sql, SelectQueryBuilder, SqlBool } from 'kysely';
import { DB } from '@docmost/db/types/db';
const SYSTEM_COLUMN_MAP: Record<string, string> = {
createdAt: 'createdAt',
lastEditedAt: 'updatedAt',
lastEditedBy: 'lastUpdatedById',
};
const ARRAY_TYPES = new Set(['multiSelect', 'person', 'file']);
function escapeIlike(value: string): string {
return value.replace(/[%_\\]/g, '\\$&');
}
@Injectable()
export class BaseRowRepo {
@@ -169,4 +182,261 @@ export class BaseRowRepo {
.execute();
}
}
async findByBaseIdFiltered(
baseId: string,
filters: Array<{ propertyId: string; operator: string; value?: unknown }>,
sorts: Array<{ propertyId: string; direction: string }>,
propertyTypeMap: Map<string, string>,
pagination: PaginationOptions,
opts?: { trx?: KyselyTransaction },
) {
const db = dbOrTx(this.db, opts?.trx);
let query = db
.selectFrom('baseRows')
.selectAll()
.where('baseId', '=', baseId)
.where('deletedAt', 'is', null) as SelectQueryBuilder<DB, 'baseRows', any>;
// Apply filters
for (const filter of filters) {
query = this.applyFilter(query, filter, propertyTypeMap);
}
// Apply sorts
for (const sort of sorts) {
query = this.applySort(query, sort, propertyTypeMap);
}
// Always add position, id as tiebreaker
query = query.orderBy('position', 'asc').orderBy('id', 'asc');
// Simple limit-based pagination (cursor pagination is not used when filters/sorts are active
// because JSONB-based cursor expressions are complex)
const limit = pagination.limit ?? 20;
const rows = await query.limit(limit + 1).execute();
const hasNextPage = rows.length > limit;
if (hasNextPage) rows.pop();
return {
items: rows,
meta: {
limit,
hasNextPage,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
};
}
private applyFilter(
query: SelectQueryBuilder<DB, 'baseRows', any>,
filter: { propertyId: string; operator: string; value?: unknown },
propertyTypeMap: Map<string, string>,
): SelectQueryBuilder<DB, 'baseRows', any> {
const { propertyId, operator, value } = filter;
const propertyType = propertyTypeMap.get(propertyId);
if (!propertyType) return query;
// System property -> use actual column
const systemCol = SYSTEM_COLUMN_MAP[propertyType];
if (systemCol) {
return this.applyColumnFilter(query, systemCol, operator, value, propertyType);
}
const isArray = ARRAY_TYPES.has(propertyType);
// isEmpty / isNotEmpty don't need a value
if (operator === 'isEmpty') {
if (isArray) {
return query.where(({ or, eb }) =>
or([
eb(sql.raw(`cells->'${propertyId}'`), 'is', null),
eb(sql`jsonb_array_length(cells->'${sql.raw(propertyId)}')`, '=', 0),
]),
);
}
return query.where(({ or, eb }) =>
or([
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
eb(sql.raw(`cells->>'${propertyId}'`), '=', ''),
]),
);
}
if (operator === 'isNotEmpty') {
if (isArray) {
return query
.where(sql.raw(`cells->'${propertyId}'`), 'is not', null)
.where(sql`jsonb_array_length(cells->'${sql.raw(propertyId)}')`, '>', 0);
}
return query
.where(sql.raw(`cells->>'${propertyId}'`), 'is not', null)
.where(sql.raw(`cells->>'${propertyId}'`), '!=', '');
}
if (value === undefined || value === null) return query;
// contains / notContains - text search
if (operator === 'contains') {
return query.where(
sql.raw(`cells->>'${propertyId}'`),
'ilike',
`%${escapeIlike(String(value))}%`,
);
}
if (operator === 'notContains') {
return query.where(({ or, eb }) =>
or([
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
eb(
sql.raw(`cells->>'${propertyId}'`),
'not ilike',
`%${escapeIlike(String(value))}%`,
),
]),
);
}
// equals / notEquals
if (operator === 'equals') {
if (isArray) {
return query.where(
sql<SqlBool>`cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb`,
);
}
if (propertyType === 'number') {
return query.where(
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric = ${Number(value)}`,
);
}
if (propertyType === 'checkbox') {
return query.where(
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::boolean = ${Boolean(value)}`,
);
}
return query.where(sql.raw(`cells->>'${propertyId}'`), '=', String(value));
}
if (operator === 'notEquals') {
if (isArray) {
return query.where(({ or, eb }) =>
or([
eb(sql.raw(`cells->'${propertyId}'`), 'is', null),
sql<SqlBool>`NOT (cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb)`,
]),
);
}
if (propertyType === 'number') {
return query.where(
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric != ${Number(value)}`,
);
}
if (propertyType === 'checkbox') {
return query.where(
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::boolean != ${Boolean(value)}`,
);
}
return query.where(({ or, eb }) =>
or([
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
eb(sql.raw(`cells->>'${propertyId}'`), '!=', String(value)),
]),
);
}
// greaterThan / lessThan - number
if (operator === 'greaterThan') {
return query.where(
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric > ${Number(value)}`,
);
}
if (operator === 'lessThan') {
return query.where(
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric < ${Number(value)}`,
);
}
// before / after - date
if (operator === 'before') {
return query.where(sql.raw(`cells->>'${propertyId}'`), '<', String(value));
}
if (operator === 'after') {
return query.where(sql.raw(`cells->>'${propertyId}'`), '>', String(value));
}
return query;
}
private applyColumnFilter(
query: SelectQueryBuilder<DB, 'baseRows', any>,
column: string,
operator: string,
value: unknown,
propertyType: string,
): SelectQueryBuilder<DB, 'baseRows', any> {
if (operator === 'isEmpty') {
return query.where(sql.raw(`"${column}"`), 'is', null);
}
if (operator === 'isNotEmpty') {
return query.where(sql.raw(`"${column}"`), 'is not', null);
}
if (value === undefined || value === null) return query;
if (operator === 'equals') {
return query.where(sql.raw(`"${column}"`), '=', value);
}
if (operator === 'notEquals') {
return query.where(({ or, eb }) =>
or([
eb(sql.raw(`"${column}"`), 'is', null),
eb(sql.raw(`"${column}"`), '!=', value),
]),
);
}
if (operator === 'before') {
return query.where(sql.raw(`"${column}"`), '<', value);
}
if (operator === 'after') {
return query.where(sql.raw(`"${column}"`), '>', value);
}
return query;
}
private applySort(
query: SelectQueryBuilder<DB, 'baseRows', any>,
sort: { propertyId: string; direction: string },
propertyTypeMap: Map<string, string>,
): SelectQueryBuilder<DB, 'baseRows', any> {
const { propertyId, direction } = sort;
const propertyType = propertyTypeMap.get(propertyId);
if (!propertyType) return query;
const dir = direction === 'desc' ? 'desc' : 'asc';
// System property -> use actual column
const systemCol = SYSTEM_COLUMN_MAP[propertyType];
if (systemCol) {
return query.orderBy(sql.raw(`"${systemCol}"`), sql`${sql.raw(dir)} NULLS LAST`);
}
// Number properties: cast to numeric for proper numeric ordering
if (propertyType === 'number') {
return query.orderBy(
sql`(cells->>'${sql.raw(propertyId)}')::numeric`,
sql`${sql.raw(dir)} NULLS LAST`,
);
}
// All other properties: use text extraction
return query.orderBy(
sql.raw(`cells->>'${propertyId}'`),
sql`${sql.raw(dir)} NULLS LAST`,
);
}
}