feat: bases - WIP

This commit is contained in:
Philipinho
2026-03-08 00:56:24 +00:00
parent 0aeaa43112
commit 94ee1e80fb
83 changed files with 9243 additions and 38 deletions
+21
View File
@@ -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 {}
+270
View File
@@ -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];
+2
View File
@@ -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 {