diff --git a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx index e05c6eef1..f38c9e4c2 100644 --- a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx +++ b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCollaborativeFragment } from '@/hooks/useCollaborativeFragment'; +import type { BudgetData } from '@op/common/client'; import { Button } from '@op/ui/Button'; import { NumberField } from '@op/ui/NumberField'; import { useEffect, useRef, useState } from 'react'; @@ -9,15 +10,6 @@ import { useTranslations } from '@/lib/i18n'; import { useCollaborativeDoc } from './CollaborativeDocContext'; -/** - * Yjs-synced budget shape — currency + amount as an atomic value - * TODO: the backend part will be done separately once we have more clarity how it's going to be used. - */ -interface BudgetValue { - currency: string; - amount: number; -} - const DEFAULT_CURRENCY = 'USD'; const DEFAULT_CURRENCY_SYMBOL = '$'; @@ -32,13 +24,13 @@ function formatBudgetDisplay(amount: number, currencySymbol: string): string { interface CollaborativeBudgetFieldProps { maxAmount?: number; - initialValue?: number | null; - onChange?: (budget: number | null) => void; + initialValue?: BudgetData | null; + onChange?: (budget: BudgetData | null) => void; } /** * Collaborative budget input synced via Yjs XmlFragment. - * Stores `{ currency, amount }` as a JSON string in the shared doc + * Stores `MoneyAmount` (`{ amount, currency }`) as a JSON string in the shared doc * for future multi-currency support. * * Displays as a pill when a value exists, switching to an inline @@ -55,7 +47,7 @@ export function CollaborativeBudgetField({ const initialBudgetValue = initialValue !== null - ? { currency: DEFAULT_CURRENCY, amount: initialValue } + ? { currency: initialValue.currency, amount: initialValue.amount } : null; const [budgetText, setBudgetText] = useCollaborativeFragment( @@ -64,12 +56,12 @@ export function CollaborativeBudgetField({ initialBudgetValue ? JSON.stringify(initialBudgetValue) : '', ); - const budget = budgetText ? (JSON.parse(budgetText) as BudgetValue) : null; - const setBudget = (value: BudgetValue | null) => - setBudgetText(value ? JSON.stringify(value) : ''); + const budget = budgetText ? (JSON.parse(budgetText) as BudgetData) : null; + const setBudget = (newBudget: BudgetData | null) => + setBudgetText(newBudget ? JSON.stringify(newBudget) : ''); const onChangeRef = useRef(onChange); - const lastEmittedAmountRef = useRef(undefined); + const lastEmittedRef = useRef(undefined); useEffect(() => { onChangeRef.current = onChange; @@ -100,13 +92,16 @@ export function CollaborativeBudgetField({ }; useEffect(() => { - if (lastEmittedAmountRef.current === budgetAmount) { + const emitted: BudgetData | null = budget; + const key = emitted ? `${emitted.amount}:${emitted.currency}` : null; + + if (lastEmittedRef.current === key) { return; } - lastEmittedAmountRef.current = budgetAmount; - onChangeRef.current?.(budgetAmount); - }, [budgetAmount]); + lastEmittedRef.current = key ?? undefined; + onChangeRef.current?.(emitted); + }, [budget]); const handleStartEditing = () => { setIsEditing(true); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/BudgetFieldConfig.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/BudgetFieldConfig.tsx index 9391ba7f5..0bb0c29e2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/BudgetFieldConfig.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/BudgetFieldConfig.tsx @@ -51,7 +51,8 @@ export function BudgetFieldConfig({ const budgetSchema = getFieldSchema(template, 'budget'); const showBudget = !!budgetSchema; const budgetCurrency = - (budgetSchema?.['x-currency'] as string | undefined) ?? 'USD'; + (budgetSchema?.properties?.currency as { default?: string } | undefined) + ?.default ?? 'USD'; const budgetCurrencySymbol = CURRENCY_SYMBOL_MAP.get(budgetCurrency) ?? '$'; const budgetMaxAmount = budgetSchema?.maximum as number | undefined; const budgetRequired = isFieldRequired(template, 'budget'); @@ -64,10 +65,13 @@ export function BudgetFieldConfig({ properties: { ...prev.properties, budget: { - type: 'number', + type: 'object', title: t('Budget'), 'x-format': 'money', - 'x-currency': 'USD', + properties: { + amount: { type: 'number' }, + currency: { type: 'string', default: 'USD' }, + }, }, }, })); @@ -95,11 +99,24 @@ export function BudgetFieldConfig({ if (!existing) { return prev; } + const existingProps = (existing.properties ?? {}) as Record< + string, + Record + >; return { ...prev, properties: { ...prev.properties, - budget: { ...existing, 'x-currency': String(key) }, + budget: { + ...existing, + properties: { + ...existingProps, + currency: { + ...(existingProps.currency ?? { type: 'string' }), + default: String(key), + }, + }, + }, }, }; }); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx index 791a03f2d..d25a8ef8c 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { trpc } from '@op/api/client'; +import { SYSTEM_FIELD_KEYS } from '@op/common/client'; import { useDebouncedCallback, useMediaQuery } from '@op/hooks'; import { screens } from '@op/styles/constants'; import { FieldConfigCard } from '@op/ui/FieldConfigCard'; @@ -102,8 +103,12 @@ export function TemplateEditorContent({ const updateInstance = trpc.decision.updateDecisionInstance.useMutation(); - // Derive field views from the template (all fields are editable) - const fields = useMemo(() => getFields(template), [template]); + // Derive field views from the template, excluding locked system fields + // that are always rendered separately above the sortable list. + const fields = useMemo( + () => getFields(template).filter((f) => !SYSTEM_FIELD_KEYS.has(f.id)), + [template], + ); // Sidebar field list — includes visual-only locked fields at the top const sidebarFields = useMemo(() => { diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index f41e81016..ad93ac69d 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -150,9 +150,24 @@ export function ProposalCardBudget({ const { budget } = parseProposalData(proposal.proposalData); // Use allocated amount if provided, otherwise fall back to budget - const displayAmount = !isNullish(allocated) ? Number(allocated) : budget; + if (!isNullish(allocated)) { + return ( + + {formatCurrency( + Number(allocated), + undefined, + budget?.currency ?? 'USD', + )} + + ); + } - if (!displayAmount) { + if (!budget) { return null; } @@ -163,7 +178,7 @@ export function ProposalCardBudget({ className, )} > - {formatCurrency(displayAmount)} + {formatCurrency(budget.amount, undefined, budget.currency)} ); } diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index 0eafb862e..d794ed9c9 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -215,7 +215,7 @@ export function ProposalView({ )} {budget && ( - {formatCurrency(budget)} + {formatCurrency(budget.amount, undefined, budget.currency)} )} diff --git a/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx b/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx index cc2366a47..9da5ad0bb 100644 --- a/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx +++ b/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx @@ -150,7 +150,7 @@ export function ProposalEditor({ missingFields.push(t('Title')); } - if (templateRequired.includes('budget') && currentDraft.budget === null) { + if (templateRequired.includes('budget') && !currentDraft.budget) { missingFields.push(t('Budget')); } @@ -178,7 +178,7 @@ export function ProposalEditor({ if ( currentDraft.budget !== null && budgetMax !== undefined && - currentDraft.budget > budgetMax + currentDraft.budget.amount > budgetMax ) { toast.error({ message: t('Budget cannot exceed {amount}', { diff --git a/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx b/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx index 9421e1b48..fbfcb82eb 100644 --- a/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx +++ b/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx @@ -192,7 +192,7 @@ function renderField( return ( onFieldChange(key, value)} /> ); diff --git a/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts b/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts index d59bcb78c..582edffb4 100644 --- a/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts +++ b/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts @@ -21,7 +21,6 @@ export type XFormat = 'short-text' | 'long-text' | 'money' | 'category'; */ export interface ProposalPropertySchema extends JSONSchema7 { 'x-format'?: string; - 'x-currency'?: string; } export interface ProposalTemplateSchema extends JSONSchema7 { diff --git a/apps/app/src/components/decisions/proposalEditor/useProposalDraft.ts b/apps/app/src/components/decisions/proposalEditor/useProposalDraft.ts index db29b41df..65c6cdc57 100644 --- a/apps/app/src/components/decisions/proposalEditor/useProposalDraft.ts +++ b/apps/app/src/components/decisions/proposalEditor/useProposalDraft.ts @@ -1,6 +1,11 @@ import { trpc } from '@op/api/client'; import type { proposalEncoder } from '@op/api/encoders'; -import { type ProposalDataInput, parseProposalData } from '@op/common/client'; +import { + type BudgetData, + type ProposalDataInput, + normalizeBudget, + parseProposalData, +} from '@op/common/client'; import { useDebouncedCallback } from '@op/hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { z } from 'zod'; @@ -16,7 +21,7 @@ type Proposal = z.infer; export interface ProposalDraftFields extends Record { title: string; category: string | null; - budget: number | null; + budget: BudgetData | null; } /** @@ -125,7 +130,7 @@ export function useProposalDraft({ } else if (key === 'category') { next.category = typeof value === 'string' ? value : null; } else if (key === 'budget') { - next.budget = typeof value === 'number' ? value : null; + next.budget = normalizeBudget(value) ?? null; } // Dynamic fields are Yjs-only — we don't store them in draft state. diff --git a/packages/common/src/client.ts b/packages/common/src/client.ts index 1b9d6c73f..d7b5b9ff1 100644 --- a/packages/common/src/client.ts +++ b/packages/common/src/client.ts @@ -1,6 +1,7 @@ // Client-safe exports for @op/common // This file should only export types and schemas that don't depend on server-only modules +export * from './money'; export * from './services/decision/proposalDataSchema'; export * from './services/decision/types'; export { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b26d971c7..ef82e62e2 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,3 +1,4 @@ +export * from './money'; export * from './realtime'; export * from './services'; export * from './utils'; diff --git a/packages/common/src/money.ts b/packages/common/src/money.ts new file mode 100644 index 000000000..8aec55cec --- /dev/null +++ b/packages/common/src/money.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** + * Canonical schema for a monetary amount. + * `amount` is the numeric value; `currency` is an ISO 4217 code (e.g. "USD"). + */ +export const moneyAmountSchema = z.object({ + amount: z.number(), + currency: z.string(), +}); + +/** A monetary amount with currency. */ +export type MoneyAmount = z.infer; diff --git a/packages/common/src/services/decision/exports/generateProposalsCsv.ts b/packages/common/src/services/decision/exports/generateProposalsCsv.ts index 9acb3f24b..16b901d13 100644 --- a/packages/common/src/services/decision/exports/generateProposalsCsv.ts +++ b/packages/common/src/services/decision/exports/generateProposalsCsv.ts @@ -45,7 +45,8 @@ export async function generateProposalsCsv( 'Proposal ID': p.id, Title: proposalData.title || '', Description: getDocumentDescription(p), - Budget: proposalData.budget ?? '', + Budget: proposalData.budget?.amount ?? '', + Currency: proposalData.budget?.currency ?? '', Category: proposalData.category ?? '', Status: p.status, 'Submitted By': p.submittedBy?.name || '', diff --git a/packages/common/src/services/decision/getProposalDocumentsContent.ts b/packages/common/src/services/decision/getProposalDocumentsContent.ts index 8ec4bbcd7..136ded39c 100644 --- a/packages/common/src/services/decision/getProposalDocumentsContent.ts +++ b/packages/common/src/services/decision/getProposalDocumentsContent.ts @@ -74,10 +74,12 @@ export async function getProposalDocumentsContent( const fragmentNames = proposalTemplate ? getProposalFragmentNames(proposalTemplate) : ['default']; + const fragments = await client.getDocumentFragments( collaborationDocId, fragmentNames, ); + return { id, fragments }; } catch (error) { console.warn('Failed to fetch TipTap document', { diff --git a/packages/common/src/services/decision/getResultsStats.ts b/packages/common/src/services/decision/getResultsStats.ts index a710872a9..dd0b4803f 100644 --- a/packages/common/src/services/decision/getResultsStats.ts +++ b/packages/common/src/services/decision/getResultsStats.ts @@ -11,6 +11,7 @@ import { assertAccess, permission } from 'access-zones'; import { NotFoundError } from '../../utils'; import { getOrgAccessUser } from '../access'; +import { extractBudgetValue } from './proposalDataSchema'; export const getResultsStats = async ({ instanceId, @@ -93,11 +94,10 @@ export const getResultsStats = async ({ // Use allocated amount if it exists, otherwise fall back to budget if (item.allocated !== null) { const allocatedNum = Number(item.allocated); - return sum + (isNaN(allocatedNum) ? 0 : allocatedNum); + return sum + (Number.isNaN(allocatedNum) ? 0 : allocatedNum); } - const proposalData = item.proposalData as any; - const budget = proposalData?.budget ?? 0; - return sum + (typeof budget === 'number' ? budget : 0); + const proposalData = item.proposalData as Record; + return sum + extractBudgetValue(proposalData?.budget); }, 0); return { diff --git a/packages/common/src/services/decision/proposalDataSchema.ts b/packages/common/src/services/decision/proposalDataSchema.ts index 803294f0a..f0ebf1d7e 100644 --- a/packages/common/src/services/decision/proposalDataSchema.ts +++ b/packages/common/src/services/decision/proposalDataSchema.ts @@ -1,5 +1,32 @@ import { z } from 'zod'; +import { type MoneyAmount, moneyAmountSchema } from '../../money'; + +/** + * Budget stored as `MoneyAmount` (`{ amount, currency }`) in proposalData. + * + * Accepts two input shapes and normalizes to `MoneyAmount`: + * - `{ amount, currency }` — canonical + * - plain number or numeric string — legacy, defaults to USD + */ +export const budgetValueSchema = z + .union([ + // Canonical shape + moneyAmountSchema, + // Legacy: plain number → { amount, currency: 'USD' } + z + .union([z.string(), z.number()]) + .pipe(z.coerce.number()) + .transform((n) => ({ amount: n, currency: 'USD' })), + ]) + .nullish(); + +/** + * Canonical budget shape — an alias for `MoneyAmount`. + * @deprecated Prefer `MoneyAmount` for new code. + */ +export type BudgetData = MoneyAmount; + /** * Zod schema for proposal data with known fields. * Uses looseObject to allow additional fields from custom proposal templates. @@ -11,7 +38,7 @@ export const proposalDataSchema = z description: z.string().nullish(), content: z.string().nullish(), // backward compatibility category: z.string().nullish(), - budget: z.union([z.string(), z.number()]).pipe(z.coerce.number()).nullish(), + budget: budgetValueSchema, attachmentIds: z .array(z.string()) .nullish() @@ -33,6 +60,26 @@ export type ProposalData = z.infer; /** Input type for proposal data (before parsing/defaults) */ export type ProposalDataInput = z.input; +/** + * Normalize a raw budget value into a `MoneyAmount` using `budgetValueSchema`. + * Accepts `{ amount, currency }`, `{ value, currency }` (legacy), a plain + * number, or a numeric string. + */ +export function normalizeBudget(raw: unknown): BudgetData | undefined { + const result = budgetValueSchema.safeParse(raw); + return result.success ? (result.data ?? undefined) : undefined; +} + +/** + * Extract the numeric value from any budget representation. + * Handles `BudgetData`, legacy plain numbers, and numeric strings. + * Returns 0 when the input can't be parsed. + */ +export function extractBudgetValue(raw: unknown): number { + const budget = normalizeBudget(raw); + return budget?.amount ?? 0; +} + /** * Safely parse proposal data with fallback. * Returns typed ProposalData on success, or preserves raw input fields on failure. @@ -55,7 +102,7 @@ export function parseProposalData(proposalData: unknown): ProposalData { description: (raw.description as string) ?? undefined, content: (raw.content as string) ?? undefined, category: (raw.category as string) ?? undefined, - budget: (raw.budget as number) ?? undefined, + budget: normalizeBudget(raw.budget), attachmentIds: (raw.attachmentIds as string[]) ?? [], collaborationDocId: (raw.collaborationDocId as string) ?? undefined, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f521f405..ac4fc4122 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1279,6 +1279,9 @@ importers: '@op/typescript-config': specifier: workspace:* version: link:../../configs/typescript-config + '@rjsf/utils': + specifier: ^6.3.1 + version: 6.3.1(react@19.2.1) '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -1607,6 +1610,9 @@ importers: '@playwright/test': specifier: ^1.52.0 version: 1.57.0 + '@rjsf/utils': + specifier: ^6.3.1 + version: 6.3.1(react@19.2.1) '@supabase/supabase-js': specifier: ^2.49.3 version: 2.49.3 @@ -4425,6 +4431,12 @@ packages: peerDependencies: react: ^19.0.1 + '@rjsf/utils@6.3.1': + resolution: {integrity: sha512-ve2KHl1ITYG8QIonnuK83/T1k/5NuxP4D1egVqP9Hz2ub28kgl0rNMwmRSxXs3WIbCcMW9g3ox+daVrbSNc4Mw==} + engines: {node: '>=20'} + peerDependencies: + react: ^19.0.1 + '@rjsf/validator-ajv8@5.24.12': resolution: {integrity: sha512-IMXdCjvDNdvb+mDgZC3AlAtr0pjYKq5s0GcLECjG5PuiX7Ib4JaDQHZY5ZJdKblMfgzhsn8AAOi573jXAt7BHQ==} engines: {node: '>=14'} @@ -5653,6 +5665,9 @@ packages: resolution: {integrity: sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==} engines: {node: '>=8'} + '@x0k/json-schema-merge@1.0.2': + resolution: {integrity: sha512-1734qiJHNX3+cJGDMMw2yz7R+7kpbAtl5NdPs1c/0gO5kYT6s4dMbLXiIfpZNsOYhGZI3aH7FWrj4Zxz7epXNg==} + '@xyflow/react@12.5.2': resolution: {integrity: sha512-J5LvWedPVNT+gtSOfP88NgyP+IpR5SvluFJt820K9a5GsG8oCpTzTd0k7k3hWkgqtQWxYyVfwkAAZ0dBeyFtFA==} peerDependencies: @@ -6747,6 +6762,9 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.3.2: resolution: {integrity: sha512-n8v8b6p4Z1sMgqRmqLJm3awW4NX7NkaKPfb3uJIBTSH7Pdvufi3PQ3/lJLQrvxcMYl7JI2jnDO90siPEpD8JBA==} hasBin: true @@ -7572,6 +7590,9 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -13391,6 +13412,16 @@ snapshots: react: 19.2.1 react-is: 18.3.1 + '@rjsf/utils@6.3.1(react@19.2.1)': + dependencies: + '@x0k/json-schema-merge': 1.0.2 + fast-uri: 3.1.0 + jsonpointer: 5.0.1 + lodash: 4.17.21 + lodash-es: 4.17.23 + react: 19.2.1 + react-is: 18.3.1 + '@rjsf/validator-ajv8@5.24.12(@rjsf/utils@5.24.12(react@19.2.1))': dependencies: '@rjsf/utils': 5.24.12(react@19.2.1) @@ -14658,6 +14689,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@x0k/json-schema-merge@1.0.2': + dependencies: + '@types/json-schema': 7.0.15 + '@xyflow/react@12.5.2(@types/react@19.0.10)(immer@10.1.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1))': dependencies: '@xyflow/system': 0.0.53 @@ -15667,6 +15702,8 @@ snapshots: fast-uri@3.0.6: {} + fast-uri@3.1.0: {} + fast-xml-parser@5.3.2: dependencies: strnum: 2.1.1 @@ -16521,6 +16558,8 @@ snapshots: lodash-es@4.17.21: {} + lodash-es@4.17.23: {} + lodash.camelcase@4.3.0: {} lodash.includes@4.3.0: {} diff --git a/services/api/package.json b/services/api/package.json index 216c8b7a9..1c68ec0c6 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -68,6 +68,7 @@ "devDependencies": { "@op/test": "workspace:*", "@op/typescript-config": "workspace:*", + "@rjsf/utils": "^6.3.1", "@types/jsonwebtoken": "^9.0.10", "jsonwebtoken": "^9.0.2", "typescript": "5.7.3", diff --git a/services/api/src/routers/decision/proposals/get.test.ts b/services/api/src/routers/decision/proposals/get.test.ts index a312f2f25..ff457f94f 100644 --- a/services/api/src/routers/decision/proposals/get.test.ts +++ b/services/api/src/routers/decision/proposals/get.test.ts @@ -1,10 +1,18 @@ import { mockCollab } from '@op/collab/testing'; import { db } from '@op/db/client'; -import { Visibility, proposals } from '@op/db/schema'; +import { + Visibility, + decisionProcesses, + processInstances, + proposals, +} from '@op/db/schema'; import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { appRouter } from '../..'; +import { transformFormDataToProcessSchema as cowopSchema } from '../../../../../../apps/app/src/components/Profile/CreateDecisionProcessModal/schemas/cowop'; +import { transformFormDataToProcessSchema as horizonSchema } from '../../../../../../apps/app/src/components/Profile/CreateDecisionProcessModal/schemas/horizon'; +import { transformFormDataToProcessSchema as simpleSchema } from '../../../../../../apps/app/src/components/Profile/CreateDecisionProcessModal/schemas/simple'; import { TestDecisionsDataManager } from '../../../test/helpers/TestDecisionsDataManager'; import { createIsolatedSession, @@ -58,7 +66,12 @@ describe.concurrent('getProposal', () => { expect(result.id).toBe(proposal.id); expect(result.profileId).toBe(proposal.profileId); expect(result.processInstanceId).toBe(instance.instance.id); - expect(result.proposalData).toMatchObject(proposalData); + expect(result.proposalData).toMatchObject({ + title: 'Community Garden Project', + description: 'A proposal to create a community garden in the park', + budget: { amount: 5000, currency: 'USD' }, + timeline: '3 months', + }); }); it('should include isEditable for admin users', async ({ @@ -446,6 +459,341 @@ describe.concurrent('getProposal', () => { }); }); + /** + * Legacy schema compatibility tests. + * + * The cowop, horizon, and simple decision templates store the proposalTemplate + * in `decision_processes.process_schema` (not in `instanceData`). The template + * defines budget as `{ type: 'number' }` with no `x-field-order`. The resolver + * (`resolveProposalTemplate`) falls back to `process_schema.proposalTemplate` + * when `instanceData.proposalTemplate` is absent. + * + * These tests simulate the production layout: legacy process_schema with + * proposalTemplate, no proposalTemplate in instanceData, and proposal data + * stored as either a plain number or an `{ amount, currency }` object. + * + * @see https://github.com/oneprojectorg/common/pull/601#discussion_r2803602140 + */ + it('should parse legacy cowop proposal via process_schema fallback', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // 1. Generate the legacy cowop process_schema using the actual schema function. + // Budget is { type: 'number' }, no x-field-order — matching production. + const cowopProcessSchema = cowopSchema({ + processName: 'COWOP Democratic Budgeting', + totalBudget: 100000, + budgetCapAmount: 10000, + requireBudget: true, + categories: ['Infrastructure', 'Education'], + }); + + await db + .update(decisionProcesses) + .set({ processSchema: cowopProcessSchema }) + .where(eq(decisionProcesses.id, setup.process.id)); + + // 2. Remove proposalTemplate from instanceData so resolveProposalTemplate + // falls back to process_schema (matching how legacy instances work). + const instanceRecord = await db._query.processInstances.findFirst({ + where: eq(processInstances.id, instance.instance.id), + }); + + if (!instanceRecord) { + throw new Error('Instance record not found'); + } + + const { proposalTemplate: _, ...instanceDataWithoutTemplate } = + instanceRecord.instanceData as Record; + + await db + .update(processInstances) + .set({ instanceData: instanceDataWithoutTemplate }) + .where(eq(processInstances.id, instance.instance.id)); + + // 3. Create proposal and simulate legacy data with budget as plain number + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Cowop Legacy Proposal' }, + }); + + await db + .update(proposals) + .set({ + proposalData: { + title: 'Cowop Legacy Proposal', + description: 'A community garden project', + budget: 7500, + category: 'Infrastructure', + collaborationDocId: null, + }, + }) + .where(eq(proposals.id, proposal.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + const result = await caller.decision.getProposal({ + profileId: proposal.profileId, + }); + + expect(result.id).toBe(proposal.id); + expect(result.proposalData).toMatchObject({ + title: 'Cowop Legacy Proposal', + description: 'A community garden project', + budget: { amount: 7500, currency: 'USD' }, + category: 'Infrastructure', + }); + + // Verify the proposalTemplate was resolved from process_schema + expect(result.proposalTemplate).toMatchObject({ + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + budget: { type: 'number', maximum: 10000 }, + }, + }); + }); + + it('should parse legacy horizon proposal via process_schema fallback', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Generate the legacy horizon process_schema using the actual schema function. + // Horizon: no categories, budget not required. + const horizonProcessSchema = horizonSchema({ + processName: 'Horizon Scanning', + totalBudget: 50000, + budgetCapAmount: 50000, + requireBudget: false, + categories: [], + }); + + await db + .update(decisionProcesses) + .set({ processSchema: horizonProcessSchema }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const instanceRecord = await db._query.processInstances.findFirst({ + where: eq(processInstances.id, instance.instance.id), + }); + + if (!instanceRecord) { + throw new Error('Instance record not found'); + } + + const { proposalTemplate: _, ...instanceDataWithoutTemplate } = + instanceRecord.instanceData as Record; + + await db + .update(processInstances) + .set({ instanceData: instanceDataWithoutTemplate }) + .where(eq(processInstances.id, instance.instance.id)); + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Horizon Legacy Proposal' }, + }); + + await db + .update(proposals) + .set({ + proposalData: { + title: 'Horizon Legacy Proposal', + description: 'A horizon scanning project', + budget: 25000, + collaborationDocId: null, + }, + }) + .where(eq(proposals.id, proposal.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + const result = await caller.decision.getProposal({ + profileId: proposal.profileId, + }); + + expect(result.id).toBe(proposal.id); + expect(result.proposalData).toMatchObject({ + title: 'Horizon Legacy Proposal', + description: 'A horizon scanning project', + budget: { amount: 25000, currency: 'USD' }, + }); + + // Verify the proposalTemplate was resolved from process_schema + expect(result.proposalTemplate).toMatchObject({ + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + budget: { type: 'number', maximum: 50000 }, + }, + }); + }); + + it('should parse legacy simple proposal via process_schema fallback', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Generate the legacy simple process_schema using the actual schema function. + // Simple: has categories, budget required. + const simpleProcessSchema = simpleSchema({ + processName: 'Simple Voting', + totalBudget: 25000, + budgetCapAmount: 25000, + requireBudget: true, + categories: ['Community', 'Environment'], + }); + + await db + .update(decisionProcesses) + .set({ processSchema: simpleProcessSchema }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const instanceRecord = await db._query.processInstances.findFirst({ + where: eq(processInstances.id, instance.instance.id), + }); + + if (!instanceRecord) { + throw new Error('Instance record not found'); + } + + const { proposalTemplate: _, ...instanceDataWithoutTemplate } = + instanceRecord.instanceData as Record; + + await db + .update(processInstances) + .set({ instanceData: instanceDataWithoutTemplate }) + .where(eq(processInstances.id, instance.instance.id)); + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Simple Legacy Proposal' }, + }); + + await db + .update(proposals) + .set({ + proposalData: { + title: 'Simple Legacy Proposal', + description: 'A simple voting proposal', + budget: 12000, + category: 'Community', + collaborationDocId: null, + }, + }) + .where(eq(proposals.id, proposal.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + const result = await caller.decision.getProposal({ + profileId: proposal.profileId, + }); + + expect(result.id).toBe(proposal.id); + expect(result.proposalData).toMatchObject({ + title: 'Simple Legacy Proposal', + description: 'A simple voting proposal', + budget: { amount: 12000, currency: 'USD' }, + category: 'Community', + }); + + // Verify the proposalTemplate was resolved from process_schema + expect(result.proposalTemplate).toMatchObject({ + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + budget: { type: 'number', maximum: 25000 }, + }, + }); + }); + + it('should normalize legacy plain-number budget to {amount, currency} object', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + 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: 'Plain Number Budget' }, + }); + + // Simulate a legacy proposal where budget was stored as a plain number + // (the shape legacy schemas produce with budget: { type: 'number' }) + await db + .update(proposals) + .set({ + proposalData: { + title: 'Plain Number Budget', + description: 'Budget stored as raw number', + budget: 3000, + collaborationDocId: null, + }, + }) + .where(eq(proposals.id, proposal.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + const result = await caller.decision.getProposal({ + profileId: proposal.profileId, + }); + + expect(result.id).toBe(proposal.id); + // Plain number should be normalized to { amount, currency: 'USD' } + expect(result.proposalData).toMatchObject({ + title: 'Plain Number Budget', + budget: { amount: 3000, currency: 'USD' }, + }); + }); + it('should return proposal with attachments when attachments exist', async ({ task, onTestFinished, diff --git a/services/api/src/routers/decision/proposals/list.test.ts b/services/api/src/routers/decision/proposals/list.test.ts index efd2adf21..026fa113a 100644 --- a/services/api/src/routers/decision/proposals/list.test.ts +++ b/services/api/src/routers/decision/proposals/list.test.ts @@ -1,8 +1,16 @@ import { mockCollab } from '@op/collab/testing'; -import { Visibility } from '@op/db/schema'; +import { db } from '@op/db/client'; +import { + Visibility, + decisionProcesses, + processInstances, + proposals, +} from '@op/db/schema'; +import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { appRouter } from '../..'; +import { transformFormDataToProcessSchema as cowopSchema } from '../../../../../../apps/app/src/components/Profile/CreateDecisionProcessModal/schemas/cowop'; import { TestDecisionsDataManager } from '../../../test/helpers/TestDecisionsDataManager'; import { createIsolatedSession, @@ -821,4 +829,230 @@ describe.concurrent('listProposals', () => { // Empty proposal has a collaborationDocId but no mock response, so documentContent is undefined expect(foundEmpty?.documentContent).toBeUndefined(); }); + + /** + * Legacy cowop process_schema fallback with mixed budget formats. + * + * Simulates production layout: proposalTemplate lives in + * `decision_processes.process_schema` (not instanceData), proposals have + * plain-number budgets and the old `content` field instead of `description`. + */ + it('should list legacy cowop proposals with budget normalization, content→description compat, and proposalTemplate from process_schema', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // 1. Set legacy cowop process_schema on the decision process + const cowopProcessSchema = cowopSchema({ + processName: 'COWOP Democratic Budgeting', + totalBudget: 100000, + budgetCapAmount: 10000, + requireBudget: true, + categories: ['Infrastructure', 'Education'], + }); + + await db + .update(decisionProcesses) + .set({ processSchema: cowopProcessSchema }) + .where(eq(decisionProcesses.id, setup.process.id)); + + // 2. Strip proposalTemplate from instanceData so resolver falls back to process_schema + const instanceRecord = await db._query.processInstances.findFirst({ + where: eq(processInstances.id, instance.instance.id), + }); + + if (!instanceRecord) { + throw new Error('Instance record not found'); + } + + const { proposalTemplate: _, ...instanceDataWithoutTemplate } = + instanceRecord.instanceData as Record; + + await db + .update(processInstances) + .set({ instanceData: instanceDataWithoutTemplate }) + .where(eq(processInstances.id, instance.instance.id)); + + // 3. Create proposals and raw-patch their data to simulate legacy DB state + const [proposalA, proposalB] = await Promise.all([ + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Legacy A' }, + }), + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Legacy B' }, + }), + ]); + + await Promise.all([ + // Plain-number budget + old `content` field (no `description`) + db + .update(proposals) + .set({ + proposalData: { + title: 'Legacy A', + content: '

body from content field

', + budget: 7500, + category: 'Infrastructure', + collaborationDocId: null, + }, + }) + .where(eq(proposals.id, proposalA.id)), + // Canonical { amount, currency } budget (new format already in DB) + db + .update(proposals) + .set({ + proposalData: { + title: 'Legacy B', + description: '

already migrated

', + budget: { amount: 4200, currency: 'EUR' }, + category: 'Education', + collaborationDocId: null, + }, + }) + .where(eq(proposals.id, proposalB.id)), + ]); + + const caller = await createAuthenticatedCaller(setup.userEmail); + const result = await caller.decision.listProposals({ + processInstanceId: instance.instance.id, + }); + + expect(result.proposals).toHaveLength(2); + + const foundA = result.proposals.find((p) => p.id === proposalA.id); + const foundB = result.proposals.find((p) => p.id === proposalB.id); + + // Plain number → { amount, currency: 'USD' } + expect(foundA?.proposalData).toMatchObject({ + title: 'Legacy A', + description: '

body from content field

', + budget: { amount: 7500, currency: 'USD' }, + category: 'Infrastructure', + }); + // content→description backward compat + expect(foundA?.documentContent).toEqual({ + type: 'html', + content: '

body from content field

', + }); + + // Canonical budget passes through unchanged + expect(foundB?.proposalData).toMatchObject({ + title: 'Legacy B', + budget: { amount: 4200, currency: 'EUR' }, + category: 'Education', + }); + expect(foundB?.documentContent).toEqual({ + type: 'html', + content: '

already migrated

', + }); + }); + + it('should normalize budgets correctly when listing mixed new-schema and legacy proposals', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Create proposals via API (new schema — gets collaborationDocId) + const [newSchemaProposal, legacyProposal] = await Promise.all([ + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'New Schema' }, + }), + testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Legacy' }, + }), + ]); + + // Raw-patch legacy proposal to simulate old DB state: + // plain-number budget, `content` instead of `description`, custom field, no collaborationDocId + await db + .update(proposals) + .set({ + proposalData: { + title: 'Legacy', + content: '

