From af2b55f086bc4ca99a0dfd882d68312ba79e9afd Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 9 Sep 2026 18:00:49 +1000 Subject: [PATCH] Fix PM-6279 copilot opportunity skill search --- docs/copilot-opportunities.md | 3 +++ .../copilot-opportunity.service.spec.ts | 24 +++++++++++++++++++ .../copilot/copilot-opportunity.service.ts | 8 ++++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/copilot-opportunities.md b/docs/copilot-opportunities.md index 61921b4..6d6580f 100644 --- a/docs/copilot-opportunities.md +++ b/docs/copilot-opportunities.md @@ -4,6 +4,9 @@ used by the Opportunities experience. It remains public and returns the legacy bare response array. Filtering, total count, stable sorting, offset, and limit are performed in PostgreSQL before the selected page's relations are loaded. +Search and skill predicates normalize legacy `json` and current `jsonb` +request-data columns to JSONB at query time, so mixed deployment histories use +the same filtering contract. ## Query parameters diff --git a/src/api/copilot/copilot-opportunity.service.spec.ts b/src/api/copilot/copilot-opportunity.service.spec.ts index 25828bf..e43f024 100644 --- a/src/api/copilot/copilot-opportunity.service.spec.ts +++ b/src/api/copilot/copilot-opportunity.service.spec.ts @@ -352,6 +352,30 @@ describe('CopilotOpportunityService', () => { ); }); + it('casts legacy JSON request data before search and skill JSONB operations', async () => { + prismaMock.copilotOpportunity.findMany.mockResolvedValue([baseOpportunity]); + + await service.listOpportunities( + { + search: 'Cadence SKILL', + skills: ['Cadence SKILL'], + }, + regularUser, + ); + + const sql = prismaMock.$queryRaw.mock.calls[0][0] as Prisma.Sql; + expect(sql.text).toContain( + "LOWER(COALESCE(r.data::jsonb -> 'skills', '[]'::jsonb)::text)", + ); + expect(sql.text).toContain( + "WHEN jsonb_typeof(r.data::jsonb -> 'skills') = 'array'", + ); + expect(sql.text).toContain("THEN r.data::jsonb -> 'skills'"); + expect(sql.values).toEqual( + expect.arrayContaining(['%cadence skill%', 'cadence skill']), + ); + }); + it('requires authentication for current-user application filters', async () => { await expect( service.listOpportunities({ applied: true }, undefined), diff --git a/src/api/copilot/copilot-opportunity.service.ts b/src/api/copilot/copilot-opportunity.service.ts index 8f899da..cc455b1 100644 --- a/src/api/copilot/copilot-opportunity.service.ts +++ b/src/api/copilot/copilot-opportunity.service.ts @@ -902,12 +902,14 @@ export class CopilotOpportunityService { if (filters.search) { const pattern = `%${this.escapeLikePattern(filters.search.toLowerCase())}%`; + // Some upgraded v5 databases retain this column as `json`; cast before + // the JSONB-only skill operations so both physical types are supported. conditions.push(Prisma.sql`( LOWER(COALESCE(r.data ->> 'opportunityTitle', '')) LIKE ${pattern} ESCAPE E'\\\\' OR LOWER(COALESCE(r.data ->> 'overview', '')) LIKE ${pattern} ESCAPE E'\\\\' OR LOWER(COALESCE(p.name, '')) LIKE ${pattern} ESCAPE E'\\\\' OR LOWER(o.type::text) LIKE ${pattern} ESCAPE E'\\\\' - OR LOWER((COALESCE(r.data -> 'skills', '[]'::jsonb))::text) LIKE ${pattern} ESCAPE E'\\\\' + OR LOWER(COALESCE(r.data::jsonb -> 'skills', '[]'::jsonb)::text) LIKE ${pattern} ESCAPE E'\\\\' )`); } @@ -918,8 +920,8 @@ export class CopilotOpportunityService { SELECT 1 FROM jsonb_array_elements( CASE - WHEN jsonb_typeof(r.data -> 'skills') = 'array' - THEN r.data -> 'skills' + WHEN jsonb_typeof(r.data::jsonb -> 'skills') = 'array' + THEN r.data::jsonb -> 'skills' ELSE '[]'::jsonb END ) AS requested_skill(value)