Date: Fri, 23 Jan 2026 13:07:48 +0100
Subject: [PATCH 10/12] empty vs not loaded
---
.../decisions/ProposalCard/ProposalCardComponents.tsx | 6 +++++-
.../src/components/decisions/proposalContentUtils.ts | 6 ++++--
.../common/src/services/decision/createProposal.ts | 10 +++++++++-
3 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx
index 6fb91f9c8..f41e81016 100644
--- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx
+++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx
@@ -340,10 +340,14 @@ export function ProposalCardPreview({
}) {
const previewText = getProposalContentPreview(proposal.documentContent);
- if (!previewText) {
+ if (previewText === null) {
return ;
}
+ if (!previewText) {
+ return null;
+ }
+
return (
Date: Fri, 23 Jan 2026 13:19:53 +0100
Subject: [PATCH 11/12] always use existing collab id
---
.../components/decisions/ProposalEditor.tsx | 55 ++++++++-----------
1 file changed, 22 insertions(+), 33 deletions(-)
diff --git a/apps/app/src/components/decisions/ProposalEditor.tsx b/apps/app/src/components/decisions/ProposalEditor.tsx
index cb1d408be..2dbb1c8d8 100644
--- a/apps/app/src/components/decisions/ProposalEditor.tsx
+++ b/apps/app/src/components/decisions/ProposalEditor.tsx
@@ -1,13 +1,12 @@
'use client';
-import { generateProposalCollabDocId } from '@/utils/proposalUtils';
import { trpc } from '@op/api/client';
import {
type ProcessInstance,
ProposalStatus,
type proposalEncoder,
} from '@op/api/encoders';
-import { parseProposalData } from '@op/common/client';
+import { type ProposalDataInput, parseProposalData } from '@op/common/client';
import { Button } from '@op/ui/Button';
import { NumberField } from '@op/ui/NumberField';
import { Select, SelectItem } from '@op/ui/Select';
@@ -96,23 +95,15 @@ export function ProposalEditor({
const isDraft =
isEditMode && existingProposal?.status === ProposalStatus.DRAFT;
- // Generate or use existing collaboration document ID
- const collabDocId = useMemo(() => {
- const existingDocId = (
- existingProposal?.proposalData as Record
- )?.collaborationDocId as string | undefined;
-
- if (isEditMode && existingDocId) {
- return existingDocId;
+ const collaborationDocId = useMemo(() => {
+ const { collaborationDocId: existingId } = parseProposalData(
+ existingProposal?.proposalData,
+ );
+ if (existingId) {
+ return existingId;
}
- // Generate new docId for new proposals or legacy proposals without one
- return generateProposalCollabDocId(instance.id, existingProposal?.id);
- }, [
- instance.id,
- isEditMode,
- existingProposal?.id,
- existingProposal?.proposalData,
- ]);
+ return `proposal-${instance.id}-${existingProposal?.id ?? crypto.randomUUID()}`;
+ }, [existingProposal?.proposalData, existingProposal?.id, instance.id]);
// Editor extensions - memoized with collaborative flag
const editorExtensions = useMemo(
@@ -319,23 +310,21 @@ export function ProposalEditor({
setIsSubmitting(true);
try {
- const proposalData: Record = {
- title,
- collaborationDocId: collabDocId,
- };
-
- if (categories && categories.length > 0) {
- proposalData.category = selectedCategory;
- }
-
- if (budget !== null) {
- proposalData.budget = budget;
- }
-
if (!existingProposal) {
throw new Error('No proposal to update');
}
+ const proposalData: ProposalDataInput = {
+ ...parseProposalData(existingProposal.proposalData),
+ collaborationDocId,
+ title,
+ category:
+ categories && categories.length > 0
+ ? (selectedCategory ?? undefined)
+ : undefined,
+ budget: budget ?? undefined,
+ };
+
// Update existing proposal
await updateProposalMutation.mutateAsync({
proposalId: existingProposal.id,
@@ -361,7 +350,7 @@ export function ProposalEditor({
budget,
budgetCapAmount,
selectedCategory,
- collabDocId,
+ collaborationDocId,
categories,
existingProposal,
isDraft,
@@ -445,7 +434,7 @@ export function ProposalEditor({
{/* Rich Text Editor with Collaboration */}
Date: Fri, 23 Jan 2026 13:52:24 +0100
Subject: [PATCH 12/12] fix tests
---
.../src/services/decision/createProposal.ts | 1 -
.../routers/decision/proposals/get.test.ts | 54 ++++----
.../routers/decision/proposals/list.test.ts | 120 ++++++++++--------
.../test/helpers/TestDecisionsDataManager.ts | 14 ++
4 files changed, 106 insertions(+), 83 deletions(-)
diff --git a/packages/common/src/services/decision/createProposal.ts b/packages/common/src/services/decision/createProposal.ts
index 42df7f8f1..43e2a1877 100644
--- a/packages/common/src/services/decision/createProposal.ts
+++ b/packages/common/src/services/decision/createProposal.ts
@@ -145,7 +145,6 @@ export const createProposal = async ({
throw new CommonError('Failed to create proposal profile');
}
- // Generate proposal ID upfront so we can create a deterministic collaborationDocId
const proposalId = crypto.randomUUID();
const collaborationDocId = `proposal-${data.processInstanceId}-${proposalId}`;
diff --git a/services/api/src/routers/decision/proposals/get.test.ts b/services/api/src/routers/decision/proposals/get.test.ts
index 09965aee6..30bf16a39 100644
--- a/services/api/src/routers/decision/proposals/get.test.ts
+++ b/services/api/src/routers/decision/proposals/get.test.ts
@@ -297,7 +297,19 @@ describe.concurrent('getProposal', () => {
throw new Error('No instance created');
}
- const collaborationDocId = `proposal-${instance.instance.id}-test-doc-123`;
+ // Create proposal first to get the API-generated collaborationDocId
+ const proposal = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: {
+ title: 'TipTap Test Proposal',
+ },
+ });
+
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+
const mockTipTapContent = {
type: 'doc',
content: [
@@ -309,16 +321,6 @@ describe.concurrent('getProposal', () => {
};
mockCollab.setDocResponse(collaborationDocId, mockTipTapContent);
- const proposal = await testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: {
- title: 'TipTap Test Proposal',
- description: 'Fallback description',
- collaborationDocId,
- } as { title: string; description: string },
- });
-
const caller = await createAuthenticatedCaller(setup.userEmail);
const result = await caller.decision.getProposal({
profileId: proposal.profileId,
@@ -326,7 +328,7 @@ describe.concurrent('getProposal', () => {
expect(result.proposalData).toMatchObject({
title: 'TipTap Test Proposal',
- collaborationDocId,
+ collaborationDocId: expect.any(String),
});
expect(result.documentContent).toEqual({
type: 'json',
@@ -350,7 +352,6 @@ describe.concurrent('getProposal', () => {
throw new Error('No instance created');
}
- const collaborationDocId = `proposal-${instance.instance.id}-nonexistent`;
// 404 is the default behavior when docId not in docResponses
const proposal = await testData.createProposal({
@@ -358,9 +359,7 @@ describe.concurrent('getProposal', () => {
processInstanceId: instance.instance.id,
proposalData: {
title: 'Missing Doc Proposal',
- description: 'Has docId but doc does not exist',
- collaborationDocId,
- } as { title: string; description: string },
+ },
});
const caller = await createAuthenticatedCaller(setup.userEmail);
@@ -370,7 +369,7 @@ describe.concurrent('getProposal', () => {
expect(result.proposalData).toMatchObject({
title: 'Missing Doc Proposal',
- collaborationDocId,
+ collaborationDocId: expect.any(String),
});
// When TipTap fetch fails, documentContent is undefined (UI handles error state)
expect(result.documentContent).toBeUndefined();
@@ -392,22 +391,23 @@ describe.concurrent('getProposal', () => {
throw new Error('No instance created');
}
- const collaborationDocId = `proposal-${instance.instance.id}-timeout-doc`;
-
- const timeoutError = new Error('Request timed out');
- timeoutError.name = 'TimeoutError';
- mockCollab.setDocError(collaborationDocId, timeoutError);
-
+ // Create proposal - API generates collaborationDocId
const proposal = await testData.createProposal({
callerEmail: setup.userEmail,
processInstanceId: instance.instance.id,
proposalData: {
title: 'Timeout Test Proposal',
- description: 'Doc fetch will timeout',
- collaborationDocId,
- } as { title: string; description: string },
+ },
});
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+
+ const timeoutError = new Error('Request timed out');
+ timeoutError.name = 'TimeoutError';
+ mockCollab.setDocError(collaborationDocId, timeoutError);
+
const caller = await createAuthenticatedCaller(setup.userEmail);
const result = await caller.decision.getProposal({
profileId: proposal.profileId,
@@ -415,7 +415,7 @@ describe.concurrent('getProposal', () => {
expect(result.proposalData).toMatchObject({
title: 'Timeout Test Proposal',
- collaborationDocId,
+ collaborationDocId: expect.any(String),
});
expect(result.documentContent).toBeUndefined();
});
diff --git a/services/api/src/routers/decision/proposals/list.test.ts b/services/api/src/routers/decision/proposals/list.test.ts
index e57c11d50..9a5eadf26 100644
--- a/services/api/src/routers/decision/proposals/list.test.ts
+++ b/services/api/src/routers/decision/proposals/list.test.ts
@@ -411,13 +411,12 @@ describe.concurrent('listProposals', () => {
// Create proposals with different data formats in parallel
const [newFormatProposal, legacyProposal, caller] = await Promise.all([
- // New format: uses collaborationDocId
+ // New format: API generates collaborationDocId
testData.createProposal({
callerEmail: setup.userEmail,
processInstanceId: instance.instance.id,
proposalData: {
title: 'New Format Proposal',
- collaborationDocId: 'doc-123',
},
}),
// Legacy format: uses description field (HTML content)
@@ -443,10 +442,10 @@ describe.concurrent('listProposals', () => {
);
const legacy = result.proposals.find((p) => p.id === legacyProposal.id);
- // New format proposal should have collaborationDocId
+ // New format proposal should have title and API-generated collaborationDocId
expect(newFormat?.proposalData).toMatchObject({
title: 'New Format Proposal',
- collaborationDocId: 'doc-123',
+ collaborationDocId: expect.any(String),
});
// Legacy proposal should have description (HTML content)
@@ -580,7 +579,19 @@ describe.concurrent('listProposals', () => {
throw new Error('No instance created');
}
- const collaborationDocId = `proposal-${instance.instance.id}-test-doc`;
+ // Create proposal first to get the API-generated collaborationDocId
+ const proposal = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: {
+ title: 'Collab Proposal',
+ },
+ });
+
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+
const mockTipTapContent = {
type: 'doc',
content: [
@@ -591,20 +602,10 @@ describe.concurrent('listProposals', () => {
],
};
- // Configure mock to return TipTap content
+ // Configure mock to return TipTap content for the generated docId
mockCollab.setDocResponse(collaborationDocId, mockTipTapContent);
- const [proposal, caller] = await Promise.all([
- testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: {
- title: 'Collab Proposal',
- collaborationDocId,
- },
- }),
- createAuthenticatedCaller(setup.userEmail),
- ]);
+ const caller = await createAuthenticatedCaller(setup.userEmail);
const result = await caller.decision.listProposals({
processInstanceId: instance.instance.id,
@@ -633,8 +634,6 @@ describe.concurrent('listProposals', () => {
throw new Error('No instance created');
}
- const collaborationDocId = `proposal-${instance.instance.id}-nonexistent`;
-
// Mock returns 404 by default for unknown docIds (no explicit setup needed)
const [proposal, caller] = await Promise.all([
@@ -643,7 +642,6 @@ describe.concurrent('listProposals', () => {
processInstanceId: instance.instance.id,
proposalData: {
title: 'Failed Fetch Proposal',
- collaborationDocId,
},
}),
createAuthenticatedCaller(setup.userEmail),
@@ -674,8 +672,26 @@ describe.concurrent('listProposals', () => {
throw new Error('No instance created');
}
- const docId1 = `proposal-${instance.instance.id}-doc1`;
- const docId2 = `proposal-${instance.instance.id}-doc2`;
+ // Create proposals first to get API-generated collaborationDocIds
+ const [proposal1, proposal2] = await Promise.all([
+ testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Proposal 1' },
+ }),
+ testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Proposal 2' },
+ }),
+ ]);
+
+ const { collaborationDocId: docId1 } = proposal1.proposalData as {
+ collaborationDocId: string;
+ };
+ const { collaborationDocId: docId2 } = proposal2.proposalData as {
+ collaborationDocId: string;
+ };
const mockContent1 = {
type: 'doc',
@@ -693,19 +709,7 @@ describe.concurrent('listProposals', () => {
mockCollab.setDocResponse(docId1, mockContent1);
mockCollab.setDocResponse(docId2, mockContent2);
- const [proposal1, proposal2, caller] = await Promise.all([
- testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: { title: 'Proposal 1', collaborationDocId: docId1 },
- }),
- testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: { title: 'Proposal 2', collaborationDocId: docId2 },
- }),
- createAuthenticatedCaller(setup.userEmail),
- ]);
+ const caller = await createAuthenticatedCaller(setup.userEmail);
const result = await caller.decision.listProposals({
processInstanceId: instance.instance.id,
@@ -740,34 +744,39 @@ describe.concurrent('listProposals', () => {
throw new Error('No instance created');
}
- const collaborationDocId = `proposal-${instance.instance.id}-mixed`;
+ // Create proposals first (collab and empty both get API-generated docIds)
+ const [collabProposal, legacyProposal, emptyProposal] = await Promise.all([
+ testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Collab' },
+ }),
+ testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Legacy', description: 'HTML
' },
+ }),
+ testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Empty' },
+ }),
+ ]);
+
+ const { collaborationDocId } = collabProposal.proposalData as {
+ collaborationDocId: string;
+ };
+
const mockTipTapContent = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'TipTap' }] },
],
};
+ // Only set up mock for the collab proposal (empty and legacy won't have valid responses)
mockCollab.setDocResponse(collaborationDocId, mockTipTapContent);
- const [collabProposal, legacyProposal, emptyProposal, caller] =
- await Promise.all([
- testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: { title: 'Collab', collaborationDocId },
- }),
- testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: { title: 'Legacy', description: 'HTML
' },
- }),
- testData.createProposal({
- callerEmail: setup.userEmail,
- processInstanceId: instance.instance.id,
- proposalData: { title: 'Empty' }, // No content
- }),
- createAuthenticatedCaller(setup.userEmail),
- ]);
+ const caller = await createAuthenticatedCaller(setup.userEmail);
const result = await caller.decision.listProposals({
processInstanceId: instance.instance.id,
@@ -789,6 +798,7 @@ describe.concurrent('listProposals', () => {
type: 'html',
content: 'HTML
',
});
+ // Empty proposal has a collaborationDocId but no mock response, so documentContent is undefined
expect(foundEmpty?.documentContent).toBeUndefined();
});
});
diff --git a/services/api/src/test/helpers/TestDecisionsDataManager.ts b/services/api/src/test/helpers/TestDecisionsDataManager.ts
index 2bb81a99e..74cabd83b 100644
--- a/services/api/src/test/helpers/TestDecisionsDataManager.ts
+++ b/services/api/src/test/helpers/TestDecisionsDataManager.ts
@@ -8,6 +8,7 @@ import {
profileUserToAccessRoles,
profileUsers,
profiles,
+ proposals,
users,
} from '@op/db/schema';
import { ROLES } from '@op/db/seedData/accessControl';
@@ -530,6 +531,7 @@ export class TestDecisionsDataManager {
/**
* Creates a proposal via the tRPC router and tracks its profile for cleanup.
+ * If `description` is provided, removes `collaborationDocId` to simulate legacy proposals.
*/
async createProposal({
callerEmail,
@@ -557,6 +559,18 @@ export class TestDecisionsDataManager {
this.createdProfileIds.push(proposal.profileId);
}
+ // Simulate legacy proposal by removing collaborationDocId when description is provided
+ if (proposalData.description) {
+ const { collaborationDocId: _, ...legacyProposalData } =
+ proposal.proposalData as Record;
+ const updatedProposalData = { ...legacyProposalData, ...proposalData };
+ await db
+ .update(proposals)
+ .set({ proposalData: updatedProposalData })
+ .where(eq(proposals.id, proposal.id));
+ return { ...proposal, proposalData: updatedProposalData };
+ }
+
return proposal;
}