old content field

', + budget: 9999, + collaborationDocId: null, + customField: 'should survive', + }, + }) + .where(eq(proposals.id, legacyProposal.id)); + + // Set up TipTap mock for the new-schema proposal + const { collaborationDocId } = newSchemaProposal.proposalData as { + collaborationDocId: string; + }; + const mockContent = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'TipTap' }] }, + ], + }; + mockCollab.setDocResponse(collaborationDocId, mockContent); + + const caller = await createAuthenticatedCaller(setup.userEmail); + const result = await caller.decision.listProposals({ + processInstanceId: instance.instance.id, + }); + + expect(result.proposals).toHaveLength(2); + + const foundNew = result.proposals.find( + (p) => p.id === newSchemaProposal.id, + ); + const foundLegacy = result.proposals.find( + (p) => p.id === legacyProposal.id, + ); + + // New-schema: collaborationDocId present, TipTap content + expect(foundNew?.proposalData).toMatchObject({ + title: 'New Schema', + collaborationDocId: expect.any(String), + }); + expect(foundNew?.documentContent).toEqual({ + type: 'json', + fragments: { default: mockContent }, + }); + + // Legacy: budget normalized, content→description, custom field preserved + expect(foundLegacy?.proposalData).toMatchObject({ + title: 'Legacy', + description: '

