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,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;
}
}