diff --git a/.env.local.example b/.env.local.example index af2cb17ac..dcc07c130 100644 --- a/.env.local.example +++ b/.env.local.example @@ -27,8 +27,9 @@ OTEL_SERVICE_NAME=common OTEL_RESOURCE_ATTRIBUTES="deployment.environment=local" ## TipTap Cloud (Real-time collaborative editing) -# Get your App ID from https://cloud.tiptap.dev/ +# Get your App ID and Secret from https://cloud.tiptap.dev/ NEXT_PUBLIC_TIPTAP_APP_ID=your-tiptap-app-id +TIPTAP_SECRET=your-tiptap-secret # TIPTAP_PRO_TOKEN is needed for pnpm to install @tiptap-pro packages. # Run before install: export TIPTAP_PRO_TOKEN="your-token" && pnpm install # Or add to your shell profile for persistence. diff --git a/apps/app/src/components/decisions/DocumentNotAvailable.tsx b/apps/app/src/components/decisions/DocumentNotAvailable.tsx new file mode 100644 index 000000000..b5060702a --- /dev/null +++ b/apps/app/src/components/decisions/DocumentNotAvailable.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { cn } from '@op/ui/utils'; +import { LuFileQuestion } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +/** Shown when document content failed to load from the collaboration server. */ +export function DocumentNotAvailable({ className }: { className?: string }) { + const t = useTranslations(); + + return ( +
+
+ +
+

+ {t('Content could not be loaded')} +

