updates and fixes

* seo friendly urls
* custom client serve-static module
* database fixes
* fix recent pages
* other fixes
This commit is contained in:
Philipinho
2024-05-18 03:19:42 +01:00
parent eefe63d1cd
commit 9c7c2f1163
102 changed files with 921 additions and 536 deletions

View File

@ -1,3 +1,4 @@
/storage
.env
package-lock.json
# compiled output

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.0.1",
"version": "0.1.0",
"description": "",
"author": "",
"private": true,
@ -13,13 +13,13 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"email:dev": "email dev -p 5019 -d ./src/integrations/transactional/emails",
"migration:create": "tsx ./src/kysely/migrate.ts create",
"migration:up": "tsx ./src/kysely/migrate.ts up",
"migration:down": "tsx ./src/kysely/migrate.ts down",
"migration:latest": "tsx ./src/kysely/migrate.ts latest",
"migration:redo": "tsx ./src/kysely/migrate.ts redo",
"migration:reset": "tsx ./src/kysely/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/kysely/types/db.d.ts",
"migration:create": "tsx src/database/migrate.ts create",
"migration:up": "tsx src/database/migrate.ts up",
"migration:down": "tsx src/database/migrate.ts down",
"migration:latest": "tsx src/database/migrate.ts latest",
"migration:redo": "tsx src/database/migrate.ts redo",
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@ -42,7 +42,6 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.8",
"@nestjs/platform-socket.io": "^10.3.8",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.3.8",
"@react-email/components": "0.0.17",
"@react-email/render": "^0.0.13",
@ -68,7 +67,6 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2",
"slugify": "^1.6.6",
"socket.io": "^4.7.5",
"tsx": "^4.8.2",
"uuid": "^9.0.1",

View File

