Merge: Products/Catalog module (agente eden-commercial)

This commit is contained in:
2026-09-03 09:40:44 -03:00
7 changed files with 430 additions and 0 deletions

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateProductDto } from "./create-product.dto.js";
export class UpdateProductDto extends PartialType(CreateProductDto) {}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
`);
};

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;
}