old content field

', + budget: { amount: 9999, currency: 'USD' }, + customField: 'should survive', + }); + expect(foundLegacy?.documentContent).toEqual({ + type: 'html', + content: '

old content field

', + }); + }); }); diff --git a/services/collab/__mocks__/index.ts b/services/collab/__mocks__/index.ts index 41024ba2f..a8137df06 100644 --- a/services/collab/__mocks__/index.ts +++ b/services/collab/__mocks__/index.ts @@ -63,10 +63,6 @@ const E2E_FIXTURE_CONTENT: TipTapDocument = { }, ], }, - { - type: 'iframely', - attrs: { src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, - }, { type: 'horizontalRule' }, { type: 'paragraph', diff --git a/tests/core/src/decision-data.ts b/tests/core/src/decision-data.ts index 6a38bd20b..1afcc5492 100644 --- a/tests/core/src/decision-data.ts +++ b/tests/core/src/decision-data.ts @@ -383,7 +383,7 @@ export interface CreateProposalOptions { title: string; description?: string; collaborationDocId?: string; - budget?: number; + budget?: number | { amount: number; currency: string }; category?: string; }; /** Proposal status (defaults to DRAFT to match production behavior) */ diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 99b68256b..0865c4ed6 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -23,6 +23,7 @@ "@op/test": "workspace:*", "@op/typescript-config": "workspace:*", "@playwright/test": "^1.52.0", + "@rjsf/utils": "^6.3.1", "@supabase/supabase-js": "^2.49.3", "dotenv": "^16.4.7", "drizzle-orm": "catalog:" diff --git a/tests/e2e/tests/proposal-listing.spec.ts b/tests/e2e/tests/proposal-listing.spec.ts new file mode 100644 index 000000000..1df60f951 --- /dev/null +++ b/tests/e2e/tests/proposal-listing.spec.ts @@ -0,0 +1,404 @@ +import { + EntityType, + ProcessStatus, + decisionProcesses, + processInstances, + profileUserToAccessRoles, + profileUsers, + profiles, +} from '@op/db/schema'; +import { ROLES } from '@op/db/seedData/accessControl'; +import { db } from '@op/db/test'; +import { createProposal } from '@op/test'; +import { randomUUID } from 'node:crypto'; + +import { transformFormDataToProcessSchema as cowopSchema } from '../../../apps/app/src/components/Profile/CreateDecisionProcessModal/schemas/cowop'; +import { expect, test } from '../fixtures/index.js'; + +/** + * The collab mock (@op/collab/testing) pre-seeds this doc ID with fixture + * content (bold/italic text, list items, links, etc.). Any other ID 404s. + */ +const MOCK_DOC_ID = 'test-proposal-doc'; + +/** + * Helper to create a decision process, instance, profile, and admin access + * in one shot. Returns everything needed to create proposals and navigate. + */ +async function createProcessAndInstance({ + org, + processSchema, + instanceData, + processName, +}: { + org: { + organizationProfile: { id: string }; + adminUser: { authUserId: string; email: string }; + }; + processSchema: Record; + instanceData: Record; + processName: string; +}) { + const [process] = await db + .insert(decisionProcesses) + .values({ + name: processName, + description: `${processName} for e2e listing test`, + processSchema, + createdByProfileId: org.organizationProfile.id, + }) + .returning(); + + if (!process) { + throw new Error(`Failed to create process: ${processName}`); + } + + const slug = `test-listing-${randomUUID()}`; + const name = `${processName} ${randomUUID().slice(0, 8)}`; + + const [profile] = await db + .insert(profiles) + .values({ name, slug, type: EntityType.DECISION }) + .returning(); + + if (!profile) { + throw new Error('Failed to create instance profile'); + } + + const [instance] = await db + .insert(processInstances) + .values({ + name, + processId: process.id, + profileId: profile.id, + instanceData, + currentStateId: + (instanceData as { currentPhaseId?: string }).currentPhaseId ?? + 'proposalSubmission', + status: ProcessStatus.PUBLISHED, + ownerProfileId: org.organizationProfile.id, + }) + .returning(); + + if (!instance) { + throw new Error('Failed to create process instance'); + } + + const [profileUser] = await db + .insert(profileUsers) + .values({ + profileId: profile.id, + authUserId: org.adminUser.authUserId, + email: org.adminUser.email, + }) + .returning(); + + if (profileUser) { + await db.insert(profileUserToAccessRoles).values({ + profileUserId: profileUser.id, + accessRoleId: ROLES.ADMIN.id, + }); + } + + return { process, instance, profile, slug, name }; +} + +test.describe('Proposal Listing', () => { + /** + * New-schema process: proposalTemplate lives in instanceData (the standard + * path for recently-created decision instances). Budget uses the new + * `{ amount, currency }` object format with `x-format: 'money'`. + */ + test('lists proposals for a new-schema decision instance', async ({ + authenticatedPage, + org, + }) => { + // 1. Create a new-schema process with proposalTemplate using x-format: 'money' + const newProcessSchema = { + id: 'new-schema-listing', + version: '1.0.0', + name: 'New Schema Listing Test', + description: 'Modern process with budget as money object', + phases: [ + { + id: 'proposalSubmission', + name: 'Proposal Submission', + description: 'Submit proposals', + rules: { + proposals: { submit: true }, + voting: { submit: false }, + advancement: { method: 'manual' as const }, + }, + }, + { + id: 'review', + name: 'Review', + description: 'Review proposals', + rules: { + proposals: { submit: false }, + voting: { submit: false }, + advancement: { method: 'manual' as const }, + }, + }, + ], + proposalTemplate: { + type: 'object', + required: ['title'], + 'x-field-order': ['title', 'summary', 'budget'], + properties: { + title: { type: 'string' }, + summary: { type: 'string', 'x-format': 'long-text' }, + budget: { + type: 'object', + 'x-format': 'money', + properties: { + amount: { type: 'number' }, + currency: { type: 'string' }, + }, + }, + }, + }, + }; + + // instanceData includes the proposalTemplate (new-schema path) + const newInstanceData = { + currentPhaseId: 'proposalSubmission', + budget: 50000, + hideBudget: false, + proposalTemplate: newProcessSchema.proposalTemplate, + phases: [ + { + phaseId: 'proposalSubmission', + startDate: '2025-09-20', + endDate: '2025-10-01', + }, + { + phaseId: 'review', + startDate: '2025-10-02', + endDate: '2025-10-20', + }, + ], + }; + + const { instance, slug, name } = await createProcessAndInstance({ + org, + processSchema: newProcessSchema, + instanceData: newInstanceData, + processName: 'New Schema Listing', + }); + + // 2. Create two proposals with new-format budgets and collaborationDocId + await createProposal({ + processInstanceId: instance.id, + submittedByProfileId: org.organizationProfile.id, + proposalData: { + title: 'Community Garden Project', + collaborationDocId: MOCK_DOC_ID, + budget: { amount: 8000, currency: 'USD' }, + }, + }); + + await createProposal({ + processInstanceId: instance.id, + submittedByProfileId: org.organizationProfile.id, + proposalData: { + title: 'Youth Mentorship Program', + collaborationDocId: MOCK_DOC_ID, + budget: { amount: 12500, currency: 'EUR' }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 3. Navigate with ?filter=all (default is "Shortlisted" which hides drafts) + await authenticatedPage.goto(`/en/decisions/${slug}?filter=all`, { + waitUntil: 'networkidle', + }); + + // Decision heading renders + await expect(authenticatedPage.getByRole('heading', { name })).toBeVisible({ + timeout: 30_000, + }); + + // Both proposal titles appear as links in the listing + await expect( + authenticatedPage.getByRole('link', { name: 'Community Garden Project' }), + ).toBeVisible({ timeout: 15_000 }); + + await expect( + authenticatedPage.getByRole('link', { + name: 'Youth Mentorship Program', + }), + ).toBeVisible(); + + // Budget values rendered with correct formatting + await expect(authenticatedPage.getByText('$8,000')).toBeVisible(); + await expect(authenticatedPage.getByText('€12,500')).toBeVisible(); + + // Card preview renders text from the collab mock fixture. + // The mock returns TipTap JSON with "Bold text and italic text..." content. + await expect( + authenticatedPage.getByText('Bold text').first(), + ).toBeVisible(); + }); + + /** + * Legacy COWOP process: proposalTemplate lives in + * `decision_processes.process_schema` (NOT in instanceData). Budget is stored + * as a plain number and must be normalised to { amount, currency: 'USD' }. + * + * This mirrors real production COWOP data where older processes never had + * proposalTemplate at the instance level. + * + * @see https://github.com/oneprojectorg/common/pull/601#discussion_r2803602140 + */ + test('lists proposals for a legacy cowop process with budget from process_schema', async ({ + authenticatedPage, + org, + }) => { + // 1. Build a COWOP process schema from the actual legacy cowop schema fn. + // Budget is { type: 'number' }, no x-field-order, no x-format. + const cowopLegacySchema = cowopSchema({ + processName: 'COWOP Listing Test', + totalBudget: 100000, + budgetCapAmount: 100000, + requireBudget: true, + categories: [ + 'Ai. Direct funding to worker-owned co-ops.', + 'Bv. Support regional co-op organizing groups.', + 'other', + ], + }); + + // Wrap in a DecisionSchemaDefinition envelope so the + // decisionSchemaDefinitionEncoder doesn't reject it. + const cowopProcessSchema = { + id: 'cowop-listing-test', + version: '1.0.0', + name: cowopLegacySchema.name, + description: cowopLegacySchema.description, + phases: [ + { + id: 'ideaCollection', + name: 'Proposal Concept Generation', + description: 'Submit proposal concepts', + rules: { + proposals: { submit: true }, + voting: { submit: false }, + advancement: { method: 'manual' as const }, + }, + }, + { + id: 'submission', + name: 'Proposal Development', + description: 'Develop proposals', + rules: { + proposals: { submit: false }, + voting: { submit: false }, + advancement: { method: 'manual' as const }, + }, + }, + ], + proposalTemplate: cowopLegacySchema.proposalTemplate, + }; + + // COWOP-style instanceData — no proposalTemplate, has fieldValues + const cowopInstanceData = { + currentPhaseId: 'ideaCollection', + budget: 100000, + hideBudget: false, + phases: [ + { + phaseId: 'ideaCollection', + startDate: '2025-09-20', + endDate: '2025-10-01', + }, + { + phaseId: 'submission', + startDate: '2025-10-02', + endDate: '2025-10-20', + }, + ], + fieldValues: { + categories: [ + 'Ai. Direct funding to worker-owned co-ops.', + 'Bv. Support regional co-op organizing groups.', + 'other', + ], + budgetCapAmount: 100000, + }, + }; + + const { instance, slug, name } = await createProcessAndInstance({ + org, + processSchema: cowopProcessSchema, + instanceData: cowopInstanceData, + processName: 'COWOP Listing', + }); + + // 2. Create two proposals with legacy plain-number budgets + await createProposal({ + processInstanceId: instance.id, + submittedByProfileId: org.organizationProfile.id, + proposalData: { + title: 'Worker Co-op Equipment Fund', + description: + '

