diff --git a/packages/common/src/services/decision/createProposal.ts b/packages/common/src/services/decision/createProposal.ts index 4119c5ac5..01647166d 100644 --- a/packages/common/src/services/decision/createProposal.ts +++ b/packages/common/src/services/decision/createProposal.ts @@ -9,7 +9,6 @@ import { proposalAttachments, proposalCategories, proposals, - taxonomyTerms, } from '@op/db/schema'; import type { User } from '@op/supabase/lib'; import { assertAccess, permission } from 'access-zones'; @@ -26,6 +25,7 @@ import { generateUniqueProfileSlug } from '../profile/utils'; import { processProposalContent } from './proposalContentProcessor'; import type { ProposalDataInput } from './proposalDataSchema'; import type { DecisionInstanceData } from './schemas/instanceData'; +import { ensureProposalTaxonomyTerm } from './utils/ensureProposalTaxonomyTerm'; import { checkProposalsAllowed } from './utils/proposal'; export interface CreateProposalInput { @@ -85,33 +85,9 @@ export const createProposal = async ({ // Extract title from proposal data const proposalTitle = extractTitleFromProposalData(data.proposalData); - // Pre-fetch category term if specified to avoid lookup inside transaction + // Ensure category taxonomy term exists (creates if missing) const categoryLabel = (data.proposalData as any)?.category; - let categoryTermId: string | null = null; - - if (categoryLabel?.trim()) { - try { - const taxonomyTerm = await db._query.taxonomyTerms.findFirst({ - where: eq(taxonomyTerms.label, categoryLabel.trim()), - with: { - taxonomy: true, - }, - }); - - if (taxonomyTerm && taxonomyTerm.taxonomy?.name === 'proposal') { - categoryTermId = taxonomyTerm.id; - } else { - console.warn( - `No valid proposal taxonomy term found for category: ${categoryLabel}`, - ); - } - } catch (error) { - console.warn( - 'Error fetching category term, proceeding without category:', - error, - ); - } - } + const categoryTermId = await ensureProposalTaxonomyTerm(categoryLabel); const profileId = await getCurrentProfileId(authUserId); const proposal = await db.transaction(async (tx) => { diff --git a/packages/common/src/services/decision/updateProposal.ts b/packages/common/src/services/decision/updateProposal.ts index 016aa987b..e8907d94a 100644 --- a/packages/common/src/services/decision/updateProposal.ts +++ b/packages/common/src/services/decision/updateProposal.ts @@ -8,8 +8,6 @@ import { processInstances, proposalCategories, proposals, - taxonomies, - taxonomyTerms, } from '@op/db/schema'; import { User } from '@op/supabase/lib'; import { checkPermission, permission } from 'access-zones'; @@ -24,6 +22,7 @@ import { getOrgAccessUser } from '../access'; import { assertUserByAuthId } from '../assert'; import type { ProposalDataInput } from './proposalDataSchema'; import { schemaValidator } from './schemaValidator'; +import { ensureProposalTaxonomyTerm } from './utils/ensureProposalTaxonomyTerm'; type ProcessInstanceWithProcess = ProcessInstance & { process: DecisionProcess; @@ -42,34 +41,16 @@ async function updateProposalCategoryLink( .delete(proposalCategories) .where(eq(proposalCategories.proposalId, proposalId)); - // Add new category link if provided - if (newCategoryLabel?.trim()) { - // Find the "proposal" taxonomy - const proposalTaxonomy = await db._query.taxonomies.findFirst({ - where: eq(taxonomies.name, 'proposal'), - }); - - if (!proposalTaxonomy) { - console.warn('No "proposal" taxonomy found, skipping category linking'); - return; - } + // Add new category link if provided (creates term if missing) + const categoryTermId = await ensureProposalTaxonomyTerm( + newCategoryLabel ?? '', + ); - // Find the taxonomy term that matches the category label - const taxonomyTerm = await db._query.taxonomyTerms.findFirst({ - where: eq(taxonomyTerms.label, newCategoryLabel.trim()), + if (categoryTermId) { + await db.insert(proposalCategories).values({ + proposalId, + taxonomyTermId: categoryTermId, }); - - if (taxonomyTerm) { - // Create the new link - await db.insert(proposalCategories).values({ - proposalId, - taxonomyTermId: taxonomyTerm.id, - }); - } else { - console.warn( - `No taxonomy term found for category: ${newCategoryLabel}`, - ); - } } } catch (error) { console.error('Error updating proposal category link:', error); diff --git a/packages/common/src/services/decision/utils/ensureProposalTaxonomyTerm.ts b/packages/common/src/services/decision/utils/ensureProposalTaxonomyTerm.ts new file mode 100644 index 000000000..76f3bdf02 --- /dev/null +++ b/packages/common/src/services/decision/utils/ensureProposalTaxonomyTerm.ts @@ -0,0 +1,77 @@ +import { db, eq } from '@op/db/client'; +import { taxonomies, taxonomyTerms } from '@op/db/schema'; + +import { CommonError } from '../../../utils'; + +/** + * Finds or creates a taxonomy term for a proposal category. + * Ensures the "proposal" taxonomy exists, then looks up the term by label. + * If the term doesn't exist, creates it with a normalized URI. + * + * Returns the taxonomyTermId, or null if the label is empty/whitespace. + */ +export async function ensureProposalTaxonomyTerm( + categoryLabel: string, +): Promise { + const trimmed = categoryLabel?.trim(); + if (!trimmed) { + return null; + } + + // Ensure "proposal" taxonomy exists + let proposalTaxonomy = await db._query.taxonomies.findFirst({ + where: eq(taxonomies.name, 'proposal'), + }); + + if (!proposalTaxonomy) { + const [newTaxonomy] = await db + .insert(taxonomies) + .values({ + name: 'proposal', + description: + 'Categories for organizing proposals in decision-making processes', + }) + .returning(); + + if (!newTaxonomy) { + throw new CommonError('Failed to create proposal taxonomy'); + } + proposalTaxonomy = newTaxonomy; + } + + // Look up existing term by label within the proposal taxonomy + const existingTerm = await db._query.taxonomyTerms.findFirst({ + where: eq(taxonomyTerms.label, trimmed), + with: { + taxonomy: true, + }, + }); + + if (existingTerm && existingTerm.taxonomy?.name === 'proposal') { + return existingTerm.id; + } + + // Create a new term + const termUri = trimmed + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + + const [newTerm] = await db + .insert(taxonomyTerms) + .values({ + taxonomyId: proposalTaxonomy.id, + termUri, + label: trimmed, + definition: `Category for ${trimmed} proposals`, + }) + .returning(); + + if (!newTerm) { + throw new CommonError( + `Failed to create taxonomy term for category: ${trimmed}`, + ); + } + + return newTerm.id; +}