Skip to content

Commit 2f7c6b7

Browse files
fix(OUT-3815): return live mapping from handleProductMap on conflict
The initial-save insert uses onConflictDoNothing, so RETURNING omitted rows skipped on a re-fired/concurrent save and the client could receive []. Always return getAll() instead; drop the now-unused returningFields param. Add a regression test covering a repeated initial save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 740ec39 commit 2f7c6b7

2 files changed

Lines changed: 61 additions & 14 deletions

File tree

‎src/app/api/quickbooks/product/product.service.ts‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,7 @@ export class ProductService extends BaseService {
208208
* On intial save, save all flatten products. If mapped, we include and if not, those are excluded
209209
* On every save after that, we update the record on the basis of productId
210210
*/
211-
async handleProductMap(
212-
body: ProductMappingSchemaType,
213-
returningFields?: (keyof typeof QBProductSync)[],
214-
) {
211+
async handleProductMap(body: ProductMappingSchemaType) {
215212
const { mappingItems, changedItemReference } = body
216213
const settingService = new SettingService(this.user)
217214
const setting = await settingService.getOneByPortalId([
@@ -228,22 +225,15 @@ export class ProductService extends BaseService {
228225
portalId: this.user.workspaceId,
229226
}
230227
})
231-
// onConflictDoNothing: a re-fired / concurrent initial save would
232-
// otherwise violate the (portal_id, product_id) unique index and 500.
233-
// where mirrors the partial index predicate so it's the arbiter.
234-
const query = this.db
228+
// Skip products already saved so a repeated save doesn't error.
229+
await this.db
235230
.insert(QBProductSync)
236231
.values(formattedPayload)
237232
.onConflictDoNothing({
238233
target: [QBProductSync.portalId, QBProductSync.productId],
239234
where: isNull(QBProductSync.deletedAt),
240235
})
241-
const products = returningFields?.length
242-
? await query.returning(
243-
buildReturningFields(QBProductSync, returningFields),
244-
)
245-
: await query.returning()
246-
return products
236+
return await this.getAll()
247237
}
248238

249239
if (changedItemReference.length > 0) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { eq } from 'drizzle-orm'
3+
4+
import { db } from '@/db'
5+
import { QBProductSync } from '@/db/schema/qbProductSync'
6+
7+
import { seedHealthyPortal } from '@test/helpers/seed'
8+
import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup'
9+
import { postProductMap } from '@test/helpers/productMap'
10+
11+
const PRODUCT_ID = '2cf93cf0-45fa-485f-b584-03c2c38a3999'
12+
13+
describe('POST /api/quickbooks/product/map — repeated initial save', () => {
14+
// Reuse the harness for truncate + mock install (mocks auth).
15+
setupProductCreatedTest()
16+
17+
it('returns the saved mapping instead of an empty list when the same initial save is sent twice', async () => {
18+
// initialProductSettingMap=false keeps both POSTs on the initial-insert
19+
// path, so the second one collides with the (portal_id, product_id) unique
20+
// index and is skipped by onConflictDoNothing.
21+
await seedHealthyPortal({ setting: { initialProductSettingMap: false } })
22+
23+
const body = {
24+
mappingItems: [
25+
{
26+
productId: PRODUCT_ID,
27+
name: 'Test Product',
28+
copilotName: 'Test Product',
29+
description: '',
30+
qbItemId: '999',
31+
qbSyncToken: '0',
32+
isExcluded: false,
33+
},
34+
],
35+
changedItemReference: [],
36+
}
37+
38+
const first = await postProductMap(body)
39+
expect(first.status).toBe(200)
40+
41+
const second = await postProductMap(body)
42+
expect(second.status).toBe(200)
43+
44+
// The re-fired save no-ops on the conflict but must still return the live
45+
// mapping, not the [] that a bare RETURNING would yield for skipped rows.
46+
const secondMapping = await second.json()
47+
expect(secondMapping).toHaveLength(1)
48+
expect(secondMapping[0].productId).toBe(PRODUCT_ID)
49+
50+
// And it must not have created a duplicate row.
51+
const rows = await db
52+
.select()
53+
.from(QBProductSync)
54+
.where(eq(QBProductSync.productId, PRODUCT_ID))
55+
expect(rows).toHaveLength(1)
56+
})
57+
})

0 commit comments

Comments
 (0)