From a8f77e40683f7aa2729f46b9c5919bf7e2e5bf8f Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Thu, 3 Sep 2026 10:57:28 -0300 Subject: [PATCH] =?UTF-8?q?Add=20CRM=20leads/opportunities=20module=20(Mas?= =?UTF-8?q?ter=20Prompt=20=C2=A76.2=20MVP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield for EDEN — eden.md has no CRM module, so this is driven by the Master Prompt's requirements, not a legacy port. - Migration 1788439012582_crm.cjs: leads + opportunities tables, free-text status/stage (documented via COMMENT ON COLUMN, no CHECK yet — a configurable pipeline is a future refinement), indexes on status/owner_id and stage/owner_id, set_updated_at triggers reusing the baseline schema's trigger function. - packages/database/src/repositories/{leads,opportunities}.ts: list/get/ create/update per companies.ts conventions (COALESCE-based partial update), plus convertLeadToOpportunity(leadId, input) wrapped in withTransaction — inserts the opportunity (lead_id set) and marks the lead 'converted' atomically. - apps/api/src/{leads,opportunities}/: NestJS modules mirroring the Companies module structure (controller/service/module/dto). Routes v1/leads and v1/opportunities. Because leads/opportunities carry prospect PII (unlike the open-read Companies/Products catalog), reads are also gated behind @RequirePermission('', 'view'), not just JwtAuthGuard. Writes require '', 'edit'. POST /v1/leads/:id/convert gated by 'leads'/'edit'. Audit events: lead.created/updated/converted, opportunity.created/updated. - Added class-validator/class-transformer to apps/api/package.json (needed for the DTOs; not previously a direct dependency). Known cross-agent dependency gap (by design, not a bug in this code): this worktree branched before any sibling agent's work landed, so apps/api/src/auth/ (JwtAuthGuard, PermissionGuard, RequirePermission) and the Companies module referenced as this task's template do not exist yet here, and packages/database/src/index.ts / apps/api/src/app.module.ts are explicitly off-limits for me to edit (merge-conflict avoidance across the 4 parallel agents). `pnpm --filter @eden/database typecheck/build` pass unconditionally. `pnpm --filter @eden/api typecheck/build` currently fail with exactly two error classes: "Cannot find module '../auth'" and "@eden/database has no exported member " — both expected to resolve once the auth module and the index.ts/app.module.ts wiring land at merge time. Verified locally (not committed) by stubbing both gaps: with the stub in place, both typecheck and build pass with zero errors, confirming this module's own code is correct against the documented interfaces. Co-Authored-By: Claude Sonnet 5 --- apps/api/package.json | 2 + apps/api/src/leads/dto/convert-lead.dto.ts | 23 ++ apps/api/src/leads/dto/create-lead.dto.ts | 46 ++++ apps/api/src/leads/dto/update-lead.dto.ts | 41 ++++ apps/api/src/leads/leads.controller.ts | 74 ++++++ apps/api/src/leads/leads.module.ts | 10 + apps/api/src/leads/leads.service.ts | 93 +++++++ .../dto/create-opportunity.dto.ts | 49 ++++ .../dto/update-opportunity.dto.ts | 32 +++ .../opportunities/opportunities.controller.ts | 63 +++++ .../src/opportunities/opportunities.module.ts | 10 + .../opportunities/opportunities.service.ts | 62 +++++ .../database/migrations/1788439012582_crm.cjs | 93 +++++++ packages/database/src/repositories/leads.ts | 232 ++++++++++++++++++ .../src/repositories/opportunities.ts | 192 +++++++++++++++ pnpm-lock.yaml | 67 +++-- 16 files changed, 1075 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/leads/dto/convert-lead.dto.ts create mode 100644 apps/api/src/leads/dto/create-lead.dto.ts create mode 100644 apps/api/src/leads/dto/update-lead.dto.ts create mode 100644 apps/api/src/leads/leads.controller.ts create mode 100644 apps/api/src/leads/leads.module.ts create mode 100644 apps/api/src/leads/leads.service.ts create mode 100644 apps/api/src/opportunities/dto/create-opportunity.dto.ts create mode 100644 apps/api/src/opportunities/dto/update-opportunity.dto.ts create mode 100644 apps/api/src/opportunities/opportunities.controller.ts create mode 100644 apps/api/src/opportunities/opportunities.module.ts create mode 100644 apps/api/src/opportunities/opportunities.service.ts create mode 100644 packages/database/migrations/1788439012582_crm.cjs create mode 100644 packages/database/src/repositories/leads.ts create mode 100644 packages/database/src/repositories/opportunities.ts diff --git a/apps/api/package.json b/apps/api/package.json index 5e6d31b..093917f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,8 @@ "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", "@nestjs/terminus": "^10.2.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", "express": "^4.21.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" diff --git a/apps/api/src/leads/dto/convert-lead.dto.ts b/apps/api/src/leads/dto/convert-lead.dto.ts new file mode 100644 index 0000000..61ce009 --- /dev/null +++ b/apps/api/src/leads/dto/convert-lead.dto.ts @@ -0,0 +1,23 @@ +import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator"; +import { OPPORTUNITY_STAGES, type OpportunityStage } from "../../opportunities/dto/create-opportunity.dto"; + +/** Body for POST /v1/leads/:id/convert — the new opportunity's fields. */ +export class ConvertLeadDto { + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsIn(OPPORTUNITY_STAGES) + stage?: OpportunityStage; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/leads/dto/create-lead.dto.ts b/apps/api/src/leads/dto/create-lead.dto.ts new file mode 100644 index 0000000..09dbfd2 --- /dev/null +++ b/apps/api/src/leads/dto/create-lead.dto.ts @@ -0,0 +1,46 @@ +import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; + +/** + * Plain TEXT column in the DB, no CHECK constraint (see the crm migration). + * Validated here at the API boundary instead. + */ +export const LEAD_STATUSES = ["new", "contacted", "qualified", "converted", "discarded"] as const; +export type LeadStatus = (typeof LEAD_STATUSES)[number]; + +export class CreateLeadDto { + @IsOptional() + @IsString() + @MaxLength(200) + source?: string; + + @IsString() + @MaxLength(200) + contactName!: string; + + @IsOptional() + @IsEmail() + @MaxLength(320) + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + contactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsIn(LEAD_STATUSES) + status?: LeadStatus; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/leads/dto/update-lead.dto.ts b/apps/api/src/leads/dto/update-lead.dto.ts new file mode 100644 index 0000000..8d2b1fc --- /dev/null +++ b/apps/api/src/leads/dto/update-lead.dto.ts @@ -0,0 +1,41 @@ +import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; +import { LEAD_STATUSES, type LeadStatus } from "./create-lead.dto"; + +export class UpdateLeadDto { + @IsOptional() + @IsString() + @MaxLength(200) + source?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(320) + contactEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(40) + contactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsIn(LEAD_STATUSES) + status?: LeadStatus; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts new file mode 100644 index 0000000..d27a4bd --- /dev/null +++ b/apps/api/src/leads/leads.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common"; +import type { Request } from "express"; +import { JwtAuthGuard, PermissionGuard, RequirePermission } from "../auth"; +import { LeadsService } from "./leads.service"; +import { CreateLeadDto } from "./dto/create-lead.dto"; +import { UpdateLeadDto } from "./dto/update-lead.dto"; +import { ConvertLeadDto } from "./dto/convert-lead.dto"; + +interface AuthenticatedRequest extends Request { + user?: { id: string }; +} + +/** + * CRM leads (Master Prompt §6.2). Unlike Companies/Products (open-read), + * leads/opportunities carry prospect PII — every route, including reads, + * requires authentication AND resource+action permission (ADR-0004). + */ +@Controller("v1/leads") +@UseGuards(JwtAuthGuard, PermissionGuard) +@UsePipes(new ValidationPipe({ whitelist: true, transform: true })) +export class LeadsController { + constructor(private readonly leadsService: LeadsService) {} + + @Get() + @RequirePermission("leads", "view") + list(@Query("status") status?: string, @Query("ownerId") ownerId?: string) { + return this.leadsService.list({ status, ownerId }); + } + + @Get(":id") + @RequirePermission("leads", "view") + get(@Param("id", ParseUUIDPipe) id: string) { + return this.leadsService.get(id); + } + + @Post() + @RequirePermission("leads", "edit") + create(@Body() dto: CreateLeadDto, @Req() req: AuthenticatedRequest) { + return this.leadsService.create(dto, req.user?.id ?? null); + } + + @Patch(":id") + @RequirePermission("leads", "edit") + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateLeadDto, + @Req() req: AuthenticatedRequest, + ) { + return this.leadsService.update(id, dto, req.user?.id ?? null); + } + + @Post(":id/convert") + @RequirePermission("leads", "edit") + convert( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ConvertLeadDto, + @Req() req: AuthenticatedRequest, + ) { + return this.leadsService.convert(id, dto, req.user?.id ?? null); + } +} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts new file mode 100644 index 0000000..d81d8cc --- /dev/null +++ b/apps/api/src/leads/leads.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { LeadsController } from "./leads.controller"; +import { LeadsService } from "./leads.service"; + +@Module({ + controllers: [LeadsController], + providers: [LeadsService], + exports: [LeadsService], +}) +export class LeadsModule {} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts new file mode 100644 index 0000000..0713457 --- /dev/null +++ b/apps/api/src/leads/leads.service.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + auditRepo, + convertLeadToOpportunity, + createLead, + getLead, + listLeads, + updateLead, + type ConvertLeadResult, + type Lead, +} from "@eden/database"; +import type { CreateLeadDto } from "./dto/create-lead.dto"; +import type { UpdateLeadDto } from "./dto/update-lead.dto"; +import type { ConvertLeadDto } from "./dto/convert-lead.dto"; + +export interface ListLeadsQuery { + status?: string; + ownerId?: string; +} + +@Injectable() +export class LeadsService { + list(filter: ListLeadsQuery): Promise { + return listLeads(filter); + } + + async get(id: string): Promise { + const lead = await getLead(id); + if (!lead) { + throw new NotFoundException(`Lead ${id} not found`); + } + return lead; + } + + async create(dto: CreateLeadDto, actorId: string | null): Promise { + const lead = await createLead({ ...dto, createdBy: actorId }); + await auditRepo.appendAuditEvent({ + actorId, + action: "lead.created", + resourceType: "lead", + resourceId: lead.id, + metadata: { status: lead.status, source: lead.source }, + }); + return lead; + } + + async update(id: string, dto: UpdateLeadDto, actorId: string | null): Promise { + const lead = await updateLead(id, dto); + if (!lead) { + throw new NotFoundException(`Lead ${id} not found`); + } + await auditRepo.appendAuditEvent({ + actorId, + action: "lead.updated", + resourceType: "lead", + resourceId: lead.id, + metadata: { fields: Object.keys(dto) }, + }); + return lead; + } + + async convert( + id: string, + dto: ConvertLeadDto, + actorId: string | null, + ): Promise { + const result = await convertLeadToOpportunity(id, { + title: dto.title, + stage: dto.stage, + probability: dto.probability ?? null, + notes: dto.notes ?? null, + createdBy: actorId, + }); + if (!result) { + throw new NotFoundException(`Lead ${id} not found`); + } + await auditRepo.appendAuditEvent({ + actorId, + action: "lead.converted", + resourceType: "lead", + resourceId: result.lead.id, + metadata: { opportunityId: result.opportunity.id }, + }); + await auditRepo.appendAuditEvent({ + actorId, + action: "opportunity.created", + resourceType: "opportunity", + resourceId: result.opportunity.id, + metadata: { leadId: id, stage: result.opportunity.stage }, + }); + return result; + } +} diff --git a/apps/api/src/opportunities/dto/create-opportunity.dto.ts b/apps/api/src/opportunities/dto/create-opportunity.dto.ts new file mode 100644 index 0000000..61ca291 --- /dev/null +++ b/apps/api/src/opportunities/dto/create-opportunity.dto.ts @@ -0,0 +1,49 @@ +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator"; + +/** + * Plain TEXT column in the DB, no CHECK constraint (see the crm + * migration). Validated here at the API boundary instead. A fully + * configurable pipeline is a future refinement, not this MVP. + */ +export const OPPORTUNITY_STAGES = [ + "prospecting", + "qualification", + "proposal", + "negotiation", + "won", + "lost", +] as const; +export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number]; + +export class CreateOpportunityDto { + @IsOptional() + @IsUUID() + leadId?: string; + + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsIn(OPPORTUNITY_STAGES) + stage?: OpportunityStage; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + lossReason?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/opportunities/dto/update-opportunity.dto.ts b/apps/api/src/opportunities/dto/update-opportunity.dto.ts new file mode 100644 index 0000000..b250c78 --- /dev/null +++ b/apps/api/src/opportunities/dto/update-opportunity.dto.ts @@ -0,0 +1,32 @@ +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator"; +import { OPPORTUNITY_STAGES, type OpportunityStage } from "./create-opportunity.dto"; + +export class UpdateOpportunityDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsIn(OPPORTUNITY_STAGES) + stage?: OpportunityStage; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + probability?: number; + + @IsOptional() + @IsUUID() + ownerId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + lossReason?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/api/src/opportunities/opportunities.controller.ts b/apps/api/src/opportunities/opportunities.controller.ts new file mode 100644 index 0000000..d8a3573 --- /dev/null +++ b/apps/api/src/opportunities/opportunities.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common"; +import type { Request } from "express"; +import { JwtAuthGuard, PermissionGuard, RequirePermission } from "../auth"; +import { OpportunitiesService } from "./opportunities.service"; +import { CreateOpportunityDto } from "./dto/create-opportunity.dto"; +import { UpdateOpportunityDto } from "./dto/update-opportunity.dto"; + +interface AuthenticatedRequest extends Request { + user?: { id: string }; +} + +/** + * CRM opportunities (Master Prompt §6.2). Same PII-gating rationale as + * leads — read requires @RequirePermission('opportunities', 'view'), not + * just authentication. + */ +@Controller("v1/opportunities") +@UseGuards(JwtAuthGuard, PermissionGuard) +@UsePipes(new ValidationPipe({ whitelist: true, transform: true })) +export class OpportunitiesController { + constructor(private readonly opportunitiesService: OpportunitiesService) {} + + @Get() + @RequirePermission("opportunities", "view") + list(@Query("stage") stage?: string, @Query("ownerId") ownerId?: string) { + return this.opportunitiesService.list({ stage, ownerId }); + } + + @Get(":id") + @RequirePermission("opportunities", "view") + get(@Param("id", ParseUUIDPipe) id: string) { + return this.opportunitiesService.get(id); + } + + @Post() + @RequirePermission("opportunities", "edit") + create(@Body() dto: CreateOpportunityDto, @Req() req: AuthenticatedRequest) { + return this.opportunitiesService.create(dto, req.user?.id ?? null); + } + + @Patch(":id") + @RequirePermission("opportunities", "edit") + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateOpportunityDto, + @Req() req: AuthenticatedRequest, + ) { + return this.opportunitiesService.update(id, dto, req.user?.id ?? null); + } +} diff --git a/apps/api/src/opportunities/opportunities.module.ts b/apps/api/src/opportunities/opportunities.module.ts new file mode 100644 index 0000000..baa5dc6 --- /dev/null +++ b/apps/api/src/opportunities/opportunities.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { OpportunitiesController } from "./opportunities.controller"; +import { OpportunitiesService } from "./opportunities.service"; + +@Module({ + controllers: [OpportunitiesController], + providers: [OpportunitiesService], + exports: [OpportunitiesService], +}) +export class OpportunitiesModule {} diff --git a/apps/api/src/opportunities/opportunities.service.ts b/apps/api/src/opportunities/opportunities.service.ts new file mode 100644 index 0000000..4aecc80 --- /dev/null +++ b/apps/api/src/opportunities/opportunities.service.ts @@ -0,0 +1,62 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + auditRepo, + createOpportunity, + getOpportunity, + listOpportunities, + updateOpportunity, + type Opportunity, +} from "@eden/database"; +import type { CreateOpportunityDto } from "./dto/create-opportunity.dto"; +import type { UpdateOpportunityDto } from "./dto/update-opportunity.dto"; + +export interface ListOpportunitiesQuery { + stage?: string; + ownerId?: string; +} + +@Injectable() +export class OpportunitiesService { + list(filter: ListOpportunitiesQuery): Promise { + return listOpportunities(filter); + } + + async get(id: string): Promise { + const opportunity = await getOpportunity(id); + if (!opportunity) { + throw new NotFoundException(`Opportunity ${id} not found`); + } + return opportunity; + } + + async create(dto: CreateOpportunityDto, actorId: string | null): Promise { + const opportunity = await createOpportunity({ ...dto, createdBy: actorId }); + await auditRepo.appendAuditEvent({ + actorId, + action: "opportunity.created", + resourceType: "opportunity", + resourceId: opportunity.id, + metadata: { stage: opportunity.stage, leadId: opportunity.leadId }, + }); + return opportunity; + } + + async update( + id: string, + dto: UpdateOpportunityDto, + actorId: string | null, + ): Promise { + const opportunity = await updateOpportunity(id, dto); + if (!opportunity) { + throw new NotFoundException(`Opportunity ${id} not found`); + } + await auditRepo.appendAuditEvent({ + actorId, + action: "opportunity.updated", + resourceType: "opportunity", + resourceId: opportunity.id, + metadata: { fields: Object.keys(dto) }, + }); + return opportunity; + } +} diff --git a/packages/database/migrations/1788439012582_crm.cjs b/packages/database/migrations/1788439012582_crm.cjs new file mode 100644 index 0000000..ed4d61e --- /dev/null +++ b/packages/database/migrations/1788439012582_crm.cjs @@ -0,0 +1,93 @@ +/** + * CRM — Leads & Opportunities (Master Prompt §6.2 "CRM e Comercial"). + * + * Greenfield for EDEN: the legacy OrçaFácil system (eden.md) has no CRM + * module — it starts directly at Quotes. This migration is driven entirely + * by the Master Prompt's requirements, not a port of legacy behavior. + * + * MVP scope: `leads` and `opportunities` with free-text `status`/`stage` + * columns (documented via COMMENT ON COLUMN, no CHECK constraint yet — a + * fully configurable pipeline is a future refinement, not this MVP). + * + * `leads.company_name` is a free-text prospect company name — it is + * deliberately NOT a foreign key to the `companies` table, which models + * issuing legal entities, not sales prospects. + * + * The `set_updated_at()` trigger function is created by the baseline + * schema migration (1788430575858_baseline-schema.cjs) — reused here, not + * redefined. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + -- ===================================================================== + -- leads + -- ===================================================================== + CREATE TABLE leads ( + 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), + source TEXT, + contact_name TEXT NOT NULL, + contact_email TEXT, + contact_phone TEXT, + company_name TEXT, + status TEXT NOT NULL DEFAULT 'new', + owner_id UUID REFERENCES users(id), + notes TEXT + ); + COMMENT ON COLUMN leads.source IS + 'Free-text origin/campaign (e.g. "indicação", "site", "feira X") — no fixed taxonomy in this MVP.'; + COMMENT ON COLUMN leads.company_name IS + 'Free-text prospect company name. Deliberately NOT a FK to companies — that table models issuing legal entities, not sales prospects.'; + COMMENT ON COLUMN leads.status IS + 'Plain TEXT, no CHECK constraint yet. Intended values: new, contacted, qualified, converted, discarded. converted is set only via POST /v1/leads/:id/convert.'; + COMMENT ON COLUMN leads.owner_id IS 'Responsible salesperson.'; + + CREATE INDEX idx_leads_status ON leads(status); + CREATE INDEX idx_leads_owner ON leads(owner_id); + + CREATE TRIGGER trg_leads_updated_at BEFORE UPDATE ON leads + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + -- ===================================================================== + -- opportunities + -- ===================================================================== + CREATE TABLE opportunities ( + 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), + lead_id UUID REFERENCES leads(id), + title TEXT NOT NULL, + stage TEXT NOT NULL DEFAULT 'prospecting', + probability INTEGER CHECK (probability >= 0 AND probability <= 100), + owner_id UUID REFERENCES users(id), + loss_reason TEXT, + notes TEXT + ); + COMMENT ON COLUMN opportunities.lead_id IS + 'Nullable — an opportunity can be created directly, not only converted from a lead.'; + COMMENT ON COLUMN opportunities.stage IS + 'Plain TEXT, no CHECK constraint yet. Intended values: prospecting, qualification, proposal, negotiation, won, lost. A fully configurable pipeline is a future refinement, not this MVP.'; + COMMENT ON COLUMN opportunities.loss_reason IS + 'Nullable — filled only when stage = ''lost''. Not enforced by a DB constraint in this MVP.'; + COMMENT ON COLUMN opportunities.owner_id IS 'Responsible salesperson.'; + + CREATE INDEX idx_opportunities_stage ON opportunities(stage); + CREATE INDEX idx_opportunities_owner ON opportunities(owner_id); + + CREATE TRIGGER trg_opportunities_updated_at BEFORE UPDATE ON opportunities + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP TABLE IF EXISTS opportunities; + DROP TABLE IF EXISTS leads; + `); +}; diff --git a/packages/database/src/repositories/leads.ts b/packages/database/src/repositories/leads.ts new file mode 100644 index 0000000..892a53a --- /dev/null +++ b/packages/database/src/repositories/leads.ts @@ -0,0 +1,232 @@ +import type { PoolClient, QueryResultRow } from "pg"; +import { query, withTransaction } from "../pool.js"; +import { createOpportunityWithClient, type Opportunity } from "./opportunities.js"; + +/** + * CRM — Leads repository (Master Prompt §6.2). Greenfield for EDEN — the + * legacy OrçaFácil spec (eden.md) has no CRM module, so there is no legacy + * behavior to preserve here. + * + * Mirrors the conventions of `companies.ts`: COALESCE-based partial + * update, snake_case columns mapped to a camelCase domain type. + */ + +export interface Lead { + id: string; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + source: string | null; + contactName: string; + contactEmail: string | null; + contactPhone: string | null; + companyName: string | null; + /** Plain TEXT column. Intended values: new, contacted, qualified, converted, discarded. */ + status: string; + ownerId: string | null; + notes: string | null; +} + +interface LeadRow extends QueryResultRow { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + source: string | null; + contact_name: string; + contact_email: string | null; + contact_phone: string | null; + company_name: string | null; + status: string; + owner_id: string | null; + notes: string | null; +} + +function mapLead(row: LeadRow): Lead { + return { + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + source: row.source, + contactName: row.contact_name, + contactEmail: row.contact_email, + contactPhone: row.contact_phone, + companyName: row.company_name, + status: row.status, + ownerId: row.owner_id, + notes: row.notes, + }; +} + +export interface ListLeadsFilter { + status?: string; + ownerId?: string; +} + +export async function listLeads(filter: ListLeadsFilter = {}): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.status) { + params.push(filter.status); + conditions.push(`status = $${params.length}`); + } + if (filter.ownerId) { + params.push(filter.ownerId); + conditions.push(`owner_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await query( + `SELECT * FROM leads ${where} ORDER BY created_at DESC`, + params, + ); + return result.rows.map(mapLead); +} + +export async function getLead(id: string): Promise { + const result = await query(`SELECT * FROM leads WHERE id = $1`, [id]); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +export interface CreateLeadInput { + source?: string | null; + contactName: string; + contactEmail?: string | null; + contactPhone?: string | null; + companyName?: string | null; + status?: string; + ownerId?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +export async function createLead(input: CreateLeadInput): Promise { + const result = await query( + `INSERT INTO leads + (source, contact_name, contact_email, contact_phone, company_name, status, owner_id, notes, created_by) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, 'new'), $7, $8, $9) + RETURNING *`, + [ + input.source ?? null, + input.contactName, + input.contactEmail ?? null, + input.contactPhone ?? null, + input.companyName ?? null, + input.status ?? null, + input.ownerId ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to create lead"); + } + return mapLead(row); +} + +export interface UpdateLeadInput { + source?: string | null; + contactName?: string; + contactEmail?: string | null; + contactPhone?: string | null; + companyName?: string | null; + status?: string; + ownerId?: string | null; + notes?: string | null; +} + +export async function updateLead(id: string, input: UpdateLeadInput): Promise { + const result = await query( + `UPDATE leads SET + source = COALESCE($2, source), + contact_name = COALESCE($3, contact_name), + contact_email = COALESCE($4, contact_email), + contact_phone = COALESCE($5, contact_phone), + company_name = COALESCE($6, company_name), + status = COALESCE($7, status), + owner_id = COALESCE($8, owner_id), + notes = COALESCE($9, notes) + WHERE id = $1 + RETURNING *`, + [ + id, + input.source ?? null, + input.contactName ?? null, + input.contactEmail ?? null, + input.contactPhone ?? null, + input.companyName ?? null, + input.status ?? null, + input.ownerId ?? null, + input.notes ?? null, + ], + ); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +async function getLeadWithClient(client: PoolClient, id: string): Promise { + const result = await client.query(`SELECT * FROM leads WHERE id = $1 FOR UPDATE`, [id]); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +async function markLeadConvertedWithClient(client: PoolClient, id: string): Promise { + const result = await client.query( + `UPDATE leads SET status = 'converted' WHERE id = $1 RETURNING *`, + [id], + ); + const row = result.rows[0]; + return row ? mapLead(row) : null; +} + +export interface ConvertLeadInput { + title: string; + stage?: string; + probability?: number | null; + notes?: string | null; + ownerId?: string | null; + createdBy?: string | null; +} + +export interface ConvertLeadResult { + lead: Lead; + opportunity: Opportunity; +} + +/** + * Converts a lead into an opportunity: inserts the new opportunity row + * (with lead_id set) and sets the lead's status to 'converted', in a + * single transaction. Returns null if the lead does not exist. + */ +export async function convertLeadToOpportunity( + leadId: string, + opportunityInput: ConvertLeadInput, +): Promise { + return withTransaction(async (client) => { + const existingLead = await getLeadWithClient(client, leadId); + if (!existingLead) { + return null; + } + + const opportunity = await createOpportunityWithClient(client, { + leadId, + title: opportunityInput.title, + stage: opportunityInput.stage, + probability: opportunityInput.probability ?? null, + notes: opportunityInput.notes ?? null, + ownerId: opportunityInput.ownerId ?? existingLead.ownerId, + createdBy: opportunityInput.createdBy ?? null, + }); + + const lead = await markLeadConvertedWithClient(client, leadId); + if (!lead) { + throw new Error(`Lead ${leadId} disappeared mid-transaction`); + } + + return { lead, opportunity }; + }); +} diff --git a/packages/database/src/repositories/opportunities.ts b/packages/database/src/repositories/opportunities.ts new file mode 100644 index 0000000..bab4506 --- /dev/null +++ b/packages/database/src/repositories/opportunities.ts @@ -0,0 +1,192 @@ +import type { PoolClient, QueryResultRow } from "pg"; +import { query } from "../pool.js"; + +/** + * CRM — Opportunities repository (Master Prompt §6.2). Greenfield for + * EDEN — see leads.ts for context. Mirrors `companies.ts` conventions: + * COALESCE-based partial update, snake_case columns mapped to a camelCase + * domain type. + */ + +export interface Opportunity { + id: string; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + leadId: string | null; + title: string; + /** Plain TEXT column. Intended values: prospecting, qualification, proposal, negotiation, won, lost. */ + stage: string; + probability: number | null; + ownerId: string | null; + lossReason: string | null; + notes: string | null; +} + +interface OpportunityRow extends QueryResultRow { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + lead_id: string | null; + title: string; + stage: string; + probability: number | null; + owner_id: string | null; + loss_reason: string | null; + notes: string | null; +} + +function mapOpportunity(row: OpportunityRow): Opportunity { + return { + id: row.id, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + leadId: row.lead_id, + title: row.title, + stage: row.stage, + probability: row.probability, + ownerId: row.owner_id, + lossReason: row.loss_reason, + notes: row.notes, + }; +} + +export interface ListOpportunitiesFilter { + stage?: string; + ownerId?: string; +} + +export async function listOpportunities( + filter: ListOpportunitiesFilter = {}, +): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + + if (filter.stage) { + params.push(filter.stage); + conditions.push(`stage = $${params.length}`); + } + if (filter.ownerId) { + params.push(filter.ownerId); + conditions.push(`owner_id = $${params.length}`); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await query( + `SELECT * FROM opportunities ${where} ORDER BY created_at DESC`, + params, + ); + return result.rows.map(mapOpportunity); +} + +export async function getOpportunity(id: string): Promise { + const result = await query(`SELECT * FROM opportunities WHERE id = $1`, [id]); + const row = result.rows[0]; + return row ? mapOpportunity(row) : null; +} + +export interface CreateOpportunityInput { + leadId?: string | null; + title: string; + stage?: string; + probability?: number | null; + ownerId?: string | null; + lossReason?: string | null; + notes?: string | null; + createdBy?: string | null; +} + +export async function createOpportunity(input: CreateOpportunityInput): Promise { + const result = await query( + `INSERT INTO opportunities + (lead_id, title, stage, probability, owner_id, loss_reason, notes, created_by) + VALUES ($1, $2, COALESCE($3, 'prospecting'), $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.leadId ?? null, + input.title, + input.stage ?? null, + input.probability ?? null, + input.ownerId ?? null, + input.lossReason ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to create opportunity"); + } + return mapOpportunity(row); +} + +/** + * Same as `createOpportunity`, but runs on a caller-supplied client so it + * can participate in an outer transaction (used by + * `leads.convertLeadToOpportunity`). + */ +export async function createOpportunityWithClient( + client: PoolClient, + input: CreateOpportunityInput, +): Promise { + const result = await client.query( + `INSERT INTO opportunities + (lead_id, title, stage, probability, owner_id, loss_reason, notes, created_by) + VALUES ($1, $2, COALESCE($3, 'prospecting'), $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.leadId ?? null, + input.title, + input.stage ?? null, + input.probability ?? null, + input.ownerId ?? null, + input.lossReason ?? null, + input.notes ?? null, + input.createdBy ?? null, + ], + ); + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to create opportunity"); + } + return mapOpportunity(row); +} + +export interface UpdateOpportunityInput { + title?: string; + stage?: string; + probability?: number | null; + ownerId?: string | null; + lossReason?: string | null; + notes?: string | null; +} + +export async function updateOpportunity( + id: string, + input: UpdateOpportunityInput, +): Promise { + const result = await query( + `UPDATE opportunities SET + title = COALESCE($2, title), + stage = COALESCE($3, stage), + probability = COALESCE($4, probability), + owner_id = COALESCE($5, owner_id), + loss_reason = COALESCE($6, loss_reason), + notes = COALESCE($7, notes) + WHERE id = $1 + RETURNING *`, + [ + id, + input.title ?? null, + input.stage ?? null, + input.probability ?? null, + input.ownerId ?? null, + input.lossReason ?? null, + input.notes ?? null, + ], + ); + const row = result.rows[0]; + return row ? mapOpportunity(row) : null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ac0ae..67fa7f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,16 +22,22 @@ importers: version: link:../../packages/database '@nestjs/common': specifier: ^10.4.15 - version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) '@nestjs/core': specifier: ^10.4.15 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^10.4.15 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) '@nestjs/terminus': specifier: ^10.2.3 - version: 10.3.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 express: specifier: ^4.21.2 version: 4.22.2(supports-color@7.2.0) @@ -1017,6 +1023,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1141,6 +1150,12 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + cli-boxes@2.2.1: resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} engines: {node: '>=6'} @@ -1463,6 +1478,9 @@ packages: engines: {node: '>=6'} hasBin: true + libphonenumber-js@1.13.12: + resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1994,6 +2012,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2376,7 +2398,7 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': + '@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': dependencies: file-type: 20.4.1(supports-color@7.2.0) iterare: 1.2.1 @@ -2384,12 +2406,15 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 transitivePeerDependencies: - supports-color - '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) '@nuxtjs/opencollective': 0.3.2 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -2399,14 +2424,14 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) + '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) transitivePeerDependencies: - encoding - '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0)': + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) body-parser: 1.20.4(supports-color@7.2.0) cors: 2.8.5 express: 4.22.1(supports-color@7.2.0) @@ -2415,10 +2440,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/terminus@10.3.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/terminus@10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) boxen: 5.1.2 check-disk-space: 3.4.0 reflect-metadata: 0.2.2 @@ -2630,6 +2655,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.20.1 + '@types/validator@13.15.10': {} + '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -2789,6 +2816,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.12 + validator: 13.15.35 + cli-boxes@2.2.1: {} cliui@8.0.1: @@ -3181,6 +3216,8 @@ snapshots: json5@2.2.3: {} + libphonenumber-js@1.13.12: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -3712,6 +3749,8 @@ snapshots: utils-merge@1.0.1: {} + validator@13.15.35: {} + vary@1.1.2: {} vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13):