diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx index 49609cb9..c4253c29 100644 --- a/apps/client/src/features/share/components/share-modal.tsx +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -29,6 +29,7 @@ import ShareAliasSection from "@/features/share/components/share-alias-section.t import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query.ts"; interface ShareModalProps { readOnly: boolean; @@ -54,6 +55,33 @@ export default function ShareModal({ readOnly }: ShareModalProps) { // if level is greater than zero, then it is a descendant page from a shared page const isDescendantShared = share && share.level > 0; + // #370 Stage B — "publish only the saved version". Mirrors the server XOR: + // approved and includeSubPages are mutually exclusive. + const isApproved = share?.publishedMode === "approved"; + const includeSubPages = share?.includeSubPages ?? false; + + // Resolve the latest MANUAL version (the one an approved share publishes) so + // the modal can caption it and warn when the live draft has drifted ahead. + // Only fetched for a directly-shared page whose modal is actually open. + const { data: historyData } = usePageHistoryListQuery( + pageIsShared ? pageId : "", + ); + const latestManual = useMemo(() => { + const items = historyData?.pages?.flatMap((p) => p.items) ?? []; + // The list is newest-first; the first manual row is the published version. + return items.find((h) => h.kind === "manual") ?? null; + }, [historyData]); + + // The live draft has edits newer than the published saved version when the + // page was updated after that manual snapshot was taken. + const hasUnpublishedEdits = useMemo(() => { + if (!isApproved || !latestManual || !page?.updatedAt) return false; + return ( + new Date(page.updatedAt).getTime() > + new Date(latestManual.createdAt).getTime() + ); + }, [isApproved, latestManual, page?.updatedAt]); + const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`; const [isPagePublic, setIsPagePublic] = useState(false); @@ -115,6 +143,23 @@ export default function ShareModal({ readOnly }: ShareModalProps) { } }; + // #370 Stage B — toggle between publishing the live draft ('live') and the + // last saved version ('approved'). The server rejects approved + sub-pages, so + // the UI keeps the two toggles mutually exclusive (see the disabled props). + const handlePublishedModeChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await updateShareMutation.mutateAsync({ + shareId: share.id, + publishedMode: value ? "approved" : "live", + }); + } catch { + // query invalidation will revert the UI + } + }; + const shareLink = useMemo( () => ( @@ -238,11 +283,42 @@ export default function ShareModal({ readOnly }: ShareModalProps) { + +
+ + {t("Publish only the saved version")} + + + {isApproved + ? t( + "Visitors see the last saved version, not live edits", + ) + : t("Visitors see live edits as you make them")} + +
+
+ {isApproved && hasUnpublishedEdits && ( + + {t( + "You have unsaved changes newer than the published version", + )} + + )}
{t("Search engine indexing")} diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts index 0752d12e..32ff3835 100644 --- a/apps/client/src/features/share/types/share.types.ts +++ b/apps/client/src/features/share/types/share.types.ts @@ -6,6 +6,9 @@ export interface IShare { pageId: string; includeSubPages: boolean; searchIndexing: boolean; + // #370 Stage B — 'live' serves the current draft; 'approved' serves the last + // manually-saved version. Mutually exclusive with includeSubPages. + publishedMode: "live" | "approved"; creatorId: string; spaceId: string; workspaceId: string; @@ -75,6 +78,7 @@ export interface ICreateShare { pageId?: string; includeSubPages?: boolean; searchIndexing?: boolean; + publishedMode?: "live" | "approved"; } export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string }; diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts index eadcd3a6..f41ed14b 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.spec.ts @@ -210,6 +210,55 @@ describe('PublicShareChatToolsService.forShare', () => { }); }); + describe('getSharePage under approved publication (owner decision: assistant sees the frozen saved version)', () => { + it('reads the FROZEN saved content/title returned by the boundary, not a live re-fetch', async () => { + // #370 Stage B owner decision: ALL public reads — including the share + // assistant's page-read tool — see the published saved version, so a + // visitor and the assistant see the same bytes. The content swap lives in + // resolveReadableSharePage (the single canonical boundary), so the tool + // simply consumes the frozen { page } it returns: the assistant must never + // re-read the live draft behind the boundary's back. + const frozenContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'FROZEN saved version body' }], + }, + ], + }; + // The boundary already swapped content+title to the manual version while + // preserving the live id (its unit test pins that); the tool sees this. + const frozenPage = { + id: 'page-1', + title: 'Saved Version Title', + deletedAt: null, + content: frozenContent, + }; + + const { svc, shareService } = makeService({ + resolveReadableSharePage: jest + .fn() + .mockResolvedValue({ share: { id: 'SHARE-A' }, page: frozenPage }), + }); + // updatePublicAttachments sanitizes the FROZEN page it was handed. + shareService.updatePublicAttachments.mockResolvedValue(frozenContent); + + const tools = svc.forShare('SHARE-A', 'ws-1'); + const out = (await (tools.getSharePage as unknown as ToolExec).execute({ + pageId: 'page-1', + })) as { title: string; markdown: string }; + + // Sanitizer is handed the frozen page (never a separate live fetch). + expect(shareService.updatePublicAttachments).toHaveBeenCalledWith( + frozenPage, + ); + // Title + body come from the saved (frozen) version. + expect(out.title).toBe('Saved Version Title'); + expect(out.markdown).toContain('FROZEN saved version body'); + }); + }); + describe('getSharePage non-resolving page (deleted / restricted / out-of-share)', () => { it('resolveReadableSharePage returns null (e.g. soft-deleted page) => generic error, NO content sanitized/returned', async () => { // The canonical boundary 404s a soft-deleted / restricted / out-of-tree diff --git a/apps/server/src/core/share/dto/share.dto.ts b/apps/server/src/core/share/dto/share.dto.ts index b6e789ec..ea6c43d9 100644 --- a/apps/server/src/core/share/dto/share.dto.ts +++ b/apps/server/src/core/share/dto/share.dto.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsNotEmpty, IsOptional, IsString, @@ -18,6 +19,13 @@ export class CreateShareDto { @IsOptional() @IsBoolean() searchIndexing: boolean; + + // #370 Stage B — publication mode. 'live' (default) serves the current draft; + // 'approved' serves the last manually-saved version (page_history.kind + // ='manual'). Mutually exclusive with includeSubPages (enforced server-side). + @IsOptional() + @IsIn(['live', 'approved']) + publishedMode?: 'live' | 'approved'; } export class UpdateShareDto extends CreateShareDto { diff --git a/apps/server/src/core/share/share-comment-strip.spec.ts b/apps/server/src/core/share/share-comment-strip.spec.ts index 19befdc1..82363c1e 100644 --- a/apps/server/src/core/share/share-comment-strip.spec.ts +++ b/apps/server/src/core/share/share-comment-strip.spec.ts @@ -39,6 +39,7 @@ function buildService() { tokenService as any, {} as any, // transclusionService (unused) workspaceRepo as any, + {} as any, // pageHistoryRepo (unused on this path) ); } diff --git a/apps/server/src/core/share/share-get-shared-page-binding.spec.ts b/apps/server/src/core/share/share-get-shared-page-binding.spec.ts index f3c62371..a54ee53b 100644 --- a/apps/server/src/core/share/share-get-shared-page-binding.spec.ts +++ b/apps/server/src/core/share/share-get-shared-page-binding.spec.ts @@ -39,6 +39,7 @@ function buildService(over: { {} as any, // tokenService {} as any, // transclusionService {} as any, // workspaceRepo + {} as any, // pageHistoryRepo ); jest diff --git a/apps/server/src/core/share/share-html-embed.spec.ts b/apps/server/src/core/share/share-html-embed.spec.ts index 49f98c55..5e8323f3 100644 --- a/apps/server/src/core/share/share-html-embed.spec.ts +++ b/apps/server/src/core/share/share-html-embed.spec.ts @@ -62,6 +62,7 @@ function buildService(opts: { tokenService as any, {} as any, // transclusionService (unused) workspaceRepo as any, + {} as any, // pageHistoryRepo (unused on this path) ); // getSharedPage resolves the share via getShareForPage (a raw db query). @@ -181,6 +182,7 @@ describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real co tokenService as any, transclusionService as any, workspaceRepo as any, + {} as any, // pageHistoryRepo (unused on this path) ); // isSharingAllowed and getShareForPage hit the raw db; stub them so the diff --git a/apps/server/src/core/share/share-resolve-readable-page.spec.ts b/apps/server/src/core/share/share-resolve-readable-page.spec.ts index 237e483e..7d434f50 100644 --- a/apps/server/src/core/share/share-resolve-readable-page.spec.ts +++ b/apps/server/src/core/share/share-resolve-readable-page.spec.ts @@ -23,6 +23,9 @@ function buildService(over: { resolvedShare?: unknown; page?: unknown; restricted?: boolean; + // #370 Stage B — the latest manual page-history row returned for the approved + // swap. `undefined` (the default) means the repo finds no manual version. + manualHistory?: unknown; } = {}) { const pageRepo = { findById: jest.fn(async () => @@ -34,6 +37,9 @@ function buildService(over: { const pagePermissionRepo = { hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false), }; + const pageHistoryRepo = { + findLatestByPageIdAndKind: jest.fn(async () => over.manualHistory ?? undefined), + }; const service = new ShareService( {} as any, // shareRepo (unused on this path) @@ -43,6 +49,7 @@ function buildService(over: { {} as any, // tokenService (unused) {} as any, // transclusionService (unused) {} as any, // workspaceRepo (unused) + pageHistoryRepo as any, ); jest @@ -53,7 +60,7 @@ function buildService(over: { : { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any, ); - return { service, pageRepo, pagePermissionRepo }; + return { service, pageRepo, pagePermissionRepo, pageHistoryRepo }; } describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => { @@ -133,4 +140,90 @@ describe('ShareService.resolveReadableSharePage (the share-access boundary)', () includeCreator: true, }); }); + + // #370 Stage B — "approved" publication freezes the served content/title to + // the latest manual page-history version, WITHOUT touching page.id or + // page.workspaceId (downstream attachment tokens are minted off those, so + // freezing them would mint tokens for the wrong owner). + describe('approved publication mode (content freeze)', () => { + const LIVE_PAGE = { + id: PAGE, + workspaceId: WS, + deletedAt: null, + title: 'Live draft title', + content: { type: 'doc', live: true }, + }; + const MANUAL = { + title: 'Saved version title', + content: { type: 'doc', saved: true }, + }; + + it('swaps content + title to the latest manual version but PRESERVES id + workspaceId', async () => { + const { service, pageHistoryRepo } = buildService({ + resolvedShare: { + id: SHARE, + pageId: PAGE, + spaceId: 'space-1', + publishedMode: 'approved', + }, + page: { ...LIVE_PAGE }, + manualHistory: MANUAL, + }); + + const out = await service.resolveReadableSharePage(SHARE, PAGE, WS); + + expect(out).not.toBeNull(); + // Content + title come from the saved version. + expect(out!.page.content).toEqual(MANUAL.content); + expect(out!.page.title).toBe(MANUAL.title); + // id + workspaceId stay LIVE (attachment-token ownership must not shift). + expect(out!.page.id).toBe(LIVE_PAGE.id); + expect(out!.page.workspaceId).toBe(LIVE_PAGE.workspaceId); + // Resolved against the LIVE page id and the manual tier, with content. + expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalledWith( + LIVE_PAGE.id, + 'manual', + { includeContent: true }, + ); + }); + + it('falls back to live content when no manual version exists yet', async () => { + const { service, pageHistoryRepo } = buildService({ + resolvedShare: { + id: SHARE, + pageId: PAGE, + spaceId: 'space-1', + publishedMode: 'approved', + }, + page: { ...LIVE_PAGE }, + // manualHistory omitted → repo returns undefined + }); + + const out = await service.resolveReadableSharePage(SHARE, PAGE, WS); + + expect(out).not.toBeNull(); + expect(out!.page.content).toEqual(LIVE_PAGE.content); + expect(out!.page.title).toBe(LIVE_PAGE.title); + expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalled(); + }); + + it('does NOT consult page-history for a live share', async () => { + const { service, pageHistoryRepo } = buildService({ + resolvedShare: { + id: SHARE, + pageId: PAGE, + spaceId: 'space-1', + publishedMode: 'live', + }, + page: { ...LIVE_PAGE }, + manualHistory: MANUAL, + }); + + const out = await service.resolveReadableSharePage(SHARE, PAGE, WS); + + expect(out).not.toBeNull(); + expect(out!.page.content).toEqual(LIVE_PAGE.content); + expect(pageHistoryRepo.findLatestByPageIdAndKind).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/core/share/share-spoiler-keep.spec.ts b/apps/server/src/core/share/share-spoiler-keep.spec.ts index bcc68b13..dbd65ab7 100644 --- a/apps/server/src/core/share/share-spoiler-keep.spec.ts +++ b/apps/server/src/core/share/share-spoiler-keep.spec.ts @@ -33,6 +33,7 @@ function buildService() { tokenService as any, {} as any, // transclusionService (unused) workspaceRepo as any, + {} as any, // pageHistoryRepo (unused on this path) ); } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 14477872..248e1dbe 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -19,6 +19,7 @@ import { } from '../../common/helpers/prosemirror/utils'; import { Node } from '@tiptap/pm/model'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { updateAttachmentAttr } from './share.util'; import { Page } from '@docmost/db/types/entity.types'; @@ -44,6 +45,7 @@ export class ShareService { private readonly tokenService: TokenService, private readonly transclusionService: TransclusionService, private readonly workspaceRepo: WorkspaceRepo, + private readonly pageHistoryRepo: PageHistoryRepo, ) {} /** @@ -92,21 +94,39 @@ export class ShareService { }) { const { authUserId, workspaceId, page, createShareDto } = opts; + const includeSubPages = createShareDto.includeSubPages ?? false; + const publishedMode = createShareDto.publishedMode ?? 'live'; + + // #370 Stage B — 'approved' freezes a SINGLE page to its saved version; a + // sub-tree cannot be version-frozen in the MVP, so the two are mutually + // exclusive. Thrown before the try so the specific 400 reaches the client + // instead of being flattened to the generic "Failed to share page". + this.assertPublishedModeCompatible(publishedMode, includeSubPages); + try { const shares = await this.shareRepo.findByPageId(page.id); if (shares) { return shares; } - return await this.shareRepo.insertShare({ + const share = await this.shareRepo.insertShare({ key: nanoIdGen().toLowerCase(), pageId: page.id, - includeSubPages: createShareDto.includeSubPages ?? false, + includeSubPages, searchIndexing: createShareDto.searchIndexing ?? false, + publishedMode, creatorId: authUserId, spaceId: page.spaceId, workspaceId, }); + + // Guarantee an approved share always has a saved version to serve: mint + // the first manual baseline from the page's current content on enable. + if (publishedMode === 'approved') { + await this.ensureApprovedBaseline(page.id); + } + + return share; } catch (err) { this.logger.error(err); throw new BadRequestException('Failed to share page'); @@ -114,20 +134,84 @@ export class ShareService { } async updateShare(shareId: string, updateShareDto: UpdateShareDto) { + // Resolve the current share so the XOR gate can be evaluated against the + // EFFECTIVE post-update state (either field may be absent from the DTO). + const current = await this.shareRepo.findById(shareId); + if (!current) { + throw new NotFoundException('Share not found'); + } + + const effectiveIncludeSubPages = + updateShareDto.includeSubPages ?? current.includeSubPages ?? false; + const effectivePublishedMode = (updateShareDto.publishedMode ?? + current.publishedMode ?? + 'live') as 'live' | 'approved'; + + // #370 Stage B — enforce the approved/includeSubPages XOR on the merged + // state. Thrown before the try so the specific 400 reaches the client. + this.assertPublishedModeCompatible( + effectivePublishedMode, + effectiveIncludeSubPages, + ); + try { - return this.shareRepo.updateShare( + const updated = await this.shareRepo.updateShare( { includeSubPages: updateShareDto.includeSubPages, searchIndexing: updateShareDto.searchIndexing, + publishedMode: updateShareDto.publishedMode, }, shareId, ); + + // On (or while) enabling approved mode, ensure a manual baseline exists so + // public readers always have a saved version to serve. Idempotent: a no-op + // when the page already has a manual history row. + if (effectivePublishedMode === 'approved') { + await this.ensureApprovedBaseline(current.pageId); + } + + return updated; } catch (err) { this.logger.error(err); throw new BadRequestException('Failed to update share'); } } + /** + * #370 Stage B — 'approved' publication is mutually exclusive with + * includeSubPages: a whole sub-tree cannot be version-frozen in the MVP. + */ + private assertPublishedModeCompatible( + publishedMode: 'live' | 'approved', + includeSubPages: boolean, + ): void { + if (publishedMode === 'approved' && includeSubPages) { + throw new BadRequestException( + 'Approved publication cannot be combined with including sub-pages', + ); + } + } + + /** + * #370 Stage B — guarantee an approved share has a saved version to serve. + * When the page has NO manual history row yet, snapshot its CURRENT content as + * the first manual version. Idempotent: returns early when a manual version + * already exists, so it is safe to call on every approved update. + */ + private async ensureApprovedBaseline(pageId: string): Promise { + const existing = await this.pageHistoryRepo.findLatestByPageIdAndKind( + pageId, + 'manual', + ); + if (existing) return; + + const page = await this.pageRepo.findById(pageId, { includeContent: true }); + if (!page) return; + + await this.pageHistoryRepo.saveHistory(page, { kind: 'manual' }); + } + /** * THE share access boundary in ONE place. * @@ -173,7 +257,7 @@ export class ShareService { // passes no shareId (it resolved the share from the page itself). if (shareId != null && share.id !== shareId) return null; - const page = await this.pageRepo.findById(pageId, { + let page = await this.pageRepo.findById(pageId, { includeContent: true, includeCreator: opts?.includeCreator ?? false, }); @@ -185,6 +269,30 @@ export class ShareService { return null; } + // #370 Stage B — "approved" publication freezes what public readers see to + // the LAST manually-saved version (page_history.kind='manual'), not the live + // draft. This is the SINGLE canonical resolver every public read funnels + // through (the shared-page view, the SEO controller, AND the share + // assistant's page-read tool), so the swap here freezes ALL of them + // consistently — a public visitor and the assistant see the same bytes. + // + // Swap ONLY content + title. page.id and page.workspaceId stay LIVE on + // purpose: downstream updatePublicAttachments mints per-attachment tokens + // off page.id/workspaceId, so freezing those would mint tokens for the + // wrong owner. The SEO controller reads resolved.page.title, so the frozen + // title flows through automatically. If no manual version exists yet (rare — + // enabling an approved share auto-creates a baseline), fall back to live. + if (share.publishedMode === 'approved') { + const hist = await this.pageHistoryRepo.findLatestByPageIdAndKind( + page.id, + 'manual', + { includeContent: true }, + ); + if (hist) { + page = { ...page, content: hist.content, title: hist.title }; + } + } + return { share, page }; } @@ -302,6 +410,7 @@ export class ShareService { 'shares.key as shareKey', 'shares.includeSubPages', 'shares.searchIndexing', + 'shares.publishedMode', 'shares.creatorId', 'shares.spaceId', 'shares.workspaceId', @@ -326,6 +435,7 @@ export class ShareService { 's.key as shareKey', 's.includeSubPages', 's.searchIndexing', + 's.publishedMode', 's.creatorId', 's.spaceId', 's.workspaceId', @@ -355,6 +465,7 @@ export class ShareService { key: share.shareKey, includeSubPages: share.includeSubPages, searchIndexing: share.searchIndexing, + publishedMode: share.publishedMode, pageId: share.id, creatorId: share.creatorId, spaceId: share.spaceId, diff --git a/apps/server/src/database/migrations/20260712T130000-shares-published-mode.ts b/apps/server/src/database/migrations/20260712T130000-shares-published-mode.ts new file mode 100644 index 00000000..fa646c51 --- /dev/null +++ b/apps/server/src/database/migrations/20260712T130000-shares-published-mode.ts @@ -0,0 +1,29 @@ +import { type Kysely, sql } from 'kysely'; + +/** + * #370 Stage B — share "approved" (publish-only-the-saved-version) mode. + * + * Adds `shares.published_mode`, the per-share switch between: + * - 'live' — public readers see the current draft (legacy behavior) + * - 'approved' — public readers see the LAST manually-saved version + * (page_history.kind='manual'), not the live draft + * + * NOT NULL with a 'live' default so every existing share keeps its current + * (live) semantics with no data backfill. Standalone + single-concern + + * additive per the #363 crash-loop rule — no coupled baseline write here; + * the first manual baseline is minted by the service on enable, not by this + * migration. Stored as a short varchar to stay forward-compatible without an + * enum migration. + */ +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('shares') + .addColumn('published_mode', 'varchar(20)', (col) => + col.notNull().defaultTo(sql`'live'`), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('shares').dropColumn('published_mode').execute(); +} diff --git a/apps/server/src/database/repos/page/page-history.repo.ts b/apps/server/src/database/repos/page/page-history.repo.ts index 03bfa927..a895d94a 100644 --- a/apps/server/src/database/repos/page/page-history.repo.ts +++ b/apps/server/src/database/repos/page/page-history.repo.ts @@ -219,6 +219,36 @@ export class PageHistoryRepo { .executeTakeFirst(); } + /** + * #370 Stage B — resolve the latest snapshot of a page at a given + * intentionality tier. Used by the "approved" share mode to serve the last + * manually-saved version (`kind='manual'`) to public readers instead of the + * live draft. Modeled on `findPageLastHistory` with an added `kind` filter and + * the same deterministic `(createdAt desc, id desc)` tie-break, so two rows + * sharing a createdAt still resolve to a single stable "latest". + */ + async findLatestByPageIdAndKind( + pageId: string, + kind: PageHistoryKind, + opts?: { + includeContent?: boolean; + trx?: KyselyTransaction; + }, + ) { + const db = dbOrTx(this.db, opts?.trx); + + return await db + .selectFrom('pageHistory') + .select(this.baseFields) + .$if(opts?.includeContent, (qb) => qb.select('content')) + .where('pageId', '=', pageId) + .where('kind', '=', kind) + .limit(1) + .orderBy('createdAt', 'desc') + .orderBy('id', 'desc') + .executeTakeFirst(); + } + withLastUpdatedBy(eb: ExpressionBuilder) { return jsonObjectFrom( eb diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts index 22ea4d8c..cec5842b 100644 --- a/apps/server/src/database/repos/share/share.repo.ts +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -28,6 +28,7 @@ export class ShareRepo { 'pageId', 'includeSubPages', 'searchIndexing', + 'publishedMode', 'creatorId', 'spaceId', 'workspaceId', diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 5530db56..044e703b 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -340,6 +340,7 @@ export interface Shares { includeSubPages: Generated; key: string; pageId: string | null; + publishedMode: Generated; searchIndexing: Generated; spaceId: string; updatedAt: Generated; diff --git a/apps/server/test/integration/share-published-mode.int-spec.ts b/apps/server/test/integration/share-published-mode.int-spec.ts new file mode 100644 index 00000000..ecb1c7ea --- /dev/null +++ b/apps/server/test/integration/share-published-mode.int-spec.ts @@ -0,0 +1,276 @@ +import { Kysely } from 'kysely'; +import { randomUUID } from 'node:crypto'; +import { BadRequestException } from '@nestjs/common'; +import { ShareService } from 'src/core/share/share.service'; +import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; +import * as publishedModeMigration from 'src/database/migrations/20260712T130000-shares-published-mode'; +import { + getTestDb, + destroyTestDb, + createWorkspace, + createSpace, + createPage, +} from './db'; + +/** + * #370 Stage B — "approved" (publish-only-the-saved-version) share mode, against + * real Postgres (so the migration column + the hand-written recursive-CTE + * threading in getShareForPage are exercised for real, not mocked). + * + * Guards: + * - getShareForPage carries `publishedMode` through ALL of the CTE points + * (anchor select, recursive-union select, returned object) — a direct share + * AND a descendant (union) share both read it back. If ANY of the 4 threading + * points is dropped, one of these reddens. + * - createShare / updateShare auto-mint the first manual baseline ON ENABLE. + * - approved + includeSubPages is rejected (BadRequestException) on both paths. + * - resolveReadableSharePage under approved swaps content+title to the manual + * version while PRESERVING page.id + workspaceId. + */ +describe('share published_mode (approved) [integration]', () => { + let db: Kysely; + let service: ShareService; + let shareRepo: ShareRepo; + let pageHistoryRepo: PageHistoryRepo; + let wsId: string; + let spaceId: string; + + // shares.creator_id / page_history.last_updated_by_id are uuid columns; use + // null (nullable) rather than a synthetic non-uuid id. + const USER = null as any; + + // resolveReadableSharePage runs the restricted-ancestor gate; keep it open. + const pagePermissionRepo = { + hasRestrictedAncestor: async () => false, + }; + + beforeAll(async () => { + db = getTestDb(); + shareRepo = new ShareRepo(db as any, {} as any); + const pageRepo = new PageRepo(db as any, {} as any, {} as any); + pageHistoryRepo = new PageHistoryRepo(db as any); + service = new ShareService( + shareRepo as any, + pageRepo as any, + pagePermissionRepo as any, + db as any, + {} as any, // tokenService — no attachments in these docs + {} as any, // transclusionService + { findById: async () => ({ settings: {} }) } as any, // workspaceRepo + pageHistoryRepo as any, + ); + wsId = (await createWorkspace(db)).id; + spaceId = (await createSpace(db, wsId)).id; + }); + + afterAll(async () => { + await destroyTestDb(); + }); + + const docWith = (text: string) => ({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text }] }, + ], + }); + + const newPage = async ( + text = 'live body', + parentPageId?: string, + ): Promise => { + const { id } = await createPage(db, { workspaceId: wsId, spaceId }); + await db + .updateTable('pages') + .set({ content: docWith(text) as any, parentPageId: parentPageId ?? null }) + .where('id', '=', id) + .execute(); + return id; + }; + + const insertShare = async (args: { + pageId: string; + publishedMode?: string; + includeSubPages?: boolean; + }) => { + const id = randomUUID(); + await db + .insertInto('shares') + .values({ + id, + key: `k-${id.slice(0, 12)}`, + pageId: args.pageId, + includeSubPages: args.includeSubPages ?? false, + publishedMode: args.publishedMode ?? 'live', + creatorId: USER, + spaceId, + workspaceId: wsId, + }) + .execute(); + return id; + }; + + const manualRows = (pageId: string) => + db + .selectFrom('pageHistory') + .select(['id', 'kind', 'title']) + .where('pageId', '=', pageId) + .where('kind', '=', 'manual') + .execute(); + + const publishedModeColumn = async () => + db + .selectFrom('information_schema.columns' as any) + .select(['columnName', 'dataType', 'isNullable', 'columnDefault'] as any) + .where('tableName' as any, '=', 'shares') + .where('columnName' as any, '=', 'published_mode') + .executeTakeFirst(); + + // --- migration up/down round-trip ---------------------------------------- + + it('migration down() drops published_mode; up() re-adds it NOT NULL default live', async () => { + // global-setup already migrated up, so the column is present. + const before: any = await publishedModeColumn(); + expect(before).toBeDefined(); + expect(before.isNullable).toBe('NO'); + expect(String(before.columnDefault)).toContain("'live'"); + + await publishedModeMigration.down(db as any); + expect(await publishedModeColumn()).toBeUndefined(); + + // Restore so the rest of the suite (and afterAll) sees the real schema. + await publishedModeMigration.up(db as any); + const after: any = await publishedModeColumn(); + expect(after).toBeDefined(); + expect(after.isNullable).toBe('NO'); + }); + + // --- getShareForPage 4-point threading ----------------------------------- + + it('getShareForPage carries publishedMode for a DIRECT share (anchor select + returned object)', async () => { + const pageId = await newPage(); + await insertShare({ pageId, publishedMode: 'approved' }); + + const share = await service.getShareForPage(pageId, wsId); + expect(share).toBeDefined(); + expect(share!.publishedMode).toBe('approved'); + }); + + it('getShareForPage carries publishedMode for a DESCENDANT share (recursive-union select)', async () => { + // Parent shared with includeSubPages + approved (inserted directly, bypassing + // the service XOR gate, purely to exercise the CTE union threading on a + // child that inherits the ancestor share). + const parentId = await newPage('parent body'); + await insertShare({ + pageId: parentId, + publishedMode: 'approved', + includeSubPages: true, + }); + const childId = await newPage('child body', parentId); + + const share = await service.getShareForPage(childId, wsId); + expect(share).toBeDefined(); + // Resolved via the recursive union up to the ancestor share. + expect((share!.level as number) > 0).toBe(true); + expect(share!.publishedMode).toBe('approved'); + }); + + // --- baseline auto-create on enable -------------------------------------- + + it('createShare(approved) mints the first manual baseline from current content', async () => { + const pageId = await newPage('baseline body'); + const page = { id: pageId, spaceId } as any; + + expect(await manualRows(pageId)).toHaveLength(0); + + await service.createShare({ + authUserId: USER, + workspaceId: wsId, + page, + createShareDto: { pageId, includeSubPages: false, publishedMode: 'approved' } as any, + }); + + const rows = await manualRows(pageId); + expect(rows).toHaveLength(1); + }); + + it('updateShare enabling approved on a live share mints the baseline; a second call is idempotent', async () => { + const pageId = await newPage('later-saved body'); + const shareId = await insertShare({ pageId, publishedMode: 'live' }); + + expect(await manualRows(pageId)).toHaveLength(0); + + await service.updateShare(shareId, { + shareId, + publishedMode: 'approved', + } as any); + expect(await manualRows(pageId)).toHaveLength(1); + + // Idempotent: staying approved does not duplicate the baseline. + await service.updateShare(shareId, { + shareId, + searchIndexing: true, + } as any); + expect(await manualRows(pageId)).toHaveLength(1); + }); + + // --- XOR gate ------------------------------------------------------------ + + it('createShare rejects approved + includeSubPages', async () => { + const pageId = await newPage(); + await expect( + service.createShare({ + authUserId: USER, + workspaceId: wsId, + page: { id: pageId, spaceId } as any, + createShareDto: { + pageId, + includeSubPages: true, + publishedMode: 'approved', + } as any, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updateShare rejects setting approved while includeSubPages is already on', async () => { + const pageId = await newPage(); + const shareId = await insertShare({ pageId, includeSubPages: true }); + await expect( + service.updateShare(shareId, { shareId, publishedMode: 'approved' } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + // --- resolveReadableSharePage content freeze ----------------------------- + + it('resolveReadableSharePage under approved swaps content+title but PRESERVES id + workspaceId', async () => { + const pageId = await newPage('LIVE draft body'); + const shareId = await insertShare({ pageId, publishedMode: 'approved' }); + + // Seed a manual version distinct from the live draft. + await pageHistoryRepo.insertPageHistory({ + pageId, + slugId: `hist-${pageId.slice(0, 8)}`, + title: 'SAVED title', + content: docWith('SAVED version body') as any, + kind: 'manual', + spaceId, + workspaceId: wsId, + } as any); + + const resolved = await service.resolveReadableSharePage( + shareId, + pageId, + wsId, + ); + expect(resolved).not.toBeNull(); + // Content + title are the SAVED version. + expect(resolved!.page.title).toBe('SAVED title'); + expect(JSON.stringify(resolved!.page.content)).toContain( + 'SAVED version body', + ); + // id + workspaceId stay LIVE (attachment-token ownership must not shift). + expect(resolved!.page.id).toBe(pageId); + expect(resolved!.page.workspaceId).toBe(wsId); + }); +});