Wip stage M4
This commit is contained in:
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AuditModule } from './common/audit/audit.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||
import { MediaUrlModule } from './common/media/media.module';
|
||||
@@ -57,6 +58,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
|
||||
EventsModule,
|
||||
MediaUrlModule,
|
||||
SecurityModule,
|
||||
AuditModule,
|
||||
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [APP_CONFIG],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
/** Global: every write path in the admin records what it did. */
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
export interface AuditEntry {
|
||||
actorUserId: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string | null;
|
||||
changes?: Prisma.InputJsonValue;
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only record of who changed what.
|
||||
*
|
||||
* Two deliberate properties:
|
||||
*
|
||||
* 1. **Writing an audit row never fails the operation.** If the log write
|
||||
* throws, the product edit that succeeded must still stand — losing an audit
|
||||
* row is bad, silently rolling back an operator's work because of it is
|
||||
* worse. Failures are logged loudly instead.
|
||||
*
|
||||
* 2. **It is fire-and-forget from the caller's perspective.** Audit writes must
|
||||
* not add latency to the write path.
|
||||
*
|
||||
* If audit completeness ever becomes a compliance requirement, this becomes an
|
||||
* outbox row inside the same transaction as the change. That is a deliberate
|
||||
* future step, not an oversight.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
record(entry: AuditEntry): void {
|
||||
void this.write(entry);
|
||||
}
|
||||
|
||||
private async write(entry: AuditEntry): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: entry.actorUserId,
|
||||
action: entry.action,
|
||||
resourceType: entry.resourceType,
|
||||
resourceId: entry.resourceId,
|
||||
changes: entry.changes,
|
||||
ipAddress: entry.ipAddress ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to write audit entry ${entry.action} on ${entry.resourceType}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,43 @@ export class RedisService implements OnModuleDestroy {
|
||||
return typeof count === 'number' ? count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every key under a prefix.
|
||||
*
|
||||
* SCAN, never KEYS: KEYS blocks the entire Redis server for the duration of
|
||||
* the scan, and this runs on the admin write path where a catalog import can
|
||||
* fire it hundreds of times.
|
||||
*
|
||||
* Note the prefix handling — the client is configured with `keyPrefix`, so
|
||||
* SCAN returns fully-prefixed keys while `del` would prefix them again. The
|
||||
* prefix is stripped before deleting.
|
||||
*/
|
||||
async deleteByPrefix(prefix: string): Promise<number> {
|
||||
const keyPrefix = this.client.options.keyPrefix ?? '';
|
||||
let cursor = '0';
|
||||
let removed = 0;
|
||||
|
||||
do {
|
||||
const [next, keys] = await this.client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${keyPrefix}${prefix}*`,
|
||||
'COUNT',
|
||||
500,
|
||||
);
|
||||
cursor = next;
|
||||
|
||||
if (keys.length > 0) {
|
||||
const unprefixed = keys.map((key) =>
|
||||
keyPrefix && key.startsWith(keyPrefix) ? key.slice(keyPrefix.length) : key,
|
||||
);
|
||||
removed += await this.client.del(...unprefixed);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.client.ping();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { InventoryListQuery } from '@sport/validation';
|
||||
|
||||
import type { AuditService } from '@/common/audit/audit.service';
|
||||
import type { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import type { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
/**
|
||||
* Pins the one thing that made stock unreachable in the admin: the list is
|
||||
* driven by `ProductVariant`, not by `StockLevel`.
|
||||
*
|
||||
* A variant that has never moved has no level row — the level is a projection
|
||||
* of the ledger. Listing the projection hid every freshly created variant, and
|
||||
* since this screen is the only route to `adjust`, those variants could never
|
||||
* be given stock at all. That is invisible from the API contract, so it is
|
||||
* asserted here rather than left to inspection.
|
||||
*/
|
||||
const location = { id: 'loc-main', name: 'Main Warehouse' };
|
||||
|
||||
interface Stub {
|
||||
service: InventoryService;
|
||||
variantArgs: () => Record<string, unknown>;
|
||||
stockLevelTouched: () => boolean;
|
||||
}
|
||||
|
||||
function stubService(rows: unknown[]): Stub {
|
||||
let variantArgs: Record<string, unknown> = {};
|
||||
let stockLevelTouched = false;
|
||||
|
||||
const prisma = {
|
||||
inventoryLocation: { findFirst: async () => location },
|
||||
productVariant: {
|
||||
findMany: async (args: Record<string, unknown>) => {
|
||||
variantArgs = args;
|
||||
return rows;
|
||||
},
|
||||
count: async () => rows.length,
|
||||
},
|
||||
stockLevel: {
|
||||
findMany: async () => {
|
||||
stockLevelTouched = true;
|
||||
return [];
|
||||
},
|
||||
count: async () => {
|
||||
stockLevelTouched = true;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
} as unknown as PrismaService;
|
||||
|
||||
return {
|
||||
service: new InventoryService(prisma, {} as RedisService, {} as AuditService),
|
||||
variantArgs: () => variantArgs,
|
||||
stockLevelTouched: () => stockLevelTouched,
|
||||
};
|
||||
}
|
||||
|
||||
const query = (overrides: Partial<InventoryListQuery> = {}): InventoryListQuery =>
|
||||
({ page: 1, perPage: 24, lowStockOnly: false, ...overrides }) as InventoryListQuery;
|
||||
|
||||
describe('InventoryService.list', () => {
|
||||
it('reports a variant with no level row as zero rather than omitting it', async () => {
|
||||
const updatedAt = new Date('2026-08-12T03:00:00.000Z');
|
||||
const stub = stubService([
|
||||
{
|
||||
id: 'v-1',
|
||||
sku: 'VEL-NOC-BLACK-M',
|
||||
title: 'Black / M',
|
||||
updatedAt,
|
||||
product: { name: 'Nocturne Jacket' },
|
||||
stockLevels: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await stub.service.list(query());
|
||||
|
||||
expect(stub.stockLevelTouched()).toBe(false);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
variantId: 'v-1',
|
||||
sku: 'VEL-NOC-BLACK-M',
|
||||
locationId: location.id,
|
||||
locationName: location.name,
|
||||
onHand: 0,
|
||||
reserved: 0,
|
||||
available: 0,
|
||||
// Nothing has moved, so the variant's own timestamp is the most recent
|
||||
// thing that is true about its stock.
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('derives available from the level when one exists', async () => {
|
||||
const stub = stubService([
|
||||
{
|
||||
id: 'v-2',
|
||||
sku: 'VEL-NOC-VOLT-L',
|
||||
title: 'Volt / L',
|
||||
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
product: { name: 'Nocturne Jacket' },
|
||||
stockLevels: [{ onHand: 12, reserved: 5, updatedAt: new Date('2026-08-12T00:00:00.000Z') }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await stub.service.list(query());
|
||||
|
||||
expect(result.items[0]).toMatchObject({ onHand: 12, reserved: 5, available: 7 });
|
||||
});
|
||||
|
||||
it('counts a missing level row as low stock', async () => {
|
||||
const stub = stubService([]);
|
||||
|
||||
await stub.service.list(query({ lowStockOnly: true }));
|
||||
|
||||
// A variant sitting at an implicit zero is the most urgent kind of low, not
|
||||
// an absent one — so the filter must reach rows with no level at all.
|
||||
expect(JSON.stringify(stub.variantArgs().where)).toContain('"none"');
|
||||
});
|
||||
|
||||
it('keeps search and low-stock filters independent', async () => {
|
||||
const stub = stubService([]);
|
||||
|
||||
await stub.service.list(query({ q: 'VEL-NOC', lowStockOnly: true }));
|
||||
|
||||
// Both are `OR` groups; combining them at the same level would let a
|
||||
// low-stock match escape the search term. They must be `AND`ed.
|
||||
const where = stub.variantArgs().where as { AND?: unknown[] };
|
||||
expect(where.AND).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AuthenticatedActor,
|
||||
type InventoryLevel,
|
||||
type OffsetPaginated,
|
||||
type StockMovementEntry,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
adjustStockSchema,
|
||||
inventoryListQuerySchema,
|
||||
type AdjustStockInput,
|
||||
type InventoryListQuery,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
@ApiTags('admin/inventory')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/inventory')
|
||||
export class InventoryController {
|
||||
constructor(private readonly service: InventoryService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
|
||||
@ApiOperation({ summary: 'Stock levels by variant and location' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(inventoryListQuerySchema)) query: InventoryListQuery,
|
||||
): Promise<OffsetPaginated<InventoryLevel>> {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get('movements/:variantId')
|
||||
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
|
||||
@ApiOperation({ summary: 'Movement ledger for one variant' })
|
||||
movements(@Param('variantId') variantId: string): Promise<StockMovementEntry[]> {
|
||||
return this.service.movements(variantId);
|
||||
}
|
||||
|
||||
@Post('adjust')
|
||||
@RequirePermissions(PERMISSIONS.INVENTORY_UPDATE)
|
||||
@ApiOperation({ summary: 'Adjust stock; always writes a ledger entry' })
|
||||
adjust(
|
||||
@Body(new ZodValidationPipe(adjustStockSchema)) body: AdjustStockInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<InventoryLevel> {
|
||||
return this.service.adjust(body, actor.userId);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { InventoryService } from './inventory.service';
|
||||
|
||||
/**
|
||||
* InventoryModule — boundary declared, implementation pending.
|
||||
* InventoryModule — owns `inventory_locations`, `stock_levels` and
|
||||
* `stock_movements`.
|
||||
*
|
||||
* Owns (exclusively): `inventory_locations`, `stock_levels`, `stock_movements`
|
||||
*
|
||||
* Stock ledger, reservations and release. Consumes order events rather than being called by OrdersModule.
|
||||
*
|
||||
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
|
||||
* therefore never read another module’s tables directly, and it communicates
|
||||
* outward through domain events.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* inventory.module.ts wiring only
|
||||
* inventory.controller.ts HTTP surface, no logic
|
||||
* inventory.service.ts business rules
|
||||
* inventory.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* The stock level is a projection of the append-only movement ledger, which is
|
||||
* what makes a discrepancy answerable. EXTRACTION CANDIDATE: it communicates
|
||||
* outward through events and reads no other module's tables.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [InventoryController],
|
||||
providers: [InventoryService],
|
||||
exports: [InventoryService],
|
||||
})
|
||||
export class InventoryModule {}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { InventoryLevel, OffsetPaginated, StockMovementEntry } from '@sport/types';
|
||||
import type { AdjustStockInput, InventoryListQuery } from '@sport/validation';
|
||||
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
/** Below this, the admin flags a variant as needing attention. */
|
||||
const LOW_STOCK_THRESHOLD = 5;
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
private readonly logger = new Logger(InventoryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Lists sellable variants with what the ledger says about them.
|
||||
*
|
||||
* Driven by `ProductVariant`, not `StockLevel`, because the level is a
|
||||
* projection and a variant that has never moved has no row yet. Listing the
|
||||
* projection made freshly created variants invisible here — and since this
|
||||
* screen is the only way to reach `adjust`, they could never be given stock
|
||||
* at all. A missing projection means zero, which is exactly what it means
|
||||
* everywhere else.
|
||||
*/
|
||||
async list(query: InventoryListQuery): Promise<OffsetPaginated<InventoryLevel>> {
|
||||
const location = await this.defaultLocation();
|
||||
|
||||
const filters: Prisma.ProductVariantWhereInput[] = [];
|
||||
|
||||
if (query.q) {
|
||||
filters.push({
|
||||
OR: [
|
||||
{ sku: { contains: query.q, mode: 'insensitive' } },
|
||||
{ product: { name: { contains: query.q, mode: 'insensitive' } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (query.lowStockOnly) {
|
||||
// "Low" includes "has no level row at all" — a variant sitting at an
|
||||
// implicit zero is the most urgent kind of low, not an absent one.
|
||||
filters.push({
|
||||
OR: [
|
||||
{ stockLevels: { none: { locationId: location.id } } },
|
||||
{
|
||||
stockLevels: {
|
||||
some: { locationId: location.id, onHand: { lte: LOW_STOCK_THRESHOLD } },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.ProductVariantWhereInput = {
|
||||
status: 'ACTIVE',
|
||||
deletedAt: null,
|
||||
...(filters.length > 0 ? { AND: filters } : {}),
|
||||
};
|
||||
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.prisma.productVariant.findMany({
|
||||
where,
|
||||
// By SKU rather than by quantity: ordering on a relation would cost a
|
||||
// join-and-sort on every page, and `lowStockOnly` is the tool for
|
||||
// finding the shortages. Stable order matters more while paging.
|
||||
orderBy: { sku: 'asc' },
|
||||
skip: (query.page - 1) * query.perPage,
|
||||
take: query.perPage,
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
title: true,
|
||||
updatedAt: true,
|
||||
product: { select: { name: true } },
|
||||
stockLevels: {
|
||||
where: { locationId: location.id },
|
||||
select: { onHand: true, reserved: true, updatedAt: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.productVariant.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => {
|
||||
const level = row.stockLevels[0];
|
||||
|
||||
return {
|
||||
variantId: row.id,
|
||||
sku: row.sku,
|
||||
variantTitle: row.title,
|
||||
productName: row.product.name,
|
||||
locationId: location.id,
|
||||
locationName: location.name,
|
||||
onHand: level?.onHand ?? 0,
|
||||
reserved: level?.reserved ?? 0,
|
||||
available: (level?.onHand ?? 0) - (level?.reserved ?? 0),
|
||||
// Without a level row nothing has moved, so the variant's own
|
||||
// timestamp is the most recent thing that is true about its stock.
|
||||
updatedAt: (level?.updatedAt ?? row.updatedAt).toISOString(),
|
||||
};
|
||||
}),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a stock change and records it in the ledger, atomically.
|
||||
*
|
||||
* The ledger entry and the level update are in one transaction because the
|
||||
* level is a *projection* of the ledger (ADR: inventory is append-only). If
|
||||
* they could diverge, "why is this number wrong?" becomes unanswerable — and
|
||||
* that question is the entire reason the ledger exists.
|
||||
*
|
||||
* Never lets stock go negative: a correction that would imply selling units
|
||||
* that were never received is a data-entry error, and silently clamping it
|
||||
* would hide the mistake.
|
||||
*/
|
||||
async adjust(input: AdjustStockInput, actorUserId: string): Promise<InventoryLevel> {
|
||||
const variant = await this.prisma.productVariant.findFirst({
|
||||
where: { id: input.variantId, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
title: true,
|
||||
productId: true,
|
||||
product: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!variant) throw AppException.notFound('Variant');
|
||||
|
||||
const locationId = input.locationId ?? (await this.defaultLocationId());
|
||||
|
||||
const level = await this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.stockLevel.findUnique({
|
||||
where: { variantId_locationId: { variantId: variant.id, locationId } },
|
||||
select: { onHand: true, reserved: true },
|
||||
});
|
||||
|
||||
const onHand = current?.onHand ?? 0;
|
||||
|
||||
// A stock take states the counted total; everything else is a delta.
|
||||
const delta =
|
||||
input.reason === 'STOCK_TAKE'
|
||||
? (input.countedQuantity ?? 0) - onHand
|
||||
: (input.quantityDelta ?? 0);
|
||||
|
||||
const nextOnHand = onHand + delta;
|
||||
|
||||
if (nextOnHand < 0) {
|
||||
throw AppException.badRequest(
|
||||
`That adjustment would leave ${variant.sku} at ${nextOnHand}. Stock cannot go negative.`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await tx.stockLevel.upsert({
|
||||
where: { variantId_locationId: { variantId: variant.id, locationId } },
|
||||
update: { onHand: nextOnHand },
|
||||
create: { variantId: variant.id, locationId, onHand: nextOnHand, reserved: 0 },
|
||||
select: {
|
||||
onHand: true,
|
||||
reserved: true,
|
||||
updatedAt: true,
|
||||
location: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Append-only: a stock take that changes nothing still records that a
|
||||
// count happened, which is exactly what an auditor looks for.
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
variantId: variant.id,
|
||||
locationId,
|
||||
quantityDelta: delta,
|
||||
reason: input.reason,
|
||||
note: input.note ?? null,
|
||||
createdByUserId: actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// `inStock` on the product read model depends on this.
|
||||
await this.recomputeProductStock(variant.productId);
|
||||
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
|
||||
this.logger.log(`Stock adjusted for ${variant.sku}; dropped ${dropped} catalog cache key(s)`);
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'inventory.adjust',
|
||||
resourceType: 'ProductVariant',
|
||||
resourceId: variant.id,
|
||||
changes: { sku: variant.sku, reason: input.reason, onHand: level.onHand },
|
||||
});
|
||||
|
||||
return {
|
||||
variantId: variant.id,
|
||||
sku: variant.sku,
|
||||
variantTitle: variant.title,
|
||||
productName: variant.product.name,
|
||||
locationId,
|
||||
locationName: level.location.name,
|
||||
onHand: level.onHand,
|
||||
reserved: level.reserved,
|
||||
available: level.onHand - level.reserved,
|
||||
updatedAt: level.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async movements(variantId: string, limit = 50): Promise<StockMovementEntry[]> {
|
||||
const rows = await this.prisma.stockMovement.findMany({
|
||||
where: { variantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
variantId: true,
|
||||
quantityDelta: true,
|
||||
reason: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
variant: { select: { sku: true } },
|
||||
createdByUserId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actorIds = [
|
||||
...new Set(rows.map((row) => row.createdByUserId).filter(Boolean)),
|
||||
] as string[];
|
||||
const actors = await this.prisma.user.findMany({
|
||||
where: { id: { in: actorIds } },
|
||||
select: { id: true, firstName: true, lastName: true, email: true },
|
||||
});
|
||||
const actorById = new Map(actors.map((actor) => [actor.id, actor]));
|
||||
|
||||
return rows.map((row) => {
|
||||
const actor = row.createdByUserId ? actorById.get(row.createdByUserId) : undefined;
|
||||
const name = actor
|
||||
? [actor.firstName, actor.lastName].filter(Boolean).join(' ') || actor.email
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
variantId: row.variantId,
|
||||
sku: row.variant.sku,
|
||||
quantityDelta: row.quantityDelta,
|
||||
reason: row.reason,
|
||||
note: row.note,
|
||||
createdByName: name,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates the default location on first use rather than failing the seed. */
|
||||
private async defaultLocation(): Promise<{ id: string; name: string }> {
|
||||
const location = await this.prisma.inventoryLocation.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { isDefault: 'desc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
return (
|
||||
location ??
|
||||
(await this.prisma.inventoryLocation.create({
|
||||
data: { code: 'MAIN', name: 'Main Warehouse', isDefault: true },
|
||||
select: { id: true, name: true },
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private async defaultLocationId(): Promise<string> {
|
||||
return (await this.defaultLocation()).id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps `Product.inStock` in step (ADR-0014).
|
||||
*
|
||||
* Cheaper than `recomputePricing`: stock changes far more often than price,
|
||||
* and touching only the one boolean avoids re-reading every variant's pricing
|
||||
* on each warehouse adjustment.
|
||||
*/
|
||||
private async recomputeProductStock(productId: string): Promise<void> {
|
||||
const variants = await this.prisma.productVariant.findMany({
|
||||
where: { productId, status: 'ACTIVE', deletedAt: null },
|
||||
select: { stockLevels: { select: { onHand: true, reserved: true } } },
|
||||
});
|
||||
|
||||
const inStock = variants.some((variant) =>
|
||||
variant.stockLevels.some((level) => level.onHand - level.reserved > 0),
|
||||
);
|
||||
|
||||
await this.prisma.product.update({ where: { id: productId }, data: { inStock } });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Public surface of InventoryModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* CheckoutModule will use this to reserve and release stock; nothing else
|
||||
* outside this module writes to the ledger.
|
||||
*/
|
||||
export {};
|
||||
export { InventoryService } from '../inventory.service';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AuthenticatedActor,
|
||||
type MediaAssetSummary,
|
||||
type OffsetPaginated,
|
||||
type PresignedUploadTarget,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
mediaListQuerySchema,
|
||||
presignUploadSchema,
|
||||
registerMediaSchema,
|
||||
type MediaListQuery,
|
||||
type PresignUploadInput,
|
||||
type RegisterMediaInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@ApiTags('admin/media')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/media')
|
||||
export class MediaController {
|
||||
constructor(private readonly mediaService: MediaService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.MEDIA_READ)
|
||||
@ApiOperation({ summary: 'List media assets' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(mediaListQuerySchema)) query: MediaListQuery,
|
||||
): Promise<OffsetPaginated<MediaAssetSummary>> {
|
||||
return this.mediaService.list(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of an upload: get a short-lived URL to PUT the file to.
|
||||
* The browser then uploads directly to object storage.
|
||||
*/
|
||||
@Post('presign')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@RequirePermissions(PERMISSIONS.MEDIA_UPLOAD)
|
||||
@ApiOperation({ summary: 'Get a presigned upload target' })
|
||||
presign(
|
||||
@Body(new ZodValidationPipe(presignUploadSchema)) body: PresignUploadInput,
|
||||
): Promise<PresignedUploadTarget> {
|
||||
return this.mediaService.presign(body);
|
||||
}
|
||||
|
||||
/** Step 2: record the asset once the bytes are in storage. */
|
||||
@Post()
|
||||
@RequirePermissions(PERMISSIONS.MEDIA_UPLOAD)
|
||||
@ApiOperation({ summary: 'Register an uploaded asset' })
|
||||
register(
|
||||
@Body(new ZodValidationPipe(registerMediaSchema)) body: RegisterMediaInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<MediaAssetSummary> {
|
||||
return this.mediaService.register(body, actor.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermissions(PERMISSIONS.MEDIA_DELETE)
|
||||
@ApiOperation({ summary: 'Delete an unused media asset' })
|
||||
delete(@Param('id') id: string, @CurrentActor() actor: AuthenticatedActor): Promise<void> {
|
||||
return this.mediaService.delete(id, actor.userId);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MediaController } from './media.controller';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
/**
|
||||
* MediaModule — boundary declared, implementation pending.
|
||||
* MediaModule — owns `media_assets`.
|
||||
*
|
||||
* Owns (exclusively): `media_assets`
|
||||
*
|
||||
* Issues presigned upload URLs and records metadata. Bytes never pass through the API and never enter PostgreSQL.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* media.module.ts wiring only
|
||||
* media.controller.ts HTTP surface, no logic
|
||||
* media.service.ts business rules
|
||||
* media.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Issues presigned upload URLs and records metadata. Bytes never pass through
|
||||
* the API and never enter PostgreSQL (ADR-0009).
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type MediaAssetSummary,
|
||||
type MediaKind,
|
||||
type OffsetPaginated,
|
||||
type PresignedUploadTarget,
|
||||
} from '@sport/types';
|
||||
import type { MediaListQuery, PresignUploadInput, RegisterMediaInput } from '@sport/validation';
|
||||
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { StorageService } from '@/infrastructure/storage/storage.service';
|
||||
|
||||
/** Enforced again at registration; the presign check alone is bypassable. */
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
const MAX_SIZE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly mediaUrl: MediaUrlService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Issues a short-lived URL the browser PUTs directly to.
|
||||
*
|
||||
* Bytes never pass through the API (ADR-0009): no memory pressure, no request
|
||||
* timeout on a 20 MB file, and no need to scale the API for bandwidth.
|
||||
*/
|
||||
async presign(input: PresignUploadInput): Promise<PresignedUploadTarget> {
|
||||
this.assertAcceptable(input.mimeType, input.sizeBytes);
|
||||
|
||||
return this.storage.presignUpload({
|
||||
prefix: input.prefix,
|
||||
filename: input.filename,
|
||||
mimeType: input.mimeType,
|
||||
maxSizeBytes: input.sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an asset after the browser has uploaded it.
|
||||
*
|
||||
* The MIME type and size are re-validated here rather than trusted from the
|
||||
* presign step: a client can call this endpoint directly with any values, so
|
||||
* treating the earlier check as sufficient would make the allow-list
|
||||
* decorative.
|
||||
*
|
||||
* Idempotent by storage key — a retried upload updates rather than duplicates.
|
||||
*/
|
||||
async register(input: RegisterMediaInput, actorUserId: string): Promise<MediaAssetSummary> {
|
||||
this.assertAcceptable(input.mimeType, input.sizeBytes);
|
||||
|
||||
const asset = await this.prisma.mediaAsset.upsert({
|
||||
where: { storageKey: input.storageKey },
|
||||
update: {
|
||||
mimeType: input.mimeType,
|
||||
sizeBytes: input.sizeBytes,
|
||||
width: input.width ?? null,
|
||||
height: input.height ?? null,
|
||||
altText: input.altText ?? null,
|
||||
blurDataUrl: input.blurDataUrl ?? null,
|
||||
},
|
||||
create: {
|
||||
kind: kindFor(input.mimeType),
|
||||
storageKey: input.storageKey,
|
||||
mimeType: input.mimeType,
|
||||
sizeBytes: input.sizeBytes,
|
||||
width: input.width ?? null,
|
||||
height: input.height ?? null,
|
||||
altText: input.altText ?? null,
|
||||
blurDataUrl: input.blurDataUrl ?? null,
|
||||
uploadedByUserId: actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'media.upload',
|
||||
resourceType: 'MediaAsset',
|
||||
resourceId: asset.id,
|
||||
changes: { storageKey: asset.storageKey, sizeBytes: asset.sizeBytes },
|
||||
});
|
||||
|
||||
return this.toSummary(asset);
|
||||
}
|
||||
|
||||
async list(query: MediaListQuery): Promise<OffsetPaginated<MediaAssetSummary>> {
|
||||
const where = query.q
|
||||
? {
|
||||
OR: [
|
||||
{ altText: { contains: query.q, mode: 'insensitive' as const } },
|
||||
{ storageKey: { contains: query.q } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [items, totalItems] = await Promise.all([
|
||||
this.prisma.mediaAsset.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (query.page - 1) * query.perPage,
|
||||
take: query.perPage,
|
||||
}),
|
||||
this.prisma.mediaAsset.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.toSummary(item)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the row, then the object.
|
||||
*
|
||||
* That order matters: if the object delete fails we are left with an orphaned
|
||||
* file, which a reconciliation job can clean up. The reverse order can leave a
|
||||
* row pointing at bytes that no longer exist, which renders as a broken image
|
||||
* on a live product page.
|
||||
*/
|
||||
async delete(id: string, actorUserId: string): Promise<void> {
|
||||
const asset = await this.prisma.mediaAsset.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, storageKey: true, _count: { select: { productImages: true } } },
|
||||
});
|
||||
|
||||
if (!asset) throw AppException.notFound('Media asset');
|
||||
|
||||
if (asset._count.productImages > 0) {
|
||||
throw AppException.conflict(
|
||||
`This image is used by ${asset._count.productImages} product(s). Remove it from them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.mediaAsset.delete({ where: { id } });
|
||||
await this.storage.delete(asset.storageKey).catch(() => undefined);
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action: 'media.delete',
|
||||
resourceType: 'MediaAsset',
|
||||
resourceId: id,
|
||||
changes: { storageKey: asset.storageKey },
|
||||
});
|
||||
}
|
||||
|
||||
private assertAcceptable(mimeType: string, sizeBytes: number): void {
|
||||
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||
throw new AppException({
|
||||
code: API_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE,
|
||||
message: `Files of type ${mimeType} are not accepted.`,
|
||||
status: 415,
|
||||
});
|
||||
}
|
||||
|
||||
if (sizeBytes > MAX_SIZE_BYTES) {
|
||||
throw new AppException({
|
||||
code: API_ERROR_CODES.FILE_TOO_LARGE,
|
||||
message: `Files must be ${Math.floor(MAX_SIZE_BYTES / 1024 / 1024)} MB or smaller.`,
|
||||
status: 413,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toSummary(asset: {
|
||||
id: string;
|
||||
kind: string;
|
||||
storageKey: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
altText: string | null;
|
||||
createdAt: Date;
|
||||
}): MediaAssetSummary {
|
||||
return {
|
||||
id: asset.id,
|
||||
kind: asset.kind as MediaKind,
|
||||
url: this.mediaUrl.url(asset.storageKey),
|
||||
storageKey: asset.storageKey,
|
||||
mimeType: asset.mimeType,
|
||||
sizeBytes: asset.sizeBytes,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
altText: asset.altText,
|
||||
createdAt: asset.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function kindFor(mimeType: string): 'IMAGE' | 'VIDEO' | 'DOCUMENT' {
|
||||
if (mimeType.startsWith('image/')) return 'IMAGE';
|
||||
if (mimeType.startsWith('video/')) return 'VIDEO';
|
||||
return 'DOCUMENT';
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Public surface of MediaModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* `ProductsModule` needs to validate that a media id exists before attaching it
|
||||
* to a product; nothing else outside this module touches media.
|
||||
*/
|
||||
export {};
|
||||
export { MediaService } from '../media.service';
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
PERMISSIONS,
|
||||
TOKEN_AUDIENCES,
|
||||
type AdminProductDetail,
|
||||
type AdminProductListItem,
|
||||
type AuthenticatedActor,
|
||||
type OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
import {
|
||||
adminProductListQuerySchema,
|
||||
bulkUpdateVariantsSchema,
|
||||
createProductSchema,
|
||||
productImagesSchema,
|
||||
updateProductSchema,
|
||||
type AdminProductListQuery,
|
||||
type BulkUpdateVariantsInput,
|
||||
type CreateProductInput,
|
||||
type ProductImagesInput,
|
||||
type UpdateProductInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { ProductsAdminService } from './products-admin.service';
|
||||
|
||||
const setStatusSchema = z.object({ status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']) });
|
||||
|
||||
/**
|
||||
* The catalog write surface.
|
||||
*
|
||||
* Note the permission split: `product.update` covers everyday editing, while
|
||||
* `product.publish` is separate — making something visible to customers is a
|
||||
* different level of trust from correcting a description.
|
||||
*/
|
||||
@ApiTags('admin/products')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/products')
|
||||
export class ProductsAdminController {
|
||||
constructor(private readonly service: ProductsAdminService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_READ)
|
||||
@ApiOperation({ summary: 'List products for the admin table' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(adminProductListQuerySchema)) query: AdminProductListQuery,
|
||||
): Promise<OffsetPaginated<AdminProductListItem>> {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_READ)
|
||||
@ApiOperation({ summary: 'Full product for the editor, in every locale' })
|
||||
getById(@Param('id') id: string): Promise<AdminProductDetail> {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_CREATE)
|
||||
@ApiOperation({ summary: 'Create a product and generate its variant matrix' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createProductSchema)) body: CreateProductInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminProductDetail> {
|
||||
return this.service.create(body, actor.userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
|
||||
@ApiOperation({ summary: 'Update a product; re-syncs the matrix if options changed' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateProductSchema)) body: UpdateProductInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminProductDetail> {
|
||||
return this.service.update(id, body, actor.userId);
|
||||
}
|
||||
|
||||
@Put(':id/variants')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
|
||||
@ApiOperation({ summary: 'Bulk-edit variant SKUs and prices (not stock)' })
|
||||
updateVariants(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(bulkUpdateVariantsSchema)) body: BulkUpdateVariantsInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminProductDetail> {
|
||||
return this.service.updateVariants(id, body, actor.userId);
|
||||
}
|
||||
|
||||
@Put(':id/images')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
|
||||
@ApiOperation({ summary: 'Replace the image set and their ordering' })
|
||||
setImages(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(productImagesSchema)) body: ProductImagesInput,
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminProductDetail> {
|
||||
return this.service.setImages(id, body, actor.userId);
|
||||
}
|
||||
|
||||
@Post(':id/status')
|
||||
@RequirePermissions(PERMISSIONS.PRODUCT_PUBLISH)
|
||||
@ApiOperation({ summary: 'Publish, unpublish or archive' })
|
||||
setStatus(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(setStatusSchema)) body: { status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED' },
|
||||
@CurrentActor() actor: AuthenticatedActor,
|
||||
): Promise<AdminProductDetail> {
|
||||
return this.service.setStatus(id, body.status, actor.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
AdminProductDetail,
|
||||
AdminProductListItem,
|
||||
CurrencyCode,
|
||||
GenderTarget,
|
||||
Locale,
|
||||
ProductStatus,
|
||||
SportType,
|
||||
TranslationMap,
|
||||
VariantStatus,
|
||||
} from '@sport/types';
|
||||
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import type { AdminProductRow } from './products.repository';
|
||||
|
||||
/** Prisma's `Locale` enum → the wire value. */
|
||||
function toLocale(dbLocale: string): Locale {
|
||||
return dbLocale === 'VI' ? 'vi' : 'en';
|
||||
}
|
||||
|
||||
/** Collapses `[{locale, ...fields}]` into `{ vi: fields, en: fields }`. */
|
||||
function byLocale<TRow extends { locale: string }, TOut>(
|
||||
rows: readonly TRow[],
|
||||
pick: (row: TRow) => TOut,
|
||||
): TranslationMap<TOut> {
|
||||
return Object.fromEntries(rows.map((row) => [toLocale(row.locale), pick(row)]));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductsAdminMapper {
|
||||
constructor(private readonly mediaUrl: MediaUrlService) {}
|
||||
|
||||
toListItem(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
isOnSale: boolean;
|
||||
publishedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
translations: { locale: string; name: string }[];
|
||||
brand: { name: string } | null;
|
||||
images: { media: { storageKey: string } }[];
|
||||
variants: {
|
||||
currency: string;
|
||||
priceAmount: number;
|
||||
salePriceAmount: number | null;
|
||||
stockLevels: { onHand: number; reserved: number }[];
|
||||
}[];
|
||||
}): AdminProductListItem {
|
||||
const currency = (row.variants[0]?.currency ?? 'VND') as CurrencyCode;
|
||||
const effective = row.variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount);
|
||||
|
||||
// Available, not on-hand: an operator planning a restock cares about what
|
||||
// can actually be sold, and reserved units cannot be.
|
||||
const totalStock = row.variants.reduce(
|
||||
(total, variant) =>
|
||||
total +
|
||||
variant.stockLevels.reduce((sum, level) => sum + (level.onHand - level.reserved), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const thumbnail = row.images[0]?.media.storageKey;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
// The admin table always shows the default-locale name so rows stay
|
||||
// comparable; the editor is where other languages are visible.
|
||||
name: row.translations[0]?.name ?? row.name,
|
||||
slug: row.slug,
|
||||
status: row.status as ProductStatus,
|
||||
brandName: row.brand?.name ?? null,
|
||||
thumbnailUrl: thumbnail ? this.mediaUrl.url(thumbnail) : null,
|
||||
variantCount: row.variants.length,
|
||||
totalStock,
|
||||
priceRange:
|
||||
effective.length > 0
|
||||
? {
|
||||
min: { amount: Math.min(...effective), currency },
|
||||
max: { amount: Math.max(...effective), currency },
|
||||
}
|
||||
: null,
|
||||
isOnSale: row.isOnSale,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(row: AdminProductRow): AdminProductDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status as ProductStatus,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
brandId: row.brandId,
|
||||
primaryCategoryId: row.primaryCategoryId,
|
||||
genderTargets: row.genderTargets as GenderTarget[],
|
||||
sportTypes: row.sportTypes as SportType[],
|
||||
collectionIds: row.collections.map((link) => link.collectionId),
|
||||
|
||||
translations: byLocale(row.translations, (translation) => ({
|
||||
name: translation.name,
|
||||
slug: translation.slug,
|
||||
shortDescription: translation.shortDescription,
|
||||
description: translation.description,
|
||||
metaTitle: translation.metaTitle,
|
||||
metaDescription: translation.metaDescription,
|
||||
})),
|
||||
|
||||
options: row.options.map((option) => ({
|
||||
id: option.id,
|
||||
key: option.key,
|
||||
position: option.position,
|
||||
names: byLocale(option.translations, (translation) => translation.name),
|
||||
values: option.values.map((value) => ({
|
||||
id: value.id,
|
||||
value: value.value,
|
||||
position: value.position,
|
||||
swatchHex: value.swatchHex,
|
||||
labels: byLocale(value.translations, (translation) => translation.label),
|
||||
})),
|
||||
})),
|
||||
|
||||
variants: row.variants.map((variant) => {
|
||||
const onHand = variant.stockLevels.reduce((sum, level) => sum + level.onHand, 0);
|
||||
const reserved = variant.stockLevels.reduce((sum, level) => sum + level.reserved, 0);
|
||||
|
||||
return {
|
||||
id: variant.id,
|
||||
sku: variant.sku,
|
||||
barcode: variant.barcode,
|
||||
title: variant.title,
|
||||
optionValueIds: variant.optionValues.map((link) => link.optionValueId),
|
||||
priceAmount: variant.priceAmount,
|
||||
salePriceAmount: variant.salePriceAmount,
|
||||
compareAtAmount: variant.compareAtAmount,
|
||||
costAmount: variant.costAmount,
|
||||
weightGrams: variant.weightGrams,
|
||||
status: variant.status as VariantStatus,
|
||||
position: variant.position,
|
||||
onHand,
|
||||
reserved,
|
||||
// Always derived, never stored — the two numbers cannot disagree.
|
||||
available: onHand - reserved,
|
||||
};
|
||||
}),
|
||||
|
||||
images: row.images.map((image) => ({
|
||||
id: image.id,
|
||||
mediaId: image.mediaId,
|
||||
url: this.mediaUrl.url(image.media.storageKey),
|
||||
altText: image.media.altText,
|
||||
position: image.position,
|
||||
optionValueId: image.optionValueId,
|
||||
})),
|
||||
|
||||
attributes: row.attributes.map((attribute) => ({
|
||||
id: attribute.id,
|
||||
key: attribute.key,
|
||||
position: attribute.position,
|
||||
translations: byLocale(attribute.translations, (translation) => ({
|
||||
label: translation.label,
|
||||
value: translation.value,
|
||||
})),
|
||||
})),
|
||||
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
type AdminProductDetail,
|
||||
type AdminProductListItem,
|
||||
type OffsetPaginated,
|
||||
} from '@sport/types';
|
||||
import type {
|
||||
AdminProductListQuery,
|
||||
BulkUpdateVariantsInput,
|
||||
CreateProductInput,
|
||||
ProductImagesInput,
|
||||
ProductOptionInput,
|
||||
UpdateProductInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { AuditService } from '@/common/audit/audit.service';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { toDbLocale } from '@/common/i18n';
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
import { ProductsAdminMapper } from './products-admin.mapper';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
import { planVariantMatrix, slugify, type MatrixOption } from './variant-matrix';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsAdminService {
|
||||
private readonly logger = new Logger(ProductsAdminService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: ProductsRepository,
|
||||
private readonly mapper: ProductsAdminMapper,
|
||||
private readonly redis: RedisService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
// ---- Reads ---------------------------------------------------------------
|
||||
|
||||
async list(query: AdminProductListQuery): Promise<OffsetPaginated<AdminProductListItem>> {
|
||||
const where: Prisma.ProductWhereInput = {
|
||||
deletedAt: null,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.brandId ? { brandId: query.brandId } : {}),
|
||||
...(query.q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: query.q, mode: 'insensitive' } },
|
||||
{ translations: { some: { name: { contains: query.q, mode: 'insensitive' } } } },
|
||||
{ variants: { some: { sku: { contains: query.q, mode: 'insensitive' } } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, totalItems] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (query.page - 1) * query.perPage,
|
||||
take: query.perPage,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
isOnSale: true,
|
||||
publishedAt: true,
|
||||
updatedAt: true,
|
||||
translations: { where: { locale: toDbLocale(DEFAULT_LOCALE) } },
|
||||
brand: { select: { name: true } },
|
||||
images: {
|
||||
orderBy: { position: 'asc' },
|
||||
take: 1,
|
||||
select: { media: { select: { storageKey: true } } },
|
||||
},
|
||||
variants: {
|
||||
where: { status: 'ACTIVE', deletedAt: null },
|
||||
select: {
|
||||
currency: true,
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.mapper.toListItem(row)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdminProductDetail> {
|
||||
const row = await this.repository.findByIdForAdmin(id);
|
||||
if (!row) throw AppException.notFound('Product');
|
||||
|
||||
return this.mapper.toDetail(row);
|
||||
}
|
||||
|
||||
// ---- Writes --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a product and its full variant matrix in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a product that exists with half its variants is
|
||||
* worse than one that failed outright, because the operator cannot tell which
|
||||
* combinations are missing without inspecting the grid row by row.
|
||||
*/
|
||||
async create(input: CreateProductInput, actorUserId: string): Promise<AdminProductDetail> {
|
||||
const canonical = this.canonicalTranslation(input);
|
||||
const baseSlug = await this.uniqueSlug(canonical.slug ?? slugify(canonical.name));
|
||||
|
||||
const id = await this.prisma.$transaction(async (tx) => {
|
||||
const product = await tx.product.create({
|
||||
data: {
|
||||
name: canonical.name,
|
||||
slug: baseSlug,
|
||||
shortDescription: canonical.shortDescription ?? null,
|
||||
description: canonical.description ?? null,
|
||||
status: 'DRAFT',
|
||||
brandId: input.brandId ?? null,
|
||||
primaryCategoryId: input.primaryCategoryId ?? null,
|
||||
genderTargets: input.genderTargets,
|
||||
sportTypes: input.sportTypes,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await this.writeTranslations(tx, product.id, input, baseSlug);
|
||||
await this.writeCollections(tx, product.id, input.collectionIds ?? []);
|
||||
await this.writeAttributes(tx, product.id, input.attributes ?? []);
|
||||
const matrix = await this.writeOptions(tx, product.id, input.options ?? []);
|
||||
await this.syncVariants(
|
||||
tx,
|
||||
product.id,
|
||||
input.skuPrefix ?? baseSlug,
|
||||
input.basePriceAmount,
|
||||
matrix,
|
||||
);
|
||||
|
||||
return product.id;
|
||||
});
|
||||
|
||||
await this.afterWrite(actorUserId, 'product.create', id, { slug: baseSlug });
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateProductInput,
|
||||
actorUserId: string,
|
||||
): Promise<AdminProductDetail> {
|
||||
const existing = await this.prisma.product.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true, slug: true, status: true },
|
||||
});
|
||||
if (!existing) throw AppException.notFound('Product');
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const canonical = input.translations
|
||||
? this.canonicalTranslation(input as CreateProductInput)
|
||||
: null;
|
||||
|
||||
await tx.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(canonical ? { name: canonical.name } : {}),
|
||||
...(canonical?.shortDescription === undefined
|
||||
? {}
|
||||
: { shortDescription: canonical.shortDescription ?? null }),
|
||||
...(canonical?.description === undefined
|
||||
? {}
|
||||
: { description: canonical.description ?? null }),
|
||||
...(input.brandId === undefined ? {} : { brandId: input.brandId ?? null }),
|
||||
...(input.primaryCategoryId === undefined
|
||||
? {}
|
||||
: { primaryCategoryId: input.primaryCategoryId ?? null }),
|
||||
...(input.genderTargets ? { genderTargets: input.genderTargets } : {}),
|
||||
...(input.sportTypes ? { sportTypes: input.sportTypes } : {}),
|
||||
...(input.status ? this.statusPatch(input.status) : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (input.translations) {
|
||||
await this.writeTranslations(tx, id, input as CreateProductInput, existing.slug);
|
||||
}
|
||||
if (input.collectionIds) {
|
||||
await this.writeCollections(tx, id, input.collectionIds);
|
||||
}
|
||||
if (input.attributes) {
|
||||
await this.writeAttributes(tx, id, input.attributes);
|
||||
}
|
||||
if (input.options) {
|
||||
const matrix = await this.writeOptions(tx, id, input.options);
|
||||
await this.syncVariants(
|
||||
tx,
|
||||
id,
|
||||
input.skuPrefix ?? existing.slug,
|
||||
input.basePriceAmount ?? 0,
|
||||
matrix,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await this.afterWrite(actorUserId, 'product.update', id, { status: input.status });
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variant edits: SKU, prices, weight, status.
|
||||
*
|
||||
* Stock is deliberately absent — it moves through the inventory endpoint so
|
||||
* every change lands in the movement ledger. A price edit and a stock
|
||||
* correction are different events with different audit requirements.
|
||||
*/
|
||||
async updateVariants(
|
||||
productId: string,
|
||||
input: BulkUpdateVariantsInput,
|
||||
actorUserId: string,
|
||||
): Promise<AdminProductDetail> {
|
||||
const owned = await this.prisma.productVariant.findMany({
|
||||
where: { productId, id: { in: input.variants.map((variant) => variant.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Reject ids belonging to another product rather than silently skipping
|
||||
// them: a partial save that reports success is how pricing work vanishes.
|
||||
if (owned.length !== input.variants.length) {
|
||||
throw AppException.badRequest('One or more variants do not belong to this product.');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(
|
||||
input.variants.map((variant) =>
|
||||
this.prisma.productVariant.update({
|
||||
where: { id: variant.id },
|
||||
data: {
|
||||
...(variant.sku === undefined ? {} : { sku: variant.sku }),
|
||||
...(variant.barcode === undefined ? {} : { barcode: variant.barcode ?? null }),
|
||||
...(variant.priceAmount === undefined ? {} : { priceAmount: variant.priceAmount }),
|
||||
...(variant.salePriceAmount === undefined
|
||||
? {}
|
||||
: { salePriceAmount: variant.salePriceAmount ?? null }),
|
||||
...(variant.compareAtAmount === undefined
|
||||
? {}
|
||||
: { compareAtAmount: variant.compareAtAmount ?? null }),
|
||||
...(variant.costAmount === undefined ? {} : { costAmount: variant.costAmount ?? null }),
|
||||
...(variant.weightGrams === undefined
|
||||
? {}
|
||||
: { weightGrams: variant.weightGrams ?? null }),
|
||||
...(variant.status === undefined ? {} : { status: variant.status }),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.afterWrite(actorUserId, 'product.variants.update', productId, {
|
||||
count: input.variants.length,
|
||||
});
|
||||
|
||||
return this.getById(productId);
|
||||
}
|
||||
|
||||
async setImages(
|
||||
productId: string,
|
||||
input: ProductImagesInput,
|
||||
actorUserId: string,
|
||||
): Promise<AdminProductDetail> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.productImage.deleteMany({ where: { productId } });
|
||||
|
||||
if (input.images.length > 0) {
|
||||
await tx.productImage.createMany({
|
||||
data: input.images.map((image) => ({
|
||||
productId,
|
||||
mediaId: image.mediaId,
|
||||
position: image.position,
|
||||
optionValueId: image.optionValueId ?? null,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await this.afterWrite(actorUserId, 'product.images.update', productId, {
|
||||
count: input.images.length,
|
||||
});
|
||||
|
||||
return this.getById(productId);
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
id: string,
|
||||
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
||||
actorUserId: string,
|
||||
): Promise<AdminProductDetail> {
|
||||
const existing = await this.prisma.product.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: { id: true, _count: { select: { variants: true } } },
|
||||
});
|
||||
if (!existing) throw AppException.notFound('Product');
|
||||
|
||||
// Publishing something with nothing to buy produces a page with a dead
|
||||
// "Add to bag" button. Catch it here rather than on the storefront.
|
||||
if (status === 'ACTIVE' && existing._count.variants === 0) {
|
||||
throw AppException.badRequest('Add at least one variant before publishing.');
|
||||
}
|
||||
|
||||
await this.prisma.product.update({ where: { id }, data: this.statusPatch(status) });
|
||||
await this.afterWrite(actorUserId, `product.${status.toLowerCase()}`, id, { status });
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
private statusPatch(status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED') {
|
||||
return {
|
||||
status,
|
||||
// `publishedAt` is set once, on first publish, and never cleared —
|
||||
// unpublishing and republishing should not reset "new arrivals" ordering.
|
||||
...(status === 'ACTIVE' ? { publishedAt: new Date() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private canonicalTranslation(input: CreateProductInput) {
|
||||
const translations = input.translations;
|
||||
const canonical = translations[DEFAULT_LOCALE] ?? Object.values(translations)[0];
|
||||
|
||||
if (!canonical) {
|
||||
throw AppException.badRequest('Provide content for at least one language.');
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/** Appends `-2`, `-3`… until the base slug is free. */
|
||||
private async uniqueSlug(base: string, excludeId?: string): Promise<string> {
|
||||
const candidate = base || 'product';
|
||||
|
||||
for (let suffix = 0; suffix < 50; suffix += 1) {
|
||||
const slug = suffix === 0 ? candidate : `${candidate}-${suffix + 1}`;
|
||||
const clash = await this.prisma.product.findFirst({
|
||||
where: { slug, ...(excludeId ? { id: { not: excludeId } } : {}) },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!clash) return slug;
|
||||
}
|
||||
|
||||
throw AppException.conflict('Could not generate a unique slug for this product.');
|
||||
}
|
||||
|
||||
private async writeTranslations(
|
||||
tx: Prisma.TransactionClient,
|
||||
productId: string,
|
||||
input: CreateProductInput,
|
||||
fallbackSlug: string,
|
||||
): Promise<void> {
|
||||
for (const locale of LOCALES) {
|
||||
const fields = input.translations[locale];
|
||||
if (!fields) continue;
|
||||
|
||||
const slug = fields.slug ?? slugify(fields.name) ?? fallbackSlug;
|
||||
|
||||
await tx.productTranslation.upsert({
|
||||
where: { productId_locale: { productId, locale: toDbLocale(locale) } },
|
||||
update: {
|
||||
name: fields.name,
|
||||
slug,
|
||||
shortDescription: fields.shortDescription ?? null,
|
||||
description: fields.description ?? null,
|
||||
metaTitle: fields.metaTitle ?? null,
|
||||
metaDescription: fields.metaDescription ?? null,
|
||||
},
|
||||
create: {
|
||||
productId,
|
||||
locale: toDbLocale(locale),
|
||||
name: fields.name,
|
||||
slug,
|
||||
shortDescription: fields.shortDescription ?? null,
|
||||
description: fields.description ?? null,
|
||||
metaTitle: fields.metaTitle ?? null,
|
||||
metaDescription: fields.metaDescription ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCollections(
|
||||
tx: Prisma.TransactionClient,
|
||||
productId: string,
|
||||
collectionIds: readonly string[],
|
||||
): Promise<void> {
|
||||
await tx.productCollection.deleteMany({ where: { productId } });
|
||||
|
||||
if (collectionIds.length > 0) {
|
||||
await tx.productCollection.createMany({
|
||||
data: collectionIds.map((collectionId) => ({ productId, collectionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async writeAttributes(
|
||||
tx: Prisma.TransactionClient,
|
||||
productId: string,
|
||||
attributes: CreateProductInput['attributes'],
|
||||
): Promise<void> {
|
||||
const keys = attributes.map((attribute) => attribute.key);
|
||||
await tx.productAttribute.deleteMany({ where: { productId, key: { notIn: keys } } });
|
||||
|
||||
for (const attribute of attributes) {
|
||||
const canonical =
|
||||
attribute.translations[DEFAULT_LOCALE] ?? Object.values(attribute.translations)[0];
|
||||
if (!canonical) continue;
|
||||
|
||||
const row = await tx.productAttribute.upsert({
|
||||
where: { productId_key: { productId, key: attribute.key } },
|
||||
update: { label: canonical.label, value: canonical.value, position: attribute.position },
|
||||
create: {
|
||||
productId,
|
||||
key: attribute.key,
|
||||
label: canonical.label,
|
||||
value: canonical.value,
|
||||
position: attribute.position,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const fields = attribute.translations[locale];
|
||||
if (!fields) continue;
|
||||
|
||||
await tx.productAttributeTranslation.upsert({
|
||||
where: { attributeId_locale: { attributeId: row.id, locale: toDbLocale(locale) } },
|
||||
update: { label: fields.label, value: fields.value },
|
||||
create: {
|
||||
attributeId: row.id,
|
||||
locale: toDbLocale(locale),
|
||||
label: fields.label,
|
||||
value: fields.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles options and their values.
|
||||
*
|
||||
* Values are matched by `value` (the machine key) rather than by id, so an
|
||||
* editor that re-sends the whole option set does not orphan every variant
|
||||
* link — which would archive the entire matrix on a cosmetic edit.
|
||||
*/
|
||||
private async writeOptions(
|
||||
tx: Prisma.TransactionClient,
|
||||
productId: string,
|
||||
options: ProductOptionInput[],
|
||||
): Promise<MatrixOption[]> {
|
||||
const keys = options.map((option) => option.key);
|
||||
|
||||
await this.removeUnreferencedOptions(tx, productId, keys);
|
||||
|
||||
const matrix: MatrixOption[] = [];
|
||||
|
||||
for (const option of options) {
|
||||
const canonicalName =
|
||||
option.names[DEFAULT_LOCALE] ?? Object.values(option.names)[0] ?? option.key;
|
||||
|
||||
const optionRow = await tx.productOption.upsert({
|
||||
where: { productId_key: { productId, key: option.key } },
|
||||
update: { name: canonicalName, position: option.position },
|
||||
create: { productId, key: option.key, name: canonicalName, position: option.position },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const name = option.names[locale];
|
||||
if (!name) continue;
|
||||
|
||||
await tx.productOptionTranslation.upsert({
|
||||
where: { optionId_locale: { optionId: optionRow.id, locale: toDbLocale(locale) } },
|
||||
update: { name },
|
||||
create: { optionId: optionRow.id, locale: toDbLocale(locale), name },
|
||||
});
|
||||
}
|
||||
|
||||
await this.removeUnreferencedValues(
|
||||
tx,
|
||||
optionRow.id,
|
||||
option.values.map((value) => value.value),
|
||||
);
|
||||
|
||||
const matrixValues: MatrixOption['values'] = [];
|
||||
|
||||
for (const value of option.values) {
|
||||
const canonicalLabel =
|
||||
value.labels[DEFAULT_LOCALE] ?? Object.values(value.labels)[0] ?? value.value;
|
||||
|
||||
const valueRow = await tx.productOptionValue.upsert({
|
||||
where: { optionId_value: { optionId: optionRow.id, value: value.value } },
|
||||
update: {
|
||||
label: canonicalLabel,
|
||||
position: value.position,
|
||||
swatchHex: value.swatchHex ?? null,
|
||||
},
|
||||
create: {
|
||||
optionId: optionRow.id,
|
||||
value: value.value,
|
||||
label: canonicalLabel,
|
||||
position: value.position,
|
||||
swatchHex: value.swatchHex ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const label = value.labels[locale];
|
||||
if (!label) continue;
|
||||
|
||||
await tx.productOptionValueTranslation.upsert({
|
||||
where: {
|
||||
optionValueId_locale: { optionValueId: valueRow.id, locale: toDbLocale(locale) },
|
||||
},
|
||||
update: { label },
|
||||
create: { optionValueId: valueRow.id, locale: toDbLocale(locale), label },
|
||||
});
|
||||
}
|
||||
|
||||
matrixValues.push({
|
||||
id: valueRow.id,
|
||||
value: value.value,
|
||||
label: canonicalLabel,
|
||||
position: value.position,
|
||||
});
|
||||
}
|
||||
|
||||
matrix.push({
|
||||
key: option.key,
|
||||
position: option.position,
|
||||
values: matrixValues,
|
||||
});
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes options the operator removed — but only when nothing references
|
||||
* them.
|
||||
*
|
||||
* `ProductVariantOptionValue.optionValue` is `onDelete: Restrict`, and that is
|
||||
* deliberate: an archived variant's link is what makes it meaningful ("this
|
||||
* was Black / M") and order lines depend on it. So a value still referenced by
|
||||
* any variant is *retained* rather than deleted; it simply stops appearing in
|
||||
* the generated matrix, and the storefront filters it out because no active
|
||||
* variant offers it.
|
||||
*/
|
||||
private async removeUnreferencedOptions(
|
||||
tx: Prisma.TransactionClient,
|
||||
productId: string,
|
||||
keepKeys: string[],
|
||||
): Promise<void> {
|
||||
const doomed = await tx.productOption.findMany({
|
||||
where: { productId, key: { notIn: keepKeys } },
|
||||
select: {
|
||||
id: true,
|
||||
values: { select: { id: true, _count: { select: { variantLinks: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const option of doomed) {
|
||||
const referenced = option.values.some((value) => value._count.variantLinks > 0);
|
||||
if (referenced) continue;
|
||||
|
||||
await tx.productOption.delete({ where: { id: option.id } });
|
||||
}
|
||||
}
|
||||
|
||||
private async removeUnreferencedValues(
|
||||
tx: Prisma.TransactionClient,
|
||||
optionId: string,
|
||||
keepValues: string[],
|
||||
): Promise<void> {
|
||||
const doomed = await tx.productOptionValue.findMany({
|
||||
where: { optionId, value: { notIn: keepValues } },
|
||||
select: { id: true, _count: { select: { variantLinks: true } } },
|
||||
});
|
||||
|
||||
const deletable = doomed.filter((value) => value._count.variantLinks === 0).map((v) => v.id);
|
||||
|
||||
if (deletable.length > 0) {
|
||||
await tx.productOptionValue.deleteMany({ where: { id: { in: deletable } } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the variant matrix plan.
|
||||
*
|
||||
* The planning itself is a pure function (`planVariantMatrix`) with its own
|
||||
* tests; this method only performs the writes it describes.
|
||||
*/
|
||||
private async syncVariants(
|
||||
tx: Prisma.TransactionClient,
|
||||
productId: string,
|
||||
skuPrefix: string,
|
||||
basePriceAmount: number,
|
||||
options: MatrixOption[],
|
||||
): Promise<void> {
|
||||
const existing = await tx.productVariant.findMany({
|
||||
where: { productId },
|
||||
select: { id: true, sku: true, optionValues: { select: { optionValueId: true } } },
|
||||
});
|
||||
|
||||
// Built from what the operator submitted, NOT from every row in the
|
||||
// database — retained-for-history values must not regenerate variants.
|
||||
const plan = planVariantMatrix({
|
||||
options,
|
||||
existing: existing.map((variant) => ({
|
||||
id: variant.id,
|
||||
sku: variant.sku,
|
||||
optionValueIds: variant.optionValues.map((link) => link.optionValueId),
|
||||
})),
|
||||
skuPrefix,
|
||||
});
|
||||
|
||||
const optionIdByValueId = new Map<string, string>();
|
||||
const optionRows = await tx.productOption.findMany({
|
||||
where: { productId },
|
||||
select: { id: true, values: { select: { id: true } } },
|
||||
});
|
||||
for (const option of optionRows) {
|
||||
for (const value of option.values) {
|
||||
optionIdByValueId.set(value.id, option.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of plan.created) {
|
||||
const variant = await tx.productVariant.create({
|
||||
data: {
|
||||
productId,
|
||||
sku: await uniqueSku(tx, row.suggestedSku),
|
||||
title: row.title,
|
||||
currency: 'VND',
|
||||
priceAmount: basePriceAmount,
|
||||
position: row.position,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.productVariantOptionValue.createMany({
|
||||
data: row.optionValueIds.map((optionValueId) => ({
|
||||
variantId: variant.id,
|
||||
optionId: optionIdByValueId.get(optionValueId) ?? '',
|
||||
optionValueId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep display order and titles in step with the current option ordering.
|
||||
for (const row of plan.kept) {
|
||||
if (!row.existingId) continue;
|
||||
await tx.productVariant.update({
|
||||
where: { id: row.existingId },
|
||||
data: { position: row.position, title: row.title, status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
|
||||
if (plan.archivedIds.length > 0) {
|
||||
// Archived, never deleted — order lines reference these ids.
|
||||
await tx.productVariant.updateMany({
|
||||
where: { id: { in: plan.archivedIds } },
|
||||
data: { status: 'ARCHIVED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the price projection, drops the catalog cache, and records the
|
||||
* change. Every write path ends here.
|
||||
*/
|
||||
private async afterWrite(
|
||||
actorUserId: string,
|
||||
action: string,
|
||||
productId: string,
|
||||
changes: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.repository.recomputePricing(productId);
|
||||
|
||||
const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix());
|
||||
this.logger.log(`${action} on ${productId}; dropped ${dropped} catalog cache key(s)`);
|
||||
|
||||
this.audit.record({
|
||||
actorUserId,
|
||||
action,
|
||||
resourceType: 'Product',
|
||||
resourceId: productId,
|
||||
changes: changes as Prisma.InputJsonValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SKUs are unique across the whole catalog, so a generated one can collide with
|
||||
* a product that already used that colour/size naming. Suffix until free.
|
||||
*/
|
||||
async function uniqueSku(tx: Prisma.TransactionClient, base: string): Promise<string> {
|
||||
for (let suffix = 0; suffix < 100; suffix += 1) {
|
||||
const sku = suffix === 0 ? base : `${base}-${suffix + 1}`;
|
||||
const clash = await tx.productVariant.findUnique({ where: { sku }, select: { id: true } });
|
||||
if (!clash) return sku;
|
||||
}
|
||||
|
||||
throw AppException.conflict(`Could not generate a unique SKU from "${base}".`);
|
||||
}
|
||||
@@ -63,9 +63,24 @@ export class ProductsMapper {
|
||||
breadcrumbs: readonly Breadcrumb[],
|
||||
): StorefrontProduct {
|
||||
const translation = pickTranslation(row.translations, locale);
|
||||
const options = row.options.map((option) => this.toOption(option, locale));
|
||||
const variants = row.variants.map((variant) => this.toStorefrontVariant(variant, locale));
|
||||
|
||||
/**
|
||||
* Only option values that at least one *active* variant offers.
|
||||
*
|
||||
* Removing a colourway retains its option value when archived variants
|
||||
* still reference it (order history depends on the link). Without this
|
||||
* filter the shopper would see a swatch that can never be selected —
|
||||
* a permanently disabled control with no explanation.
|
||||
*/
|
||||
const sellableValueIds = new Set(
|
||||
variants.flatMap((variant) => variant.optionValues.map((ov) => ov.optionValueId)),
|
||||
);
|
||||
|
||||
const options = row.options
|
||||
.map((option) => this.toOption(option, locale, sellableValueIds))
|
||||
.filter((option) => option.values.length > 0);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: coalesceRequired(translation?.name, row.name),
|
||||
@@ -218,21 +233,29 @@ export class ProductsMapper {
|
||||
};
|
||||
}
|
||||
|
||||
private toOption(option: ProductDetailRow['options'][number], locale: Locale): ProductOption {
|
||||
private toOption(
|
||||
option: ProductDetailRow['options'][number],
|
||||
locale: Locale,
|
||||
sellableValueIds: ReadonlySet<string>,
|
||||
): ProductOption {
|
||||
return {
|
||||
id: option.id,
|
||||
name: coalesceRequired(pickTranslation(option.translations, locale)?.name, option.name),
|
||||
key: option.key,
|
||||
position: option.position,
|
||||
values: option.values.map((value) => ({
|
||||
id: value.id,
|
||||
optionId: value.optionId,
|
||||
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
|
||||
value: value.value,
|
||||
position: value.position,
|
||||
swatchHex: value.swatchHex,
|
||||
swatchImageUrl: value.swatchImage ? this.mediaUrl.url(value.swatchImage.storageKey) : null,
|
||||
})),
|
||||
values: option.values
|
||||
.filter((value) => sellableValueIds.has(value.id))
|
||||
.map((value) => ({
|
||||
id: value.id,
|
||||
optionId: value.optionId,
|
||||
label: coalesceRequired(pickTranslation(value.translations, locale)?.label, value.label),
|
||||
value: value.value,
|
||||
position: value.position,
|
||||
swatchHex: value.swatchHex,
|
||||
swatchImageUrl: value.swatchImage
|
||||
? this.mediaUrl.url(value.swatchImage.storageKey)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { CategoriesModule } from '@/modules/categories/categories.module';
|
||||
|
||||
import { ProductsAdminController } from './products-admin.controller';
|
||||
import { ProductsAdminMapper } from './products-admin.mapper';
|
||||
import { ProductsAdminService } from './products-admin.service';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsMapper } from './products.mapper';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
@@ -11,13 +14,21 @@ import { ProductsService } from './products.service';
|
||||
* ProductsModule — owns `products`, `product_translations`, `product_options`,
|
||||
* `product_option_values`, `product_images` and `product_attributes`.
|
||||
*
|
||||
* The catalog aggregate root. Other modules reference a product by id and read
|
||||
* through this module's public service.
|
||||
* The catalog aggregate root. Read and write live in the same module but in
|
||||
* separate services: they have different consumers, different payloads and very
|
||||
* different caching rules, and merging them would mean the storefront query
|
||||
* grows admin-only joins it must never expose.
|
||||
*/
|
||||
@Module({
|
||||
imports: [CategoriesModule],
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService, ProductsRepository, ProductsMapper],
|
||||
controllers: [ProductsController, ProductsAdminController],
|
||||
providers: [
|
||||
ProductsService,
|
||||
ProductsAdminService,
|
||||
ProductsRepository,
|
||||
ProductsMapper,
|
||||
ProductsAdminMapper,
|
||||
],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
|
||||
@@ -413,6 +413,81 @@ export class ProductsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Full admin payload: every locale, cost prices, stock, and DRAFT/ARCHIVED
|
||||
* products included. Deliberately separate from the storefront query, which
|
||||
* must never see cost price or unpublished rows.
|
||||
*/
|
||||
findByIdForAdmin(id: string) {
|
||||
return this.prisma.product.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
brandId: true,
|
||||
primaryCategoryId: true,
|
||||
genderTargets: true,
|
||||
sportTypes: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
translations: true,
|
||||
collections: { select: { collectionId: true } },
|
||||
options: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
position: true,
|
||||
translations: true,
|
||||
values: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
position: true,
|
||||
swatchHex: true,
|
||||
translations: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
images: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
mediaId: true,
|
||||
position: true,
|
||||
optionValueId: true,
|
||||
media: { select: { storageKey: true, altText: true } },
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, key: true, position: true, translations: true },
|
||||
},
|
||||
variants: {
|
||||
orderBy: { position: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
barcode: true,
|
||||
title: true,
|
||||
priceAmount: true,
|
||||
salePriceAmount: true,
|
||||
compareAtAmount: true,
|
||||
costAmount: true,
|
||||
weightGrams: true,
|
||||
status: true,
|
||||
position: true,
|
||||
optionValues: { select: { optionValueId: true } },
|
||||
stockLevels: { select: { onHand: true, reserved: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** All translated slugs for a locale — feeds `generateStaticParams`/sitemaps. */
|
||||
findAllSlugs(locale: Locale) {
|
||||
return this.prisma.productTranslation.findMany({
|
||||
@@ -534,3 +609,6 @@ export class ProductsRepository {
|
||||
|
||||
export type ProductListRow = Awaited<ReturnType<ProductsRepository['findList']>>[number];
|
||||
export type ProductDetailRow = NonNullable<Awaited<ReturnType<ProductsRepository['findBySlug']>>>;
|
||||
export type AdminProductRow = NonNullable<
|
||||
Awaited<ReturnType<ProductsRepository['findByIdForAdmin']>>
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
buildSku,
|
||||
combinationSignature,
|
||||
planVariantMatrix,
|
||||
slugify,
|
||||
type MatrixOption,
|
||||
} from './variant-matrix';
|
||||
|
||||
/**
|
||||
* The variant matrix decides what is purchasable. A bug here either deletes a
|
||||
* merchandiser's pricing work or resurrects products that should be gone, so
|
||||
* the behaviour is pinned rather than left to inspection.
|
||||
*/
|
||||
const colour: MatrixOption = {
|
||||
key: 'colour',
|
||||
position: 0,
|
||||
values: [
|
||||
{ id: 'c-black', value: 'black', label: 'Black', position: 0 },
|
||||
{ id: 'c-white', value: 'white', label: 'White', position: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const size: MatrixOption = {
|
||||
key: 'size',
|
||||
position: 1,
|
||||
values: [
|
||||
{ id: 's-s', value: 's', label: 'S', position: 0 },
|
||||
{ id: 's-m', value: 'm', label: 'M', position: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('planVariantMatrix', () => {
|
||||
it('generates every combination for a fresh product', () => {
|
||||
const plan = planVariantMatrix({ options: [colour, size], existing: [], skuPrefix: 'TEE' });
|
||||
|
||||
expect(plan.created).toHaveLength(4);
|
||||
expect(plan.kept).toHaveLength(0);
|
||||
expect(plan.archivedIds).toHaveLength(0);
|
||||
expect(plan.created.map((row) => row.title)).toEqual([
|
||||
'Black / S',
|
||||
'Black / M',
|
||||
'White / S',
|
||||
'White / M',
|
||||
]);
|
||||
});
|
||||
|
||||
it('orders combinations with the first option varying slowest', () => {
|
||||
// "all sizes of black, then all sizes of white" — not interleaved, which is
|
||||
// what makes the generated grid readable.
|
||||
const plan = planVariantMatrix({ options: [colour, size], existing: [], skuPrefix: 'TEE' });
|
||||
expect(plan.created.map((row) => row.suggestedSku)).toEqual([
|
||||
'TEE-BLACK-S',
|
||||
'TEE-BLACK-M',
|
||||
'TEE-WHITE-S',
|
||||
'TEE-WHITE-M',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps existing variants so their price and stock survive an edit', () => {
|
||||
const existing = [
|
||||
{ id: 'v1', sku: 'TEE-BLACK-S', optionValueIds: ['c-black', 's-s'] },
|
||||
{ id: 'v2', sku: 'TEE-BLACK-M', optionValueIds: ['c-black', 's-m'] },
|
||||
];
|
||||
|
||||
const plan = planVariantMatrix({ options: [colour, size], existing, skuPrefix: 'TEE' });
|
||||
|
||||
expect(plan.kept.map((row) => row.existingId)).toEqual(['v1', 'v2']);
|
||||
expect(plan.created).toHaveLength(2);
|
||||
expect(plan.archivedIds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches combinations regardless of option-value order', () => {
|
||||
// A reordered option list must not read as a brand-new set of variants —
|
||||
// that would wipe every price and stock level on the product.
|
||||
const existing = [{ id: 'v1', sku: 'X', optionValueIds: ['s-s', 'c-black'] }];
|
||||
|
||||
const plan = planVariantMatrix({ options: [colour, size], existing, skuPrefix: 'TEE' });
|
||||
|
||||
expect(plan.kept).toHaveLength(1);
|
||||
expect(plan.kept[0]?.existingId).toBe('v1');
|
||||
});
|
||||
|
||||
it('archives combinations that no longer exist rather than deleting them', () => {
|
||||
// Order lines reference variant ids; deleting one breaks history.
|
||||
const existing = [
|
||||
{ id: 'v1', sku: 'TEE-BLACK-S', optionValueIds: ['c-black', 's-s'] },
|
||||
{ id: 'gone', sku: 'TEE-RED-S', optionValueIds: ['c-red', 's-s'] },
|
||||
];
|
||||
|
||||
const plan = planVariantMatrix({ options: [colour, size], existing, skuPrefix: 'TEE' });
|
||||
|
||||
expect(plan.archivedIds).toEqual(['gone']);
|
||||
});
|
||||
|
||||
it('refuses to plan when an option has no values, instead of archiving everything', () => {
|
||||
const existing = [{ id: 'v1', sku: 'X', optionValueIds: ['c-black', 's-s'] }];
|
||||
const empty: MatrixOption = { key: 'size', position: 1, values: [] };
|
||||
|
||||
const plan = planVariantMatrix({ options: [colour, empty], existing, skuPrefix: 'TEE' });
|
||||
|
||||
// An empty option is a half-finished edit, not an instruction to unpublish
|
||||
// every variant on the product.
|
||||
expect(plan.archivedIds).toHaveLength(0);
|
||||
expect(plan.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scales to a third option without special-casing', () => {
|
||||
const width: MatrixOption = {
|
||||
key: 'width',
|
||||
position: 2,
|
||||
values: [
|
||||
{ id: 'w-r', value: 'regular', label: 'Regular', position: 0 },
|
||||
{ id: 'w-w', value: 'wide', label: 'Wide', position: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const plan = planVariantMatrix({
|
||||
options: [colour, size, width],
|
||||
existing: [],
|
||||
skuPrefix: 'TEE',
|
||||
});
|
||||
|
||||
expect(plan.created).toHaveLength(8);
|
||||
expect(plan.created[0]?.title).toBe('Black / S / Regular');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combinationSignature', () => {
|
||||
it('is order-independent', () => {
|
||||
expect(combinationSignature(['b', 'a'])).toBe(combinationSignature(['a', 'b']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSku', () => {
|
||||
it('uppercases and hyphenates', () => {
|
||||
expect(buildSku('vel art', ['black', 'xl'])).toBe('VEL-ART-BLACK-XL');
|
||||
});
|
||||
|
||||
it('strips diacritics and punctuation', () => {
|
||||
expect(buildSku('Áo', ['xanh neon'])).toBe('AO-XANH-NEON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('handles Vietnamese diacritics including đ', () => {
|
||||
expect(slugify('Áo Chạy Bộ Aero')).toBe('ao-chay-bo-aero');
|
||||
expect(slugify('Giày Đá Bóng')).toBe('giay-da-bong');
|
||||
});
|
||||
|
||||
it('collapses punctuation and trims separators', () => {
|
||||
expect(slugify(' Tempo — Split/Short! ')).toBe('tempo-split-short');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Variant matrix generation.
|
||||
*
|
||||
* A pure function on purpose: this is the single most consequential piece of
|
||||
* logic in the catalog, and it needs to be testable without a database, a Nest
|
||||
* container or a fixture. Everything it touches is plain data.
|
||||
*
|
||||
* The rule it enforces (ADR-0003): a product's purchasable units are exactly
|
||||
* the cartesian product of its option values. Adding a colour must not silently
|
||||
* destroy the price and stock of every existing size.
|
||||
*/
|
||||
|
||||
export interface MatrixOption {
|
||||
key: string;
|
||||
position: number;
|
||||
values: { id: string; value: string; label: string; position: number }[];
|
||||
}
|
||||
|
||||
export interface ExistingVariant {
|
||||
id: string;
|
||||
sku: string;
|
||||
/** The option-value ids this variant resolves to. Order-independent. */
|
||||
optionValueIds: string[];
|
||||
}
|
||||
|
||||
export interface MatrixRow {
|
||||
/** Present when this combination already exists. */
|
||||
existingId: string | null;
|
||||
optionValueIds: string[];
|
||||
/** Stable identity for the combination, independent of option order. */
|
||||
signature: string;
|
||||
/** "Black / M" — from the option values, in option order. */
|
||||
title: string;
|
||||
suggestedSku: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface MatrixPlan {
|
||||
/** Combinations that already exist and should be kept as-is. */
|
||||
kept: MatrixRow[];
|
||||
/** Combinations that do not exist yet and must be created. */
|
||||
created: MatrixRow[];
|
||||
/**
|
||||
* Variants whose combination no longer exists.
|
||||
*
|
||||
* ARCHIVED, never deleted: order lines reference variant ids, and deleting
|
||||
* one would either break a historical order or cascade into it. An archived
|
||||
* variant stops being sellable and stops appearing anywhere except history.
|
||||
*/
|
||||
archivedIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Order-independent identity for a combination.
|
||||
*
|
||||
* Sorting the ids means `[black, M]` and `[M, black]` produce the same
|
||||
* signature — without it, a reordered option list would look like an entirely
|
||||
* new set of variants and wipe every price and stock level on the product.
|
||||
*/
|
||||
export function combinationSignature(optionValueIds: readonly string[]): string {
|
||||
return [...optionValueIds].sort().join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full matrix and diffs it against what exists.
|
||||
*
|
||||
* Returns a *plan* rather than performing writes, so the caller can apply it in
|
||||
* one transaction and so the decision is inspectable in a test.
|
||||
*/
|
||||
export function planVariantMatrix(params: {
|
||||
options: MatrixOption[];
|
||||
existing: ExistingVariant[];
|
||||
skuPrefix: string;
|
||||
}): MatrixPlan {
|
||||
const options = [...params.options].sort((a, b) => a.position - b.position);
|
||||
|
||||
// A product with no options has no combinations, so every existing variant
|
||||
// would be archived. That is almost certainly a mistake in the caller rather
|
||||
// than an intent to unpublish everything, so refuse instead.
|
||||
if (options.length === 0 || options.some((option) => option.values.length === 0)) {
|
||||
return { kept: [], created: [], archivedIds: [] };
|
||||
}
|
||||
|
||||
const existingBySignature = new Map(
|
||||
params.existing.map((variant) => [combinationSignature(variant.optionValueIds), variant]),
|
||||
);
|
||||
|
||||
const combinations = cartesian(options.map((option) => option.values));
|
||||
|
||||
const kept: MatrixRow[] = [];
|
||||
const created: MatrixRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
combinations.forEach((combination, index) => {
|
||||
const optionValueIds = combination.map((value) => value.id);
|
||||
const signature = combinationSignature(optionValueIds);
|
||||
seen.add(signature);
|
||||
|
||||
const row: MatrixRow = {
|
||||
existingId: existingBySignature.get(signature)?.id ?? null,
|
||||
optionValueIds,
|
||||
signature,
|
||||
title: combination.map((value) => value.label).join(' / '),
|
||||
suggestedSku: buildSku(
|
||||
params.skuPrefix,
|
||||
combination.map((value) => value.value),
|
||||
),
|
||||
position: index,
|
||||
};
|
||||
|
||||
if (row.existingId) {
|
||||
kept.push(row);
|
||||
} else {
|
||||
created.push(row);
|
||||
}
|
||||
});
|
||||
|
||||
const archivedIds = params.existing
|
||||
.filter((variant) => !seen.has(combinationSignature(variant.optionValueIds)))
|
||||
.map((variant) => variant.id);
|
||||
|
||||
return { kept, created, archivedIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cartesian product, preserving option order so the first option varies
|
||||
* slowest — which is what makes the generated grid read as "all sizes of black,
|
||||
* then all sizes of white" rather than an interleaved jumble.
|
||||
*/
|
||||
function cartesian<T>(groups: T[][]): T[][] {
|
||||
return groups.reduce<T[][]>(
|
||||
(accumulator, group) =>
|
||||
accumulator.flatMap((combination) => group.map((item) => [...combination, item])),
|
||||
[[]],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `VEL-ART` + `black` + `m` → `VEL-ART-BLACK-M`.
|
||||
*
|
||||
* A suggestion only. SKUs frequently have to match an existing ERP or
|
||||
* warehouse scheme, so the editor always lets an operator override it — and the
|
||||
* database, not this function, is what guarantees uniqueness.
|
||||
*/
|
||||
export function buildSku(prefix: string, values: readonly string[]): string {
|
||||
const clean = (input: string) =>
|
||||
input
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^A-Za-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toUpperCase();
|
||||
|
||||
return [clean(prefix), ...values.map(clean)].filter(Boolean).join('-');
|
||||
}
|
||||
|
||||
/** `Áo Chạy Bộ Aero` → `ao-chay-bo-aero`. Vietnamese diacritics included. */
|
||||
export function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
// đ/Đ has no combining form, so NFD leaves it intact.
|
||||
.replace(/[đĐ]/g, 'd')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 180)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user