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