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

@ -0,0 +1,125 @@
import {
Global,
Logger,
Module,
OnApplicationBootstrap,
OnModuleDestroy,
} from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely';
import { Pool, types } from 'pg';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageRepo } from './repos/page/page.repo';
import { CommentRepo } from './repos/comment/comment.repo';
import { PageHistoryRepo } from './repos/page/page-history.repo';
import { AttachmentRepo } from './repos/attachment/attachment.repo';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@Global()
@Module({
imports: [
KyselyModule.forRootAsync({
imports: [],
inject: [EnvironmentService],
useFactory: (environmentService: EnvironmentService) => ({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: environmentService.getDatabaseURL(),
}),
}),
plugins: [new CamelCasePlugin()],
log: (event: LogEvent) => {
if (environmentService.getEnv() !== 'development') return;
if (event.level === 'query') {
// console.log(event.query.sql);
//if (event.query.parameters.length > 0) {
//console.log('parameters: ' + event.query.parameters);
//}
// console.log('time: ' + event.queryDurationMillis);
}
},
}),
}),
],
providers: [
WorkspaceRepo,
UserRepo,
GroupRepo,
GroupUserRepo,
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
],
exports: [
WorkspaceRepo,
UserRepo,
GroupRepo,
GroupUserRepo,
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
],
})
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {
private readonly logger = new Logger(DatabaseModule.name);
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async onApplicationBootstrap() {
await this.establishConnection();
}
async onModuleDestroy(): Promise<void> {
if (this.db) {
await this.db.destroy();
}
}
async establishConnection() {
const retryAttempts = 10;
const retryDelay = 3000;
this.logger.log('Establishing database connection');
for (let i = 0; i < retryAttempts; i++) {
try {
await sql`SELECT 1=1`.execute(this.db);
this.logger.log('Database connection successful');
break;
} catch (err) {
if (err['errors']) {
this.logger.error(err['errors'][0]);
} else {
this.logger.error(err);
}
if (i < retryAttempts - 1) {
this.logger.log(
`Retrying [${i + 1}/${retryAttempts}] in ${retryDelay} ms`,
);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
this.logger.error(
`Failed to connect to database after ${retryAttempts} attempts. Exiting...`,
);
process.exit(1);
}
}
}
}
}

View File

@ -0,0 +1,35 @@
import * as path from 'path';
import { promises as fs } from 'fs';
import pg from 'pg';
import {
Kysely,
Migrator,
PostgresDialect,
FileMigrationProvider,
} from 'kysely';
import { run } from 'kysely-migration-cli';
import * as dotenv from 'dotenv';
import { envPath } from '../helpers/utils';
dotenv.config({ path: envPath });
const migrationFolder = path.join(__dirname, './migrations');
const db = new Kysely<any>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}) as any,
}),
});
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder,
}),
});
run(db, migrator, migrationFolder);

View File

@ -0,0 +1,37 @@
import { Kysely, sql } from 'kysely';
import { UserRole } from '../../helpers/types/permission';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('workspaces')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('name', 'varchar', (col) => col)
.addColumn('description', 'varchar', (col) => col)
.addColumn('logo', 'varchar', (col) => col)
.addColumn('hostname', 'varchar', (col) => col)
.addColumn('custom_domain', 'varchar', (col) => col)
.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()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.addUniqueConstraint('workspaces_hostname_unique', ['hostname'])
.addUniqueConstraint('workspaces_custom_domain_unique', ['custom_domain'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('workspaces').execute();
}

View File

@ -0,0 +1,43 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('name', 'varchar', (col) => col)
.addColumn('email', 'varchar', (col) => col.notNull())
.addColumn('email_verified_at', 'timestamptz', (col) => col)
.addColumn('password', 'varchar', (col) => col)
.addColumn('avatar_url', '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'),
)
.addColumn('locale', 'varchar', (col) => col)
.addColumn('timezone', 'varchar', (col) => col)
.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',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute();
}

View File

