Compare commits

...

1 Commits

Author SHA1 Message Date
agent_coder fbac8e6976 feat(share): Stage B — "approved" publish-only-the-saved-version mode (#370)
A share can now be published in 'approved' mode: public readers by URL see
the last manually-saved version (page_history.kind='manual') instead of the
live draft. The content swap lives in the single canonical resolver
resolveReadableSharePage, so EVERY public read path is frozen consistently —
the shared-page view, the SEO controller, AND the share assistant's page-read
tool (owner decision: a visitor and the assistant see the same bytes). Swaps
only content+title; page.id/workspaceId stay LIVE so attachment tokens keep
minting for the right owner. MVP residue kept live: the assistant's page
LIST/TREE tools and transclusions.

- migration 20260712T130000: shares.published_mode varchar(20) NOT NULL
  DEFAULT 'live' (additive, standalone; existing shares stay live)
- db.d.ts: publishedMode on Shares (Share/Insertable/Updatable derive)
- page-history.repo: findLatestByPageIdAndKind(pageId, kind, {includeContent})
- share.repo: thread publishedMode through baseFields/insert/update
- share.service.getShareForPage: 4-point CTE threading (anchor + union +
  returned object + repo allowlist) — type-guarded, so dropping any point
  fails the build/tests
- createShare/updateShare: accept publishedMode; reject approved+includeSubPages
  (BadRequestException); auto-mint the first manual baseline ON ENABLE
- share.dto: publishedMode @IsOptional @IsIn(['live','approved'])
- client: "Publish only the saved version" toggle (XOR with sub-pages),
  version caption + "unsaved changes newer than the published version" hint;
  share types/api carry publishedMode
- tests: resolve-swap (content+title swapped, id+workspaceId preserved),
  assistant getSharePage sees frozen content, getShareForPage threading
  (direct + descendant), baseline auto-create, XOR reject, migration up/down

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:08:50 +03:00
15 changed files with 690 additions and 7 deletions
@@ -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<boolean>(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<HTMLInputElement>,
) => {
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(
() => (
<Group my="sm" gap={4} wrap="nowrap">
@@ -238,11 +283,42 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
checked={includeSubPages}
size="xs"
disabled={readOnly}
// XOR with approved mode: a version-frozen share cannot also
// publish a whole sub-tree.
disabled={readOnly || isApproved}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div>
<Text size="sm">
{t("Publish only the saved version")}
</Text>
<Text size="xs" c="dimmed">
{isApproved
? t(
"Visitors see the last saved version, not live edits",
)
: t("Visitors see live edits as you make them")}
</Text>
</div>
<Switch
onChange={handlePublishedModeChange}
checked={isApproved}
size="xs"
// XOR with sub-pages: hidden intent is enforced by disabling
// this toggle whenever sub-pages are shared.
disabled={readOnly || includeSubPages}
/>
</Group>
{isApproved && hasUnpublishedEdits && (
<Text size="xs" c="orange" mt={4}>
{t(
"You have unsaved changes newer than the published version",
)}
</Text>
)}
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
@@ -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 };
@@ -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
@@ -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 {
@@ -39,6 +39,7 @@ function buildService() {
tokenService as any,
{} as any, // transclusionService (unused)
workspaceRepo as any,
{} as any, // pageHistoryRepo (unused on this path)
);
}
@@ -39,6 +39,7 @@ function buildService(over: {
{} as any, // tokenService
{} as any, // transclusionService
{} as any, // workspaceRepo
{} as any, // pageHistoryRepo
);
jest
@@ -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
@@ -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();
});
});
});
@@ -33,6 +33,7 @@ function buildService() {
tokenService as any,
{} as any, // transclusionService (unused)
workspaceRepo as any,
{} as any, // pageHistoryRepo (unused on this path)
);
}
+115 -4
View File
@@ -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<void> {
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,
@@ -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<any>): Promise<void> {
await db.schema
.alterTable('shares')
.addColumn('published_mode', 'varchar(20)', (col) =>
col.notNull().defaultTo(sql`'live'`),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('shares').dropColumn('published_mode').execute();
}
@@ -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<DB, 'pageHistory'>) {
return jsonObjectFrom(
eb
@@ -28,6 +28,7 @@ export class ShareRepo {
'pageId',
'includeSubPages',
'searchIndexing',
'publishedMode',
'creatorId',
'spaceId',
'workspaceId',
+1
View File
@@ -340,6 +340,7 @@ export interface Shares {
includeSubPages: Generated<boolean | null>;
key: string;
pageId: string | null;
publishedMode: Generated<string>;
searchIndexing: Generated<boolean | null>;
spaceId: string;
updatedAt: Generated<Timestamp>;
@@ -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<any>;
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<string> => {
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);
});
});