mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
feat(beta): public spaces (#2473)
This commit is contained in:
@@ -22,6 +22,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||
@@ -88,6 +89,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PublicSpaceRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
@@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
PublicSpaceRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('public_spaces')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull().unique(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(false))
|
||||
.addColumn('search_indexing', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('settings', 'jsonb', (col) => col)
|
||||
.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()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('public_spaces_workspace_id_idx')
|
||||
.on('public_spaces')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('public_spaces').execute();
|
||||
}
|
||||
@@ -605,4 +605,83 @@ export class PageRepo {
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All pages of a space excluding restricted subtrees.
|
||||
* Used by public spaces; a restricted page hides its whole subtree.
|
||||
*/
|
||||
async getSpacePagesExcludingRestricted(spaceId: string) {
|
||||
return this.db
|
||||
.withRecursive('page_hierarchy', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.position',
|
||||
'pages.parentPageId',
|
||||
'pages.spaceId',
|
||||
'pages.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.where('pages.spaceId', '=', spaceId)
|
||||
.where('pages.parentPageId', 'is', null)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
|
||||
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
|
||||
.select([
|
||||
'p.id',
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
'p.workspaceId',
|
||||
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
|
||||
])
|
||||
.where('p.deletedAt', 'is', null)
|
||||
.where('ph.isRestricted', '=', false),
|
||||
),
|
||||
)
|
||||
.selectFrom('page_hierarchy')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.where('isRestricted', '=', false)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getFirstUnrestrictedRootPage(spaceId: string) {
|
||||
return this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId'])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('parentPageId', 'is', null)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom('pageAccess')
|
||||
.select('pageAccess.id')
|
||||
.whereRef('pageAccess.pageId', '=', 'pages.id'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy('position', (ob) => ob.collate('C').asc())
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import { PublicSpace } from '@docmost/db/types/entity.types';
|
||||
import { DB, Json } from '@docmost/db/types/db';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@Injectable()
|
||||
export class PublicSpaceRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findBySpaceId(
|
||||
spaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PublicSpace> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('publicSpaces')
|
||||
.selectAll()
|
||||
.where('spaceId', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async upsert(opts: {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
enabled: boolean;
|
||||
searchIndexing?: boolean;
|
||||
creatorId: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<PublicSpace> {
|
||||
const settingsColumn =
|
||||
typeof opts.settings !== 'undefined'
|
||||
? { settings: sql<Json>`${JSON.stringify(opts.settings)}::text::jsonb` }
|
||||
: {};
|
||||
|
||||
return this.db
|
||||
.insertInto('publicSpaces')
|
||||
.values({
|
||||
spaceId: opts.spaceId,
|
||||
workspaceId: opts.workspaceId,
|
||||
enabled: opts.enabled,
|
||||
searchIndexing: opts.searchIndexing ?? false,
|
||||
creatorId: opts.creatorId,
|
||||
...settingsColumn,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column('spaceId').doUpdateSet({
|
||||
enabled: opts.enabled,
|
||||
updatedAt: new Date(),
|
||||
...(typeof opts.searchIndexing !== 'undefined'
|
||||
? { searchIndexing: opts.searchIndexing }
|
||||
: {}),
|
||||
...settingsColumn,
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
// Published spaces are public by definition, so the list spans the whole
|
||||
// workspace; userId only resolves the viewer's per-space role.
|
||||
async getPublishedSpaces(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const query = this.db
|
||||
.selectFrom('publicSpaces')
|
||||
.select([
|
||||
'id',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'searchIndexing',
|
||||
'settings',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
])
|
||||
.select((eb) => this.withSpace(eb, userId))
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('enabled', '=', true)
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom('spaces')
|
||||
.select('spaces.id')
|
||||
.whereRef('spaces.id', '=', 'publicSpaces.spaceId')
|
||||
.where('spaces.deletedAt', 'is', null),
|
||||
),
|
||||
);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'updatedAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
withSpace(eb: ExpressionBuilder<DB, 'publicSpaces'>, userId: string) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.select((eb) => this.withUserSpaceRole(eb, userId))
|
||||
.whereRef('spaces.id', '=', 'publicSpaces.spaceId'),
|
||||
).as('space');
|
||||
}
|
||||
|
||||
withUserSpaceRole(eb: ExpressionBuilder<DB, 'spaces'>, userId: string) {
|
||||
return eb
|
||||
.selectFrom(
|
||||
eb
|
||||
.selectFrom('spaceMembers')
|
||||
.select(['spaceMembers.role'])
|
||||
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
|
||||
.where('spaceMembers.userId', '=', userId)
|
||||
.unionAll(
|
||||
eb
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin(
|
||||
'groupUsers',
|
||||
'groupUsers.groupId',
|
||||
'spaceMembers.groupId',
|
||||
)
|
||||
.select(['spaceMembers.role'])
|
||||
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
|
||||
.where('groupUsers.userId', '=', userId),
|
||||
)
|
||||
.as('roles_union'),
|
||||
)
|
||||
.select('roles_union.role')
|
||||
.orderBy(
|
||||
sql`CASE roles_union.role
|
||||
WHEN 'admin' THEN 3
|
||||
WHEN 'writer' THEN 2
|
||||
WHEN 'reader' THEN 1
|
||||
ELSE 0
|
||||
END`,
|
||||
'desc',
|
||||
)
|
||||
.limit(1)
|
||||
.as('userRole');
|
||||
}
|
||||
|
||||
withCreator(eb: ExpressionBuilder<DB, 'publicSpaces'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'publicSpaces.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
async findEnabledWithSpaceByWorkspaceId(workspaceId: string) {
|
||||
return this.db
|
||||
.selectFrom('publicSpaces')
|
||||
.innerJoin('spaces', 'spaces.id', 'publicSpaces.spaceId')
|
||||
.select([
|
||||
'publicSpaces.settings',
|
||||
'publicSpaces.searchIndexing',
|
||||
'spaces.name',
|
||||
'spaces.slug',
|
||||
'spaces.description',
|
||||
'spaces.logo',
|
||||
])
|
||||
.where('publicSpaces.workspaceId', '=', workspaceId)
|
||||
.where('publicSpaces.enabled', '=', true)
|
||||
.where('spaces.deletedAt', 'is', null)
|
||||
.orderBy('spaces.name', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async disableByWorkspaceId(
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('publicSpaces')
|
||||
.set({ enabled: false, updatedAt: new Date() })
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export class SpaceRepo {
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.select((eb) => this.withIsPublished(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (isValidUUID(spaceId)) {
|
||||
@@ -52,6 +53,7 @@ export class SpaceRepo {
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.select((eb) => this.withIsPublished(eb))
|
||||
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -170,6 +172,7 @@ export class SpaceRepo {
|
||||
.selectFrom('spaces')
|
||||
.selectAll('spaces')
|
||||
.select((eb) => [this.withMemberCount(eb)])
|
||||
.select((eb) => this.withIsPublished(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
if (pagination.query) {
|
||||
@@ -221,6 +224,18 @@ export class SpaceRepo {
|
||||
.as('memberCount');
|
||||
}
|
||||
|
||||
withIsPublished(eb: ExpressionBuilder<DB, 'spaces'>) {
|
||||
return eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('publicSpaces')
|
||||
.select('publicSpaces.id')
|
||||
.whereRef('publicSpaces.spaceId', '=', 'spaces.id')
|
||||
.where('publicSpaces.enabled', '=', true),
|
||||
)
|
||||
.as('isPublished');
|
||||
}
|
||||
|
||||
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('spaces')
|
||||
|
||||
@@ -249,6 +249,26 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updatePublicSpacesSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('publicSpaces', COALESCE(settings->'publicSpaces', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateTemplateSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
|
||||
+13
@@ -336,6 +336,18 @@ export interface Pages {
|
||||
ydoc: Buffer | null;
|
||||
}
|
||||
|
||||
export interface PublicSpaces {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
enabled: Generated<boolean>;
|
||||
id: Generated<string>;
|
||||
searchIndexing: Generated<boolean>;
|
||||
settings: Json | null;
|
||||
spaceId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Shares {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -747,6 +759,7 @@ export interface DB {
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
publicSpaces: PublicSpaces;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
siemDestinations: SiemDestinations;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
AuthProviders,
|
||||
AuthAccounts,
|
||||
Shares,
|
||||
PublicSpaces,
|
||||
Favorites,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
@@ -152,6 +153,11 @@ export type Share = Selectable<Shares>;
|
||||
export type InsertableShare = Insertable<Shares>;
|
||||
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
|
||||
|
||||
// PublicSpace
|
||||
export type PublicSpace = Selectable<PublicSpaces>;
|
||||
export type InsertablePublicSpace = Insertable<PublicSpaces>;
|
||||
export type UpdatablePublicSpace = Updateable<Omit<PublicSpaces, 'id'>>;
|
||||
|
||||
// Favorite
|
||||
export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
|
||||
Reference in New Issue
Block a user