diff --git a/furniture-3d-system/src/server/repositories/__tests__/intake-ai-asset.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/intake-ai-asset.repository.test.ts new file mode 100644 index 00000000..b238c885 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/intake-ai-asset.repository.test.ts @@ -0,0 +1,122 @@ +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findMany: jest.fn(), + }, + intakeNonIntegratedProduct: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { IntakeAiAssetRepository } from '../intake-ai-asset.repository' +import { + buildDevelopmentKindWhere, + buildProductKindWhere, +} from '../intake-product-list-filters' + +const mockPrisma = prisma as unknown as { + user: { + findMany: jest.Mock + } + intakeNonIntegratedProduct: { + findMany: jest.Mock + findFirst: jest.Mock + } +} + +describe('IntakeAiAssetRepository catalog scope', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPrisma.user.findMany.mockResolvedValue([ + { id: 'external-intake-1' }, + { id: 'external-intake-2' }, + ]) + mockPrisma.intakeNonIntegratedProduct.findMany.mockResolvedValue([]) + mockPrisma.intakeNonIntegratedProduct.findFirst.mockResolvedValue(null) + }) + + it('lists all product-development SPUs while excluding stock, series, and external intake', async () => { + const repository = new IntakeAiAssetRepository() + + await repository.findCatalogProducts('') + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + businessRoles: { + has: 'EXTERNAL_INTAKE', + }, + }, + select: { id: true }, + }) + expect(mockPrisma.intakeNonIntegratedProduct.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + deletedAt: null, + AND: [ + buildDevelopmentKindWhere('product'), + buildProductKindWhere('new'), + { + createdBy: { + notIn: ['external-intake-1', 'external-intake-2'], + }, + }, + ], + }, + }), + ) + + const query = mockPrisma.intakeNonIntegratedProduct.findMany.mock.calls[0][0] + expect(query).not.toHaveProperty('take') + }) + + it('keeps SPU and barcode search inside the same catalog scope', async () => { + const repository = new IntakeAiAssetRepository() + + await repository.findCatalogProducts(' BAR-001 ') + + const query = mockPrisma.intakeNonIntegratedProduct.findMany.mock.calls[0][0] + const searchWhere = query.where.AND[3] + + expect(searchWhere.OR).toEqual( + expect.arrayContaining([ + { spu: { contains: 'BAR-001', mode: 'insensitive' } }, + expect.objectContaining({ + skuItems: { + some: { + OR: expect.arrayContaining([ + { barcode: { contains: 'BAR-001', mode: 'insensitive' } }, + ]), + }, + }, + }), + ]), + ) + }) + + it('applies the same scope when a product workspace is opened directly', async () => { + const repository = new IntakeAiAssetRepository() + + await repository.findProductById('product-1') + + expect(mockPrisma.intakeNonIntegratedProduct.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'product-1', + deletedAt: null, + AND: [ + buildDevelopmentKindWhere('product'), + buildProductKindWhere('new'), + { + createdBy: { + notIn: ['external-intake-1', 'external-intake-2'], + }, + }, + ], + }, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts b/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts index b3f676e8..ab5189c6 100644 --- a/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts +++ b/furniture-3d-system/src/server/repositories/intake-ai-asset.repository.ts @@ -1,5 +1,9 @@ import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' +import { + buildDevelopmentKindWhere, + buildProductKindWhere, +} from './intake-product-list-filters' const skuAiProfileInclude = { currentModelVersion: { @@ -43,14 +47,27 @@ export interface CreateModelVersionInput { createdBy: string } -export class IntakeAiAssetRepository { - findCatalogProducts(query: string, take = 200) { - const normalizedQuery = query.trim() - return prisma.intakeNonIntegratedProduct.findMany({ - where: { - deletedAt: null, - ...(normalizedQuery - ? { +function buildCatalogWhere(input: { + query?: string + productId?: string + excludedCreatorIds: string[] +}): Prisma.IntakeNonIntegratedProductWhereInput { + const normalizedQuery = input.query?.trim() ?? '' + const developmentWhere = buildDevelopmentKindWhere('product') + const productKindWhere = buildProductKindWhere('new') + + return { + ...(input.productId ? { id: input.productId } : {}), + deletedAt: null, + AND: [ + ...(developmentWhere ? [developmentWhere] : []), + ...(productKindWhere ? [productKindWhere] : []), + ...(input.excludedCreatorIds.length > 0 + ? [{ createdBy: { notIn: input.excludedCreatorIds } }] + : []), + ...(normalizedQuery + ? [ + { OR: [ { spu: { contains: normalizedQuery, mode: 'insensitive' as const } }, { spuBase: { contains: normalizedQuery, mode: 'insensitive' as const } }, @@ -71,18 +88,40 @@ export class IntakeAiAssetRepository { }, }, ], - } - : {}), + }, + ] + : []), + ], + } +} + +export class IntakeAiAssetRepository { + private async findExcludedCatalogCreatorIds() { + const users = await prisma.user.findMany({ + where: { + businessRoles: { + has: 'EXTERNAL_INTAKE', + }, }, + select: { id: true }, + }) + + return users.map((user) => user.id) + } + + async findCatalogProducts(query: string) { + const excludedCreatorIds = await this.findExcludedCatalogCreatorIds() + return prisma.intakeNonIntegratedProduct.findMany({ + where: buildCatalogWhere({ query, excludedCreatorIds }), include: catalogProductInclude, orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], - take, }) } - findProductById(productId: string) { + async findProductById(productId: string) { + const excludedCreatorIds = await this.findExcludedCatalogCreatorIds() return prisma.intakeNonIntegratedProduct.findFirst({ - where: { id: productId, deletedAt: null }, + where: buildCatalogWhere({ productId, excludedCreatorIds }), include: catalogProductInclude, }) } diff --git a/furniture-3d-system/src/server/repositories/intake-product-list-filters.ts b/furniture-3d-system/src/server/repositories/intake-product-list-filters.ts new file mode 100644 index 00000000..78329f4d --- /dev/null +++ b/furniture-3d-system/src/server/repositories/intake-product-list-filters.ts @@ -0,0 +1,169 @@ +import { Prisma } from '@prisma/client' + +type ProductDevelopmentKind = 'product' | 'series' | undefined +type ProductKind = 'all' | 'new' | 'stock' | undefined + +const JST_SKU_SOURCE_PATHS = [ + ['source'], + ['sourceTable'], + ['importSource'], + ['externalSource'], + ['metadata', 'source'], + ['metadata', 'sourceTable'], + ['metadata', 'importSource'], + ['metadata', 'externalSource'], +] as const + +function jsonTextEquals( + path: string[], + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + payload: { + path, + equals: value, + }, + } +} + +function jsonTextMissingOrNotEquals( + path: string[], + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + OR: [ + { + NOT: jsonTextEquals(path, value), + }, + { + payload: { + path, + equals: Prisma.AnyNull, + }, + }, + ], + } +} + +function profileTextEquals( + field: string, + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + profile: { + is: { + [field]: value, + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput +} + +function profileTextNonEmpty(field: string): Prisma.IntakeNonIntegratedProductWhereInput { + return { + profile: { + is: { + [field]: { + not: '', + }, + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput +} + +function payloadJsonWhenProfileMissing( + condition: Prisma.IntakeNonIntegratedProductWhereInput, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + AND: [ + { + profile: null, + }, + condition, + ], + } +} + +function profileTextNotEqualsWhenPresent( + field: string, + value: string, +): Prisma.IntakeNonIntegratedProductWhereInput { + return { + AND: [ + { + NOT: { + profile: null, + }, + }, + { + OR: [ + { + NOT: profileTextEquals(field, value), + }, + { + profile: { + is: { + [field]: '', + }, + }, + } as Prisma.IntakeNonIntegratedProductWhereInput, + ], + }, + ], + } +} + +export function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput { + return { + OR: [ + { id: { startsWith: 'jst-stock-' } }, + profileTextNonEmpty('sourceSpu'), + ...JST_SKU_SOURCE_PATHS.map((path) => + payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')), + ), + ], + } +} + +export function buildDevelopmentKindWhere( + productDevelopmentKind: ProductDevelopmentKind, +): Prisma.IntakeNonIntegratedProductWhereInput | null { + if (productDevelopmentKind === 'series') { + return { + OR: [ + profileTextEquals('productDevelopmentKind', 'series'), + payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')), + ], + } + } + + if (productDevelopmentKind === 'product') { + return { + OR: [ + profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'), + payloadJsonWhenProfileMissing( + jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series'), + ), + ], + } + } + + return null +} + +export function buildProductKindWhere( + productKind: ProductKind, +): Prisma.IntakeNonIntegratedProductWhereInput | null { + const stockWhere = buildStockProductWhere() + + if (productKind === 'stock') { + return stockWhere + } + + if (productKind === 'new') { + return { + NOT: stockWhere, + } + } + + return null +} diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 62517aca..e7fe3da3 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -51,6 +51,11 @@ import { intakeNonIntegratedProductRepository, type IntakeNonIntegratedProductRepositoryClass, } from '../repositories/intake-non-integrated-product.repository' +import { + buildDevelopmentKindWhere, + buildProductKindWhere, + buildStockProductWhere, +} from '../repositories/intake-product-list-filters' import { userRepository } from '../repositories/user.repository' import { assetSkuGroupRepository, @@ -3066,25 +3071,6 @@ function jsonTextContains( } } -function jsonTextMissingOrNotEquals( - path: string[], - value: string, -): Prisma.IntakeNonIntegratedProductWhereInput { - return { - OR: [ - { - NOT: jsonTextEquals(path, value), - }, - { - payload: { - path, - equals: Prisma.AnyNull, - }, - }, - ], - } -} - function profileTextEquals( field: string, value: string, @@ -3139,35 +3125,6 @@ function payloadJsonWhenProfileMissing( } } -function profileTextNotEqualsWhenPresent( - field: string, - value: string, -): Prisma.IntakeNonIntegratedProductWhereInput { - return { - AND: [ - { - NOT: { - profile: null, - }, - }, - { - OR: [ - { - NOT: profileTextEquals(field, value), - }, - { - profile: { - is: { - [field]: '', - }, - }, - } as Prisma.IntakeNonIntegratedProductWhereInput, - ], - }, - ], - } -} - function payloadTextContainsWhenProfileMissing(path: string[], keyword: string) { return payloadJsonWhenProfileMissing(jsonTextContains(path, keyword)) } @@ -3184,71 +3141,6 @@ function buildTextContainsAny( } } -const JST_SKU_SOURCE_PATHS = [ - ['source'], - ['sourceTable'], - ['importSource'], - ['externalSource'], - ['metadata', 'source'], - ['metadata', 'sourceTable'], - ['metadata', 'importSource'], - ['metadata', 'externalSource'], -] as const - -function buildStockProductWhere(): Prisma.IntakeNonIntegratedProductWhereInput { - return { - OR: [ - { id: { startsWith: 'jst-stock-' } }, - profileTextNonEmpty('sourceSpu'), - ...JST_SKU_SOURCE_PATHS.map((path) => - payloadJsonWhenProfileMissing(jsonTextEquals([...path], 'jst_sku')), - ), - ], - } -} - -function buildDevelopmentKindWhere( - productDevelopmentKind: IntakeNonIntegratedProductListParams['productDevelopmentKind'], -): Prisma.IntakeNonIntegratedProductWhereInput | null { - if (productDevelopmentKind === 'series') { - return { - OR: [ - profileTextEquals('productDevelopmentKind', 'series'), - payloadJsonWhenProfileMissing(jsonTextEquals(['productDevelopmentKind'], 'series')), - ], - } - } - - if (productDevelopmentKind === 'product') { - return { - OR: [ - profileTextNotEqualsWhenPresent('productDevelopmentKind', 'series'), - payloadJsonWhenProfileMissing(jsonTextMissingOrNotEquals(['productDevelopmentKind'], 'series')), - ], - } - } - - return null -} - -function buildProductKindWhere( - productKind: IntakeNonIntegratedProductListParams['productKind'], -): Prisma.IntakeNonIntegratedProductWhereInput | null { - const stockWhere = buildStockProductWhere() - - if (productKind === 'stock') { - return stockWhere - } - - if (productKind === 'new') { - return { - NOT: stockWhere, - } - } - - return null -} - function buildSpuSearchWhere(spu: string): Prisma.IntakeNonIntegratedProductWhereInput | null { const keyword = trimListFilter(spu) if (!keyword) return null