Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 19 additions & 41 deletions apps/app/src/components/decisions/proposalContentUtils.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,55 @@
import type { proposalEncoder } from '@op/api/encoders';
import { SYSTEM_FIELD_KEYS } from '@op/common/client';
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<typeof proposalEncoder>;
type DocumentContent = NonNullable<Proposal['documentContent']>;

/**
* Extracts content from proposal documentContent for use with RichTextViewer.
* Returns Content for rendering, or null if no content available.
* Extracts a plain-text preview from proposal document content.
*
* System fields (title, budget, category) are excluded because they are
* rendered separately in the card header and their TipTap fragments contain
* double-nested arrays rather than standard TipTap nodes, which crashes
* `generateText()`.
*/
export function getProposalContent(
export function getProposalContentPreview(
documentContent?: DocumentContent,
): Content | null {
): string | null {
if (!documentContent) {
return null;
}

if (documentContent.type === 'json') {
// Merge all fragment contents (excluding title, rendered separately) into a single doc
const { fragments } = documentContent;
const allContent: unknown[] = [];
for (const [key, fragment] of Object.entries(documentContent.fragments)) {
if (key === 'title' || !fragment?.content) {

for (const [key, fragment] of Object.entries(fragments)) {
if (SYSTEM_FIELD_KEYS.has(key) || !fragment?.content) {
continue;
}
allContent.push(...fragment.content);
}

// Fall back to legacy `default` fragment if no keyed fragments matched
// Fall back to legacy `default` fragment
if (allContent.length === 0) {
const defaultFragment = documentContent.fragments.default;
if (!defaultFragment?.content) {
return null;
const defaultFragment = fragments.default;
if (defaultFragment?.content) {
allContent.push(...defaultFragment.content);
}
allContent.push(...defaultFragment.content);
}

if (allContent.length === 0) {
return null;
}

return {
type: 'doc',
content: allContent,
} 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') {
const content = getProposalContent(documentContent);

if (!content || typeof content === 'string') {
return null;
}
const content = { type: 'doc', content: allContent } as JSONContent;

try {
const text = generateText(
content as JSONContent,
defaultViewerExtensions,
);
return text.trim();
return generateText(content, defaultViewerExtensions).trim() || null;
} catch {
return null;
}
Expand Down
43 changes: 5 additions & 38 deletions packages/common/src/services/decision/submitProposal.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { createTipTapClient } from '@op/collab';
import { db, eq } from '@op/db/client';
import { type ProcessInstance, ProposalStatus, proposals } from '@op/db/schema';
import { assertAccess, permission } from 'access-zones';

import { CommonError, NotFoundError, ValidationError } from '../../utils';
import { getProfileAccessUser } from '../access';
import { assembleProposalData } from './assembleProposalData';
import { getProposalFragmentNames } from './getProposalFragmentNames';
import { parseProposalData } from './proposalDataSchema';
import { resolveProposalTemplate } from './resolveProposalTemplate';
import { schemaValidator } from './schemaValidator';
import type { DecisionInstanceData } from './schemas/instanceData';
import { checkProposalsAllowed } from './utils/proposal';
import { validateProposalAgainstTemplate } from './validateProposalAgainstTemplate';

export interface SubmitProposalInput {
proposalId: string;
Expand Down Expand Up @@ -86,39 +82,10 @@ export const submitProposal = async ({
);

if (proposalTemplate) {
const parsed = parseProposalData(existingProposal.proposalData);

if (parsed.collaborationDocId) {
// Fetch field values from the Yjs collaboration document
const appId = process.env.NEXT_PUBLIC_TIPTAP_APP_ID;
const secret = process.env.TIPTAP_SECRET;

if (!appId || !secret) {
throw new CommonError(
'TipTap credentials not configured, cannot validate proposal',
);
}

const client = createTipTapClient({ appId, secret });
const fragmentNames = getProposalFragmentNames(proposalTemplate);
const fragmentTexts = await client.getDocumentFragments(
parsed.collaborationDocId,
fragmentNames,
'text',
);
const validationData = assembleProposalData(
proposalTemplate,
fragmentTexts,
);

schemaValidator.validateProposalData(proposalTemplate, validationData);
} else {
// Legacy proposal without collaboration doc — validate DB data directly
schemaValidator.validateProposalData(
proposalTemplate,
existingProposal.proposalData,
);
}
await validateProposalAgainstTemplate(
proposalTemplate,
existingProposal.proposalData,
);
}

// Update proposal status to submitted
Expand Down
45 changes: 21 additions & 24 deletions packages/common/src/services/decision/updateProposal.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { db, eq } from '@op/db/client';
import {
DecisionProcess,
ProcessInstance,
type ProcessInstance,
ProposalStatus,
Visibility,
type Visibility,
proposalCategories,
proposals,
taxonomies,
taxonomyTerms,
} from '@op/db/schema';
import { User } from '@op/supabase/lib';
import type { User } from '@op/supabase/lib';
import { permission } from 'access-zones';

import {
Expand All @@ -21,11 +20,9 @@ import {
import { assertInstanceProfileAccess } from '../access';
import { assertUserByAuthId } from '../assert';
import type { ProposalDataInput } from './proposalDataSchema';
import { schemaValidator } from './schemaValidator';

type ProcessInstanceWithProcess = ProcessInstance & {
process: DecisionProcess;
};
import { resolveProposalTemplate } from './resolveProposalTemplate';
import type { DecisionInstanceData } from './schemas/instanceData';
import { validateProposalAgainstTemplate } from './validateProposalAgainstTemplate';

/**
* Updates the category link for a proposal
Expand Down Expand Up @@ -101,20 +98,15 @@ export const updateProposal = async ({
const existingProposal = await db._query.proposals.findFirst({
where: eq(proposals.id, proposalId),
with: {
processInstance: {
with: {
process: true,
},
},
processInstance: true,
},
});

if (!existingProposal) {
throw new NotFoundError('Proposal not found');
}

const processInstance =
existingProposal.processInstance as ProcessInstanceWithProcess;
const processInstance = existingProposal.processInstance as ProcessInstance;

await assertInstanceProfileAccess({
user: { id: user.id },
Expand All @@ -134,14 +126,19 @@ export const updateProposal = async ({
}

// Validate proposal data against schema if updating proposalData
if (data.proposalData && processInstance.process) {
const process = processInstance.process as any;
const processSchema = process.processSchema;

if (processSchema?.proposalTemplate) {
schemaValidator.validateProposalData(
processSchema.proposalTemplate,
data.proposalData,
if (data.proposalData) {
const instanceData =
processInstance.instanceData as DecisionInstanceData | null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to type this since we asserted earlier?


const proposalTemplate = await resolveProposalTemplate(
instanceData,
processInstance.processId,
);

if (proposalTemplate) {
await validateProposalAgainstTemplate(
proposalTemplate,
existingProposal.proposalData,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createTipTapClient } from '@op/collab';

import { CommonError } from '../../utils';
import { assembleProposalData } from './assembleProposalData';
import { getProposalFragmentNames } from './getProposalFragmentNames';
import { parseProposalData } from './proposalDataSchema';
import { schemaValidator } from './schemaValidator';
import type { ProposalTemplateSchema } from './types';

/**
* Validates proposal data against a proposal template schema.
*
* For proposals with a TipTap collaboration document, fetches the latest
* field values from the Yjs doc and assembles them before validation.
* For legacy proposals without a collab doc, validates the raw proposalData directly.
*
* @throws {ValidationError} when the proposal data does not satisfy the template schema
* @throws {CommonError} when TipTap credentials are missing for a collab-doc proposal
*/
export async function validateProposalAgainstTemplate(
proposalTemplate: ProposalTemplateSchema,
proposalData: unknown,
): Promise<void> {
const parsed = parseProposalData(proposalData);

if (parsed.collaborationDocId) {
const appId = process.env.NEXT_PUBLIC_TIPTAP_APP_ID;
const secret = process.env.TIPTAP_SECRET;

if (!appId || !secret) {
throw new CommonError(
'TipTap credentials not configured, cannot validate proposal',
);
}

const client = createTipTapClient({ appId, secret });
const fragmentNames = getProposalFragmentNames(proposalTemplate);
const fragmentTexts = await client.getDocumentFragments(
parsed.collaborationDocId,
fragmentNames,
'text',
);
const validationData = assembleProposalData(
proposalTemplate,
fragmentTexts,
);

schemaValidator.validateProposalData(proposalTemplate, validationData);
} else {
schemaValidator.validateProposalData(proposalTemplate, proposalData);
}
}
73 changes: 72 additions & 1 deletion services/api/src/routers/decision/proposals/update.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ProposalStatus, Visibility } from '@op/db/schema';
import { mockCollab } from '@op/collab/testing';
import { ProposalStatus, Visibility, proposals } from '@op/db/schema';
import { db, eq } from '@op/db/test';
import { describe, expect, it } from 'vitest';

import { appRouter } from '../..';
Expand Down Expand Up @@ -467,3 +469,72 @@ describe.concurrent('updateProposal status', () => {
expect(result.visibility).toBe(Visibility.HIDDEN);
});
});

describe.concurrent('updateProposal validation', () => {
it('should reject update when required fields are missing from collaboration document', async ({
task,
onTestFinished,
}) => {
const testData = new TestDecisionsDataManager(task.id, onTestFinished);

const setup = await testData.createDecisionSetup({
instanceCount: 1,
grantAccess: true,
proposalTemplate: {
type: 'object',
required: ['title', 'summary'],
'x-field-order': ['title', 'summary'],
properties: {
title: {
type: 'string',
title: 'Title',
minLength: 1,
'x-format': 'short-text',
},
summary: {
type: 'string',
title: 'Project Summary',
minLength: 1,
'x-format': 'long-text',
},
},
},
});

const instance = setup.instances[0];
if (!instance) {
throw new Error('No instance created');
}

const proposal = await testData.createProposal({
callerEmail: setup.userEmail,
processInstanceId: instance.instance.id,
proposalData: { title: 'Draft' },
});

const collaborationDocId = `proposal-${proposal.id}`;

await db
.update(proposals)
.set({
proposalData: { title: 'Draft', collaborationDocId },
})
.where(eq(proposals.id, proposal.id));

// Seed title but omit the required summary field
mockCollab.setDocFragments(collaborationDocId, {
title: 'Draft',
});

const caller = await createAuthenticatedCaller(setup.userEmail);

await expect(
caller.decision.updateProposal({
proposalId: proposal.id,
data: { proposalData: { title: 'Updated Draft' } },
}),
).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
});
});