@ -0,0 +1,56 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('groups')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('description', 'text', (col) => col)
.addColumn('is_default', 'boolean', (col) => col.notNull())
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('groups_name_workspace_id_unique', [
'name',
'workspace_id',
])
.execute();
await db.schema
.createTable('group_users')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('group_users_group_id_user_id_unique', [
'group_id',
'user_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('group_users').execute();
await db.schema.dropTable('groups').execute();
}

View File

@ -0,0 +1,77 @@
import { Kysely, sql } from 'kysely';
import { SpaceRole, SpaceVisibility } from '../../helpers/types/permission';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('spaces')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('name', 'varchar', (col) => col)
.addColumn('description', 'text', (col) => col)
.addColumn('slug', 'varchar', (col) => col)
.addColumn('icon', 'varchar', (col) => col)
.addColumn('visibility', 'varchar', (col) =>
col.defaultTo(SpaceVisibility.OPEN).notNull(),
)
.addColumn('default_role', 'varchar', (col) =>
col.defaultTo(SpaceRole.WRITER).notNull(),
)
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.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', (col) => col)
.addUniqueConstraint('spaces_slug_workspace_id_unique', [
'slug',
'workspace_id',
])
.execute();
await db.schema
.createTable('space_members')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('added_by_id', 'uuid', (col) => col.references('users.id'))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('space_members_space_id_user_id_unique', [
'space_id',
'user_id',
])
.addUniqueConstraint('space_members_space_id_group_id_unique', [
'space_id',
'group_id',
])
.addCheckConstraint(
'allow_either_user_id_or_group_id_check',
sql`(("user_id" IS NOT NULL AND "group_id" IS NULL) OR ("user_id" IS NULL AND "group_id" IS NOT NULL))`,
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('space_members').execute();
await db.schema.dropTable('spaces').execute();
}

View File

@ -0,0 +1,21 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.addForeignKeyConstraint(
'workspaces_default_space_id_fkey',
['default_space_id'],
'spaces',
['id'],
)
.onDelete('set null')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('workspaces')
.dropConstraint('workspaces_default_space_id_fkey')
.execute();
}

View File

@ -0,0 +1,32 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('workspace_invitations')
.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('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();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('workspace_invitations').execute();
}

View File

@ -0,0 +1,59 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('pages')
.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('cover_photo', 'varchar', (col) => col)
.addColumn('position', 'varchar', (col) => col)
.addColumn('content', 'jsonb', (col) => col)
.addColumn('ydoc', 'bytea', (col) => col)
.addColumn('text_content', 'text', (col) => col)
.addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('parent_page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.addColumn('last_updated_by_id', 'uuid', (col) =>
col.references('users.id'),
)
.addColumn('deleted_by_id', 'uuid', (col) => col.references('users.id'))
.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('is_locked', 'boolean', (col) => col.defaultTo(false).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', (col) => col)
.addUniqueConstraint('pages_slug_id_unique', ['slug_id'])
.execute();
await db.schema
.createIndex('pages_tsv_idx')
.on('pages')
.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.dropTable('pages').execute();
}

View File

@ -0,0 +1,39 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_history')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.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)
.addColumn('last_updated_by_id', 'uuid', (col) =>
col.references('users.id'),
)
.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('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_history').execute();
}

View File

@ -0,0 +1,32 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('comments')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('content', 'jsonb', (col) => col)
.addColumn('selection', 'varchar', (col) => col)
.addColumn('type', 'varchar', (col) => col)
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade').notNull(),
)
.addColumn('parent_comment_id', 'uuid', (col) =>
col.references('comments.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('edited_at', 'timestamptz', (col) => col)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('comments').execute();
}

View File

@ -0,0 +1,33 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('attachments')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn('file_name', 'varchar', (col) => col.notNull())
.addColumn('file_path', 'varchar', (col) => col.notNull())
.addColumn('file_size', 'int8', (col) => col)
.addColumn('file_ext', 'varchar', (col) => col.notNull())
.addColumn('mime_type', 'varchar', (col) => col)
.addColumn('type', 'varchar', (col) => col)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').notNull(),
)
.addColumn('page_id', 'uuid', (col) => col.references('pages.id'))
.addColumn('space_id', 'uuid', (col) => col.references('spaces.id'))
.addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id'))
.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)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('attachments').execute();
}

View File

@ -0,0 +1,22 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION pages_tsvector_trigger() RETURNS trigger AS $$
begin
new.tsv :=
setweight(to_tsvector('english', coalesce(new.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(new.text_content, '')), 'B');
return new;
end;
$$ LANGUAGE plpgsql;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER pages_tsvector_update BEFORE INSERT OR UPDATE
ON pages FOR EACH ROW EXECUTE FUNCTION pages_tsvector_trigger();`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP trigger pages_tsvector_update ON pages`.execute(db);
await sql`DROP FUNCTION pages_tsvector_trigger`.execute(db);
}

View File

