Merge pull request 'feat(editor): page templates — live whole-page embed (MVP)' (#17) from feat/page-templates into develop
Some checks failed
Develop / build (push) Has been cancelled
Some checks failed
Develop / build (push) Has been cancelled
This commit was merged in pull request #17.
This commit is contained in:
@@ -356,6 +356,7 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isTemplate',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
@@ -708,6 +709,18 @@ export class PageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Remap whole-page embeds (pageEmbed) the same way: if the embedded
|
||||
// source page is also part of the copied set, point at its new copy;
|
||||
// otherwise leave it pointing at the original (live embed of original).
|
||||
if (node.type.name === 'pageEmbed') {
|
||||
const sourcePageId = node.attrs.sourcePageId;
|
||||
if (sourcePageId && pageMap.has(sourcePageId)) {
|
||||
const mappedPage = pageMap.get(sourcePageId);
|
||||
//@ts-ignore
|
||||
node.attrs.sourcePageId = mappedPage.newPageId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update internal page links in link marks
|
||||
for (const mark of node.marks) {
|
||||
if (
|
||||
@@ -818,6 +831,21 @@ export class PageService {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transclusionService.insertTemplateReferencesForPages(
|
||||
insertablePages.map((p) => ({
|
||||
id: p.id,
|
||||
workspaceId: p.workspaceId,
|
||||
content: p.content,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Failed to insert page template references for duplicated pages',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
const insertedPageIds = insertablePages.map((page) => page.id);
|
||||
// `spaceId` is the single destination space for the whole copy/duplicate
|
||||
// (every inserted page above gets `spaceId: spaceId`). It lets the WS
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TemplateLookupDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsUUID('all', { each: true })
|
||||
sourcePageIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class ToggleTemplateDto {
|
||||
@IsUUID()
|
||||
pageId!: string;
|
||||
|
||||
/** When omitted, the flag is toggled relative to its current value. */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isTemplate?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { TransclusionService } from './transclusion.service';
|
||||
import { TemplateLookupDto } from './dto/template-lookup.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../page-access/page-access.service';
|
||||
import { ToggleTemplateDto } from './dto/toggle-template.dto';
|
||||
import { UserThrottlerGuard } from '../../../integrations/throttle/user-throttler.guard';
|
||||
import { PAGE_TEMPLATE_THROTTLER } from '../../../integrations/throttle/throttler-names';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
export class PageTemplateController {
|
||||
constructor(
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Whole-page live embed lookup for authenticated viewers. Returns current
|
||||
* content (comment marks stripped) for accessible source pages.
|
||||
*
|
||||
* DoS note: the embed cycle/depth cap (PAGE_EMBED_MAX_DEPTH=5) is enforced
|
||||
* CLIENT-side only — a scripted client could otherwise drive heavy full-doc
|
||||
* fan-out. The server bounds the cost with this per-user throttle plus the
|
||||
* DTO's ArrayMaxSize(50) cap; server-side recursive expansion is out of scope.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('template/lookup')
|
||||
async lookup(@Body() dto: TemplateLookupDto, @AuthUser() user: User) {
|
||||
return this.transclusionService.lookupTemplate(
|
||||
dto.sourcePageIds,
|
||||
user.id,
|
||||
user.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip `pages.is_template`. Requires Edit on the page/space (CASL is enforced
|
||||
* inside `validateCanEdit`). The flag only affects template picker discovery;
|
||||
* it does not restrict editing or embedding.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [PAGE_TEMPLATE_THROTTLER]: { limit: 30, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('toggle-template')
|
||||
async toggleTemplate(
|
||||
@Body() dto: ToggleTemplateDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
const isTemplate =
|
||||
typeof dto.isTemplate === 'boolean' ? dto.isTemplate : !page.isTemplate;
|
||||
|
||||
await this.pageRepo.updatePage({ isTemplate }, page.id);
|
||||
|
||||
return { pageId: page.id, isTemplate };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { collectPageEmbedsFromPmJson } from '../utils/transclusion-prosemirror.util';
|
||||
import {
|
||||
htmlToJson,
|
||||
jsonToHtml,
|
||||
} from '../../../../collaboration/collaboration.util';
|
||||
|
||||
describe('collectPageEmbedsFromPmJson', () => {
|
||||
it('returns [] for null/undefined doc', () => {
|
||||
expect(collectPageEmbedsFromPmJson(null)).toEqual([]);
|
||||
expect(collectPageEmbedsFromPmJson(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for a doc with no pageEmbed nodes', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts a top-level pageEmbed', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } }],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'p1' }]);
|
||||
});
|
||||
|
||||
it('skips pageEmbed nodes missing sourcePageId', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: {} },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: '' } },
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes identical sourcePageIds, first-seen order preserved', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'p2' } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'p1' } },
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'p1' },
|
||||
{ sourcePageId: 'p2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds pageEmbed nested in other block containers (column)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'nested' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'nested' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not descend into a transclusion source', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'src' },
|
||||
content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'hidden' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageEmbed HTML <-> JSON round-trip (server schema)', () => {
|
||||
it('preserves sourcePageId across jsonToHtml -> htmlToJson', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'before' }] },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'abc-123' } },
|
||||
],
|
||||
};
|
||||
|
||||
const html = jsonToHtml(doc);
|
||||
expect(html).toContain('data-source-page-id="abc-123"');
|
||||
|
||||
const back = htmlToJson(html);
|
||||
const embeds = collectPageEmbedsFromPmJson(back);
|
||||
expect(embeds).toEqual([{ sourcePageId: 'abc-123' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Exercises the REAL security core of the whole-page template feature rather
|
||||
* than mocking it away:
|
||||
* - `filterViewerAccessiblePageIds` runs for real (space-visibility query +
|
||||
* page-permission filter are stubbed, but the branching/AND-ing is real), so
|
||||
* `lookupTemplate` actually maps no_access vs content based on it.
|
||||
* - the workspace scoping of `page_template_references` writes is verified to
|
||||
* drop cross-workspace source ids before they are persisted.
|
||||
*/
|
||||
describe('TransclusionService — template access core (real filter)', () => {
|
||||
/**
|
||||
* Build a chainable kysely `db` stub. `selectFrom(...).select(...).where(...)`
|
||||
* all return the same builder; `.execute()` resolves the supplied rows. The
|
||||
* `where('spaceId','in', getUserSpaceIdsQuery(...))` sub-query argument is
|
||||
* ignored — space visibility is decided by what `execute()` returns.
|
||||
*/
|
||||
function makeDb(executeRows: Array<{ id: string }>) {
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () => executeRows);
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeService(opts: {
|
||||
/** rows returned by the space-visibility query (workspace + space scoped) */
|
||||
spaceVisibleRows: Array<{ id: string }>;
|
||||
/** ids that survive page-level permission filtering */
|
||||
permissionAccessibleIds: string[];
|
||||
pages?: Array<{
|
||||
id: string;
|
||||
slugId?: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
content: unknown;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
}) {
|
||||
const db = makeDb(opts.spaceVisibleRows);
|
||||
|
||||
const spaceMemberRepo = {
|
||||
// The real code only passes this query object into `.where(...)`; our db
|
||||
// stub ignores it, so a sentinel is fine.
|
||||
getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })),
|
||||
};
|
||||
|
||||
const pagePermissionRepo = {
|
||||
filterAccessiblePageIds: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.permissionAccessibleIds),
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
findManyByIds: jest.fn().mockResolvedValue(opts.pages ?? []),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
db as any,
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
|
||||
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
|
||||
}
|
||||
|
||||
const now = new Date('2026-06-20T00:00:00.000Z');
|
||||
|
||||
it('returns no_access when the viewer fails the page-permission filter (real filter runs)', async () => {
|
||||
// Space-visible, but page-permission filter rejects it.
|
||||
const { service, pagePermissionRepo } = makeService({
|
||||
spaceVisibleRows: [{ id: 'p1' }],
|
||||
permissionAccessibleIds: [],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
expect(items).toEqual([{ sourcePageId: 'p1', status: 'no_access' }]);
|
||||
// proves the real filter executed and consulted page permissions
|
||||
expect(pagePermissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['p1'],
|
||||
userId: 'u1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no_access for a cross-workspace id (space-visibility query excludes it)', async () => {
|
||||
// The workspace/space-scoped query returns nothing → permission filter is
|
||||
// never reached and the id is not returned as accessible.
|
||||
const { service, pagePermissionRepo } = makeService({
|
||||
spaceVisibleRows: [],
|
||||
permissionAccessibleIds: ['cross-ws'],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTemplate(['cross-ws'], 'u1', 'w1');
|
||||
expect(items).toEqual([{ sourcePageId: 'cross-ws', status: 'no_access' }]);
|
||||
// short-circuited before page-permission filtering
|
||||
expect(pagePermissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns content with comment marks stripped for an accessible page', async () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
marks: [{ type: 'comment', attrs: { commentId: 'c1' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { service } = makeService({
|
||||
spaceVisibleRows: [{ id: 'p1' }],
|
||||
permissionAccessibleIds: ['p1'],
|
||||
pages: [
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Tmpl',
|
||||
icon: '📄',
|
||||
content,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBeUndefined();
|
||||
expect(item.title).toBe('Tmpl');
|
||||
const json = JSON.stringify(item.content);
|
||||
expect(json).not.toContain('comment');
|
||||
expect(json).toContain('hello');
|
||||
});
|
||||
|
||||
it('mixes accessible and inaccessible ids in one batch positionally', async () => {
|
||||
const { service } = makeService({
|
||||
spaceVisibleRows: [{ id: 'ok' }, { id: 'denied' }],
|
||||
permissionAccessibleIds: ['ok'],
|
||||
pages: [
|
||||
{
|
||||
id: 'ok',
|
||||
slugId: 's',
|
||||
title: 'A',
|
||||
icon: null,
|
||||
content: { type: 'doc', content: [] },
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTemplate(
|
||||
['denied', 'ok', 'cross'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect((items[0] as any).status).toBe('no_access'); // space-visible but no perm
|
||||
expect((items[1] as any).status).toBeUndefined(); // accessible
|
||||
expect((items[2] as any).status).toBe('no_access'); // not space-visible
|
||||
});
|
||||
|
||||
it('honours the DTO-level ≤50 cap by deduping ids passed to the filter', async () => {
|
||||
// The DTO enforces ArrayMaxSize(50); the service dedupes before filtering.
|
||||
const ids = ['a', 'a', 'b'];
|
||||
const { service, db } = makeService({
|
||||
spaceVisibleRows: [],
|
||||
permissionAccessibleIds: [],
|
||||
});
|
||||
|
||||
await service.lookupTemplate(ids, 'u1', 'w1');
|
||||
// db.where('id','in', <uniqueIds>) — verify the in-clause got deduped ids
|
||||
const inCall = db.where.mock.calls.find((c: any[]) => c[0] === 'id');
|
||||
expect(inCall?.[2]).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.syncPageTemplateReferences — workspace scoping', () => {
|
||||
function makeService(opts: { inWorkspaceIds: string[] }) {
|
||||
// db stub: the in-workspace existence query returns only allowed ids.
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () =>
|
||||
opts.inWorkspaceIds.map((id) => ({ id })),
|
||||
);
|
||||
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
findByReferencePageId: jest.fn().mockResolvedValue([]),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
builder as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
pageTemplateReferencesRepo as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
return { service, insertMany, pageTemplateReferencesRepo };
|
||||
}
|
||||
|
||||
function docWithEmbeds(sourceIds: string[]) {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: sourceIds.map((id) => ({
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: id },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it('does NOT write a row for a cross-workspace sourcePageId, but writes the in-workspace one', async () => {
|
||||
const { service, insertMany } = makeService({
|
||||
// only the in-workspace id survives the existence query
|
||||
inWorkspaceIds: ['in-ws'],
|
||||
});
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
docWithEmbeds(['in-ws', 'cross-ws']),
|
||||
);
|
||||
|
||||
expect(result.inserted).toBe(1);
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
const rows = insertMany.mock.calls[0][0];
|
||||
expect(rows).toEqual([
|
||||
{ workspaceId: 'w1', referencePageId: 'host', sourcePageId: 'in-ws' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inserts nothing when every embed points at a cross-workspace source', async () => {
|
||||
const { service, insertMany } = makeService({ inWorkspaceIds: [] });
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
docWithEmbeds(['cross-a', 'cross-b']),
|
||||
);
|
||||
|
||||
expect(result.inserted).toBe(0);
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Exercises the pure access/mapping logic of `lookupTemplate`:
|
||||
* - accessible + present -> content (comments stripped) + meta
|
||||
* - accessible + missing -> not_found
|
||||
* - inaccessible -> no_access
|
||||
* The access decision is taken from `filterViewerAccessiblePageIds`, which we
|
||||
* stub; DB/repo internals are mocked.
|
||||
*/
|
||||
describe('TransclusionService.lookupTemplate (access mapping)', () => {
|
||||
function makeService(opts: {
|
||||
accessibleIds: string[];
|
||||
pages: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
content: unknown;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
}) {
|
||||
const pageRepo = {
|
||||
findManyByIds: jest.fn().mockResolvedValue(opts.pages),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, 'filterViewerAccessiblePageIds')
|
||||
.mockResolvedValue(opts.accessibleIds);
|
||||
|
||||
return { service, pageRepo };
|
||||
}
|
||||
|
||||
const now = new Date('2026-06-20T00:00:00.000Z');
|
||||
|
||||
it('returns no_access for ids the viewer cannot see', async () => {
|
||||
const { service } = makeService({ accessibleIds: [], pages: [] });
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
expect(items).toEqual([{ sourcePageId: 'p1', status: 'no_access' }]);
|
||||
});
|
||||
|
||||
it('returns not_found for accessible-but-missing pages', async () => {
|
||||
const { service } = makeService({ accessibleIds: ['p1'], pages: [] });
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
expect(items).toEqual([{ sourcePageId: 'p1', status: 'not_found' }]);
|
||||
});
|
||||
|
||||
it('returns content + meta for accessible pages and strips comment marks', async () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
marks: [{ type: 'comment', attrs: { commentId: 'c1' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { service } = makeService({
|
||||
accessibleIds: ['p1'],
|
||||
pages: [
|
||||
{ id: 'p1', title: 'Tmpl', icon: '📄', content, updatedAt: now },
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
expect(items).toHaveLength(1);
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBeUndefined();
|
||||
expect(item.title).toBe('Tmpl');
|
||||
expect(item.icon).toBe('📄');
|
||||
expect(item.sourceUpdatedAt).toBe(now);
|
||||
|
||||
// comment mark must be gone from the returned content
|
||||
const json = JSON.stringify(item.content);
|
||||
expect(json).not.toContain('comment');
|
||||
expect(json).toContain('hello');
|
||||
});
|
||||
|
||||
it('maps a mixed batch positionally', async () => {
|
||||
const { service } = makeService({
|
||||
accessibleIds: ['ok'],
|
||||
pages: [
|
||||
{ id: 'ok', title: 'A', icon: null, content: { type: 'doc', content: [] }, updatedAt: now },
|
||||
],
|
||||
});
|
||||
const { items } = await service.lookupTemplate(
|
||||
['no', 'ok', 'gone'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect((items[0] as any).status).toBe('no_access');
|
||||
expect((items[1] as any).status).toBeUndefined();
|
||||
expect((items[2] as any).status).toBe('no_access');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { PageTemplateController } from '../page-template.controller';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
import { UserThrottlerGuard } from '../../../../integrations/throttle/user-throttler.guard';
|
||||
|
||||
describe('PageTemplateController.toggleTemplate', () => {
|
||||
let controller: PageTemplateController;
|
||||
let pageRepo: { findById: jest.Mock; updatePage: jest.Mock };
|
||||
let pageAccessService: { validateCanEdit: jest.Mock };
|
||||
let transclusionService: Partial<jest.Mocked<TransclusionService>>;
|
||||
|
||||
const user = { id: 'u1', workspaceId: 'w1' } as any;
|
||||
const page = {
|
||||
id: 'p1',
|
||||
workspaceId: 'w1',
|
||||
deletedAt: null,
|
||||
isTemplate: false,
|
||||
} as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
pageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(page),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageAccessService = {
|
||||
validateCanEdit: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
transclusionService = { lookupTemplate: jest.fn() };
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [PageTemplateController],
|
||||
providers: [
|
||||
{ provide: TransclusionService, useValue: transclusionService },
|
||||
{ provide: PageRepo, useValue: pageRepo },
|
||||
{ provide: PageAccessService, useValue: pageAccessService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(UserThrottlerGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get(PageTemplateController);
|
||||
});
|
||||
|
||||
it('throws NotFound and does not touch the page when the page is missing', async () => {
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
controller.toggleTemplate({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enforces CASL edit: when validateCanEdit throws, the flag is NOT flipped', async () => {
|
||||
pageAccessService.validateCanEdit.mockRejectedValue(
|
||||
new ForbiddenException(),
|
||||
);
|
||||
await expect(
|
||||
controller.toggleTemplate({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user);
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flips is_template (toggle) when the user can edit', async () => {
|
||||
const out = await controller.toggleTemplate(
|
||||
{ pageId: 'p1' } as any,
|
||||
user,
|
||||
);
|
||||
expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user);
|
||||
// page.isTemplate was false → toggled to true
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith({ isTemplate: true }, 'p1');
|
||||
expect(out).toEqual({ pageId: 'p1', isTemplate: true });
|
||||
});
|
||||
|
||||
it('respects an explicit isTemplate flag instead of toggling', async () => {
|
||||
const out = await controller.toggleTemplate(
|
||||
{ pageId: 'p1', isTemplate: false } as any,
|
||||
user,
|
||||
);
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledWith(
|
||||
{ isTemplate: false },
|
||||
'p1',
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', isTemplate: false });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TransclusionController } from './transclusion.controller';
|
||||
import { PageTemplateController } from './page-template.controller';
|
||||
import { TransclusionService } from './transclusion.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TransclusionController],
|
||||
controllers: [TransclusionController, PageTemplateController],
|
||||
providers: [TransclusionService],
|
||||
exports: [TransclusionService],
|
||||
})
|
||||
|
||||
@@ -10,17 +10,27 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||
import { PageTemplateReferencesRepo } from '@docmost/db/repos/page-template-references/page-template-references.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import {
|
||||
collectPageEmbedsFromPmJson,
|
||||
collectReferencesFromPmJson,
|
||||
collectTransclusionsFromPmJson,
|
||||
} from './utils/transclusion-prosemirror.util';
|
||||
import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
|
||||
import { TransclusionLookup } from './transclusion.types';
|
||||
import {
|
||||
PageTemplateLookup,
|
||||
TransclusionLookup,
|
||||
} from './transclusion.types';
|
||||
import {
|
||||
getProsemirrorContent,
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import { jsonToNode } from '../../../collaboration/collaboration.util';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PageAccessService } from '../page-access/page-access.service';
|
||||
import {
|
||||
@@ -48,6 +58,7 @@ export class TransclusionService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
|
||||
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
|
||||
private readonly pageTemplateReferencesRepo: PageTemplateReferencesRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
@@ -225,6 +236,225 @@ export class TransclusionService {
|
||||
return { inserted: rows.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Whole-page live embeds (pageEmbed node)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Restrict a set of candidate `pageEmbed` source ids to the pages that
|
||||
* actually live in `workspaceId` (and are not soft-deleted). Defense in depth:
|
||||
* `page_template_references` is NOT access-filtered, so we must never persist a
|
||||
* reference to a cross-workspace source page. This is a single workspace-scoped
|
||||
* existence query; it does NOT do per-viewer permission filtering (that stays
|
||||
* the job of `lookupTemplate` at read time — see the warning below).
|
||||
*/
|
||||
private async filterInWorkspaceSourceIds(
|
||||
sourceIds: string[],
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Set<string>> {
|
||||
if (sourceIds.length === 0) return new Set();
|
||||
const db = trx ?? this.db;
|
||||
const rows = await db
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('id', 'in', sourceIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
return new Set(rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff `page_template_references` for a host page against the `pageEmbed`
|
||||
* nodes currently in its content. Mirror of `syncPageReferences` but keyed by
|
||||
* `sourcePageId` only (whole-page, no transclusionId). Idempotent.
|
||||
*
|
||||
* SECURITY: `page_template_references` rows are NOT access-filtered. Inserts
|
||||
* are restricted here to in-workspace source pages so the graph can never
|
||||
* accumulate cross-workspace edges, but rows are still NOT per-viewer
|
||||
* permission-filtered. EVERY consumer of these rows MUST permission-filter at
|
||||
* read time (as `lookupTemplate` does via `filterViewerAccessiblePageIds`).
|
||||
*/
|
||||
async syncPageTemplateReferences(
|
||||
referencePageId: string,
|
||||
workspaceId: string,
|
||||
pmJson: unknown,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ inserted: number; deleted: number }> {
|
||||
const desired = collectPageEmbedsFromPmJson(pmJson);
|
||||
const inWorkspace = await this.filterInWorkspaceSourceIds(
|
||||
desired.map((d) => d.sourcePageId),
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
const desiredIds = new Set(
|
||||
desired.map((d) => d.sourcePageId).filter((id) => inWorkspace.has(id)),
|
||||
);
|
||||
|
||||
const existing =
|
||||
await this.pageTemplateReferencesRepo.findByReferencePageId(
|
||||
referencePageId,
|
||||
trx,
|
||||
);
|
||||
const existingIds = new Set(existing.map((e) => e.sourcePageId));
|
||||
|
||||
const toInsert = Array.from(desiredIds)
|
||||
.filter((id) => !existingIds.has(id))
|
||||
.map((sourcePageId) => ({
|
||||
workspaceId,
|
||||
referencePageId,
|
||||
sourcePageId,
|
||||
}));
|
||||
|
||||
const toDelete = existing
|
||||
.filter((e) => !desiredIds.has(e.sourcePageId))
|
||||
.map((e) => e.sourcePageId);
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await this.pageTemplateReferencesRepo.insertMany(toInsert, trx);
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
await this.pageTemplateReferencesRepo.deleteByReferenceAndSources(
|
||||
referencePageId,
|
||||
toDelete,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
return { inserted: toInsert.length, deleted: toDelete.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-insert `page_template_references` for brand-new pages (duplication,
|
||||
* import) where there is nothing to diff against.
|
||||
*
|
||||
* SECURITY: like `syncPageTemplateReferences`, inserts are restricted to
|
||||
* in-workspace source pages so the (non-access-filtered) reference graph never
|
||||
* gains a cross-workspace edge. Read-time per-viewer permission filtering is
|
||||
* still required by every consumer.
|
||||
*/
|
||||
async insertTemplateReferencesForPages(
|
||||
pages: Array<{ id: string; workspaceId: string; content: unknown }>,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ inserted: number }> {
|
||||
// Collect candidate source ids per workspace, then validate each workspace's
|
||||
// set in a single existence query before building insert rows.
|
||||
const candidatesByWorkspace = new Map<string, Set<string>>();
|
||||
const pageEmbeds = pages.map((page) => {
|
||||
const sourceIds = collectPageEmbedsFromPmJson(page.content).map(
|
||||
(e) => e.sourcePageId,
|
||||
);
|
||||
let set = candidatesByWorkspace.get(page.workspaceId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
candidatesByWorkspace.set(page.workspaceId, set);
|
||||
}
|
||||
for (const id of sourceIds) set.add(id);
|
||||
return { page, sourceIds };
|
||||
});
|
||||
|
||||
const inWorkspaceByWorkspace = new Map<string, Set<string>>();
|
||||
for (const [workspaceId, candidates] of candidatesByWorkspace) {
|
||||
inWorkspaceByWorkspace.set(
|
||||
workspaceId,
|
||||
await this.filterInWorkspaceSourceIds(
|
||||
Array.from(candidates),
|
||||
workspaceId,
|
||||
trx,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{
|
||||
workspaceId: string;
|
||||
referencePageId: string;
|
||||
sourcePageId: string;
|
||||
}> = [];
|
||||
for (const { page, sourceIds } of pageEmbeds) {
|
||||
const inWorkspace = inWorkspaceByWorkspace.get(page.workspaceId);
|
||||
for (const sourcePageId of sourceIds) {
|
||||
if (!inWorkspace?.has(sourcePageId)) continue;
|
||||
rows.push({
|
||||
workspaceId: page.workspaceId,
|
||||
referencePageId: page.id,
|
||||
sourcePageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return { inserted: 0 };
|
||||
await this.pageTemplateReferencesRepo.insertMany(rows, trx);
|
||||
return { inserted: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whole-page content for a set of source page ids on behalf of an
|
||||
* authenticated viewer. For each accessible page returns its current content
|
||||
* with `comment` marks stripped (comments belong to the source). Inaccessible
|
||||
* pages return `no_access`, missing/deleted pages return `not_found`. Does NOT
|
||||
* require `is_template` — any accessible page can be embedded (the template
|
||||
* flag only affects picker discovery).
|
||||
*/
|
||||
async lookupTemplate(
|
||||
sourcePageIds: string[],
|
||||
viewerUserId: string,
|
||||
workspaceId: string,
|
||||
): Promise<{ items: PageTemplateLookup[] }> {
|
||||
if (sourcePageIds.length === 0) return { items: [] };
|
||||
|
||||
const uniqueIds = Array.from(new Set(sourcePageIds));
|
||||
const accessibleSet = new Set(
|
||||
await this.filterViewerAccessiblePageIds(
|
||||
uniqueIds,
|
||||
viewerUserId,
|
||||
workspaceId,
|
||||
),
|
||||
);
|
||||
|
||||
const accessibleIds = uniqueIds.filter((id) => accessibleSet.has(id));
|
||||
const pages = await this.pageRepo.findManyByIds(accessibleIds, {
|
||||
workspaceId,
|
||||
includeContent: true,
|
||||
});
|
||||
const pageById = new Map(pages.map((p) => [p.id, p]));
|
||||
|
||||
const items: PageTemplateLookup[] = sourcePageIds.map((sourcePageId) => {
|
||||
if (!accessibleSet.has(sourcePageId)) {
|
||||
return { sourcePageId, status: 'no_access' as const };
|
||||
}
|
||||
const page = pageById.get(sourcePageId);
|
||||
if (!page) {
|
||||
return { sourcePageId, status: 'not_found' as const };
|
||||
}
|
||||
|
||||
let content: unknown = null;
|
||||
try {
|
||||
const pmJson = getProsemirrorContent(page.content);
|
||||
const doc = jsonToNode(pmJson);
|
||||
content = doc ? removeMarkTypeFromDoc(doc, 'comment').toJSON() : pmJson;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, sourcePageId },
|
||||
'Failed to prepare template content for lookup',
|
||||
);
|
||||
// Never return content carrying the source's comment marks. If the
|
||||
// happy-path stripping failed, treat the page as not resolvable.
|
||||
return { sourcePageId, status: 'not_found' as const };
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePageId,
|
||||
slugId: page.slugId,
|
||||
title: page.title ?? null,
|
||||
icon: page.icon ?? null,
|
||||
content,
|
||||
sourceUpdatedAt: page.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve viewer access for source page IDs supplied by an authenticated
|
||||
* caller. Restricts candidates to pages the viewer can see at the space
|
||||
@@ -232,7 +462,7 @@ export class TransclusionService {
|
||||
* cannot read a sync block from a private space they don't belong to via
|
||||
* an unrestricted source page.
|
||||
*/
|
||||
private async filterViewerAccessiblePageIds(
|
||||
async filterViewerAccessiblePageIds(
|
||||
pageIds: string[],
|
||||
viewerUserId: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -12,3 +12,15 @@ export type TransclusionNodeSnapshot = {
|
||||
transclusionId: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
export type PageTemplateLookup =
|
||||
| {
|
||||
sourcePageId: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
content: unknown;
|
||||
sourceUpdatedAt: Date;
|
||||
}
|
||||
| { sourcePageId: string; status: 'not_found' }
|
||||
| { sourcePageId: string; status: 'no_access' };
|
||||
|
||||
@@ -2,12 +2,17 @@ import { TransclusionNodeSnapshot } from '../transclusion.types';
|
||||
|
||||
const TRANSCLUSION_TYPE = 'transclusionSource';
|
||||
const REFERENCE_TYPE = 'transclusionReference';
|
||||
const PAGE_EMBED_TYPE = 'pageEmbed';
|
||||
|
||||
export type TransclusionReferenceSnapshot = {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
};
|
||||
|
||||
export type PageEmbedSnapshot = {
|
||||
sourcePageId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks a ProseMirror JSON document and returns one snapshot per top-level
|
||||
* `transclusion` node. Does not recurse into transclusions (schema disallows
|
||||
@@ -93,3 +98,42 @@ export function collectReferencesFromPmJson(
|
||||
visit(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a ProseMirror JSON document and returns one snapshot per unique
|
||||
* `sourcePageId` found on `pageEmbed` nodes (whole-page live embeds). Order
|
||||
* preserved by first-seen, duplicates deduped. `pageEmbed` is an atom so it
|
||||
* has no relevant children; we don't descend into transclusion sources.
|
||||
*/
|
||||
export function collectPageEmbedsFromPmJson(
|
||||
doc: unknown,
|
||||
): PageEmbedSnapshot[] {
|
||||
if (!doc || typeof doc !== 'object') return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: PageEmbedSnapshot[] = [];
|
||||
|
||||
const visit = (node: any): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (node.type === PAGE_EMBED_TYPE) {
|
||||
const sourcePageId = node.attrs?.sourcePageId;
|
||||
if (typeof sourcePageId === 'string' && sourcePageId.length > 0) {
|
||||
if (!seen.has(sourcePageId)) {
|
||||
seen.add(sourcePageId);
|
||||
out.push({ sourcePageId });
|
||||
}
|
||||
}
|
||||
return; // atom node - no children
|
||||
}
|
||||
|
||||
if (node.type === TRANSCLUSION_TYPE) return;
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
visit(doc);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export class SearchSuggestionDTO {
|
||||
@IsBoolean()
|
||||
includePages?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onlyTemplates?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId?: string;
|
||||
|
||||
@@ -216,6 +216,11 @@ export class SearchService {
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.limit(limit);
|
||||
|
||||
// Template picker: restrict to pages flagged as templates.
|
||||
if (suggestion.onlyTemplates) {
|
||||
pageSearch = pageSearch.where('isTemplate', '=', true);
|
||||
}
|
||||
|
||||
// search all spaces the user has access to, prioritizing the current space
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user