mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 23:14:07 +08:00
filter/sort, file, person
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user