mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: bases - WIP
This commit is contained in:
@@ -106,7 +106,8 @@
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^2.1.0",
|
||||
"ws": "^8.19.0",
|
||||
"yauzl": "^3.2.0"
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,6 +27,10 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { normalizePostgresUrl } from '../common/helpers';
|
||||
@@ -85,6 +89,10 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
PageListener,
|
||||
BaseRepo,
|
||||
BasePropertyRepo,
|
||||
BaseRowRepo,
|
||||
BaseViewRepo,
|
||||
],
|
||||
exports: [
|
||||
WorkspaceRepo,
|
||||
@@ -102,6 +110,10 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
BaseRepo,
|
||||
BasePropertyRepo,
|
||||
BaseRowRepo,
|
||||
BaseViewRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('bases')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('description', 'varchar')
|
||||
.addColumn('icon', 'varchar')
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_bases_space_id')
|
||||
.on('bases')
|
||||
.column('space_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('base_properties')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('base_id', 'uuid', (col) =>
|
||||
col.references('bases.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type_options', 'jsonb')
|
||||
.addColumn('is_primary', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_properties_base_id')
|
||||
.on('base_properties')
|
||||
.column('base_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('base_rows')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('base_id', 'uuid', (col) =>
|
||||
col.references('bases.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('cells', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('last_updated_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_rows_base_id')
|
||||
.on('base_rows')
|
||||
.column('base_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_rows_cells_gin')
|
||||
.on('base_rows')
|
||||
.using('gin')
|
||||
.column('cells')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('base_views')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('base_id', 'uuid', (col) =>
|
||||
col.references('bases.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('table'))
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('config', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_views_base_id')
|
||||
.on('base_views')
|
||||
.column('base_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('base_views').execute();
|
||||
await db.schema.dropTable('base_rows').execute();
|
||||
await db.schema.dropTable('base_properties').execute();
|
||||
await db.schema.dropTable('bases').execute();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
BaseProperty,
|
||||
InsertableBaseProperty,
|
||||
UpdatableBaseProperty,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class BasePropertyRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
propertyId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseProperty | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll()
|
||||
.where('id', '=', propertyId)
|
||||
.executeTakeFirst() as Promise<BaseProperty | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseProperty[]> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy('position', 'asc')
|
||||
.execute() as Promise<BaseProperty[]>;
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseProperties')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result?.position ?? null;
|
||||
}
|
||||
|
||||
async insertProperty(
|
||||
property: InsertableBaseProperty,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseProperty> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('baseProperties')
|
||||
.values(property)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseProperty>;
|
||||
}
|
||||
|
||||
async updateProperty(
|
||||
propertyId: string,
|
||||
data: UpdatableBaseProperty,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseProperties')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteProperty(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('baseProperties')
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
BaseRow,
|
||||
InsertableBaseRow,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRowRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
rowId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseRow | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('id', '=', rowId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<BaseRow | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
pagination: PaginationOptions,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
const query = db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'position', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
position: cursor.position,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseRows')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result?.position ?? null;
|
||||
}
|
||||
|
||||
async insertRow(
|
||||
row: InsertableBaseRow,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseRow> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('baseRows')
|
||||
.values(row)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseRow>;
|
||||
}
|
||||
|
||||
async updateCells(
|
||||
rowId: string,
|
||||
cells: Record<string, unknown>,
|
||||
userId?: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells || ${cells}`,
|
||||
updatedAt: new Date(),
|
||||
lastUpdatedById: userId ?? null,
|
||||
})
|
||||
.where('id', '=', rowId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updatePosition(
|
||||
rowId: string,
|
||||
position: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({ position, updatedAt: new Date() })
|
||||
.where('id', '=', rowId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async softDelete(rowId: string, trx?: KyselyTransaction): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', rowId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeCellKey(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells - ${propertyId}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('baseId', '=', baseId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findAllByBaseId(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseRow[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute() as Promise<BaseRow[]>;
|
||||
}
|
||||
|
||||
async batchUpdateCells(
|
||||
updates: Array<{ id: string; cells: Record<string, unknown> }>,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
for (const update of updates) {
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells || ${update.cells}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', update.id)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
BaseView,
|
||||
InsertableBaseView,
|
||||
UpdatableBaseView,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class BaseViewRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
viewId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseView | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseViews')
|
||||
.selectAll()
|
||||
.where('id', '=', viewId)
|
||||
.executeTakeFirst() as Promise<BaseView | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseView[]> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseViews')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy('position', 'asc')
|
||||
.execute() as Promise<BaseView[]>;
|
||||
}
|
||||
|
||||
async countByBaseId(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseViews')
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('baseId', '=', baseId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseViews')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result?.position ?? null;
|
||||
}
|
||||
|
||||
async insertView(
|
||||
view: InsertableBaseView,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseView> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('baseViews')
|
||||
.values(view)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseView>;
|
||||
}
|
||||
|
||||
async updateView(
|
||||
viewId: string,
|
||||
data: UpdatableBaseView,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseViews')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', viewId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteView(
|
||||
viewId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('baseViews')
|
||||
.where('id', '=', viewId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
Base,
|
||||
InsertableBase,
|
||||
UpdatableBase,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof Base> = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'icon',
|
||||
'pageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
];
|
||||
|
||||
async findById(
|
||||
baseId: string,
|
||||
opts?: {
|
||||
includeProperties?: boolean;
|
||||
includeViews?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Base | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
let query = db
|
||||
.selectFrom('bases')
|
||||
.select(this.baseFields)
|
||||
.where('id', '=', baseId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
if (opts?.includeProperties) {
|
||||
query = query.select((eb) => this.withProperties(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeViews) {
|
||||
query = query.select((eb) => this.withViews(eb));
|
||||
}
|
||||
|
||||
return query.executeTakeFirst() as Promise<Base | undefined>;
|
||||
}
|
||||
|
||||
async findBySpaceId(
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
const query = db
|
||||
.selectFrom('bases')
|
||||
.select(this.baseFields)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'createdAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
createdAt: new Date(cursor.createdAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async insertBase(
|
||||
base: InsertableBase,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Base> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('bases')
|
||||
.values(base)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<Base>;
|
||||
}
|
||||
|
||||
async updateBase(
|
||||
baseId: string,
|
||||
data: UpdatableBase,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('bases')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', baseId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async softDelete(baseId: string, trx?: KyselyTransaction): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('bases')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', baseId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private withProperties(eb: ExpressionBuilder<DB, 'bases'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll('baseProperties')
|
||||
.whereRef('baseProperties.baseId', '=', 'bases.id')
|
||||
.orderBy('baseProperties.position', 'asc'),
|
||||
).as('properties');
|
||||
}
|
||||
|
||||
private withViews(eb: ExpressionBuilder<DB, 'bases'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('baseViews')
|
||||
.selectAll('baseViews')
|
||||
.whereRef('baseViews.baseId', '=', 'bases.id')
|
||||
.orderBy('baseViews.position', 'asc'),
|
||||
).as('views');
|
||||
}
|
||||
}
|
||||
+57
@@ -390,9 +390,66 @@ export interface Watchers {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Bases {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
pageId: string | null;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
creatorId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface BaseProperties {
|
||||
id: Generated<string>;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
position: string;
|
||||
typeOptions: Json | null;
|
||||
isPrimary: Generated<boolean>;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface BaseRows {
|
||||
id: Generated<string>;
|
||||
baseId: string;
|
||||
cells: Generated<Json>;
|
||||
position: string;
|
||||
creatorId: string | null;
|
||||
lastUpdatedById: string | null;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface BaseViews {
|
||||
id: Generated<string>;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: Generated<string>;
|
||||
position: string;
|
||||
config: Generated<Json>;
|
||||
workspaceId: string;
|
||||
creatorId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
baseProperties: BaseProperties;
|
||||
baseRows: BaseRows;
|
||||
baseViews: BaseViews;
|
||||
bases: Bases;
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
AuthAccounts,
|
||||
AuthProviders,
|
||||
Backlinks,
|
||||
BaseProperties,
|
||||
BaseRows,
|
||||
BaseViews,
|
||||
Bases,
|
||||
Billing,
|
||||
Comments,
|
||||
FileTasks,
|
||||
@@ -27,6 +31,10 @@ import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
export interface DbInterface {
|
||||
attachments: Attachments;
|
||||
authAccounts: AuthAccounts;
|
||||
baseProperties: BaseProperties;
|
||||
baseRows: BaseRows;
|
||||
baseViews: BaseViews;
|
||||
bases: Bases;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import {
|
||||
Attachments,
|
||||
BaseProperties,
|
||||
BaseRows,
|
||||
BaseViews,
|
||||
Bases,
|
||||
Comments,
|
||||
Groups,
|
||||
Notifications,
|
||||
@@ -143,3 +147,23 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
||||
export type Watcher = Selectable<Watchers>;
|
||||
export type InsertableWatcher = Insertable<Watchers>;
|
||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||
|
||||
// Base
|
||||
export type Base = Selectable<Bases>;
|
||||
export type InsertableBase = Insertable<Bases>;
|
||||
export type UpdatableBase = Updateable<Omit<Bases, 'id'>>;
|
||||
|
||||
// Base Property
|
||||
export type BaseProperty = Selectable<BaseProperties>;
|
||||
export type InsertableBaseProperty = Insertable<BaseProperties>;
|
||||
export type UpdatableBaseProperty = Updateable<Omit<BaseProperties, 'id'>>;
|
||||
|
||||
// Base Row
|
||||
export type BaseRow = Selectable<BaseRows>;
|
||||
export type InsertableBaseRow = Insertable<BaseRows>;
|
||||
export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
|
||||
|
||||
// Base View
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
export type InsertableBaseView = Insertable<BaseViews>;
|
||||
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
|
||||
const BASE_ID = '019c69a5-1d84-7985-a7f6-8ee2871d8669';
|
||||
const TOTAL_ROWS = 100_000;
|
||||
const BATCH_SIZE = 2000;
|
||||
|
||||
const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||
dotenv.config({ path: envFilePath });
|
||||
|
||||
function normalizePostgresUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
const newParams = new URLSearchParams();
|
||||
for (const [key, value] of parsed.searchParams) {
|
||||
if (key === 'sslmode' && value === 'no-verify') continue;
|
||||
if (key === 'schema') continue;
|
||||
newParams.append(key, value);
|
||||
}
|
||||
parsed.search = newParams.toString();
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const db = new Kysely<any>({
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL!)),
|
||||
}),
|
||||
});
|
||||
|
||||
const SKIP_TYPES = new Set([
|
||||
'createdAt',
|
||||
'lastEditedAt',
|
||||
'lastEditedBy',
|
||||
'person',
|
||||
'file',
|
||||
]);
|
||||
|
||||
const WORDS = [
|
||||
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf',
|
||||
'Hotel', 'India', 'Juliet', 'Kilo', 'Lima', 'Mike', 'November',
|
||||
'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform',
|
||||
'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu', 'Report', 'Analysis',
|
||||
'Summary', 'Review', 'Update', 'Draft', 'Final', 'Proposal', 'Budget',
|
||||
'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
|
||||
];
|
||||
|
||||
function randomWords(min: number, max: number): string {
|
||||
const count = min + Math.floor(Math.random() * (max - min + 1));
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
||||
}
|
||||
return result.join(' ');
|
||||
}
|
||||
|
||||
type CellGenerator = () => unknown;
|
||||
|
||||
function buildCellGenerator(property: any): CellGenerator | null {
|
||||
if (SKIP_TYPES.has(property.type)) return null;
|
||||
|
||||
const typeOptions = property.type_options;
|
||||
|
||||
switch (property.type) {
|
||||
case 'text':
|
||||
return () => randomWords(2, 6);
|
||||
|
||||
case 'number':
|
||||
return () => Math.round(Math.random() * 10000 * 100) / 100;
|
||||
|
||||
case 'select':
|
||||
case 'status': {
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
if (choices.length === 0) return null;
|
||||
return () => choices[Math.floor(Math.random() * choices.length)].id;
|
||||
}
|
||||
|
||||
case 'multiSelect': {
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
if (choices.length === 0) return () => [];
|
||||
return () => {
|
||||
const count = 1 + Math.floor(Math.random() * Math.min(3, choices.length));
|
||||
const shuffled = [...choices].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count).map((c: any) => c.id);
|
||||
};
|
||||
}
|
||||
|
||||
case 'date': {
|
||||
const start = new Date(2020, 0, 1).getTime();
|
||||
const range = new Date(2026, 0, 1).getTime() - start;
|
||||
return () => new Date(start + Math.random() * range).toISOString();
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
return () => Math.random() > 0.5;
|
||||
|
||||
case 'url':
|
||||
return () => `https://example.com/page/${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
case 'email':
|
||||
return () => `user${Math.floor(Math.random() * 100000)}@example.com`;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding ${TOTAL_ROWS.toLocaleString()} rows for base ${BASE_ID}\n`);
|
||||
|
||||
const base = await db
|
||||
.selectFrom('bases')
|
||||
.selectAll()
|
||||
.where('id', '=', BASE_ID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const workspaceId = base.workspace_id;
|
||||
console.log(`Workspace: ${workspaceId}`);
|
||||
|
||||
const user = await db
|
||||
.selectFrom('users')
|
||||
.select('id')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
const creatorId = user?.id ?? null;
|
||||
console.log(`Creator: ${creatorId ?? '(none)'}`);
|
||||
|
||||
const properties = await db
|
||||
.selectFrom('base_properties')
|
||||
.selectAll()
|
||||
.where('base_id', '=', BASE_ID)
|
||||
.execute();
|
||||
|
||||
console.log(`Properties: ${properties.length}`);
|
||||
for (const p of properties) {
|
||||
console.log(` - ${p.name} (${p.type})${SKIP_TYPES.has(p.type) ? ' [skipped]' : ''}`);
|
||||
}
|
||||
|
||||
const generators: Array<{ propertyId: string; generate: CellGenerator }> = [];
|
||||
for (const prop of properties) {
|
||||
const gen = buildCellGenerator(prop);
|
||||
if (gen) {
|
||||
generators.push({ propertyId: prop.id, generate: gen });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGenerating ${TOTAL_ROWS.toLocaleString()} positions...`);
|
||||
|
||||
const lastRow = await db
|
||||
.selectFrom('base_rows')
|
||||
.select('position')
|
||||
.where('base_id', '=', BASE_ID)
|
||||
.where('deleted_at', 'is', null)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`desc`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
let lastPosition: string | null = lastRow?.position ?? null;
|
||||
const positions: string[] = new Array(TOTAL_ROWS);
|
||||
for (let i = 0; i < TOTAL_ROWS; i++) {
|
||||
lastPosition = generateJitteredKeyBetween(lastPosition, null);
|
||||
positions[i] = lastPosition;
|
||||
}
|
||||
console.log(`Positions generated (last: ${positions[positions.length - 1]})\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const totalBatches = Math.ceil(TOTAL_ROWS / BATCH_SIZE);
|
||||
|
||||
for (let batchStart = 0; batchStart < TOTAL_ROWS; batchStart += BATCH_SIZE) {
|
||||
const batchEnd = Math.min(batchStart + BATCH_SIZE, TOTAL_ROWS);
|
||||
const rows: any[] = [];
|
||||
|
||||
for (let i = batchStart; i < batchEnd; i++) {
|
||||
const cells: Record<string, unknown> = {};
|
||||
for (const { propertyId, generate } of generators) {
|
||||
cells[propertyId] = generate();
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: uuid7(),
|
||||
base_id: BASE_ID,
|
||||
cells,
|
||||
position: positions[i],
|
||||
creator_id: creatorId,
|
||||
workspace_id: workspaceId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
await db.insertInto('base_rows').values(rows).execute();
|
||||
|
||||
const batchNum = Math.floor(batchStart / BATCH_SIZE) + 1;
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`Batch ${batchNum}/${totalBatches} inserted (${batchEnd.toLocaleString()} rows, ${elapsed}s elapsed)`);
|
||||
}
|
||||
|
||||
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
|
||||
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Seed script failed:', err);
|
||||
db.destroy().finally(() => process.exit(1));
|
||||
});
|
||||
Reference in New Issue
Block a user