Skip to content

Commit 7e9c12e

Browse files
committed
extract shared proposal validation and apply to updateProposal
Extract validateProposalAgainstTemplate to DRY up the collab-doc-aware validation logic shared by submitProposal and updateProposal. The update endpoint now uses resolveProposalTemplate (checking both instanceData and processSchema) instead of only reading processSchema directly.
1 parent 438f085 commit 7e9c12e

4 files changed

Lines changed: 150 additions & 63 deletions

File tree

packages/common/src/services/decision/submitProposal.ts

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
import { createTipTapClient } from '@op/collab';
21
import { db, eq } from '@op/db/client';
32
import { type ProcessInstance, ProposalStatus, proposals } from '@op/db/schema';
43
import { assertAccess, permission } from 'access-zones';
54

65
import { CommonError, NotFoundError, ValidationError } from '../../utils';
76
import { getProfileAccessUser } from '../access';
8-
import { assembleProposalData } from './assembleProposalData';
9-
import { getProposalFragmentNames } from './getProposalFragmentNames';
10-
import { parseProposalData } from './proposalDataSchema';
117
import { resolveProposalTemplate } from './resolveProposalTemplate';
12-
import { schemaValidator } from './schemaValidator';
138
import type { DecisionInstanceData } from './schemas/instanceData';
149
import { checkProposalsAllowed } from './utils/proposal';
10+
import { validateProposalAgainstTemplate } from './validateProposalAgainstTemplate';
1511