Requesting funds for equipment upgrades.

', + budget: 15000, + category: 'Ai. Direct funding to worker-owned co-ops.', + }, + }); + + await createProposal({ + processInstanceId: instance.id, + submittedByProfileId: org.organizationProfile.id, + proposalData: { + title: 'Regional Organizer Training', + description: + '

Training program for regional co-op organizers.

', + budget: 25000, + category: 'Bv. Support regional co-op organizing groups.', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 3. Navigate with ?filter=all (default is "Shortlisted" which hides drafts) + await authenticatedPage.goto(`/en/decisions/${slug}?filter=all`, { + waitUntil: 'networkidle', + }); + + // Decision heading renders + await expect(authenticatedPage.getByRole('heading', { name })).toBeVisible({ + timeout: 30_000, + }); + + // Both proposal titles appear as links + await expect( + authenticatedPage.getByRole('link', { + name: 'Worker Co-op Equipment Fund', + }), + ).toBeVisible({ timeout: 15_000 }); + + await expect( + authenticatedPage.getByRole('link', { + name: 'Regional Organizer Training', + }), + ).toBeVisible(); + + // Legacy plain-number budgets (15000, 25000) normalised to USD and + // rendered as "$15,000" and "$25,000" + await expect(authenticatedPage.getByText('$15,000')).toBeVisible(); + await expect(authenticatedPage.getByText('$25,000')).toBeVisible(); + + // Legacy HTML descriptions render as text preview in the card + await expect( + authenticatedPage.getByText('Requesting funds for equipment upgrades.'), + ).toBeVisible(); + await expect( + authenticatedPage.getByText( + 'Training program for regional co-op organizers.', + ), + ).toBeVisible(); + }); +}); diff --git a/tests/e2e/tests/proposal-view.spec.ts b/tests/e2e/tests/proposal-view.spec.ts index e450493fb..2679dcc3e 100644 --- a/tests/e2e/tests/proposal-view.spec.ts +++ b/tests/e2e/tests/proposal-view.spec.ts @@ -1,9 +1,22 @@ +import { + EntityType, + ProcessStatus, + decisionProcesses, + processInstances, + profileUserToAccessRoles, + profileUsers, + profiles, +} from '@op/db/schema'; +import { ROLES } from '@op/db/seedData/accessControl'; +import { db } from '@op/db/test'; import { createDecisionInstance, createProposal, getSeededTemplate, } from '@op/test'; +import { randomUUID } from 'node:crypto'; +import { transformFormDataToProcessSchema as cowopSchema } from '../../../apps/app/src/components/Profile/CreateDecisionProcessModal/schemas/cowop'; import { expect, test } from '../fixtures/index.js'; /** @@ -33,6 +46,7 @@ test.describe('Proposal View', () => { proposalData: { title: 'Community Solar Initiative', collaborationDocId: MOCK_DOC_ID, + budget: { amount: 10000, currency: 'EUR' }, }, }); @@ -68,12 +82,8 @@ test.describe('Proposal View', () => { await expect(link).toBeVisible(); await expect(link).toHaveAttribute('href', 'https://example.com'); - // Iframely embed renders YouTube URL as a link card (no iframely API in e2e) - await expect( - authenticatedPage.locator( - 'a[href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"]', - ), - ).toBeVisible(); + // New-format budget { value: 10000, currency: 'EUR' } rendered as "€10,000" + await expect(authenticatedPage.getByText('€10,000')).toBeVisible(); }); test('renders legacy HTML description when no collaborationDocId exists', async ({ @@ -90,7 +100,8 @@ test.describe('Proposal View', () => { schema: template.processSchema, }); - // Legacy proposal: raw HTML in `description`, no collaborationDocId + // Legacy proposal: raw HTML in `description`, no collaborationDocId, + // plain number budget (pre-currency-object format) const proposal = await createProposal({ processInstanceId: instance.instance.id, submittedByProfileId: org.organizationProfile.id, @@ -102,6 +113,7 @@ test.describe('Proposal View', () => { '
  • First legacy item
  • Second legacy item
