Add Products/Catalog module (MVP): migration, repository, API

Ports the legacy products table's core shape (eden.md §2 §1.1, Master
Prompt §6.3) faithfully but MVP-scoped: product_code sequence, the 5 fixed
price tiers (price_0/12/24/36/48), impl_unit_price, and stock/serial/
asset-tag/MAC tracking flags. Leaves out legacy telecom-billing specifics
(service_type, metered, tariff_rates, ixc_product_code) for a later phase.

Mirrors the Companies module's structure and conventions exactly: COALESCE
partial updates, JwtAuthGuard/PermissionGuard with open read / gated write
(products:edit) per ADR-0004, product.created/product.updated audit events.
Money fields are validated as non-negative NUMERIC in the DTO only — no
pricing/calculation logic here, that belongs to the future Quotes phase.

Does not touch apps/api/src/app.module.ts or packages/database/src/index.ts
(wiring is left to integration) to avoid conflicting with sibling agents'
concurrent edits to those files; the ProductsService deep-imports the
repository module directly instead of via the shared barrel, noted in a
code comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:38:35 -03:00
parent 8d81cf506b
commit e5fd75c44a
7 changed files with 430 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
import { query } from "../pool.js";
export interface Product {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
product_code: number;
name: string;
description: string | null;
product_type: string;
product_group: string | null;
product_subgroup: string | null;
brand: string | null;
unit: string;
active: boolean;
discontinued: boolean;
tracks_stock: boolean;
tracks_serial: boolean;
tracks_asset_tag: boolean;
tracks_mac: boolean;
price_0: string | null;
price_12: string | null;
price_24: string | null;
price_36: string | null;
price_48: string | null;
impl_unit_price: string;
}
export async function listProducts(includeInactive = false): Promise<Product[]> {
const { rows } = await query<Product>(
includeInactive
? "SELECT * FROM products ORDER BY created_at ASC"
: "SELECT * FROM products WHERE active = true ORDER BY created_at ASC",
);
return rows;
}
export async function getProductById(id: string): Promise<Product | null> {
const { rows } = await query<Product>("SELECT * FROM products WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateProductInput {
name: string;
description?: string | null;
productType: string;
productGroup?: string | null;
productSubgroup?: string | null;
brand?: string | null;
unit?: string;
active?: boolean;
discontinued?: boolean;
tracksStock?: boolean;
tracksSerial?: boolean;
tracksAssetTag?: boolean;
tracksMac?: boolean;
price0?: number | null;
price12?: number | null;
price24?: number | null;
price36?: number | null;
price48?: number | null;
implUnitPrice?: number;
createdBy?: string | null;
}
export async function createProduct(input: CreateProductInput): Promise<Product> {
const { rows } = await query<Product>(
`INSERT INTO products (
name, description, product_type, product_group, product_subgroup, brand, unit,
active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac,
price_0, price_12, price_24, price_36, price_48, impl_unit_price, created_by
) VALUES (
$1,$2,$3,$4,$5,$6,COALESCE($7,'unidade'),
COALESCE($8,true),COALESCE($9,false),COALESCE($10,false),COALESCE($11,false),COALESCE($12,false),COALESCE($13,false),
$14,$15,$16,$17,$18,COALESCE($19,0),$20
)
RETURNING *`,
[
input.name,
input.description ?? null,
input.productType,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
input.tracksSerial ?? null,
input.tracksAssetTag ?? null,
input.tracksMac ?? null,
input.price0 ?? null,
input.price12 ?? null,
input.price24 ?? null,
input.price36 ?? null,
input.price48 ?? null,
input.implUnitPrice ?? null,
input.createdBy ?? null,
],
);
const product = rows[0];
if (!product) throw new Error("failed to create product");
return product;
}
export type UpdateProductInput = Partial<Omit<CreateProductInput, "createdBy">>;
export async function updateProduct(id: string, input: UpdateProductInput): Promise<Product | null> {
const { rows } = await query<Product>(
`UPDATE products SET
name = COALESCE($2, name),
description = COALESCE($3, description),
product_type = COALESCE($4, product_type),
product_group = COALESCE($5, product_group),
product_subgroup = COALESCE($6, product_subgroup),
brand = COALESCE($7, brand),
unit = COALESCE($8, unit),
active = COALESCE($9, active),
discontinued = COALESCE($10, discontinued),
tracks_stock = COALESCE($11, tracks_stock),
tracks_serial = COALESCE($12, tracks_serial),
tracks_asset_tag = COALESCE($13, tracks_asset_tag),
tracks_mac = COALESCE($14, tracks_mac),
price_0 = COALESCE($15, price_0),
price_12 = COALESCE($16, price_12),
price_24 = COALESCE($17, price_24),
price_36 = COALESCE($18, price_36),
price_48 = COALESCE($19, price_48),
impl_unit_price = COALESCE($20, impl_unit_price)
WHERE id = $1
RETURNING *`,
[
id,
input.name ?? null,
input.description ?? null,
input.productType ?? null,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
input.tracksSerial ?? null,
input.tracksAssetTag ?? null,
input.tracksMac ?? null,
input.price0 ?? null,
input.price12 ?? null,
input.price24 ?? null,
input.price36 ?? null,
input.price48 ?? null,
input.implUnitPrice ?? null,
],
);
return rows[0] ?? null;
}