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:
36
apps/api/src/products/dto/create-product.dto.ts
Normal file
36
apps/api/src/products/dto/create-product.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Min, MinLength } from "class-validator";
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional() @IsString() description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productType!: string;
|
||||
|
||||
@IsOptional() @IsString() productGroup?: string;
|
||||
@IsOptional() @IsString() productSubgroup?: string;
|
||||
@IsOptional() @IsString() brand?: string;
|
||||
@IsOptional() @IsString() unit?: string;
|
||||
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsBoolean() discontinued?: boolean;
|
||||
@IsOptional() @IsBoolean() tracksStock?: boolean;
|
||||
@IsOptional() @IsBoolean() tracksSerial?: boolean;
|
||||
@IsOptional() @IsBoolean() tracksAssetTag?: boolean;
|
||||
@IsOptional() @IsBoolean() tracksMac?: boolean;
|
||||
|
||||
// Money fields: format-validated here only (non-negative number). The
|
||||
// backend never trusts these for anything beyond storage in this MVP —
|
||||
// no pricing/discount calculation happens yet, that's Quotes (a later
|
||||
// phase). Always persisted as NUMERIC, never float.
|
||||
@IsOptional() @IsNumber() @Min(0) price0?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) price12?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) price24?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) price36?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) price48?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) implUnitPrice?: number;
|
||||
}
|
||||
4
apps/api/src/products/dto/update-product.dto.ts
Normal file
4
apps/api/src/products/dto/update-product.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateProductDto } from "./create-product.dto.js";
|
||||
|
||||
export class UpdateProductDto extends PartialType(CreateProductDto) {}
|
||||
52
apps/api/src/products/products.controller.ts
Normal file
52
apps/api/src/products/products.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { ProductsService } from "./products.service.js";
|
||||
import { CreateProductDto } from "./dto/create-product.dto.js";
|
||||
import { UpdateProductDto } from "./dto/update-product.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Read is open to any authenticated user (not gated by a resource
|
||||
* permission) — the catalog needs to be browsable by any seller building a
|
||||
* quote, mirroring the legacy (eden.md §2 §1.1) and the same choice made
|
||||
* for companies. Write requires products:edit. Backend always
|
||||
* recalculates/validates: money fields are format-checked (non-negative)
|
||||
* in the DTO but no pricing logic runs here — that's Quotes, a later phase.
|
||||
*/
|
||||
@ApiTags("products")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/products")
|
||||
export class ProductsController {
|
||||
constructor(private readonly products: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.products.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.products.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("products", "edit")
|
||||
create(@Body() dto: CreateProductDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.products.create(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("products", "edit")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.products.update(id, dto, user.id);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/products/products.module.ts
Normal file
11
apps/api/src/products/products.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { ProductsController } from "./products.controller.js";
|
||||
import { ProductsService } from "./products.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
102
apps/api/src/products/products.service.ts
Normal file
102
apps/api/src/products/products.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { auditRepo } from "@eden/database";
|
||||
// Deliberately deep-imported rather than added to the shared @eden/database
|
||||
// barrel (packages/database/src/index.ts) — that file is off limits for
|
||||
// this module per the integration boundary (avoids merge conflicts with
|
||||
// sibling agents editing the same barrel concurrently). Whoever wires
|
||||
// modules together should add
|
||||
// export * as productsRepo from "./repositories/products.js";
|
||||
// export type { Product } from "./repositories/products.js";
|
||||
// to the barrel for parity with how companiesRepo is consumed; until then
|
||||
// this deep path is functionally equivalent (requires @eden/database to be
|
||||
// built first, same as the barrel import would).
|
||||
import * as productsRepo from "@eden/database/dist/repositories/products.js";
|
||||
import type { Product } from "@eden/database/dist/repositories/products.js";
|
||||
import type { CreateProductDto } from "./dto/create-product.dto.js";
|
||||
import type { UpdateProductDto } from "./dto/update-product.dto.js";
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
list(includeInactive: boolean): Promise<Product[]> {
|
||||
return productsRepo.listProducts(includeInactive);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Product> {
|
||||
const product = await productsRepo.getProductById(id);
|
||||
if (!product) throw new NotFoundException("Produto não encontrado");
|
||||
return product;
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto, actorId: string): Promise<Product> {
|
||||
const product = await productsRepo.createProduct({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
productType: dto.productType,
|
||||
productGroup: dto.productGroup,
|
||||
productSubgroup: dto.productSubgroup,
|
||||
brand: dto.brand,
|
||||
unit: dto.unit,
|
||||
active: dto.active,
|
||||
discontinued: dto.discontinued,
|
||||
tracksStock: dto.tracksStock,
|
||||
tracksSerial: dto.tracksSerial,
|
||||
tracksAssetTag: dto.tracksAssetTag,
|
||||
tracksMac: dto.tracksMac,
|
||||
price0: dto.price0,
|
||||
price12: dto.price12,
|
||||
price24: dto.price24,
|
||||
price36: dto.price36,
|
||||
price48: dto.price48,
|
||||
implUnitPrice: dto.implUnitPrice,
|
||||
createdBy: actorId,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "product.created",
|
||||
resourceType: "product",
|
||||
resourceId: product.id,
|
||||
});
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateProductDto, actorId: string): Promise<Product> {
|
||||
await this.get(id); // 404 if missing
|
||||
|
||||
const product = await productsRepo.updateProduct(id, {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
productType: dto.productType,
|
||||
productGroup: dto.productGroup,
|
||||
productSubgroup: dto.productSubgroup,
|
||||
brand: dto.brand,
|
||||
unit: dto.unit,
|
||||
active: dto.active,
|
||||
discontinued: dto.discontinued,
|
||||
tracksStock: dto.tracksStock,
|
||||
tracksSerial: dto.tracksSerial,
|
||||
tracksAssetTag: dto.tracksAssetTag,
|
||||
tracksMac: dto.tracksMac,
|
||||
price0: dto.price0,
|
||||
price12: dto.price12,
|
||||
price24: dto.price24,
|
||||
price36: dto.price36,
|
||||
price48: dto.price48,
|
||||
implUnitPrice: dto.implUnitPrice,
|
||||
});
|
||||
if (!product) throw new NotFoundException("Produto não encontrado");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "product.updated",
|
||||
resourceType: "product",
|
||||
resourceId: id,
|
||||
metadata: { fields: Object.keys(dto) },
|
||||
});
|
||||
|
||||
return product;
|
||||
}
|
||||
}
|
||||
68
packages/database/migrations/1788438907074_products.cjs
Normal file
68
packages/database/migrations/1788438907074_products.cjs
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Commercial bounded context, first slice: products (sellable catalog
|
||||
* items) — Master Prompt §6.3, eden.md §2 §1.1. MVP scope only: the shape
|
||||
* below ports the legacy `products` table's core identity/pricing/flag
|
||||
* columns faithfully (product_code sequence, the 5 fixed price tiers,
|
||||
* impl_unit_price), but deliberately leaves out service_type/metered/
|
||||
* tariff_rates/ixc_product_code — those are legacy telecom-billing
|
||||
* specifics that belong to a later phase, not invented here. `product_type`
|
||||
* is free text for now (no CHECK) since the enum of types isn't finalized;
|
||||
* tightening it to a CHECK/lookup table is future work once Quotes needs
|
||||
* to branch behavior on it.
|
||||
*
|
||||
* Quotes/pricing-tier selection logic is explicitly out of scope here —
|
||||
* this migration only stores the data, it computes nothing.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE SEQUENCE products_product_code_seq START 1;
|
||||
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
product_code INTEGER NOT NULL UNIQUE DEFAULT nextval('products_product_code_seq'),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
product_type TEXT NOT NULL,
|
||||
product_group TEXT,
|
||||
product_subgroup TEXT,
|
||||
brand TEXT,
|
||||
unit TEXT NOT NULL DEFAULT 'unidade',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
discontinued BOOLEAN NOT NULL DEFAULT false,
|
||||
tracks_stock BOOLEAN NOT NULL DEFAULT false,
|
||||
tracks_serial BOOLEAN NOT NULL DEFAULT false,
|
||||
tracks_asset_tag BOOLEAN NOT NULL DEFAULT false,
|
||||
tracks_mac BOOLEAN NOT NULL DEFAULT false,
|
||||
price_0 NUMERIC(12,2),
|
||||
price_12 NUMERIC(12,2),
|
||||
price_24 NUMERIC(12,2),
|
||||
price_36 NUMERIC(12,2),
|
||||
price_48 NUMERIC(12,2),
|
||||
impl_unit_price NUMERIC(12,2) NOT NULL DEFAULT 0
|
||||
);
|
||||
COMMENT ON TABLE products IS
|
||||
'Catálogo de produtos vendáveis — eden.md §2 §1.1, Master Prompt §6.3. product_code é a sequência legada (distinta do UUID); price_0/12/24/36/48 são as 5 faixas fixas de preço por período de fidelidade, não uma tabela relacionada. Leitura aberta a qualquer autenticado (catálogo precisa ser navegável por qualquer vendedor); escrita restrita à permissão products:edit.';
|
||||
COMMENT ON COLUMN products.impl_unit_price IS
|
||||
'Valor de implantação por unidade, multiplicado pela quantidade do item na oferta (fase futura — Quotes). NUMERIC sempre, nunca float.';
|
||||
|
||||
CREATE TRIGGER trg_products_updated_at BEFORE UPDATE ON products
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_products_active ON products(active);
|
||||
|
||||
ALTER SEQUENCE products_product_code_seq OWNED BY products.product_code;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS products;
|
||||
DROP SEQUENCE IF EXISTS products_product_code_seq;
|
||||
`);
|
||||
};
|
||||
157
packages/database/src/repositories/products.ts
Normal file
157
packages/database/src/repositories/products.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user