+
+ ); +} diff --git a/apps/app/src/components/decisions/MyBallot.tsx b/apps/app/src/components/decisions/MyBallot.tsx index ca704b060..7be8f1877 100644 --- a/apps/app/src/components/decisions/MyBallot.tsx +++ b/apps/app/src/components/decisions/MyBallot.tsx @@ -10,10 +10,10 @@ import { useTranslations } from '@/lib/i18n'; import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCardContent, - ProposalCardDescription, ProposalCardFooter, ProposalCardHeader, ProposalCardMeta, + ProposalCardPreview, } from './ProposalCard'; import { VotingProposalCard } from './VotingProposalCard'; @@ -92,7 +92,7 @@ export const MyBallot = ({ - +
diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index f062493d7..f41e81016 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -8,7 +8,7 @@ import { type proposalEncoder, } from '@op/api/encoders'; import { parseProposalData } from '@op/common/client'; -import { getTextPreview, isNullish, match } from '@op/core'; +import { isNullish, match } from '@op/core'; import { Avatar } from '@op/ui/Avatar'; import { Chip } from '@op/ui/Chip'; import { Surface } from '@op/ui/Surface'; @@ -23,6 +23,8 @@ import { useTranslations } from '@/lib/i18n'; import { Link } from '@/lib/i18n/routing'; import { Bullet } from '../../Bullet'; +import { DocumentNotAvailable } from '../DocumentNotAvailable'; +import { getProposalContentPreview } from '../proposalContentUtils'; export type Proposal = z.infer; @@ -328,17 +330,21 @@ export function ProposalCardStatus({ } /** - * Description text component + * Content preview/excerpt component */ -export function ProposalCardDescription({ +export function ProposalCardPreview({ proposal, className, }: BaseProposalCardProps & { className?: string; }) { - const { description } = parseProposalData(proposal.proposalData); + const previewText = getProposalContentPreview(proposal.documentContent); - if (!description) { + if (previewText === null) { + return ; + } + + if (!previewText) { return null; } @@ -349,7 +355,7 @@ export function ProposalCardDescription({ className, )} > - {getTextPreview({ content: description })} + {previewText}

); } 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 */} ; @@ -97,11 +99,11 @@ export function ProposalView({ }, []); // Parse proposal data using shared utility - const { title, budget, category, description } = parseProposalData( + const { title, budget, category } = parseProposalData( currentProposal.proposalData, ); - const proposalContent = description || `

${t('No content available')}

`; + const proposalContent = getProposalContent(currentProposal.documentContent); return ( {/* Proposal Content */} - + {proposalContent ? ( + + ) : ( + + )} {/* Comments Section */}
diff --git a/apps/app/src/components/decisions/ProposalsList.tsx b/apps/app/src/components/decisions/ProposalsList.tsx index 6059e32ab..34169dd03 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -25,13 +25,13 @@ import { ProposalCard, ProposalCardActions, ProposalCardContent, - ProposalCardDescription, ProposalCardFooter, ProposalCardHeader, ProposalCardMenu, ProposalCardMeta, ProposalCardMetrics, ProposalCardOwnerActions, + ProposalCardPreview, } from './ProposalCard'; import { VoteSubmissionModal } from './VoteSubmissionModal'; import { VoteSuccessModal } from './VoteSuccessModal'; @@ -261,7 +261,7 @@ const VotingProposalsList = ({ } /> - + - +
@@ -389,7 +389,7 @@ const ViewProposalsList = ({ } /> - +
diff --git a/apps/app/src/components/decisions/ResultsList.tsx b/apps/app/src/components/decisions/ResultsList.tsx index ac3704988..e74b71114 100644 --- a/apps/app/src/components/decisions/ResultsList.tsx +++ b/apps/app/src/components/decisions/ResultsList.tsx @@ -9,10 +9,10 @@ import { EmptyProposalsState } from './EmptyProposalsState'; import { ProposalCard, ProposalCardContent, - ProposalCardDescription, ProposalCardFooter, ProposalCardHeader, ProposalCardMeta, + ProposalCardPreview, } from './ProposalCard'; const NoProposalsFound = () => { @@ -74,7 +74,7 @@ export const ResultsList = ({ - + diff --git a/apps/app/src/components/decisions/proposalContentUtils.ts b/apps/app/src/components/decisions/proposalContentUtils.ts new file mode 100644 index 000000000..640806fdc --- /dev/null +++ b/apps/app/src/components/decisions/proposalContentUtils.ts @@ -0,0 +1,56 @@ +import type { proposalEncoder } from '@op/api/encoders'; +import { getTextPreview } from '@op/core'; +import { defaultViewerExtensions } from '@op/ui/RichTextEditor'; +import { type JSONContent, generateText } from '@tiptap/core'; +import type { Content } from '@tiptap/react'; +import type { z } from 'zod'; + +type Proposal = z.infer; +type DocumentContent = NonNullable; + +/** + * Extracts content from proposal documentContent for use with RichTextViewer. + * Returns Content for rendering, or null if no content available. + */ +export function getProposalContent( + documentContent?: DocumentContent, +): Content | null { + if (!documentContent) { + return null; + } + + if (documentContent.type === 'json') { + return { + type: 'doc', + content: documentContent.content, + } as JSONContent; + } + + return documentContent.content; +} + +/** Extracts plain text preview from proposal content. */ +export function getProposalContentPreview( + documentContent?: DocumentContent, +): string | null { + if (!documentContent) { + return null; + } + + if (documentContent.type === 'json') { + try { + const doc = { + type: 'doc', + content: documentContent.content as JSONContent[], + }; + const text = generateText(doc, defaultViewerExtensions); + return text.trim(); + } catch { + return null; + } + } + + return ( + getTextPreview({ content: documentContent.content, maxLines: 3 }) ?? '' + ); +} diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 724983faf..86de5362f 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -279,7 +279,7 @@ "Embed Link Preview": "লিঙ্ক প্রিভিউ এমবেড করুন", "Add Image": "ছবি যোগ করুন", "Add Horizontal Rule": "অনুভূমিক রুল যোগ করুন", - "No content available": "কোন বিষয়বস্তু উপলব্ধ নেই", + "Content could not be loaded": "বিষয়বস্তু লোড করা যায়নি", "Loading comments": "মন্তব্য লোড করা", "No comments": "কোনো মন্তব্য নেই", "Filter proposals by category": "বিভাগ অনুসারে প্রস্তাবগুলি ফিল্টার করুন", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 19a13bfa7..983570612 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -287,7 +287,7 @@ "Embed Link Preview": "Embed Link Preview", "Add Image": "Add Image", "Add Horizontal Rule": "Add Horizontal Rule", - "No content available": "No content available", + "Content could not be loaded": "Content could not be loaded", "Loading comments": "Loading comments", "No comments": "No comments", "Filter proposals by category": "Filter proposals by category", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index a7ad5cc75..b447b2241 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -286,7 +286,7 @@ "Embed Link Preview": "Insertar vista previa de enlace", "Add Image": "Agregar imagen", "Add Horizontal Rule": "Agregar línea horizontal", - "No content available": "No hay contenido disponible", + "Content could not be loaded": "No se pudo cargar el contenido", "Loading comments": "Cargando comentarios", "No comments": "Sin comentarios", "Filter proposals by category": "Filtrar propuestas por categoría", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 4e064c56c..36be8c23f 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -287,7 +287,7 @@ "Embed Link Preview": "Intégrer un aperçu de lien", "Add Image": "Ajouter une image", "Add Horizontal Rule": "Ajouter une ligne horizontale", - "No content available": "Aucun contenu disponible", + "Content could not be loaded": "Le contenu n'a pas pu être chargé", "Loading comments": "Chargement des commentaires", "No comments": "Aucun commentaire", "Filter proposals by category": "Filtrer les propositions par catégorie", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index fd1cd55a4..75603f293 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -287,7 +287,7 @@ "Embed Link Preview": "Incorporar visualização de link", "Add Image": "Adicionar imagem", "Add Horizontal Rule": "Adicionar linha horizontal", - "No content available": "Nenhum conteúdo disponível", + "Content could not be loaded": "Não foi possível carregar o conteúdo", "Loading comments": "Carregando comentários", "No comments": "Sem comentários", "Filter proposals by category": "Filtrar propostas por categoria", diff --git a/packages/common/src/services/decision/createProposal.ts b/packages/common/src/services/decision/createProposal.ts index 4e80f274a..43e2a1877 100644 --- a/packages/common/src/services/decision/createProposal.ts +++ b/packages/common/src/services/decision/createProposal.ts @@ -145,11 +145,18 @@ export const createProposal = async ({ throw new CommonError('Failed to create proposal profile'); } + const proposalId = crypto.randomUUID(); + const collaborationDocId = `proposal-${data.processInstanceId}-${proposalId}`; + const [proposal] = await tx .insert(proposals) .values({ + id: proposalId, processInstanceId: data.processInstanceId, - proposalData: data.proposalData, + proposalData: { + ...data.proposalData, + collaborationDocId, + }, submittedByProfileId: profileId, profileId: proposalProfile.id, status: ProposalStatus.DRAFT, diff --git a/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx b/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx index 0b682c669..87e375292 100644 --- a/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx +++ b/packages/ui/src/components/RichTextEditor/RichTextViewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { Extensions } from '@tiptap/react'; +import type { Content, Extensions } from '@tiptap/react'; import { RichTextEditorSkeleton } from './RichTextEditorSkeleton'; import { StyledRichTextContent } from './StyledRichTextContent'; @@ -13,6 +13,8 @@ import { useRichTextEditor } from './useRichTextEditor'; * Read-only viewer component for displaying rich text content. * Use this for displaying content that should not be edited. * Links will open on click. + * + * @param content - Can be HTML string or TipTap JSON document (array of nodes) */ export function RichTextViewer({ extensions = defaultViewerExtensions, @@ -21,7 +23,7 @@ export function RichTextViewer({ editorClassName = '', }: { extensions?: Extensions; - content: string; + content: Content; className?: string; editorClassName?: string; }) { diff --git a/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts b/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts index e415027b3..00ed99da2 100644 --- a/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts +++ b/packages/ui/src/components/RichTextEditor/useRichTextEditor.ts @@ -1,4 +1,4 @@ -import type { Editor, Extensions } from '@tiptap/react'; +import type { Content, Editor, Extensions } from '@tiptap/react'; import { useEditor } from '@tiptap/react'; import { useEffect } from 'react'; @@ -15,7 +15,7 @@ export function useRichTextEditor({ editable = true, }: { extensions?: Extensions; - content?: string; + content?: Content; editorClassName?: string; onUpdate?: (content: string) => void; onChange?: (content: string) => void; @@ -50,7 +50,7 @@ export function useRichTextEditor({ editor.commands.setContent(content); } } - }, [editor]); // Only depend on editor, not content + }, [editor]); // Only run when editor is ready, not on content changes // Notify parent when editor is ready useEffect(() => { 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; }