@@ -538,44 +538,37 @@ async function upsertInsightsProject(
538538 )
539539
540540 // INSERT for the subproject segment only (the matched leaf).
541- // Partial unique index on segmentId WHERE deletedAt IS NULL means ON CONFLICT won't fire
542- // for soft-deleted rows — use UPDATE-then-INSERT pattern (UPDATE already done above).
541+ // Before inserting, check if a same-family sibling (group/project level sharing the same
542+ // sourceId) already holds this name. Shallow hierarchies (eff=1/2) have group+project+subproject
543+ // all sharing the same name and sourceId — the group/project rows are written first and would
544+ // cause a name conflict on the subproject INSERT. Skip the INSERT in that case; the family is
545+ // already represented.
546+ const sameFamilyNameHolder = await db . oneOrNone (
547+ `SELECT 1
548+ FROM "insightsProjects" ip
549+ JOIN segments s ON s.id = ip."segmentId"
550+ WHERE ip.name = $(name)
551+ AND ip."deletedAt" IS NULL
552+ AND s."sourceId" = $(sourceId)
553+ AND s."tenantId" = $(tenantId)
554+ LIMIT 1` ,
555+ { name : project . name , sourceId, tenantId : DEFAULT_TENANT_ID } ,
556+ )
557+ if ( sameFamilyNameHolder ) return false
558+
543559 const exists = await db . oneOrNone < { id : string } > (
544560 `SELECT id FROM "insightsProjects" WHERE "segmentId" = $(segmentId) AND "deletedAt" IS NULL` ,
545561 { segmentId } ,
546562 )
547563 if ( exists ) return false
548564
549565 // logoUrl intentionally omitted from the INSERT column list — see note above.
550- const inserted = await db . result (
566+ await db . none (
551567 `INSERT INTO "insightsProjects" (name, slug, description, "segmentId", "isLF")
552- VALUES ($(name), generate_slug('insightsProjects', $(name)), $(description), $(segmentId), TRUE)
553- ON CONFLICT (name) WHERE "deletedAt" IS NULL DO NOTHING` ,
568+ VALUES ($(name), generate_slug('insightsProjects', $(name)), $(description), $(segmentId), TRUE)` ,
554569 { name : project . name , description : project . description , segmentId } ,
555570 )
556571
557- if ( inserted . rowCount === 0 ) {
558- // INSERT was a no-op on the partial unique index (name) WHERE "deletedAt" IS NULL.
559- // The pre-check above already ruled out cross-sourceId conflicts, so the row holding
560- // the name must be a same-sourceId sibling — shallow hierarchies (eff=1/2) where
561- // group/project/subproject share both name and sourceId. Verify before concluding
562- // it's not a conflict (guards against a hypothetical race with another writer).
563- const holder = await db . oneOrNone < { sameFamily : boolean } > (
564- `SELECT s."sourceId" = $(sourceId) AS "sameFamily"
565- FROM "insightsProjects" ip
566- JOIN segments s ON s.id = ip."segmentId"
567- WHERE ip.name = $(name)
568- AND ip."deletedAt" IS NULL
569- AND s."tenantId" = $(tenantId)
570- LIMIT 1` ,
571- { name : project . name , sourceId, tenantId : DEFAULT_TENANT_ID } ,
572- )
573- // Same-family holder (or holder vanished between INSERT and re-check) → not a real
574- // conflict; the project family is already represented via the sibling row.
575- if ( ! holder || holder . sameFamily ) return false
576- return true
577- }
578-
579572 return false
580573}
581574
0 commit comments