feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,118 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { InventoryService } from '../application/inventory-service.js';
import {
InsufficientReservedStockError,
InsufficientStockError,
InvalidStockQuantityError,
} from '../domain/errors.js';
import type { StockItem } from '../domain/stock.js';
import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js';
export interface InventoryRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const variantParamSchema = z.object({ variantId: z.uuid() });
const availabilityQuerySchema = z.object({
quantity: z.coerce.number().int().positive().default(1),
});
const stockBodySchema = z.object({ quantity: z.number().int().min(0) });
const stockCommandBodySchema = z.object({ quantity: z.number().int().positive() });
export async function registerInventoryRoutes(
app: FastifyInstance,
deps: InventoryRoutesDeps,
): Promise<void> {
const inventory = new InventoryService(new PgInventoryRepository(deps.pool));
app.get('/inventory/:variantId/availability', async (request, reply) => {
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(availabilityQuerySchema, request.query);
const availability = await inventory.checkAvailability(variantId, quantity);
return reply.send(availability);
});
app.put('/inventory/:variantId/stock', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockBodySchema, request.body);
try {
const item = await inventory.setAvailable({ variantId, quantity });
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
app.post('/inventory/:variantId/reservations', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.reserve({ variantId, quantity });
return reply.code(201).send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
app.post('/inventory/:variantId/reservations/release', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.release({ variantId, quantity });
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
app.post('/inventory/:variantId/reservations/confirm', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.confirm({ variantId, quantity });
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
}
function mapInventoryError(error: unknown): Error {
if (error instanceof InsufficientStockError) {
return new AppError(409, 'INSUFFICIENT_STOCK', error.message);
}
if (error instanceof InsufficientReservedStockError) {
return new AppError(409, 'INSUFFICIENT_RESERVED_STOCK', error.message);
}
if (error instanceof InvalidStockQuantityError) {
return new AppError(422, 'INVALID_STOCK_QUANTITY', error.message);
}
return error instanceof Error ? error : new Error('Unknown inventory error');
}
function serializeStockItem(item: StockItem) {
return {
id: item.id,
variantId: item.variantId,
available: item.available,
reserved: item.reserved,
sold: item.sold,
incoming: item.incoming,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,70 @@
import {
InsufficientReservedStockError,
InsufficientStockError,
InvalidStockQuantityError,
} from '../domain/errors.js';
import type {
InventoryRepository,
InventoryService as InventoryServicePort,
} from '../domain/ports.js';
import type {
Availability,
SetAvailableStockCommand,
StockCommand,
StockItem,
} from '../domain/stock.js';
export class InventoryService implements InventoryServicePort {
constructor(private readonly repository: InventoryRepository) {}
async checkAvailability(variantId: string, quantity: number): Promise<Availability> {
ensurePositiveQuantity(quantity);
const item = await this.repository.findByVariantId(variantId);
const availableQuantity = item?.available ?? 0;
return { available: availableQuantity >= quantity, availableQuantity };
}
async reserve(input: StockCommand): Promise<StockItem> {
ensurePositiveQuantity(input.quantity);
const item = await this.repository.reserve(input);
if (!item) {
throw new InsufficientStockError();
}
return item;
}
async release(input: StockCommand): Promise<StockItem> {
ensurePositiveQuantity(input.quantity);
const item = await this.repository.release(input);
if (!item) {
throw new InsufficientReservedStockError();
}
return item;
}
async confirm(input: StockCommand): Promise<StockItem> {
ensurePositiveQuantity(input.quantity);
const item = await this.repository.confirm(input);
if (!item) {
throw new InsufficientReservedStockError();
}
return item;
}
async setAvailable(input: SetAvailableStockCommand): Promise<StockItem> {
ensureNonNegativeInteger(input.quantity);
return this.repository.setAvailable(input);
}
}
function ensurePositiveQuantity(quantity: number): void {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new InvalidStockQuantityError();
}
}
function ensureNonNegativeInteger(quantity: number): void {
if (!Number.isInteger(quantity) || quantity < 0) {
throw new InvalidStockQuantityError();
}
}

View File

@@ -0,0 +1,20 @@
export class InvalidStockQuantityError extends Error {
constructor() {
super('Stock quantity must be a positive integer');
this.name = 'InvalidStockQuantityError';
}
}
export class InsufficientStockError extends Error {
constructor() {
super('Insufficient stock available');
this.name = 'InsufficientStockError';
}
}
export class InsufficientReservedStockError extends Error {
constructor() {
super('Insufficient reserved stock');
this.name = 'InsufficientReservedStockError';
}
}

View File

@@ -0,0 +1,17 @@
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
export interface InventoryService {
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
reserve(input: StockCommand): Promise<StockItem>;
release(input: StockCommand): Promise<StockItem>;
confirm(input: StockCommand): Promise<StockItem>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
}
export interface InventoryRepository {
findByVariantId(variantId: string): Promise<StockItem | undefined>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
reserve(input: StockCommand): Promise<StockItem | undefined>;
release(input: StockCommand): Promise<StockItem | undefined>;
confirm(input: StockCommand): Promise<StockItem | undefined>;
}

View File

@@ -0,0 +1,30 @@
export type StockState = 'available' | 'reserved' | 'sold' | 'incoming';
export interface StockItem {
id: string;
variantId: string;
available: number;
reserved: number;
sold: number;
incoming: number;
createdAt: Date;
updatedAt: Date;
}
export interface StockCommand {
variantId: string;
quantity: number;
}
export type SetAvailableStockCommand = StockCommand;
export interface Availability {
available: boolean;
availableQuantity: number;
}
export function assertPositiveQuantity(quantity: number): void {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error('Stock quantity must be a positive integer');
}
}

View File

@@ -0,0 +1,16 @@
/** Public API of the inventory module. */
import type pg from 'pg';
import { InventoryService } from './application/inventory-service.js';
import { PgInventoryRepository } from './infrastructure/pg-inventory-repository.js';
export { registerInventoryRoutes, type InventoryRoutesDeps } from './api/inventory.routes.js';
export { InventoryService } from './application/inventory-service.js';
export type {
InventoryRepository,
InventoryService as InventoryServicePort,
} from './domain/ports.js';
export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js';
export function createInventoryService(pool: pg.Pool): InventoryService {
return new InventoryService(new PgInventoryRepository(pool));
}

View File

@@ -0,0 +1,147 @@
import type pg from 'pg';
import type { InventoryRepository } from '../domain/ports.js';
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
interface StockRow {
id: string;
variant_id: string;
available: number;
reserved: number;
sold: number;
incoming: number;
created_at: Date;
updated_at: Date;
}
type InventoryOperation = 'reserve' | 'release' | 'confirm' | 'set_available';
export class PgInventoryRepository implements InventoryRepository {
constructor(private readonly pool: pg.Pool) {}
async findByVariantId(variantId: string): Promise<StockItem | undefined> {
const result = await this.pool.query<StockRow>(
'SELECT * FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
const row = result.rows[0];
return row ? toStockItem(row) : undefined;
}
async setAvailable(input: SetAvailableStockCommand): Promise<StockItem> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query<StockRow>(
`INSERT INTO inventory_stock (variant_id, available)
VALUES ($1, $2)
ON CONFLICT (variant_id) DO UPDATE
SET available = EXCLUDED.available, updated_at = now()
RETURNING *`,
[input.variantId, input.quantity],
);
const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row');
await insertMovement(client, input.variantId, 'set_available', input.quantity);
await client.query('COMMIT');
return item;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async reserve(input: StockCommand): Promise<StockItem | undefined> {
return this.applyAtomicOperation(input, 'reserve', (client, command) =>
client.query<StockRow>(
`UPDATE inventory_stock
SET available = available - $2, reserved = reserved + $2, updated_at = now()
WHERE variant_id = $1 AND available >= $2
RETURNING *`,
[command.variantId, command.quantity],
),
);
}
async release(input: StockCommand): Promise<StockItem | undefined> {
return this.applyAtomicOperation(input, 'release', (client, command) =>
client.query<StockRow>(
`UPDATE inventory_stock
SET reserved = reserved - $2, available = available + $2, updated_at = now()
WHERE variant_id = $1 AND reserved >= $2
RETURNING *`,
[command.variantId, command.quantity],
),
);
}
async confirm(input: StockCommand): Promise<StockItem | undefined> {
return this.applyAtomicOperation(input, 'confirm', (client, command) =>
client.query<StockRow>(
`UPDATE inventory_stock
SET reserved = reserved - $2, sold = sold + $2, updated_at = now()
WHERE variant_id = $1 AND reserved >= $2
RETURNING *`,
[command.variantId, command.quantity],
),
);
}
private async applyAtomicOperation(
input: StockCommand,
operation: InventoryOperation,
update: (client: pg.PoolClient, input: StockCommand) => Promise<pg.QueryResult<StockRow>>,
): Promise<StockItem | undefined> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await update(client, input);
const row = result.rows[0];
if (!row) {
await client.query('ROLLBACK');
return undefined;
}
await insertMovement(client, input.variantId, operation, input.quantity);
await client.query('COMMIT');
return toStockItem(row);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
async function insertMovement(
client: pg.PoolClient,
variantId: string,
operation: InventoryOperation,
quantity: number,
): Promise<void> {
await client.query(
`INSERT INTO inventory_movements (variant_id, operation, quantity)
VALUES ($1, $2, $3)`,
[variantId, operation, quantity],
);
}
function rowOrThrow(row: StockRow | undefined, message: string): StockItem {
if (!row) {
throw new Error(message);
}
return toStockItem(row);
}
function toStockItem(row: StockRow): StockItem {
return {
id: row.id,
variantId: row.variant_id,
available: row.available,
reserved: row.reserved,
sold: row.sold,
incoming: row.incoming,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,29 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
const entries = readdirSync(dir);
return entries.flatMap((entry) => {
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) {
return sourceFiles(path);
}
return path.endsWith('.ts') ? [path] : [];
});
}
describe('inventory module boundary', () => {
it('keeps catalog isolated from inventory internals and tables (AC3)', () => {
const catalogDir = new URL('../../catalog', import.meta.url);
for (const file of sourceFiles(catalogDir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toContain('inventory_');
expect(source).not.toMatch(
/modules\/inventory\/(?:api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { InventoryService } from '../application/inventory-service.js';
import {
InsufficientReservedStockError,
InsufficientStockError,
InvalidStockQuantityError,
} from '../domain/errors.js';
import type { InventoryRepository } from '../domain/ports.js';
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
const STOCK: StockItem = {
id: 'stock-1',
variantId: 'variant-1',
available: 1,
reserved: 0,
sold: 0,
incoming: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
function repository(overrides: Partial<InventoryRepository> = {}): InventoryRepository {
return {
findByVariantId: async () => STOCK,
setAvailable: async (_input: SetAvailableStockCommand) => STOCK,
reserve: async (_input: StockCommand) => STOCK,
release: async (_input: StockCommand) => STOCK,
confirm: async (_input: StockCommand) => STOCK,
...overrides,
};
}
describe('InventoryService', () => {
it('rejects non-positive reserve quantities before persistence', async () => {
const service = new InventoryService(repository());
await expect(service.reserve({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf(
InvalidStockQuantityError,
);
});
it('maps failed reservation to insufficient stock', async () => {
const service = new InventoryService(repository({ reserve: async () => undefined }));
await expect(service.reserve({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
InsufficientStockError,
);
});
it('maps failed release to insufficient reserved stock', async () => {
const service = new InventoryService(repository({ release: async () => undefined }));
await expect(service.release({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
InsufficientReservedStockError,
);
});
it('checks availability without mutating stock', async () => {
const service = new InventoryService(repository());
await expect(service.checkAvailability('variant-1', 1)).resolves.toEqual({
available: true,
availableQuantity: 1,
});
await expect(service.checkAvailability('variant-1', 2)).resolves.toEqual({
available: false,
availableQuantity: 1,
});
});
});