mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 18:16:57 +08:00
feat: bases - WIP
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BaseController } from './controllers/base.controller';
|
||||
import { BasePropertyController } from './controllers/base-property.controller';
|
||||
import { BaseRowController } from './controllers/base-row.controller';
|
||||
import { BaseViewController } from './controllers/base-view.controller';
|
||||
import { BaseService } from './services/base.service';
|
||||
import { BasePropertyService } from './services/base-property.service';
|
||||
import { BaseRowService } from './services/base-row.service';
|
||||
import { BaseViewService } from './services/base-view.service';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
BaseController,
|
||||
BasePropertyController,
|
||||
BaseRowController,
|
||||
BaseViewController,
|
||||
],
|
||||
providers: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
|
||||
exports: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
|
||||
})
|
||||
export class BaseModule {}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BasePropertyType = {
|
||||
TEXT: 'text',
|
||||
NUMBER: 'number',
|
||||
SELECT: 'select',
|
||||
STATUS: 'status',
|
||||
MULTI_SELECT: 'multiSelect',
|
||||
DATE: 'date',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
CHECKBOX: 'checkbox',
|
||||
URL: 'url',
|
||||
EMAIL: 'email',
|
||||
CREATED_AT: 'createdAt',
|
||||
LAST_EDITED_AT: 'lastEditedAt',
|
||||
LAST_EDITED_BY: 'lastEditedBy',
|
||||
} as const;
|
||||
|
||||
const SYSTEM_PROPERTY_TYPES: Set<string> = new Set([
|
||||
BasePropertyType.CREATED_AT,
|
||||
BasePropertyType.LAST_EDITED_AT,
|
||||
BasePropertyType.LAST_EDITED_BY,
|
||||
]);
|
||||
|
||||
export function isSystemPropertyType(type: string): boolean {
|
||||
return SYSTEM_PROPERTY_TYPES.has(type);
|
||||
}
|
||||
|
||||
export type BasePropertyTypeValue =
|
||||
(typeof BasePropertyType)[keyof typeof BasePropertyType];
|
||||
|
||||
export const BASE_PROPERTY_TYPES = Object.values(BasePropertyType);
|
||||
|
||||
export const choiceSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
color: z.string(),
|
||||
category: z.enum(['todo', 'inProgress', 'complete']).optional(),
|
||||
});
|
||||
|
||||
export const selectTypeOptionsSchema = z
|
||||
.object({
|
||||
choices: z.array(choiceSchema).default([]),
|
||||
choiceOrder: z.array(z.string().uuid()).default([]),
|
||||
disableColors: z.boolean().optional(),
|
||||
defaultValue: z
|
||||
.union([z.string().uuid(), z.array(z.string().uuid())])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const numberTypeOptionsSchema = z
|
||||
.object({
|
||||
format: z
|
||||
.enum(['plain', 'currency', 'percent', 'progress'])
|
||||
.optional()
|
||||
.default('plain'),
|
||||
precision: z.number().int().min(0).max(10).optional(),
|
||||
currencySymbol: z.string().max(5).optional(),
|
||||
defaultValue: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const dateTypeOptionsSchema = z
|
||||
.object({
|
||||
dateFormat: z.string().optional(),
|
||||
timeFormat: z.enum(['12h', '24h']).optional(),
|
||||
includeTime: z.boolean().optional(),
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const textTypeOptionsSchema = z
|
||||
.object({
|
||||
richText: z.boolean().optional(),
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const checkboxTypeOptionsSchema = z
|
||||
.object({
|
||||
defaultValue: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const urlTypeOptionsSchema = z
|
||||
.object({
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const emailTypeOptionsSchema = z
|
||||
.object({
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const emptyTypeOptionsSchema = z.object({}).passthrough();
|
||||
|
||||
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
||||
[BasePropertyType.TEXT]: textTypeOptionsSchema,
|
||||
[BasePropertyType.NUMBER]: numberTypeOptionsSchema,
|
||||
[BasePropertyType.SELECT]: selectTypeOptionsSchema,
|
||||
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
|
||||
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
|
||||
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
||||
[BasePropertyType.PERSON]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
||||
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
||||
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
|
||||
[BasePropertyType.CREATED_AT]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.LAST_EDITED_AT]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.LAST_EDITED_BY]: emptyTypeOptionsSchema,
|
||||
};
|
||||
|
||||
export function validateTypeOptions(
|
||||
type: BasePropertyTypeValue,
|
||||
typeOptions: unknown,
|
||||
): z.SafeParseReturnType<unknown, 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 schema.safeParse(typeOptions ?? {});
|
||||
}
|
||||
|
||||
export function parseTypeOptions(
|
||||
type: BasePropertyTypeValue,
|
||||
typeOptions: unknown,
|
||||
): unknown {
|
||||
const result = validateTypeOptions(type, typeOptions);
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
||||
[BasePropertyType.TEXT]: z.string(),
|
||||
[BasePropertyType.NUMBER]: z.number(),
|
||||
[BasePropertyType.SELECT]: z.string().uuid(),
|
||||
[BasePropertyType.STATUS]: z.string().uuid(),
|
||||
[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.CHECKBOX]: z.boolean(),
|
||||
[BasePropertyType.URL]: z.string().url(),
|
||||
[BasePropertyType.EMAIL]: z.string().email(),
|
||||
};
|
||||
|
||||
export function getCellValueSchema(
|
||||
type: BasePropertyTypeValue,
|
||||
): z.ZodType | undefined {
|
||||
return cellValueSchemaMap[type];
|
||||
}
|
||||
|
||||
export function validateCellValue(
|
||||
type: BasePropertyTypeValue,
|
||||
value: unknown,
|
||||
): z.SafeParseReturnType<unknown, 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 schema.safeParse(value);
|
||||
}
|
||||
|
||||
export function attemptCellConversion(
|
||||
fromType: BasePropertyTypeValue,
|
||||
toType: BasePropertyTypeValue,
|
||||
value: unknown,
|
||||
): { converted: boolean; value: unknown } {
|
||||
if (value === null || value === undefined) {
|
||||
return { converted: true, value: null };
|
||||
}
|
||||
|
||||
const targetSchema = cellValueSchemaMap[toType];
|
||||
if (!targetSchema) {
|
||||
return { converted: false, value: null };
|
||||
}
|
||||
|
||||
const directResult = targetSchema.safeParse(value);
|
||||
if (directResult.success) {
|
||||
return { converted: true, value: directResult.data };
|
||||
}
|
||||
|
||||
if (toType === BasePropertyType.TEXT) {
|
||||
return { converted: true, value: String(value) };
|
||||
}
|
||||
|
||||
if (toType === BasePropertyType.NUMBER && typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
if (!isNaN(num)) {
|
||||
return { converted: true, value: num };
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === BasePropertyType.CHECKBOX) {
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'true' || lower === '1' || lower === 'yes') {
|
||||
return { converted: true, value: true };
|
||||
}
|
||||
if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') {
|
||||
return { converted: true, value: false };
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return { converted: true, value: value !== 0 };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
toType === BasePropertyType.MULTI_SELECT &&
|
||||
fromType === BasePropertyType.SELECT &&
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return { converted: true, value: [value] };
|
||||
}
|
||||
|
||||
if (
|
||||
toType === BasePropertyType.SELECT &&
|
||||
fromType === BasePropertyType.MULTI_SELECT &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
) {
|
||||
return { converted: true, value: value[0] };
|
||||
}
|
||||
|
||||
return { converted: false, value: null };
|
||||
}
|
||||
|
||||
export const viewSortSchema = z.object({
|
||||
propertyId: z.string().uuid(),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
});
|
||||
|
||||
export const viewFilterSchema = z.object({
|
||||
propertyId: z.string().uuid(),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'before',
|
||||
'after',
|
||||
]),
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const viewConfigSchema = z
|
||||
.object({
|
||||
sorts: z.array(viewSortSchema).optional(),
|
||||
filters: z.array(viewFilterSchema).optional(),
|
||||
visiblePropertyIds: z.array(z.string().uuid()).optional(),
|
||||
hiddenPropertyIds: z.array(z.string().uuid()).optional(),
|
||||
propertyWidths: z.record(z.string(), z.number().positive()).optional(),
|
||||
propertyOrder: z.array(z.string().uuid()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type ViewConfig = z.infer<typeof viewConfigSchema>;
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BasePropertyService } from '../services/base-property.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreatePropertyDto } from '../dto/create-property.dto';
|
||||
import {
|
||||
UpdatePropertyDto,
|
||||
DeletePropertyDto,
|
||||
ReorderPropertyDto,
|
||||
} from '../dto/update-property.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';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases/properties')
|
||||
export class BasePropertyController {
|
||||
constructor(
|
||||
private readonly basePropertyService: BasePropertyService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreatePropertyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.basePropertyService.create(workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdatePropertyDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.basePropertyService.update(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: DeletePropertyDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.basePropertyService.delete(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(@Body() dto: ReorderPropertyDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.basePropertyService.reorder(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BaseRowService } from '../services/base-row.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateRowDto } from '../dto/create-row.dto';
|
||||
import {
|
||||
UpdateRowDto,
|
||||
DeleteRowDto,
|
||||
RowIdDto,
|
||||
ListRowsDto,
|
||||
ReorderRowDto,
|
||||
} from '../dto/update-row.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';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases/rows')
|
||||
export class BaseRowController {
|
||||
constructor(
|
||||
private readonly baseRowService: BaseRowService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateRowDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.create(user.id, workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async getRow(@Body() dto: RowIdDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.getRowInfo(dto.rowId, dto.baseId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateRowDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.update(dto, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: DeleteRowDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseRowService.delete(dto.rowId, dto.baseId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(
|
||||
@Body() dto: ListRowsDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.list(dto, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(@Body() dto: ReorderRowDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseRowService.reorder(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BaseViewService } from '../services/base-view.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateViewDto } from '../dto/create-view.dto';
|
||||
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
|
||||
import { BaseIdDto } from '../dto/base.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';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases/views')
|
||||
export class BaseViewController {
|
||||
constructor(
|
||||
private readonly baseViewService: BaseViewService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateViewDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.create(user.id, workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateViewDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.update(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: DeleteViewDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseViewService.delete(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(@Body() dto: BaseIdDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.listByBaseId(dto.baseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BaseService } from '../services/base.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 { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
import { SpaceIdDto } from '../../space/dto/space-id.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases')
|
||||
export class BaseController {
|
||||
constructor(
|
||||
private readonly baseService: BaseService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateBaseDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseService.create(user.id, workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async getBase(@Body() dto: BaseIdDto, @AuthUser() user: User) {
|
||||
const base = await this.baseService.getBaseInfo(dto.baseId);
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateBaseDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseService.update(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: BaseIdDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseService.delete(dto.baseId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(
|
||||
@Body() dto: SpaceIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseService.listBySpaceId(dto.spaceId, pagination);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class BaseIdDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateBaseDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId?: string;
|
||||
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { BASE_PROPERTY_TYPES } from '../base.schemas';
|
||||
|
||||
export class CreatePropertyDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsIn(BASE_PROPERTY_TYPES)
|
||||
type: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
typeOptions?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateRowDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
cells?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
afterRowId?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateViewDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['table', 'kanban', 'calendar'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateBaseDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { BASE_PROPERTY_TYPES } from '../base.schemas';
|
||||
|
||||
export class UpdatePropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BASE_PROPERTY_TYPES)
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
typeOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DeletePropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
export class ReorderPropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
position: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsObject()
|
||||
cells: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DeleteRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
export class RowIdDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
export class ListRowsDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
viewId?: string;
|
||||
}
|
||||
|
||||
export class ReorderRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
position: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateViewDto {
|
||||
@IsUUID()
|
||||
viewId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['table', 'kanban', 'calendar'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DeleteViewDto {
|
||||
@IsUUID()
|
||||
viewId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { CreatePropertyDto } from '../dto/create-property.dto';
|
||||
import {
|
||||
UpdatePropertyDto,
|
||||
DeletePropertyDto,
|
||||
ReorderPropertyDto,
|
||||
} from '../dto/update-property.dto';
|
||||
import {
|
||||
BasePropertyTypeValue,
|
||||
parseTypeOptions,
|
||||
attemptCellConversion,
|
||||
validateTypeOptions,
|
||||
isSystemPropertyType,
|
||||
} from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
|
||||
@Injectable()
|
||||
export class BasePropertyService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseRowRepo: BaseRowRepo,
|
||||
) {}
|
||||
|
||||
async create(workspaceId: string, dto: CreatePropertyDto) {
|
||||
const type = dto.type as BasePropertyTypeValue;
|
||||
let validatedTypeOptions = null;
|
||||
|
||||
if (dto.typeOptions) {
|
||||
validatedTypeOptions = parseTypeOptions(type, dto.typeOptions);
|
||||
} else {
|
||||
validatedTypeOptions = parseTypeOptions(type, {});
|
||||
}
|
||||
|
||||
const lastPosition = await this.basePropertyRepo.getLastPosition(
|
||||
dto.baseId,
|
||||
);
|
||||
const position = generateJitteredKeyBetween(lastPosition, null);
|
||||
|
||||
return this.basePropertyRepo.insertProperty({
|
||||
baseId: dto.baseId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
position,
|
||||
typeOptions: validatedTypeOptions as any,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(dto: UpdatePropertyDto) {
|
||||
const property = await this.basePropertyRepo.findById(dto.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property not found');
|
||||
}
|
||||
|
||||
if (property.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Property does not belong to this base');
|
||||
}
|
||||
|
||||
const isTypeChange = dto.type && dto.type !== property.type;
|
||||
const newType = (dto.type ?? property.type) as BasePropertyTypeValue;
|
||||
|
||||
let validatedTypeOptions = property.typeOptions;
|
||||
if (dto.typeOptions !== undefined) {
|
||||
validatedTypeOptions = parseTypeOptions(newType, dto.typeOptions) as any;
|
||||
} else if (isTypeChange) {
|
||||
const result = validateTypeOptions(newType, {});
|
||||
validatedTypeOptions = result.success ? (result.data as any) : null;
|
||||
}
|
||||
|
||||
let conversionSummary: {
|
||||
converted: number;
|
||||
cleared: number;
|
||||
total: number;
|
||||
} | null = null;
|
||||
|
||||
if (isTypeChange) {
|
||||
const involvesSystem =
|
||||
isSystemPropertyType(property.type) || isSystemPropertyType(newType);
|
||||
|
||||
if (involvesSystem) {
|
||||
conversionSummary = await this.clearCellValues(
|
||||
dto.baseId,
|
||||
dto.propertyId,
|
||||
);
|
||||
} else {
|
||||
conversionSummary = await this.convertCellValues(
|
||||
dto.baseId,
|
||||
dto.propertyId,
|
||||
property.type as BasePropertyTypeValue,
|
||||
newType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.basePropertyRepo.updateProperty(dto.propertyId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.type !== undefined && { type: dto.type }),
|
||||
typeOptions: validatedTypeOptions,
|
||||
});
|
||||
|
||||
const updatedProperty = await this.basePropertyRepo.findById(
|
||||
dto.propertyId,
|
||||
);
|
||||
|
||||
return { property: updatedProperty, conversionSummary };
|
||||
}
|
||||
|
||||
async delete(dto: DeletePropertyDto) {
|
||||
const property = await this.basePropertyRepo.findById(dto.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property not found');
|
||||
}
|
||||
|
||||
if (property.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Property does not belong to this base');
|
||||
}
|
||||
|
||||
if (property.isPrimary) {
|
||||
throw new BadRequestException('Cannot delete the primary property');
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.basePropertyRepo.deleteProperty(dto.propertyId, trx);
|
||||
await this.baseRowRepo.removeCellKey(dto.baseId, dto.propertyId, trx);
|
||||
});
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderPropertyDto) {
|
||||
const property = await this.basePropertyRepo.findById(dto.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property not found');
|
||||
}
|
||||
|
||||
if (property.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Property does not belong to this base');
|
||||
}
|
||||
|
||||
await this.basePropertyRepo.updateProperty(dto.propertyId, {
|
||||
position: dto.position,
|
||||
});
|
||||
}
|
||||
|
||||
private async clearCellValues(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
): Promise<{ converted: number; cleared: number; total: number }> {
|
||||
const rows = await this.baseRowRepo.findAllByBaseId(baseId);
|
||||
const updates: Array<{ id: string; cells: Record<string, unknown> }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.cells as Record<string, unknown>;
|
||||
if (propertyId in cells) {
|
||||
updates.push({ id: row.id, cells: { [propertyId]: null } });
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.baseRowRepo.batchUpdateCells(updates, trx);
|
||||
});
|
||||
}
|
||||
|
||||
return { converted: 0, cleared: updates.length, total: updates.length };
|
||||
}
|
||||
|
||||
private async convertCellValues(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
fromType: BasePropertyTypeValue,
|
||||
toType: BasePropertyTypeValue,
|
||||
): Promise<{ converted: number; cleared: number; total: number }> {
|
||||
const rows = await this.baseRowRepo.findAllByBaseId(baseId);
|
||||
let converted = 0;
|
||||
let cleared = 0;
|
||||
let total = 0;
|
||||
|
||||
const updates: Array<{ id: string; cells: Record<string, unknown> }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.cells as Record<string, unknown>;
|
||||
if (!(propertyId in cells)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total++;
|
||||
const currentValue = cells[propertyId];
|
||||
const result = attemptCellConversion(fromType, toType, currentValue);
|
||||
|
||||
if (result.converted) {
|
||||
converted++;
|
||||
updates.push({ id: row.id, cells: { [propertyId]: result.value } });
|
||||
} else {
|
||||
cleared++;
|
||||
updates.push({ id: row.id, cells: { [propertyId]: null } });
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.baseRowRepo.batchUpdateCells(updates, trx);
|
||||
});
|
||||
}
|
||||
|
||||
return { converted, cleared, total };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { CreateRowDto } from '../dto/create-row.dto';
|
||||
import {
|
||||
UpdateRowDto,
|
||||
ListRowsDto,
|
||||
ReorderRowDto,
|
||||
} from '../dto/update-row.dto';
|
||||
import {
|
||||
BasePropertyTypeValue,
|
||||
validateCellValue,
|
||||
isSystemPropertyType,
|
||||
} from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { BaseProperty } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRowService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly baseRowRepo: BaseRowRepo,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseViewRepo: BaseViewRepo,
|
||||
) {}
|
||||
|
||||
async create(userId: string, workspaceId: string, dto: CreateRowDto) {
|
||||
let position: string;
|
||||
|
||||
if (dto.afterRowId) {
|
||||
const afterRow = await this.baseRowRepo.findById(dto.afterRowId);
|
||||
if (!afterRow || afterRow.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Invalid afterRowId');
|
||||
}
|
||||
position = generateJitteredKeyBetween(afterRow.position, null);
|
||||
} else {
|
||||
const lastPosition = await this.baseRowRepo.getLastPosition(dto.baseId);
|
||||
position = generateJitteredKeyBetween(lastPosition, null);
|
||||
}
|
||||
|
||||
let validatedCells: Record<string, unknown> = {};
|
||||
if (dto.cells && Object.keys(dto.cells).length > 0) {
|
||||
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
|
||||
validatedCells = this.validateCells(dto.cells, properties);
|
||||
}
|
||||
|
||||
return this.baseRowRepo.insertRow({
|
||||
baseId: dto.baseId,
|
||||
cells: validatedCells as any,
|
||||
position,
|
||||
creatorId: userId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getRowInfo(rowId: string, baseId: string) {
|
||||
const row = await this.baseRowRepo.findById(rowId);
|
||||
if (!row || row.baseId !== baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(dto: UpdateRowDto, userId?: string) {
|
||||
const row = await this.baseRowRepo.findById(dto.rowId);
|
||||
if (!row || row.baseId !== dto.baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
|
||||
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
|
||||
const validatedCells = this.validateCells(dto.cells, properties);
|
||||
|
||||
await this.baseRowRepo.updateCells(dto.rowId, validatedCells, userId);
|
||||
|
||||
return this.baseRowRepo.findById(dto.rowId);
|
||||
}
|
||||
|
||||
async delete(rowId: string, baseId: string) {
|
||||
const row = await this.baseRowRepo.findById(rowId);
|
||||
if (!row || row.baseId !== baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
|
||||
await this.baseRowRepo.softDelete(rowId);
|
||||
}
|
||||
|
||||
async list(dto: ListRowsDto, pagination: PaginationOptions) {
|
||||
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderRowDto) {
|
||||
const row = await this.baseRowRepo.findById(dto.rowId);
|
||||
if (!row || row.baseId !== dto.baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
|
||||
try {
|
||||
generateJitteredKeyBetween(dto.position, null);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid position value');
|
||||
}
|
||||
|
||||
await this.baseRowRepo.updatePosition(dto.rowId, dto.position);
|
||||
}
|
||||
|
||||
private validateCells(
|
||||
cells: Record<string, unknown>,
|
||||
properties: BaseProperty[],
|
||||
): Record<string, unknown> {
|
||||
const propertyMap = new Map(properties.map((p) => [p.id, p]));
|
||||
const validatedCells: Record<string, unknown> = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [propertyId, value] of Object.entries(cells)) {
|
||||
const property = propertyMap.get(propertyId);
|
||||
if (!property) {
|
||||
errors.push(`Unknown property: ${propertyId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSystemPropertyType(property.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
validatedCells[propertyId] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = validateCellValue(
|
||||
property.type as BasePropertyTypeValue,
|
||||
value,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
errors.push(
|
||||
`Invalid value for property "${property.name}" (${property.type}): ${result.error.issues[0]?.message}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
validatedCells[propertyId] = result.data;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'Cell validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
return validatedCells;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { CreateViewDto } from '../dto/create-view.dto';
|
||||
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
|
||||
import { viewConfigSchema } from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
|
||||
@Injectable()
|
||||
export class BaseViewService {
|
||||
constructor(private readonly baseViewRepo: BaseViewRepo) {}
|
||||
|
||||
async create(userId: string, workspaceId: string, dto: CreateViewDto) {
|
||||
let validatedConfig = {};
|
||||
if (dto.config) {
|
||||
const result = viewConfigSchema.safeParse(dto.config);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: 'Invalid view config',
|
||||
errors: result.error.issues.map((i) => i.message),
|
||||
});
|
||||
}
|
||||
validatedConfig = result.data;
|
||||
}
|
||||
|
||||
const lastPosition = await this.baseViewRepo.getLastPosition(dto.baseId);
|
||||
const position = generateJitteredKeyBetween(lastPosition, null);
|
||||
|
||||
return this.baseViewRepo.insertView({
|
||||
baseId: dto.baseId,
|
||||
name: dto.name,
|
||||
type: dto.type ?? 'table',
|
||||
position,
|
||||
config: validatedConfig as any,
|
||||
workspaceId,
|
||||
creatorId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(dto: UpdateViewDto) {
|
||||
const view = await this.baseViewRepo.findById(dto.viewId);
|
||||
if (!view) {
|
||||
throw new NotFoundException('View not found');
|
||||
}
|
||||
|
||||
if (view.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('View does not belong to this base');
|
||||
}
|
||||
|
||||
let validatedConfig = undefined;
|
||||
if (dto.config !== undefined) {
|
||||
const result = viewConfigSchema.safeParse(dto.config);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: 'Invalid view config',
|
||||
errors: result.error.issues.map((i) => i.message),
|
||||
});
|
||||
}
|
||||
validatedConfig = result.data;
|
||||
}
|
||||
|
||||
await this.baseViewRepo.updateView(dto.viewId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.type !== undefined && { type: dto.type }),
|
||||
...(validatedConfig !== undefined && { config: validatedConfig as any }),
|
||||
});
|
||||
|
||||
return this.baseViewRepo.findById(dto.viewId);
|
||||
}
|
||||
|
||||
async delete(dto: DeleteViewDto) {
|
||||
const view = await this.baseViewRepo.findById(dto.viewId);
|
||||
if (!view) {
|
||||
throw new NotFoundException('View not found');
|
||||
}
|
||||
|
||||
if (view.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('View does not belong to this base');
|
||||
}
|
||||
|
||||
const viewCount = await this.baseViewRepo.countByBaseId(dto.baseId);
|
||||
if (viewCount <= 1) {
|
||||
throw new BadRequestException('Cannot delete the last view');
|
||||
}
|
||||
|
||||
await this.baseViewRepo.deleteView(dto.viewId);
|
||||
}
|
||||
|
||||
async listByBaseId(baseId: string) {
|
||||
return this.baseViewRepo.findByBaseId(baseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { CreateBaseDto } from '../dto/create-base.dto';
|
||||
import { UpdateBaseDto } from '../dto/update-base.dto';
|
||||
import { BasePropertyType } from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
@Injectable()
|
||||
export class BaseService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseViewRepo: BaseViewRepo,
|
||||
) {}
|
||||
|
||||
async create(userId: string, workspaceId: string, dto: CreateBaseDto) {
|
||||
return executeTx(this.db, async (trx) => {
|
||||
const base = await this.baseRepo.insertBase(
|
||||
{
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
icon: dto.icon,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.spaceId,
|
||||
workspaceId,
|
||||
creatorId: userId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
const firstPosition = generateJitteredKeyBetween(null, null);
|
||||
|
||||
await this.basePropertyRepo.insertProperty(
|
||||
{
|
||||
baseId: base.id,
|
||||
name: 'Title',
|
||||
type: BasePropertyType.TEXT,
|
||||
position: firstPosition,
|
||||
isPrimary: true,
|
||||
workspaceId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
await this.baseViewRepo.insertView(
|
||||
{
|
||||
baseId: base.id,
|
||||
name: 'Table View 1',
|
||||
type: 'table',
|
||||
position: firstPosition,
|
||||
workspaceId,
|
||||
creatorId: userId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
return this.baseRepo.findById(base.id, {
|
||||
includeProperties: true,
|
||||
includeViews: true,
|
||||
trx,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getBaseInfo(baseId: string) {
|
||||
const base = await this.baseRepo.findById(baseId, {
|
||||
includeProperties: true,
|
||||
includeViews: true,
|
||||
});
|
||||
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
async update(dto: UpdateBaseDto) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
await this.baseRepo.updateBase(dto.baseId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.icon !== undefined && { icon: dto.icon }),
|
||||
});
|
||||
|
||||
return this.baseRepo.findById(dto.baseId);
|
||||
}
|
||||
|
||||
async delete(baseId: string) {
|
||||
const base = await this.baseRepo.findById(baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
await this.baseRepo.softDelete(baseId);
|
||||
}
|
||||
|
||||
async listBySpaceId(spaceId: string, pagination: PaginationOptions) {
|
||||
return this.baseRepo.findBySpaceId(spaceId, pagination);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ function buildSpaceAdminAbility() {
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ function buildSpaceWriterAbility() {
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -68,5 +70,6 @@ function buildSpaceReaderAbility() {
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Base);
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ export enum SpaceCaslSubject {
|
||||
Member = 'member',
|
||||
Page = 'page',
|
||||
Share = 'share',
|
||||
Base = 'base',
|
||||
}
|
||||
|
||||
export type ISpaceAbility =
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Page]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Share];
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Share]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Base];
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { BaseModule } from './base/base.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
BaseModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
|
||||
Reference in New Issue
Block a user