@ -5,26 +5,11 @@ import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
import { WsModule } from './ws/ws.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { DatabaseModule } from '@docmost/db/database.module';
import * as fs from 'fs';
import { StorageModule } from './integrations/storage/storage.module';
import { MailModule } from './integrations/mail/mail.module';
import { QueueModule } from './integrations/queue/queue.module';
const clientDistPath = join(__dirname, '..', '..', 'client/dist');
function getServeStaticModule() {
if (fs.existsSync(clientDistPath)) {
return [
ServeStaticModule.forRoot({
rootPath: clientDistPath,
}),
];
}
return [];
}
import { StaticModule } from './integrations/static/static.module';
@Module({
imports: [
@ -34,7 +19,7 @@ function getServeStaticModule() {
CollaborationModule,
WsModule,
QueueModule,
...getServeStaticModule(),
StaticModule,
StorageModule.forRootAsync({
imports: [EnvironmentModule],
}),

View File

@ -78,6 +78,7 @@ export class PersistenceExtension implements Extension {
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
updatedAt: new Date(),
},
pageId,
);

View File

@ -9,6 +9,7 @@ import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { UserRole } from '../../../helpers/types/permission';
@Injectable()
export class SignupService {
@ -75,7 +76,11 @@ export class SignupService {
this.db,
async (trx) => {
// create user
const user = await this.userRepo.insertUser(createAdminUserDto, trx);
const user = await this.userRepo.insertUser(
{ ...createAdminUserDto, role: UserRole.OWNER },
trx,
);
// create workspace with full setup
const workspaceData: CreateWorkspaceDto = {

View File

@ -1,4 +1,8 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
AbilityBuilder,
createMongoAbility,
@ -33,9 +37,7 @@ export default class SpaceAbilityFactory {
case SpaceRole.READER:
return buildSpaceReaderAbility();
default:
throw new ForbiddenException(
'You do not have permission to access this space',
);
throw new NotFoundException('Space permissions not found');
}
}
}

View File

@ -52,7 +52,12 @@ export class CommentController {
throw new ForbiddenException();
}
return this.commentService.create(user.id, workspace.id, createCommentDto);
return this.commentService.create(
user.id,
page.id,
workspace.id,
createCommentDto,
);
}
@HttpCode(HttpStatus.OK)
@ -73,7 +78,7 @@ export class CommentController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.commentService.findByPageId(input.pageId, pagination);
return this.commentService.findByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@ -84,7 +89,6 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
// TODO: add spaceId to comment entity.
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
@ -104,6 +108,7 @@ export class CommentController {
return this.commentService.update(
updateCommentDto.commentId,
updateCommentDto,
user,
);
}
@ -111,6 +116,6 @@ export class CommentController {
@Post('delete')
remove(@Body() input: CommentIdDto, @AuthUser() user: User) {
// TODO: only comment creators and admins can delete their comments
return this.commentService.remove(input.commentId);
return this.commentService.remove(input.commentId, user);
}
}

View File

@ -1,12 +1,13 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment } from '@docmost/db/types/entity.types';
import { Comment, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@ -30,24 +31,18 @@ export class CommentService {
async create(
userId: string,
pageId: string,
workspaceId: string,
createCommentDto: CreateCommentDto,
) {
const commentContent = JSON.parse(createCommentDto.content);
const page = await this.pageRepo.findById(createCommentDto.pageId);
// const spaceId = null; // todo, get from page
if (!page) {
throw new BadRequestException('Page not found');
}
if (createCommentDto.parentCommentId) {
const parentComment = await this.commentRepo.findById(
createCommentDto.parentCommentId,
);
if (!parentComment) {
if (!parentComment || parentComment.pageId !== pageId) {
throw new BadRequestException('Parent comment not found');
}
@ -57,10 +52,10 @@ export class CommentService {
}
const createdComment = await this.commentRepo.insertComment({
pageId: createCommentDto.pageId,
pageId: pageId,
content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250),
type: 'inline', // for now
type: 'inline',
parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId,
workspaceId: workspaceId,
@ -90,6 +85,7 @@ export class CommentService {
async update(
commentId: string,
updateCommentDto: UpdateCommentDto,
authUser: User,
): Promise<Comment> {
const commentContent = JSON.parse(updateCommentDto.content);
@ -98,6 +94,10 @@ export class CommentService {
throw new NotFoundException('Comment not found');
}
if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only edit your own comments');
}
const editedAt = new Date();
await this.commentRepo.updateComment(
@ -113,12 +113,17 @@ export class CommentService {
return comment;
}
async remove(commentId: string): Promise<void> {
async remove(commentId: string, authUser: User): Promise<void> {
const comment = await this.commentRepo.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
if (comment.creatorId !== authUser.id) {
throw new ForbiddenException('You can only delete your own comments');
}
await this.commentRepo.deleteComment(commentId);
}
}

View File

@ -1,7 +1,7 @@
import { IsUUID } from 'class-validator';
import { IsString, IsUUID } from 'class-validator';
export class PageIdDto {
@IsUUID()
@IsString()
pageId: string;
}

View File

@ -1,7 +1,7 @@
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateCommentDto {
@IsUUID()
@IsString()
pageId: string;
@IsJSON()

View File

@ -10,7 +10,7 @@ export class CreatePageDto {
icon?: string;
@IsOptional()
@IsUUID()
@IsString()
parentPageId?: string;
@IsUUID()

View File

@ -1,13 +1,7 @@
import {
IsString,
IsUUID,
IsOptional,
MinLength,
MaxLength,
} from 'class-validator';
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
export class MovePageDto {
@IsUUID()
@IsString()
pageId: string;
@IsString()

View File

@ -1,3 +0,0 @@
import { Page } from '@docmost/db/types/entity.types';
export type PageWithOrderingDto = Page & { childrenIds?: string[] };

View File

@ -1,7 +1,7 @@
import { IsUUID } from 'class-validator';
import { IsString, IsUUID } from 'class-validator';
export class PageIdDto {
@IsUUID()
@IsString()
pageId: string;
}

View File

@ -1,8 +1,8 @@
import { IsOptional, IsUUID } from 'class-validator';
import { IsOptional, IsString } from 'class-validator';
import { SpaceIdDto } from './page.dto';
export class SidebarPageDto extends SpaceIdDto {
@IsOptional()
@IsUUID()
@IsString()
pageId: string;
}

View File

@ -1,8 +1,8 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto } from './create-page.dto';
import { IsUUID } from 'class-validator';
import { IsString } from 'class-validator';
export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsUUID()
@IsString()
pageId: string;
}

View File

@ -12,7 +12,7 @@ import { PageService } from './services/page.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto } from './dto/move-page.dto';
import { PageHistoryIdDto, PageIdDto, SpaceIdDto } from './dto/page.dto';
import { PageHistoryIdDto, PageIdDto } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
@ -118,18 +118,23 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('recent')
async getRecentSpacePages(
@Body() spaceIdDto: SpaceIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
spaceIdDto.spaceId,
workspace.defaultSpaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getRecentSpacePages(spaceIdDto.spaceId, pagination);
return this.pageService.getRecentSpacePages(
workspace.defaultSpaceId,
pagination,
);
}
// TODO: scope to workspaces
@ -146,7 +151,7 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageHistoryService.findHistoryByPageId(dto.pageId, pagination);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@ -181,7 +186,17 @@ export class PageController {
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(dto, pagination);
let pageId = null;
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (page.spaceId !== dto.spaceId) {
throw new ForbiddenException();
}
pageId = page.id;
}
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
}
@HttpCode(HttpStatus.OK)
@ -207,10 +222,14 @@ export class PageController {
@Post('/breadcrumbs')
async getPageBreadcrumbs(@Body() dto: PageIdDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getPageBreadCrumbs(dto.pageId);
return this.pageService.getPageBreadCrumbs(page.id);
}
}

View File

@ -18,7 +18,7 @@ import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { SidebarPageDto } from '../dto/sidebar-page.dto';
import { genPageShortId } from '../../../helpers/nanoid.utils';
@Injectable()
export class PageService {
@ -40,14 +40,19 @@ export class PageService {
workspaceId: string,
createPageDto: CreatePageDto,
): Promise<Page> {
let parentPageId = undefined;
// check if parent page exists
if (createPageDto.parentPageId) {
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId)
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
}
let pagePosition: string;
@ -59,10 +64,10 @@ export class PageService {
.orderBy('position', 'desc')
.limit(1);
if (createPageDto.parentPageId) {
if (parentPageId) {
// check for children of this page
const lastPage = await lastPageQuery
.where('parentPageId', '=', createPageDto.parentPageId)
.where('parentPageId', '=', parentPageId)
.executeTakeFirst();
if (!lastPage) {
@ -87,10 +92,11 @@ export class PageService {
}
const createdPage = await this.pageRepo.insertPage({
slugId: genPageShortId(),
title: createPageDto.title,
position: pagePosition,
icon: createPageDto.icon,
parentPageId: createPageDto.parentPageId,
parentPageId: parentPageId,
spaceId: createPageDto.spaceId,
creatorId: userId,
workspaceId: workspaceId,
@ -110,6 +116,7 @@ export class PageService {
title: updatePageDto.title,
icon: updatePageDto.icon,
lastUpdatedById: userId,
updatedAt: new Date(),
},
pageId,
);
@ -135,13 +142,15 @@ export class PageService {
}
async getSidebarPages(
dto: SidebarPageDto,
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
): Promise<any> {
let query = this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
@ -151,10 +160,10 @@ export class PageService {
])
.select((eb) => this.withHasChildren(eb))
.orderBy('position', 'asc')
.where('spaceId', '=', dto.spaceId);
.where('spaceId', '=', spaceId);
if (dto.pageId) {
query = query.where('parentPageId', '=', dto.pageId);
if (pageId) {
query = query.where('parentPageId', '=', pageId);
} else {
query = query.where('parentPageId', 'is', null);
}
@ -185,8 +194,8 @@ export class PageService {
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
}
parentPageId = dto.parentPageId;
}
await this.pageRepo.updatePage(
@ -205,6 +214,7 @@ export class PageService {
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
@ -218,6 +228,7 @@ export class PageService {
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
@ -255,10 +266,7 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Page>> {
const pages = await this.pageRepo.getRecentPagesInSpace(
spaceId,
pagination,
);
const pages = await this.pageRepo.getRecentPageUpdates(spaceId, pagination);
return pages;
}
@ -267,6 +275,7 @@ export class PageService {
await this.pageRepo.deletePage(pageId);
}
}
/*
// TODO: page deletion and restoration
async delete(pageId: string): Promise<void> {

View File

@ -5,12 +5,13 @@ import {
} from '@nestjs/common';
import { CreateSpaceDto } from '../dto/create-space.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import slugify from 'slugify';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { slugify } = require('fix-esm').require('@sindresorhus/slugify');
@Injectable()
export class SpaceService {

View File

@ -36,12 +36,16 @@ export class WorkspaceController {
private readonly workspaceInvitationService: WorkspaceInvitationService,
) {}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/public')
async getWorkspacePublicInfo(@Req() req) {
return this.workspaceService.getWorkspacePublicData(req.raw.workspaceId);
}
@HttpCode(HttpStatus.OK)
@Post('/info')
async getWorkspace(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
async getWorkspace(@AuthWorkspace() workspace: Workspace) {
return this.workspaceService.getWorkspaceInfo(workspace.id);
}

View File

@ -46,6 +46,19 @@ export class WorkspaceService {
return workspace;
}
async getWorkspacePublicData(workspaceId: string) {
const workspace = await this.db
.selectFrom('workspaces')
.select(['id'])
.where('id', '=', workspaceId)
.executeTakeFirst();
if (!workspace) {
throw new NotFoundException('Workspace not found');
}
return workspace;
}
async create(
user: User,
createWorkspaceDto: CreateWorkspaceDto,

View File

@ -8,20 +8,17 @@ export async function up(db: Kysely<any>): Promise<void> {
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('name', 'varchar', (col) => col)
.addColumn('description', 'text', (col) => col)
.addColumn('description', 'varchar', (col) => col)
.addColumn('logo', 'varchar', (col) => col)
.addColumn('hostname', 'varchar', (col) => col)
.addColumn('custom_domain', 'varchar', (col) => col)
.addColumn('enable_invite', 'boolean', (col) =>
col.defaultTo(true).notNull(),
)
.addColumn('invite_code', 'varchar', (col) =>
col.defaultTo(sql`gen_random_uuid()`),
)
.addColumn('settings', 'jsonb', (col) => col)
.addColumn('default_role', 'varchar', (col) =>
col.defaultTo(UserRole.MEMBER).notNull(),
)
.addColumn('allowed_email_domains', sql`varchar[]`, (col) =>
col.defaultTo('{}'),
)
.addColumn('default_space_id', 'uuid', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
@ -31,7 +28,7 @@ export async function up(db: Kysely<any>): Promise<void> {
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('workspaces_hostname_unique', ['hostname'])
.addUniqueConstraint('workspaces_invite_code_unique', ['invite_code'])
.addUniqueConstraint('workspaces_custom_domain_unique', ['custom_domain'])
.execute();
}

View File

@ -9,10 +9,12 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('name', 'varchar', (col) => col)
.addColumn('email', 'varchar', (col) => col.notNull())
.addColumn('email_verified_at', 'timestamptz', (col) => col)
.addColumn('password', 'varchar', (col) => col.notNull())
.addColumn('password', 'varchar', (col) => col)
.addColumn('avatar_url', 'varchar', (col) => col)
.addColumn('role', 'varchar', (col) => col)
.addColumn('status', 'varchar', (col) => col)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('invited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade'),
)
@ -21,12 +23,14 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('settings', 'jsonb', (col) => col)
.addColumn('last_active_at', 'timestamptz', (col) => col)
.addColumn('last_login_at', 'timestamptz', (col) => col)
.addColumn('deactivated_at', 'timestamptz', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('users_email_workspace_id_unique', [
'email',
'workspace_id',

View File

@ -49,7 +49,7 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('addedById', 'uuid', (col) => col.references('users.id'))
.addColumn('added_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)

View File

@ -6,19 +6,24 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('email', 'varchar', (col) => col)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('token', 'varchar', (col) => col.notNull())
.addColumn('group_ids', sql`uuid[]`, (col) => col)
.addColumn('invited_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('invited_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('email', 'varchar', (col) => col.notNull())
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('status', 'varchar', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('invitations_email_workspace_id_unique', [
'email',
'workspace_id',
])
.execute();
}

View File

@ -6,17 +6,15 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('slug_id', 'varchar', (col) => col.notNull())
.addColumn('title', 'varchar', (col) => col)
.addColumn('icon', 'varchar', (col) => col)
.addColumn('key', 'varchar', (col) => col)
.addColumn('cover_photo', 'varchar', (col) => col)
.addColumn('position', 'varchar', (col) => col)
.addColumn('content', 'jsonb', (col) => col)
.addColumn('html', 'text', (col) => col)
.addColumn('ydoc', 'bytea', (col) => col)
.addColumn('text_content', 'text', (col) => col)
.addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('ydoc', 'bytea', (col) => col)
.addColumn('slug', 'varchar', (col) => col)
.addColumn('cover_photo', 'varchar', (col) => col)
.addColumn('editor', 'varchar', (col) => col)
.addColumn('parent_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
@ -32,8 +30,6 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('is_locked', 'boolean', (col) => col.defaultTo(false).notNull())
.addColumn('status', 'varchar', (col) => col)
.addColumn('published_at', 'date', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
@ -41,6 +37,7 @@ export async function up(db: Kysely<any>): Promise<void> {
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('pages_slug_id_unique', ['slug_id'])
.execute();
await db.schema
@ -49,38 +46,14 @@ export async function up(db: Kysely<any>): Promise<void> {
.using('GIN')
.column('tsv')
.execute();
await db.schema
.createIndex('pages_slug_id_idx')
.on('pages')
.column('slug_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.dropConstraint('pages_creator_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_last_updated_by_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_deleted_by_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_space_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_workspace_id_fkey')
.execute();
await db.schema
.alterTable('pages')
.dropConstraint('pages_parent_page_id_fkey')
.execute();
await db.schema.dropTable('pages').execute();
}

View File

@ -9,12 +9,13 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('slug_id', 'varchar', (col) => col)
.addColumn('title', 'varchar', (col) => col)
.addColumn('content', 'jsonb', (col) => col)
.addColumn('slug', 'varchar', (col) => col)
.addColumn('icon', 'varchar', (col) => col)
.addColumn('cover_photo', 'varchar', (col) => col)
.addColumn('version', 'int4', (col) => col.notNull())
.addColumn('version', 'int4', (col) => col)
.addColumn('last_updated_by_id', 'uuid', (col) =>
col.references('users.id'),
)

View File

@ -6,7 +6,6 @@ import {
InsertablePageHistory,
Page,
PageHistory,
UpdatablePageHistory,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
@ -27,19 +26,6 @@ export class PageHistoryRepo {
.executeTakeFirst();
}
async updatePageHistory(
updatablePageHistory: UpdatablePageHistory,
pageHistoryId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('pageHistory')
.set(updatablePageHistory)
.where('id', '=', pageHistoryId)
.execute();
}
async insertPageHistory(
insertablePageHistory: InsertablePageHistory,
trx?: KyselyTransaction,
@ -55,11 +41,10 @@ export class PageHistoryRepo {
async saveHistory(page: Page): Promise<void> {
await this.insertPageHistory({
pageId: page.id,
slugId: page.slugId,
title: page.title,
content: page.content,
slug: page.slug,
icon: page.icon,
version: 1, // TODO: make incremental
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
spaceId: page.spaceId,

View File

@ -7,22 +7,20 @@ import {
Page,
UpdatablePage,
} from '@docmost/db/types/entity.types';
import { sql } from 'kysely';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
// TODO: scope to space/workspace
@Injectable()
export class PageRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Page> = [
'id',
'slugId',
'title',
'slug',
'icon',
'coverPhoto',
'key',
'position',
'parentPageId',
'creatorId',
@ -30,8 +28,6 @@ export class PageRepo {
'spaceId',
'workspaceId',
'isLocked',
'status',
'publishedAt',
'createdAt',
'updatedAt',
'deletedAt',
@ -44,21 +40,19 @@ export class PageRepo {
includeYdoc?: boolean;
},
): Promise<Page> {
return await this.db
let query = this.db
.selectFrom('pages')
.select(this.baseFields)
.where('id', '=', pageId)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
.executeTakeFirst();
}
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
async slug(slug: string): Promise<Page> {
return await this.db
.selectFrom('pages')
.selectAll()
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
.executeTakeFirst();
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
return query.executeTakeFirst();
}
async updatePage(
@ -67,11 +61,15 @@ export class PageRepo {
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('pages')
.set(updatablePage)
.where('id', '=', pageId)
.executeTakeFirst();
let query = db.updateTable('pages').set(updatablePage);
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
return query.executeTakeFirst();
}
async insertPage(
@ -87,10 +85,20 @@ export class PageRepo {
}
async deletePage(pageId: string): Promise<void> {
await this.db.deleteFrom('pages').where('id', '=', pageId).execute();
let query = this.db.deleteFrom('pages');
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
await query.execute();
}
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
async getRecentPageUpdates(spaceId: string, pagination: PaginationOptions) {
//TODO: should fetch pages from all spaces the user is member of
// for now, fetch from default space
const query = this.db
.selectFrom('pages')
.select(this.baseFields)

View File

@ -28,8 +28,10 @@ export class UserRepo {
'timezone',
'settings',
'lastLoginAt',
'deactivatedAt',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
@ -97,6 +99,7 @@ export class UserRepo {
email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password),
locale: 'en',
role: insertableUser?.role,
lastLoginAt: new Date(),
};

View File

@ -79,10 +79,11 @@ export interface PageHistory {
lastUpdatedById: string | null;
pageId: string;
slug: string | null;
slugId: string | null;
spaceId: string;
title: string | null;
updatedAt: Generated<Timestamp>;
version: number;
version: number | null;
workspaceId: string;
}
@ -93,19 +94,14 @@ export interface Pages {
creatorId: string | null;
deletedAt: Timestamp | null;
deletedById: string | null;
editor: string | null;
html: string | null;
icon: string | null;
id: Generated<string>;
isLocked: Generated<boolean>;
key: string | null;
lastUpdatedById: string | null;
parentPageId: string | null;
position: string | null;
publishedAt: Timestamp | null;
slug: string | null;
slugId: string;
spaceId: string;
status: string | null;
textContent: string | null;
title: string | null;
tsv: string | null;
@ -115,8 +111,8 @@ export interface Pages {
}
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
creatorId: string | null;
groupId: string | null;
id: Generated<string>;
role: string;
@ -143,6 +139,8 @@ export interface Spaces {
export interface Users {
avatarUrl: string | null;
createdAt: Generated<Timestamp>;
deactivatedAt: Timestamp | null;
deletedAt: Timestamp | null;
email: string;
emailVerifiedAt: Timestamp | null;
id: Generated<string>;
@ -151,10 +149,9 @@ export interface Users {
lastLoginAt: Timestamp | null;
locale: string | null;
name: string | null;
password: string;
role: string | null;
password: string | null;
role: string;
settings: Json | null;
status: string | null;
timezone: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string | null;
@ -162,27 +159,26 @@ export interface Users {
export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>;
email: string;
email: string | null;
groupIds: string[] | null;
id: Generated<string>;
invitedById: string | null;
role: string;
token: string | null;
token: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Workspaces {
allowedEmailDomains: Generated<string[] | null>;
createdAt: Generated<Timestamp>;
customDomain: string | null;
defaultRole: Generated<string>;
defaultSpaceId: string | null;
deletedAt: Timestamp | null;
description: string | null;
enableInvite: Generated<boolean>;
hostname: string | null;
id: Generated<string>;
inviteCode: Generated<string | null>;
logo: string | null;
name: string | null;
settings: Json | null;

View File

@ -3,3 +3,7 @@ const { customAlphabet } = require('fix-esm').require('nanoid');
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
export const nanoIdGen = customAlphabet(alphabet, 10);
const slugIdAlphabet =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const genPageShortId = customAlphabet(slugIdAlphabet, 12);

View File

@ -0,0 +1,57 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { join } from 'path';
import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
@Module({})
export class StaticModule implements OnModuleInit {
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly environmentService: EnvironmentService,
) {}
public async onModuleInit() {
const httpAdapter = this.httpAdapterHost.httpAdapter;
const app = httpAdapter.getInstance();
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (fs.existsSync(clientDistPath)) {
const indexFilePath = join(clientDistPath, 'index.html');
const windowVar = '<!--window-config-->';
const configString = {
env: this.environmentService.getEnv(),
appUrl: this.environmentService.getAppUrl(),
isCloud: this.environmentService.isCloud(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html.replace(windowVar, windowScriptContent);
fs.writeFileSync(indexFilePath, transformedHtml);
const RENDER_PATH = '*';
await app.register(fastifyStatic, {
root: clientDistPath,
wildcard: false,
});
app.get(RENDER_PATH, (req: any, res: any) => {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
});
}
}
}

View File

@ -1,12 +0,0 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.addColumn('position', 'varchar', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('pages').dropColumn('position').execute();
}

View File

@ -1,43 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.addColumn('token', 'varchar', (col) => col)
.addColumn('group_ids', sql`uuid[]`, (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('status')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addUniqueConstraint('invitation_email_workspace_id_unique', [
'email',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspace_invitations')
.dropColumn('token')
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropColumn('group_ids')
.execute();
await db.schema
.alterTable('workspace_invitations')
.addColumn('status', 'varchar', (col) => col)
.execute();
await db.schema
.alterTable('workspace_invitations')
.dropConstraint('invitation_email_workspace_id_unique')
.execute();
}

View File

@ -1,14 +0,0 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.addColumn('invited_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('users').dropColumn('invited_by_id').execute();
}

View File

@ -4,7 +4,7 @@ import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';
import { NotFoundException, ValidationPipe } from '@nestjs/common';
import { TransformHttpResponseInterceptor } from './interceptors/http-response.interceptor';
import fastifyMultipart from '@fastify/multipart';
@ -14,12 +14,29 @@ async function bootstrap() {
new FastifyAdapter({
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
} as any),
}),
);
app.setGlobalPrefix('api');
await app.register(fastifyMultipart as any);
await app.register(fastifyMultipart);
app
.getHttpAdapter()
.getInstance()
.addHook('preHandler', function (req, reply, done) {
if (
req.originalUrl.startsWith('/api') &&
!req.originalUrl.startsWith('/api/auth/setup')
) {
if (!req.raw?.['workspaceId']) {
throw new NotFoundException('Workspace not found');
}
done();
} else {
done();
}
});
app.useGlobalPipes(
new ValidationPipe({

View File

@ -17,7 +17,9 @@ export class DomainMiddleware implements NestMiddleware {
if (this.environmentService.isSelfHosted()) {
const workspace = await this.workspaceRepo.findFirst();
if (!workspace) {
throw new NotFoundException('Workspace not found');
//throw new NotFoundException('Workspace not found');
(req as any).workspaceId = null;
return next();
}
(req as any).workspaceId = workspace.id;

View File

@ -20,8 +20,7 @@
"strict": true,
"jsx": "react",
"paths": {
"@docmost/db": ["./src/kysely"],
"@docmost/db/*": ["./src/kysely/*"],
"@docmost/db/*": ["./src/database/*"],
"@docmost/transactional/*": ["./src/integrations/transactional/*"]
}
}