mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-17 02:01:03 +10:00
updates and fixes
* seo friendly urls * custom client serve-static module * database fixes * fix recent pages * other fixes
This commit is contained in:
125
apps/server/src/database/database.module.ts
Normal file
125
apps/server/src/database/database.module.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
apps/server/src/database/migrate.ts
Normal file
35
apps/server/src/database/migrate.ts
Normal 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);
|
||||
@ -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();
|
||||
}
|
||||
43
apps/server/src/database/migrations/20240324T085600-users.ts
Normal file
43
apps/server/src/database/migrations/20240324T085600-users.ts
Normal 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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
59
apps/server/src/database/migrations/20240324T086300-pages.ts
Normal file
59
apps/server/src/database/migrations/20240324T086300-pages.ts
Normal 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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
26
apps/server/src/database/pagination/pagination-options.ts
Normal file
26
apps/server/src/database/pagination/pagination-options.ts
Normal 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;
|
||||
}
|
||||
69
apps/server/src/database/pagination/pagination.ts
Normal file
69
apps/server/src/database/pagination/pagination.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
51
apps/server/src/database/repos/attachment/attachment.repo.ts
Normal file
51
apps/server/src/database/repos/attachment/attachment.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
86
apps/server/src/database/repos/comment/comment.repo.ts
Normal file
86
apps/server/src/database/repos/comment/comment.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
154
apps/server/src/database/repos/group/group-user.repo.ts
Normal file
154
apps/server/src/database/repos/group/group-user.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
148
apps/server/src/database/repos/group/group.repo.ts
Normal file
148
apps/server/src/database/repos/group/group.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
79
apps/server/src/database/repos/page/page-history.repo.ts
Normal file
79
apps/server/src/database/repos/page/page-history.repo.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
115
apps/server/src/database/repos/page/page.repo.ts
Normal file
115
apps/server/src/database/repos/page/page.repo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
187
apps/server/src/database/repos/space/space-member.repo.ts
Normal file
187
apps/server/src/database/repos/space/space-member.repo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
143
apps/server/src/database/repos/space/space.repo.ts
Normal file
143
apps/server/src/database/repos/space/space.repo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
22
apps/server/src/database/repos/space/types.ts
Normal file
22
apps/server/src/database/repos/space/types.ts
Normal 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;
|
||||
23
apps/server/src/database/repos/space/utils.ts
Normal file
23
apps/server/src/database/repos/space/utils.ts
Normal 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;
|
||||
}
|
||||
152
apps/server/src/database/repos/user/user.repo.ts
Normal file
152
apps/server/src/database/repos/user/user.repo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
73
apps/server/src/database/repos/workspace/workspace.repo.ts
Normal file
73
apps/server/src/database/repos/workspace/workspace.repo.ts
Normal 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
200
apps/server/src/database/types/db.d.ts
vendored
Normal 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;
|
||||
}
|
||||
73
apps/server/src/database/types/entity.types.ts
Normal file
73
apps/server/src/database/types/entity.types.ts
Normal 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'>>;
|
||||
5
apps/server/src/database/types/kysely.types.ts
Normal file
5
apps/server/src/database/types/kysely.types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { DB } from './db';
|
||||
import { Kysely, Transaction } from 'kysely';
|
||||
|
||||
export type KyselyDB = Kysely<DB>;
|
||||
export type KyselyTransaction = Transaction<DB>;
|
||||
33
apps/server/src/database/utils.ts
Normal file
33
apps/server/src/database/utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user