', '

Contact us at our website.

', ].join(''), + budget: 5000, }, }); @@ -141,6 +153,10 @@ test.describe('Proposal View', () => { const link = authenticatedPage.locator('a', { hasText: 'our website' }); await expect(link).toBeVisible(); await expect(link).toHaveAttribute('href', 'https://example.org'); + + // Legacy plain-number budget (5000) is normalised to { value: 5000, currency: 'USD' } + // and rendered as "$5,000" via formatCurrency + await expect(authenticatedPage.getByText('$5,000')).toBeVisible(); }); test('renders legacy proposal with old template format and description field', async ({ @@ -219,6 +235,196 @@ test.describe('Proposal View', () => { ).toBeVisible(); }); + /** + * Simulates a real COWOP production instance where the proposalTemplate + * lives in `decision_processes.process_schema` (not instanceData) and + * budget is stored as a plain number. Verifies the full render path: + * resolveProposalTemplate fallback → proposalDataSchema normalization → UI. + * + * @see https://github.com/oneprojectorg/common/pull/601#discussion_r2803602140 + */ + test('renders legacy cowop proposal with budget from process_schema', async ({ + authenticatedPage, + org, + }) => { + // 1. Create a dedicated cowop decision process. + // The proposalTemplate in process_schema has budget: { type: 'number' }, + // matching real COWOP production data. We generate it from the actual + // cowop schema function, then wrap it in a valid DecisionSchemaDefinition + // envelope so the getDecisionBySlug encoder doesn't reject it. + const cowopLegacySchema = cowopSchema({ + processName: 'COWOP Democratic Budgeting', + totalBudget: 100000, + budgetCapAmount: 100000, + requireBudget: true, + categories: [ + 'Ai. Direct funding to worker-owned co-ops.', + 'Bv. Support regional co-op organizing groups.', + 'other', + ], + }); + + const cowopProcessSchema = { + id: 'cowop-legacy', + version: '1.0.0', + name: cowopLegacySchema.name, + description: cowopLegacySchema.description, + phases: [ + { + id: 'ideaCollection', + name: 'Proposal Concept Generation', + description: 'Submit proposal concepts', + rules: { + proposals: { submit: true }, + voting: { submit: false }, + advancement: { method: 'manual' as const }, + }, + }, + { + id: 'submission', + name: 'Proposal Development', + description: 'Develop proposals', + rules: { + proposals: { submit: false }, + voting: { submit: false }, + advancement: { method: 'manual' as const }, + }, + }, + ], + // The legacy proposalTemplate — budget is { type: 'number' }, no x-field-order + proposalTemplate: cowopLegacySchema.proposalTemplate, + }; + + const [cowopProcess] = await db + .insert(decisionProcesses) + .values({ + name: `COWOP Test ${randomUUID().slice(0, 8)}`, + description: 'Legacy cowop process for e2e testing', + processSchema: cowopProcessSchema, + createdByProfileId: org.organizationProfile.id, + }) + .returning(); + + if (!cowopProcess) { + throw new Error('Failed to create cowop process'); + } + + // 2. Create the instance with COWOP-style instanceData (no proposalTemplate). + // Mirrors production: proposalTemplate is only in process_schema. + const instanceSlug = `test-cowop-${randomUUID()}`; + const instanceName = `COWOP Instance ${randomUUID().slice(0, 8)}`; + + const [instanceProfile] = await db + .insert(profiles) + .values({ + name: instanceName, + slug: instanceSlug, + type: EntityType.DECISION, + }) + .returning(); + + if (!instanceProfile) { + throw new Error('Failed to create instance profile'); + } + + // COWOP-style instanceData — no proposalTemplate, has fieldValues. + // Must include currentPhaseId for the instanceDataWithSchemaEncoder. + const cowopInstanceData = { + currentPhaseId: 'ideaCollection', + budget: 100000, + hideBudget: false, + phases: [ + { + phaseId: 'ideaCollection', + startDate: '2025-09-20', + endDate: '2025-10-01', + }, + { + phaseId: 'submission', + startDate: '2025-10-02', + endDate: '2025-10-20', + }, + ], + fieldValues: { + categories: [ + 'Ai. Direct funding to worker-owned co-ops.', + 'Bv. Support regional co-op organizing groups.', + 'other', + ], + budgetCapAmount: 100000, + }, + }; + + const [processInstance] = await db + .insert(processInstances) + .values({ + name: instanceName, + processId: cowopProcess.id, + profileId: instanceProfile.id, + instanceData: cowopInstanceData, + currentStateId: 'ideaCollection', + status: ProcessStatus.PUBLISHED, + ownerProfileId: org.organizationProfile.id, + }) + .returning(); + + if (!processInstance) { + throw new Error('Failed to create process instance'); + } + + // 3. Grant admin access + const [profileUser] = await db + .insert(profileUsers) + .values({ + profileId: instanceProfile.id, + authUserId: org.adminUser.authUserId, + email: org.adminUser.email, + }) + .returning(); + + if (profileUser) { + await db.insert(profileUserToAccessRoles).values({ + profileUserId: profileUser.id, + accessRoleId: ROLES.ADMIN.id, + }); + } + + // 4. Create a proposal with legacy plain-number budget and description + const proposal = await createProposal({ + processInstanceId: processInstance.id, + submittedByProfileId: org.organizationProfile.id, + proposalData: { + title: 'Worker Co-op Equipment Fund', + description: + '

Requesting funds for equipment upgrades to support our worker-owned bakery.

', + budget: 15000, + category: 'Ai. Direct funding to worker-owned co-ops.', + }, + }); + + await authenticatedPage.goto( + `/en/decisions/${instanceSlug}/proposal/${proposal.profileId}`, + ); + + // Title renders + await expect( + authenticatedPage.getByRole('heading', { + name: 'Worker Co-op Equipment Fund', + }), + ).toBeVisible({ timeout: 30_000 }); + + // Legacy plain-number budget (15000) normalised to { amount: 15000, currency: 'USD' } + // and rendered as "$15,000" + await expect(authenticatedPage.getByText('$15,000')).toBeVisible(); + + // Description content renders + await expect( + authenticatedPage.locator('strong', { + hasText: 'equipment upgrades', + }), + ).toBeVisible(); + }); + test('handles missing document gracefully', async ({ authenticatedPage, org,