@ -0,0 +1,26 @@
import {
IsNumber,
IsOptional,
IsPositive,
IsString,
Max,
Min,
} from 'class-validator';
export class PaginationOptions {
@IsOptional()
@IsNumber()
@Min(1)
page = 1;
@IsOptional()
@IsNumber()
@IsPositive()
@Min(1)
@Max(100)
limit = 20;
@IsOptional()
@IsString()
query: string;
}

View File

@ -0,0 +1,69 @@
// adapted from https://github.com/charlie-hadden/kysely-paginate/blob/main/src/offset.ts - MIT
import { SelectQueryBuilder, StringReference, sql } from 'kysely';
export type PaginationMeta = {
limit: number;
page: number;
hasNextPage: boolean;
hasPrevPage: boolean;
};
export type PaginationResult<T> = {
items: T[];
meta: PaginationMeta;
};
export async function executeWithPagination<O, DB, TB extends keyof DB>(
qb: SelectQueryBuilder<DB, TB, O>,
opts: {
perPage: number;
page: number;
experimental_deferredJoinPrimaryKey?: StringReference<DB, TB>;
},
): Promise<PaginationResult<O>> {
if (opts.page < 1) {
opts.page = 1;
}
qb = qb.limit(opts.perPage + 1).offset((opts.page - 1) * opts.perPage);
const deferredJoinPrimaryKey = opts.experimental_deferredJoinPrimaryKey;
if (deferredJoinPrimaryKey) {
const primaryKeys = await qb
.clearSelect()
.select((eb) => eb.ref(deferredJoinPrimaryKey).as('primaryKey'))
.execute()
// @ts-expect-error TODO: Fix the type here later
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
.then((rows) => rows.map((row) => row.primaryKey));
qb = qb
.where((eb) =>
primaryKeys.length > 0
? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
eb(deferredJoinPrimaryKey, 'in', primaryKeys as any)
: eb(sql`1`, '=', 0),
)
.clearOffset()
.clearLimit();
}
const rows = await qb.execute();
const hasNextPage = rows.length > 0 ? rows.length > opts.perPage : false;
const hasPrevPage = rows.length > 0 ? opts.page > 1 : false;
// If we fetched an extra row to determine if we have a next page, that
// shouldn't be in the returned results
if (rows.length > opts.perPage) {
rows.pop();
}
return {
items: rows,
meta: {
limit: opts.perPage,
page: opts.page,
hasNextPage,
hasPrevPage,
},
};
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
Attachment,
InsertableAttachment,
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
@Injectable()
export class AttachmentRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
attachmentId: string,
workspaceId: string,
): Promise<Attachment> {
return this.db
.selectFrom('attachments')
.selectAll()
.where('id', '=', attachmentId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async insertAttachment(
insertableAttachment: InsertableAttachment,
trx?: KyselyTransaction,
): Promise<Attachment> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('attachments')
.values(insertableAttachment)
.returningAll()
.executeTakeFirst();
}
async updateAttachment(
updatableAttachment: UpdatableAttachment,
attachmentId: string,
): Promise<void> {
await this.db
.updateTable('attachments')
.set(updatableAttachment)
.where('id', '=', attachmentId)
.returningAll()
.executeTakeFirst();
}
}

View File

@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
Comment,
InsertableComment,
UpdatableComment,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@Injectable()
export class CommentRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
// todo, add workspaceId
async findById(
commentId: string,
opts?: { includeCreator: boolean },
): Promise<Comment> {
return await this.db
.selectFrom('comments')
.selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
.where('id', '=', commentId)
.executeTakeFirst();
}
async findPageComments(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('comments')
.selectAll('comments')
.select((eb) => this.withCreator(eb))
.where('pageId', '=', pageId)
.orderBy('createdAt', 'asc');
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
async updateComment(
updatableComment: UpdatableComment,
commentId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
await db
.updateTable('comments')
.set(updatableComment)
.where('id', '=', commentId)
.execute();
}
async insertComment(
insertableComment: InsertableComment,
trx?: KyselyTransaction,
): Promise<Comment> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('comments')
.values(insertableComment)
.returningAll()
.executeTakeFirst();
}
withCreator(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'comments.creatorId'),
).as('creator');
}
async deleteComment(commentId: string): Promise<void> {
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
}
}

View File