1612
export interface SubmitProposalInput {
1713
proposalId: string;
@@ -86,39 +82,10 @@ export const submitProposal = async ({
8682
);
8783

8884
if (proposalTemplate) {
89-
const parsed = parseProposalData(existingProposal.proposalData);
90-
91-
if (parsed.collaborationDocId) {
92-
// Fetch field values from the Yjs collaboration document
93-
const appId = process.env.NEXT_PUBLIC_TIPTAP_APP_ID;
94-
const secret = process.env.TIPTAP_SECRET;
95-
96-
if (!appId || !secret) {
97-
throw new CommonError(
98-
'TipTap credentials not configured, cannot validate proposal',
99-
);
100-
}
101-
102-
const client = createTipTapClient({ appId, secret });
103-
const fragmentNames = getProposalFragmentNames(proposalTemplate);
104-
const fragmentTexts = await client.getDocumentFragments(
105-
parsed.collaborationDocId,
106-
fragmentNames,
107-
'text',
108-
);
109-
const validationData = assembleProposalData(
110-
proposalTemplate,
111-
fragmentTexts,
112-
);
113-
114-
schemaValidator.validateProposalData(proposalTemplate, validationData);
115-
} else {
116-
// Legacy proposal without collaboration doc — validate DB data directly
117-
schemaValidator.validateProposalData(
118-
proposalTemplate,
119-
existingProposal.proposalData,
120-
);
121-
}
85+
await validateProposalAgainstTemplate(
86+
proposalTemplate,
87+
existingProposal.proposalData,
88+
);
12289
}
12390

12491
// Update proposal status to submitted

packages/common/src/services/decision/updateProposal.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { db, eq } from '@op/db/client';
22
import {
3-
DecisionProcess,
4-
ProcessInstance,
3+
type ProcessInstance,
54
ProposalStatus,
6-
Visibility,
5+
type Visibility,
76
proposalCategories,
87
proposals,
98
taxonomies,
109
taxonomyTerms,
1110
} from '@op/db/schema';
12-
import { User } from '@op/supabase/lib';
11+
import type { User } from '@op/supabase/lib';
1312
import { permission } from 'access-zones';
1413

1514
import {
@@ -21,11 +20,9 @@ import {
2120
import { assertInstanceProfileAccess } from '../access';
2221
import { assertUserByAuthId } from '../assert';
2322
import type { ProposalDataInput } from './proposalDataSchema';
24-
import { schemaValidator } from './schemaValidator';
25-
26-
type ProcessInstanceWithProcess = ProcessInstance & {
27-
process: DecisionProcess;
28-
};
23+
import { resolveProposalTemplate } from './resolveProposalTemplate';
24+
import type { DecisionInstanceData } from './schemas/instanceData';
25+
import { validateProposalAgainstTemplate } from './validateProposalAgainstTemplate';
2926

3027
/**
3128
* Updates the category link for a proposal
@@ -101,20 +98,15 @@ export const updateProposal = async ({
10198
const existingProposal = await db._query.proposals.findFirst({
10299
where: eq(proposals.id, proposalId),
103100
with: {
104-
processInstance: {
105-
with: {
106-
process: true,
107-
},
108-
},
101+
processInstance: true,
109102
},
110103
});
111104

112105
if (!existingProposal) {
113106
throw new NotFoundError('Proposal not found');
114107
}
115108

116-
const processInstance =
117-
existingProposal.processInstance as ProcessInstanceWithProcess;
109+
const processInstance = existingProposal.processInstance as ProcessInstance;
118110

119111
await assertInstanceProfileAccess({
120112
user: { id: user.id },
@@ -134,14 +126,19 @@ export const updateProposal = async ({
134126
}
135127

136128
// Validate proposal data against schema if updating proposalData
137-
if (data.proposalData && processInstance.process) {
138-
const process = processInstance.process as any;
139-
const processSchema = process.processSchema;
140-
141-
if (processSchema?.proposalTemplate) {
142-
schemaValidator.validateProposalData(
143-
processSchema.proposalTemplate,
144-
data.proposalData,
129+
if (data.proposalData) {
130+
const instanceData =
131+
processInstance.instanceData as DecisionInstanceData | null;
132+
133+
const proposalTemplate = await resolveProposalTemplate(
134+
instanceData,
135+
processInstance.processId,
136+
);
137+
138+
if (proposalTemplate) {
139+
await validateProposalAgainstTemplate(
140+
proposalTemplate,
141+
existingProposal.proposalData,
145142
);
146143
}
147144
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createTipTapClient } from '@op/collab';
2+
3+
import { CommonError } from '../../utils';
4+
import { assembleProposalData } from './assembleProposalData';
5+
import { getProposalFragmentNames } from './getProposalFragmentNames';
6+
import { parseProposalData } from './proposalDataSchema';
7+
import { schemaValidator } from './schemaValidator';
8+
import type { ProposalTemplateSchema } from './types';
9+
10+
/**
11+
* Validates proposal data against a proposal template schema.
12+
*
13+
* For proposals with a TipTap collaboration document, fetches the latest
14+
* field values from the Yjs doc and assembles them before validation.
15+
* For legacy proposals without a collab doc, validates the raw proposalData directly.
16+
*
17+
* @throws {ValidationError} when the proposal data does not satisfy the template schema
18+
* @throws {CommonError} when TipTap credentials are missing for a collab-doc proposal
19+
*/
20+
export async function validateProposalAgainstTemplate(
21+
proposalTemplate: ProposalTemplateSchema,
22+
proposalData: unknown,
23+
): Promise<void> {
24+
const parsed = parseProposalData(proposalData);
25+
26+
if (parsed.collaborationDocId) {
27+
const appId = process.env.NEXT_PUBLIC_TIPTAP_APP_ID;
28+
const secret = process.env.TIPTAP_SECRET;
29+
30+
if (!appId || !secret) {
31+
throw new CommonError(
32+
'TipTap credentials not configured, cannot validate proposal',
33+
);
34+
}
35+
36+
const client = createTipTapClient({ appId, secret });
37+
const fragmentNames = getProposalFragmentNames(proposalTemplate);
38+
const fragmentTexts = await client.getDocumentFragments(
39+
parsed.collaborationDocId,
40+
fragmentNames,
41+
'text',
42+
);
43+
const validationData = assembleProposalData(
44+
proposalTemplate,
45+
fragmentTexts,
46+
);
47+
48+
schemaValidator.validateProposalData(proposalTemplate, validationData);
49+
} else {
50+
schemaValidator.validateProposalData(proposalTemplate, proposalData);
51+
}
52+
}

services/api/src/routers/decision/proposals/update.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { ProposalStatus, Visibility } from '@op/db/schema';
1+
import { mockCollab } from '@op/collab/testing';
2+
import { ProposalStatus, Visibility, proposals } from '@op/db/schema';
3+
import { db, eq } from '@op/db/test';
24
import { describe, expect, it } from 'vitest';
35

46
import { appRouter } from '../..';
@@ -467,3 +469,72 @@ describe.concurrent('updateProposal status', () => {
467469
expect(result.visibility).toBe(Visibility.HIDDEN);
468470
});
469471
});
472+
473+
describe.concurrent('updateProposal validation', () => {
474+
it('should reject update when required fields are missing from collaboration document', async ({
475+
task,
476+
onTestFinished,
477+
}) => {
478+
const testData = new TestDecisionsDataManager(task.id, onTestFinished);
479+
480+
const setup = await testData.createDecisionSetup({
481+
instanceCount: 1,
482+
grantAccess: true,
483+
proposalTemplate: {
484+
type: 'object',
485+
required: ['title', 'summary'],
486+
'x-field-order': ['title', 'summary'],
487+
properties: {
488+
title: {
489+
type: 'string',
490+
title: 'Title',
491+
minLength: 1,
492+
'x-format': 'short-text',
493+
},
494+
summary: {
495+
type: 'string',
496+
title: 'Project Summary',
497+
minLength: 1,
498+
'x-format': 'long-text',
499+
},
500+
},
501+
},
502+
});
503+
504+
const instance = setup.instances[0];
505+
if (!instance) {
506+
throw new Error('No instance created');
507+
}
508+
509+
const proposal = await testData.createProposal({
510+
callerEmail: setup.userEmail,
511+
processInstanceId: instance.instance.id,
512+
proposalData: { title: 'Draft' },
513+
});
514+
515+
const collaborationDocId = `proposal-${proposal.id}`;
516+
517+
await db
518+
.update(proposals)
519+
.set({
520+
proposalData: { title: 'Draft', collaborationDocId },
521+
})
522+
.where(eq(proposals.id, proposal.id));
523+
524+
// Seed title but omit the required summary field
525+
mockCollab.setDocFragments(collaborationDocId, {
526+
title: 'Draft',
527+
});
528+
529+
const caller = await createAuthenticatedCaller(setup.userEmail);
530+
531+
await expect(
532+
caller.decision.updateProposal({
533+
proposalId: proposal.id,
534+
data: { proposalData: { title: 'Updated Draft' } },
535+
}),
536+
).rejects.toMatchObject({
537+
code: 'BAD_REQUEST',
538+
});
539+
});
540+
});

0 commit comments

Comments
 (0)