Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbac8e6976 |
@@ -29,6 +29,7 @@ import ShareAliasSection from "@/features/share/components/share-alias-section.t
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||||
|
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query.ts";
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
readOnly: boolean;
|
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
|
// if level is greater than zero, then it is a descendant page from a shared page
|
||||||
const isDescendantShared = share && share.level > 0;
|
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 publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
||||||
|
|
||||||
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
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(
|
const shareLink = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Group my="sm" gap={4} wrap="nowrap">
|
<Group my="sm" gap={4} wrap="nowrap">
|
||||||
@@ -238,11 +283,42 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
|||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
onChange={handleSubPagesChange}
|
onChange={handleSubPagesChange}
|
||||||
checked={share.includeSubPages}
|
checked={includeSubPages}
|
||||||
size="xs"
|
size="xs"
|
||||||
disabled={readOnly}
|
// XOR with approved mode: a version-frozen share cannot also
|
||||||
|
// publish a whole sub-tree.
|
||||||
|
disabled={readOnly || isApproved}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</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">
|
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
<Text size="sm">{t("Search engine indexing")}</Text>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface IShare {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
includeSubPages: boolean;
|
includeSubPages: boolean;
|
||||||
searchIndexing: 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;
|
creatorId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -75,6 +78,7 @@ export interface ICreateShare {
|
|||||||
pageId?: string;
|
pageId?: string;
|
||||||
includeSubPages?: boolean;
|
includeSubPages?: boolean;
|
||||||
searchIndexing?: boolean;
|
searchIndexing?: boolean;
|
||||||
|
publishedMode?: "live" | "approved";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
|
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)', () => {
|
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 () => {
|
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
|
// The canonical boundary 404s a soft-deleted / restricted / out-of-tree
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -18,6 +19,13 @@ export class CreateShareDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
searchIndexing: boolean;
|
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 {
|
export class UpdateShareDto extends CreateShareDto {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function buildService() {
|
|||||||
tokenService as any,
|
tokenService as any,
|
||||||
{} as any, // transclusionService (unused)
|
{} as any, // transclusionService (unused)
|
||||||
workspaceRepo as any,
|
workspaceRepo as any,
|
||||||
|
{} as any, // pageHistoryRepo (unused on this path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function buildService(over: {
|
|||||||
{} as any, // tokenService
|
{} as any, // tokenService
|
||||||
{} as any, // transclusionService
|
{} as any, // transclusionService
|
||||||
{} as any, // workspaceRepo
|
{} as any, // workspaceRepo
|
||||||
|
{} as any, // pageHistoryRepo
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function buildService(opts: {
|
|||||||
tokenService as any,
|
tokenService as any,
|
||||||
{} as any, // transclusionService (unused)
|
{} as any, // transclusionService (unused)
|
||||||
workspaceRepo as any,
|
workspaceRepo as any,
|
||||||
|
{} as any, // pageHistoryRepo (unused on this path)
|
||||||
);
|
);
|
||||||
|
|
||||||
// getSharedPage resolves the share via getShareForPage (a raw db query).
|
// 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,
|
tokenService as any,
|
||||||
transclusionService as any,
|
transclusionService as any,
|
||||||
workspaceRepo as any,
|
workspaceRepo as any,
|
||||||
|
{} as any, // pageHistoryRepo (unused on this path)
|
||||||
);
|
);
|
||||||
|
|
||||||
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
|
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function buildService(over: {
|
|||||||
resolvedShare?: unknown;
|
resolvedShare?: unknown;
|
||||||
page?: unknown;
|
page?: unknown;
|
||||||
restricted?: boolean;
|
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 = {
|
const pageRepo = {
|
||||||
findById: jest.fn(async () =>
|
findById: jest.fn(async () =>
|
||||||
@@ -34,6 +37,9 @@ function buildService(over: {
|
|||||||
const pagePermissionRepo = {
|
const pagePermissionRepo = {
|
||||||
hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false),
|
hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false),
|
||||||
};
|
};
|
||||||
|
const pageHistoryRepo = {
|
||||||
|
findLatestByPageIdAndKind: jest.fn(async () => over.manualHistory ?? undefined),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new ShareService(
|
const service = new ShareService(
|
||||||
{} as any, // shareRepo (unused on this path)
|
{} as any, // shareRepo (unused on this path)
|
||||||
@@ -43,6 +49,7 @@ function buildService(over: {
|
|||||||
{} as any, // tokenService (unused)
|
{} as any, // tokenService (unused)
|
||||||
{} as any, // transclusionService (unused)
|
{} as any, // transclusionService (unused)
|
||||||
{} as any, // workspaceRepo (unused)
|
{} as any, // workspaceRepo (unused)
|
||||||
|
pageHistoryRepo as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest
|
jest
|
||||||
@@ -53,7 +60,7 @@ function buildService(over: {
|
|||||||
: { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any,
|
: { 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)', () => {
|
describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => {
|
||||||
@@ -133,4 +140,90 @@ describe('ShareService.resolveReadableSharePage (the share-access boundary)', ()
|
|||||||
includeCreator: true,
|
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,
|
tokenService as any,
|
||||||
{} as any, // transclusionService (unused)
|
{} as any, // transclusionService (unused)
|
||||||
workspaceRepo as any,
|
workspaceRepo as any,
|
||||||
|
{} as any, // pageHistoryRepo (unused on this path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
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 { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
import { updateAttachmentAttr } from './share.util';
|
import { updateAttachmentAttr } from './share.util';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
@@ -44,6 +45,7 @@ export class ShareService {
|
|||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly transclusionService: TransclusionService,
|
private readonly transclusionService: TransclusionService,
|
||||||
private readonly workspaceRepo: WorkspaceRepo,
|
private readonly workspaceRepo: WorkspaceRepo,
|
||||||
|
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,21 +94,39 @@ export class ShareService {
|
|||||||
}) {
|
}) {
|
||||||
const { authUserId, workspaceId, page, createShareDto } = opts;
|
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 {
|
try {
|
||||||
const shares = await this.shareRepo.findByPageId(page.id);
|
const shares = await this.shareRepo.findByPageId(page.id);
|
||||||
if (shares) {
|
if (shares) {
|
||||||
return shares;
|
return shares;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.shareRepo.insertShare({
|
const share = await this.shareRepo.insertShare({
|
||||||
key: nanoIdGen().toLowerCase(),
|
key: nanoIdGen().toLowerCase(),
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
includeSubPages: createShareDto.includeSubPages ?? false,
|
includeSubPages,
|
||||||
searchIndexing: createShareDto.searchIndexing ?? false,
|
searchIndexing: createShareDto.searchIndexing ?? false,
|
||||||
|
publishedMode,
|
||||||
creatorId: authUserId,
|
creatorId: authUserId,
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
workspaceId,
|
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) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
throw new BadRequestException('Failed to share page');
|
throw new BadRequestException('Failed to share page');
|
||||||
@@ -114,20 +134,84 @@ export class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
|
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 {
|
try {
|
||||||
return this.shareRepo.updateShare(
|
const updated = await this.shareRepo.updateShare(
|
||||||
{
|
{
|
||||||
includeSubPages: updateShareDto.includeSubPages,
|
includeSubPages: updateShareDto.includeSubPages,
|
||||||
searchIndexing: updateShareDto.searchIndexing,
|
searchIndexing: updateShareDto.searchIndexing,
|
||||||
|
publishedMode: updateShareDto.publishedMode,
|
||||||
},
|
},
|
||||||
shareId,
|
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) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
throw new BadRequestException('Failed to update share');
|
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.
|
* 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).
|
// passes no shareId (it resolved the share from the page itself).
|
||||||
if (shareId != null && share.id !== shareId) return null;
|
if (shareId != null && share.id !== shareId) return null;
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
let page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
includeCreator: opts?.includeCreator ?? false,
|
includeCreator: opts?.includeCreator ?? false,
|
||||||
});
|
});
|
||||||
@@ -185,6 +269,30 @@ export class ShareService {
|
|||||||
return null;
|
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 };
|
return { share, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +410,7 @@ export class ShareService {
|
|||||||
'shares.key as shareKey',
|
'shares.key as shareKey',
|
||||||
'shares.includeSubPages',
|
'shares.includeSubPages',
|
||||||
'shares.searchIndexing',
|
'shares.searchIndexing',
|
||||||
|
'shares.publishedMode',
|
||||||
'shares.creatorId',
|
'shares.creatorId',
|
||||||
'shares.spaceId',
|
'shares.spaceId',
|
||||||
'shares.workspaceId',
|
'shares.workspaceId',
|
||||||
@@ -326,6 +435,7 @@ export class ShareService {
|
|||||||
's.key as shareKey',
|
's.key as shareKey',
|
||||||
's.includeSubPages',
|
's.includeSubPages',
|
||||||
's.searchIndexing',
|
's.searchIndexing',
|
||||||
|
's.publishedMode',
|
||||||
's.creatorId',
|
's.creatorId',
|
||||||
's.spaceId',
|
's.spaceId',
|
||||||
's.workspaceId',
|
's.workspaceId',
|
||||||
@@ -355,6 +465,7 @@ export class ShareService {
|
|||||||
key: share.shareKey,
|
key: share.shareKey,
|
||||||
includeSubPages: share.includeSubPages,
|
includeSubPages: share.includeSubPages,
|
||||||
searchIndexing: share.searchIndexing,
|
searchIndexing: share.searchIndexing,
|
||||||
|
publishedMode: share.publishedMode,
|
||||||
pageId: share.id,
|
pageId: share.id,
|
||||||
creatorId: share.creatorId,
|
creatorId: share.creatorId,
|
||||||
spaceId: share.spaceId,
|
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();
|
.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'>) {
|
withLastUpdatedBy(eb: ExpressionBuilder<DB, 'pageHistory'>) {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class ShareRepo {
|
|||||||
'pageId',
|
'pageId',
|
||||||
'includeSubPages',
|
'includeSubPages',
|
||||||
'searchIndexing',
|
'searchIndexing',
|
||||||
|
'publishedMode',
|
||||||
'creatorId',
|
'creatorId',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
|
|||||||
+1
@@ -340,6 +340,7 @@ export interface Shares {
|
|||||||
includeSubPages: Generated<boolean | null>;
|
includeSubPages: Generated<boolean | null>;
|
||||||
key: string;
|
key: string;
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
|
publishedMode: Generated<string>;
|
||||||
searchIndexing: Generated<boolean | null>;
|
searchIndexing: Generated<boolean | null>;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user