@ -0,0 +1,154 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx, executeTx } from '@docmost/db/utils';
import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@Injectable()
export class GroupUserRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly userRepo: UserRepo,
) {}
async getGroupUserById(
userId: string,
groupId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('groupUsers')
.selectAll()
.where('userId', '=', userId)
.where('groupId', '=', groupId)
.executeTakeFirst();
}
async insertGroupUser(
insertableGroupUser: InsertableGroupUser,
trx?: KyselyTransaction,
): Promise<GroupUser> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('groupUsers')
.values(insertableGroupUser)
.returningAll()
.executeTakeFirst();
}
async getGroupUsersPaginated(groupId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('groupUsers')
.innerJoin('users', 'users.id', 'groupUsers.userId')
.selectAll('users')
.where('groupId', '=', groupId)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb('users.name', 'ilike', `%${pagination.query}%`),
);
}
const result = await executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
result.items.map((user) => {
delete user.password;
});
return result;
}
async addUserToGroup(
userId: string,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const group = await this.groupRepo.findById(groupId, workspaceId, {
trx,
});
if (!group) {
throw new NotFoundException('Group not found');
}
const user = await this.userRepo.findById(userId, workspaceId, {
trx: trx,
});
if (!user) {
throw new NotFoundException('User not found');
}
const groupUserExists = await this.getGroupUserById(
userId,
groupId,
trx,
);
if (groupUserExists) {
throw new BadRequestException(
'User is already a member of this group',
);
}
await this.insertGroupUser(
{
userId,
groupId,
},
trx,
);
},
trx,
);
}
async addUserToDefaultGroup(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await executeTx(
this.db,
async (trx) => {
const defaultGroup = await this.groupRepo.getDefaultGroup(
workspaceId,
trx,
);
await this.insertGroupUser(
{
userId,
groupId: defaultGroup.id,
},
trx,
);
},
trx,
);
}
async delete(userId: string, groupId: string): Promise<void> {
await this.db
.deleteFrom('groupUsers')
.where('userId', '=', userId)
.where('groupId', '=', groupId)
.execute();
}
}

View File

