diff --git a/apps/api/src/products/dto/create-product.dto.ts b/apps/api/src/products/dto/create-product.dto.ts new file mode 100644 index 0000000..3a577d5 --- /dev/null +++ b/apps/api/src/products/dto/create-product.dto.ts @@ -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; +} diff --git a/apps/api/src/products/dto/update-product.dto.ts b/apps/api/src/products/dto/update-product.dto.ts new file mode 100644 index 0000000..bcc23dd --- /dev/null +++ b/apps/api/src/products/dto/update-product.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateProductDto } from "./create-product.dto.js"; + +export class UpdateProductDto extends PartialType(CreateProductDto) {} diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts new file mode 100644 index 0000000..3f27bd5 --- /dev/null +++ b/apps/api/src/products/products.controller.ts @@ -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); + } +} diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts new file mode 100644 index 0000000..c41a524 --- /dev/null +++ b/apps/api/src/products/products.module.ts @@ -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 {} diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts new file mode 100644 index 0000000..ef537cc --- /dev/null +++ b/apps/api/src/products/products.service.ts @@ -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 { + return productsRepo.listProducts(includeInactive); + } + + async get(id: string): Promise { + const product = await productsRepo.getProductById(id); + if (!product) throw new NotFoundException("Produto não encontrado"); + return product; + } + + async create(dto: CreateProductDto, actorId: string): Promise { + 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 { + 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; + } +} diff --git a/packages/database/migrations/1788438907074_products.cjs b/packages/database/migrations/1788438907074_products.cjs new file mode 100644 index 0000000..b17aa21 --- /dev/null +++ b/packages/database/migrations/1788438907074_products.cjs @@ -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; + `); +}; diff --git a/packages/database/src/repositories/products.ts b/packages/database/src/repositories/products.ts new file mode 100644 index 0000000..c52c8ce --- /dev/null +++ b/packages/database/src/repositories/products.ts @@ -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 { + const { rows } = await query( + 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 { + const { rows } = await query("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 { + const { rows } = await query( + `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>; + +export async function updateProduct(id: string, input: UpdateProductInput): Promise { + const { rows } = await query( + `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; +}