@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
Group,
InsertableGroup,
UpdatableGroup,
} from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
@Injectable()
export class GroupRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
groupId: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where('id', '=', groupId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findByName(
groupName: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async update(
updatableGroup: UpdatableGroup,
groupId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('groups')
.set(updatableGroup)
.where('id', '=', groupId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async insertGroup(
insertableGroup: InsertableGroup,
trx?: KyselyTransaction,
): Promise<Group> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('groups')
.values(insertableGroup)
.returningAll()
.executeTakeFirst();
}
async getDefaultGroup(
workspaceId: string,
trx: KyselyTransaction,
): Promise<Group> {
const db = dbOrTx(this.db, trx);
return (
db
.selectFrom('groups')
.selectAll()
// .select((eb) => this.withMemberCount(eb))
.where('isDefault', '=', true)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst()
);
}
async createDefaultGroup(
workspaceId: string,
opts?: { userId?: string; trx?: KyselyTransaction },
): Promise<Group> {
const { userId, trx } = opts;
const insertableGroup: InsertableGroup = {
name: DefaultGroup.EVERYONE,
isDefault: true,
creatorId: userId,
workspaceId: workspaceId,
};
return this.insertGroup(insertableGroup, trx);
}
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('groups')
.selectAll('groups')
.select((eb) => this.withMemberCount(eb))
.where('workspaceId', '=', workspaceId)
.orderBy('memberCount', 'desc')
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb('name', 'ilike', `%${pagination.query}%`).or(
'description',
'ilike',
`%${pagination.query}%`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
withMemberCount(eb: ExpressionBuilder<DB, 'groups'>) {
return eb
.selectFrom('groupUsers')
.select((eb) => eb.fn.countAll().as('count'))
.whereRef('groupUsers.groupId', '=', 'groups.id')
.as('memberCount');
}
async delete(groupId: string, workspaceId: string): Promise<void> {
await this.db
.deleteFrom('groups')
.where('id', '=', groupId)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertablePageHistory,
Page,
PageHistory,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
@Injectable()
export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(pageHistoryId: string): Promise<PageHistory> {
return await this.db
.selectFrom('pageHistory')
.selectAll()
.select((eb) => this.withLastUpdatedBy(eb))
.where('id', '=', pageHistoryId)
.executeTakeFirst();
}
async insertPageHistory(
insertablePageHistory: InsertablePageHistory,
trx?: KyselyTransaction,
): Promise<PageHistory> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('pageHistory')
.values(insertablePageHistory)
.returningAll()
.executeTakeFirst();
}
async saveHistory(page: Page): Promise<void> {
await this.insertPageHistory({
pageId: page.id,
slugId: page.slugId,
title: page.title,
content: page.content,
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
});
}
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pageHistory')
.selectAll()
.select((eb) => this.withLastUpdatedBy(eb))
.where('pageId', '=', pageId)
.orderBy('createdAt', 'desc');
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
withLastUpdatedBy(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy');
}
}

View File

@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertablePage,
Page,
UpdatablePage,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
@Injectable()
export class PageRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Page> = [
'id',
'slugId',
'title',
'icon',
'coverPhoto',
'position',
'parentPageId',
'creatorId',
'lastUpdatedById',
'spaceId',
'workspaceId',
'isLocked',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
pageId: string,
opts?: {
includeContent?: boolean;
includeYdoc?: boolean;
},
): Promise<Page> {
let query = this.db
.selectFrom('pages')
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'));
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
return query.executeTakeFirst();
}
async updatePage(
updatablePage: UpdatablePage,
pageId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
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(
insertablePage: InsertablePage,
trx?: KyselyTransaction,
): Promise<Page> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('pages')
.values(insertablePage)
.returning(this.baseFields)
.executeTakeFirst();
}
async deletePage(pageId: string): Promise<void> {
let query = this.db.deleteFrom('pages');
if (isValidUUID(pageId)) {
query = query.where('id', '=', pageId);
} else {
query = query.where('slugId', '=', pageId);
}
await query.execute();
}
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)
.where('spaceId', '=', spaceId)
.orderBy('updatedAt', 'desc');
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
}

View File

@ -0,0 +1,187 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableSpaceMember,
SpaceMember,
UpdatableSpaceMember,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { MemberInfo, UserSpaceRole } from './types';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
@Injectable()
export class SpaceMemberRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
) {}
async insertSpaceMember(
insertableSpaceMember: InsertableSpaceMember,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.insertInto('spaceMembers')
.values(insertableSpaceMember)
.returningAll()
.execute();
}
async updateSpaceMember(
updatableSpaceMember: UpdatableSpaceMember,
spaceMemberId: string,
spaceId: string,
): Promise<void> {
await this.db
.updateTable('spaceMembers')
.set(updatableSpaceMember)
.where('id', '=', spaceMemberId)
.where('spaceId', '=', spaceId)
.execute();
}
async getSpaceMemberByTypeId(
spaceId: string,
opts: {
userId?: string;
groupId?: string;
},
trx?: KyselyTransaction,
): Promise<SpaceMember> {
const db = dbOrTx(this.db, trx);
let query = db
.selectFrom('spaceMembers')
.selectAll()
.where('spaceId', '=', spaceId);
if (opts.userId) {
query = query.where('userId', '=', opts.userId);
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
} else {
throw new BadRequestException('Please provider a userId or groupId');
}
return query.executeTakeFirst();
}
async removeSpaceMemberById(
memberId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('spaceMembers')
.where('id', '=', memberId)
.where('spaceId', '=', spaceId)
.execute();
}
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
const { count } = await this.db
.selectFrom('spaceMembers')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('spaceId', '=', spaceId)
.executeTakeFirst();
return count as number;
}
async getSpaceMembersPaginated(
spaceId: string,
pagination: PaginationOptions,
) {
const query = this.db
.selectFrom('spaceMembers')
.leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
.select([
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
'users.email as userEmail',
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
'spaceMembers.role',
'spaceMembers.createdAt',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('spaceId', '=', spaceId)
.orderBy('spaceMembers.createdAt', 'asc');
const result = await executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
let memberInfo: MemberInfo;
const members = result.items.map((member) => {
if (member.userId) {
memberInfo = {
id: member.userId,
name: member.userName,
email: member.userEmail,
avatarUrl: member.userAvatarUrl,
type: 'user',
};
} else if (member.groupId) {
memberInfo = {
id: member.groupId,
name: member.groupName,
memberCount: member.memberCount as number,
isDefault: member.groupIsDefault,
type: 'group',
};
}
return {
...memberInfo,
role: member.role,
createdAt: member.createdAt,
};
});
result.items = members as any;
return result;
}
/*
* we want to get a user's role in a space.
* they user can be a member either directly or via a group
* we will pass the user id and space id to return the user's roles
* if the user is a member of the space via multiple groups
* if the user has no space permission it should return an empty array,
* maybe we should throw an exception?
*/
async getUserSpaceRoles(
userId: string,
spaceId: string,
): Promise<UserSpaceRole[]> {
const roles = await this.db
.selectFrom('spaceMembers')
.select(['userId', 'role'])
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select(['groupUsers.userId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', '=', spaceId),
)
.execute();
if (!roles || roles.length === 0) {
return undefined;
}
return roles;
}
}

View File

@ -0,0 +1,143 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableSpace,
Space,
UpdatableSpace,
} from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DB } from '@docmost/db/types/db';
@Injectable()
export class SpaceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
spaceId: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
): Promise<Space> {
return await this.db
.selectFrom('spaces')
.selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findBySlug(
slug: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
): Promise<Space> {
return await this.db
.selectFrom('spaces')
.selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async slugExists(
slug: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<boolean> {
const db = dbOrTx(this.db, trx);
let { count } = await db
.selectFrom('spaces')
.select((eb) => eb.fn.count('id').as('count'))
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
count = count as number;
return count != 0;
}
async updateSpace(
updatableSpace: UpdatableSpace,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('spaces')
.set(updatableSpace)
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
): Promise<Space> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('spaces')
.values(insertableSpace)
.returningAll()
.executeTakeFirst();
}
async getSpacesInWorkspace(
workspaceId: string,
pagination: PaginationOptions,
) {
// todo: show spaces user have access based on visibility and memberships
let query = this.db
.selectFrom('spaces')
.selectAll('spaces')
.select((eb) => [this.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb('name', 'ilike', `%${pagination.query}%`).or(
'description',
'ilike',
`%${pagination.query}%`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
return eb
.selectFrom('spaceMembers')
.innerJoin('groups', 'groups.id', 'spaceMembers.groupId')
.innerJoin('groupUsers', 'groupUsers.groupId', 'groups.id')
.select((eb) =>
eb.fn
.count(sql`concat(space_members.user_id, group_users.user_id)`)
.distinct()
.as('count'),
)
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.as('memberCount');
}
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
await this.db
.deleteFrom('spaces')
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@ -0,0 +1,22 @@
export interface UserSpaceRole {
userId: string;
role: string;
}
interface SpaceUserInfo {
id: string;
name: string;
email: string;
avatarUrl: string;
type: 'user';
}
interface SpaceGroupInfo {
id: string;
name: string;
isDefault: boolean;
memberCount: number;
type: 'group';
}
export type MemberInfo = SpaceUserInfo | SpaceGroupInfo;

View File

@ -0,0 +1,23 @@
import { UserSpaceRole } from '@docmost/db/repos/space/types';
import { SpaceRole } from '../../../helpers/types/permission';
export function findHighestUserSpaceRole(userSpaceRoles: UserSpaceRole[]) {
if (!userSpaceRoles) {
return undefined;
}
const roleOrder: { [key in SpaceRole]: number } = {
[SpaceRole.ADMIN]: 3,
[SpaceRole.WRITER]: 2,
[SpaceRole.READER]: 1,
};
let highestRole: string;
for (const userSpaceRole of userSpaceRoles) {
const currentRole = userSpaceRole.role;
if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) {
highestRole = currentRole;
}
}
return highestRole;
}

View File

@ -0,0 +1,152 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Users } from '@docmost/db/types/db';
import { hashPassword } from '../../../helpers';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableUser,
UpdatableUser,
User,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
@Injectable()
export class UserRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
public baseFields: Array<keyof Users> = [
'id',
'email',
'name',
'emailVerifiedAt',
'avatarUrl',
'role',
'workspaceId',
'locale',
'timezone',
'settings',
'lastLoginAt',
'deactivatedAt',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
userId: string,
workspaceId: string,
opts?: {
includePassword?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('users')
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findByEmail(
email: string,
workspaceId: string,
includePassword?: boolean,
): Promise<User> {
return this.db
.selectFrom('users')
.select(this.baseFields)
.$if(includePassword, (qb) => qb.select('password'))
.where('email', '=', email)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async updateUser(
updatableUser: UpdatableUser,
userId: string,
workspaceId: string,
) {
return await this.db
.updateTable('users')
.set(updatableUser)
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async updateLastLogin(userId: string, workspaceId: string) {
return await this.db
.updateTable('users')
.set({
lastLoginAt: new Date(),
})
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async insertUser(
insertableUser: InsertableUser,
trx?: KyselyTransaction,
): Promise<User> {
const user: InsertableUser = {
name: insertableUser.name || insertableUser.email.toLowerCase(),
email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password),
locale: 'en',
role: insertableUser?.role,
lastLoginAt: new Date(),
};
const db = dbOrTx(this.db, trx);
return db
.insertInto('users')
.values(user)
.returningAll()
.executeTakeFirst();
}
async roleCountByWorkspaceId(
role: string,
workspaceId: string,
): Promise<number> {
const { count } = await this.db
.selectFrom('users')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
return count as number;
}
async getUsersPaginated(workspaceId: string, pagination: PaginationOptions) {
let query = this.db
.selectFrom('users')
.select(this.baseFields)
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb('users.name', 'ilike', `%${pagination.query}%`).or(
'users.email',
'ilike',
`%${pagination.query}%`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
}

View File

@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertableWorkspace,
UpdatableWorkspace,
Workspace,
} from '@docmost/db/types/entity.types';
import { sql } from 'kysely';
@Injectable()
export class WorkspaceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(workspaceId: string): Promise<Workspace> {
return await this.db
.selectFrom('workspaces')
.selectAll()
.where('id', '=', workspaceId)
.executeTakeFirst();
}
async findFirst(): Promise<Workspace> {
return await this.db
.selectFrom('workspaces')
.selectAll()
.orderBy('createdAt asc')
.limit(1)
.executeTakeFirst();
}
async findByHostname(hostname: string): Promise<Workspace> {
return await this.db
.selectFrom('workspaces')
.selectAll()
.where(sql`LOWER(hostname)`, '=', sql`LOWER(${hostname})`)
.executeTakeFirst();
}
async updateWorkspace(
updatableWorkspace: UpdatableWorkspace,
workspaceId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set(updatableWorkspace)
.where('id', '=', workspaceId)
.execute();
}
async insertWorkspace(
insertableWorkspace: InsertableWorkspace,
trx?: KyselyTransaction,
): Promise<Workspace> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('workspaces')
.values(insertableWorkspace)
.returningAll()
.executeTakeFirst();
}
async count(): Promise<number> {
const { count } = await this.db
.selectFrom('workspaces')
.select((eb) => eb.fn.count('id').as('count'))
.executeTakeFirst();
return count as number;
}
}

200
apps/server/src/database/types/db.d.ts vendored Normal file
View File

@ -0,0 +1,200 @@
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Json = JsonValue;
export type JsonArray = JsonValue[];
export type JsonObject = {
[K in string]?: JsonValue;
};
export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface Attachments {
createdAt: Generated<Timestamp>;
creatorId: string;
deletedAt: Timestamp | null;
fileExt: string;
fileName: string;
filePath: string;
fileSize: Int8 | null;
id: Generated<string>;
mimeType: string | null;
pageId: string | null;
spaceId: string | null;
type: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string | null;
}
export interface Comments {
content: Json | null;
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
editedAt: Timestamp | null;
id: Generated<string>;
pageId: string;
parentCommentId: string | null;
selection: string | null;
type: string | null;
workspaceId: string;
}
export interface Groups {
createdAt: Generated<Timestamp>;
creatorId: string | null;
description: string | null;
id: Generated<string>;
isDefault: boolean;
name: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface GroupUsers {
createdAt: Generated<Timestamp>;
groupId: string;
id: Generated<string>;
updatedAt: Generated<Timestamp>;
userId: string;
}
export interface PageHistory {
content: Json | null;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
icon: string | null;
id: Generated<string>;
lastUpdatedById: string | null;
pageId: string;
slug: string | null;
slugId: string | null;
spaceId: string;
title: string | null;
updatedAt: Generated<Timestamp>;
version: number | null;
workspaceId: string;
}
export interface Pages {
content: Json | null;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
deletedById: string | null;
icon: string | null;
id: Generated<string>;
isLocked: Generated<boolean>;
lastUpdatedById: string | null;
parentPageId: string | null;
position: string | null;
slugId: string;
spaceId: string;
textContent: string | null;
title: string | null;
tsv: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
ydoc: Buffer | null;
}
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
groupId: string | null;
id: Generated<string>;
role: string;
spaceId: string;
updatedAt: Generated<Timestamp>;
userId: string | null;
}
export interface Spaces {
createdAt: Generated<Timestamp>;
creatorId: string | null;
defaultRole: Generated<string>;
deletedAt: Timestamp | null;
description: string | null;
icon: string | null;
id: Generated<string>;
name: string | null;
slug: string | null;
updatedAt: Generated<Timestamp>;
visibility: Generated<string>;
workspaceId: string;
}
export interface Users {
avatarUrl: string | null;
createdAt: Generated<Timestamp>;
deactivatedAt: Timestamp | null;
deletedAt: Timestamp | null;
email: string;
emailVerifiedAt: Timestamp | null;
id: Generated<string>;
invitedById: string | null;
lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null;
locale: string | null;
name: string | null;
password: string | null;
role: string;
settings: Json | null;
timezone: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string | null;
}
export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>;
email: string | null;
groupIds: string[] | null;
id: Generated<string>;
invitedById: string | null;
role: string;
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;
hostname: string | null;
id: Generated<string>;
logo: string | null;
name: string | null;
settings: Json | null;
updatedAt: Generated<Timestamp>;
}
export interface DB {
attachments: Attachments;
comments: Comments;
groups: Groups;
groupUsers: GroupUsers;
pageHistory: PageHistory;
pages: Pages;
spaceMembers: SpaceMembers;
spaces: Spaces;
users: Users;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
}

View File

@ -0,0 +1,73 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
Attachments,
Comments,
Groups,
Pages,
Spaces,
Users,
Workspaces,
PageHistory as History,
GroupUsers,
SpaceMembers,
WorkspaceInvitations,
} from './db';
// Workspace
export type Workspace = Selectable<Workspaces>;
export type InsertableWorkspace = Insertable<Workspaces>;
export type UpdatableWorkspace = Updateable<Omit<Workspaces, 'id'>>;
// WorkspaceInvitation
export type WorkspaceInvitation = Selectable<WorkspaceInvitations>;
export type InsertableWorkspaceInvitation = Insertable<WorkspaceInvitations>;
export type UpdatableWorkspaceInvitation = Updateable<
Omit<WorkspaceInvitations, 'id'>
>;
// User
export type User = Selectable<Users>;
export type InsertableUser = Insertable<Users>;
export type UpdatableUser = Updateable<Omit<Users, 'id'>>;
// Space
export type Space = Selectable<Spaces>;
export type InsertableSpace = Insertable<Spaces>;
export type UpdatableSpace = Updateable<Omit<Spaces, 'id'>>;
// SpaceMember
export type SpaceMember = Selectable<SpaceMembers>;
export type InsertableSpaceMember = Insertable<SpaceMembers>;
export type UpdatableSpaceMember = Updateable<Omit<SpaceMembers, 'id'>>;
// Group
export type ExtendedGroup = Groups & { memberCount: number };
export type Group = Selectable<Groups>;
export type InsertableGroup = Insertable<Groups>;
export type UpdatableGroup = Updateable<Omit<Groups, 'id'>>;
// GroupUser
export type GroupUser = Selectable<GroupUsers>;
export type InsertableGroupUser = Insertable<GroupUsers>;
export type UpdatableGroupUser = Updateable<Omit<GroupUsers, 'id'>>;
// Page
export type Page = Selectable<Pages>;
export type InsertablePage = Insertable<Pages>;
export type UpdatablePage = Updateable<Omit<Pages, 'id'>>;
// PageHistory
export type PageHistory = Selectable<History>;
export type InsertablePageHistory = Insertable<History>;
export type UpdatablePageHistory = Updateable<Omit<History, 'id'>>;
// Comment
export type Comment = Selectable<Comments>;
export type InsertableComment = Insertable<Comments>;
export type UpdatableComment = Updateable<Omit<Comments, 'id'>>;
// Attachment
export type Attachment = Selectable<Attachments>;
export type InsertableAttachment = Insertable<Attachments>;
export type UpdatableAttachment = Updateable<Omit<Attachments, 'id'>>;

View File

@ -0,0 +1,5 @@
import { DB } from './db';
import { Kysely, Transaction } from 'kysely';
export type KyselyDB = Kysely<DB>;
export type KyselyTransaction = Transaction<DB>;

View File

@ -0,0 +1,33 @@
import { KyselyDB, KyselyTransaction } from './types/kysely.types';
/*
* Executes a transaction or a callback using the provided database instance.
* If an existing transaction is provided, it directly executes the callback with it.
* Otherwise, it starts a new transaction using the provided database instance and executes the callback within that transaction.
*/
export async function executeTx<T>(
db: KyselyDB,
callback: (trx: KyselyTransaction) => Promise<T>,
existingTrx?: KyselyTransaction,
): Promise<T> {
if (existingTrx) {
return await callback(existingTrx); // Execute callback with existing transaction
} else {
return await db.transaction().execute((trx) => callback(trx)); // Start new transaction and execute callback
}
}
/*
* This function returns either an existing transaction if provided,
* or the normal database instance.
*/
export function dbOrTx(
db: KyselyDB,
existingTrx?: KyselyTransaction,
): KyselyDB | KyselyTransaction {
if (existingTrx) {
return existingTrx; // Use existing transaction
} else {
return db; // Use normal database instance
}
}