Add CRM leads/opportunities module (Master Prompt §6.2 MVP)
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('<resource>', 'view'), not
just JwtAuthGuard. Writes require '<resource>', '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 <leads/opportunities repo fns,
auditRepo>" — 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 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/terminus": "^10.2.3",
|
"@nestjs/terminus": "^10.2.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
|
|||||||
23
apps/api/src/leads/dto/convert-lead.dto.ts
Normal file
23
apps/api/src/leads/dto/convert-lead.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
46
apps/api/src/leads/dto/create-lead.dto.ts
Normal file
46
apps/api/src/leads/dto/create-lead.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
41
apps/api/src/leads/dto/update-lead.dto.ts
Normal file
41
apps/api/src/leads/dto/update-lead.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
74
apps/api/src/leads/leads.controller.ts
Normal file
74
apps/api/src/leads/leads.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/leads/leads.module.ts
Normal file
10
apps/api/src/leads/leads.module.ts
Normal file
@@ -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 {}
|
||||||
93
apps/api/src/leads/leads.service.ts
Normal file
93
apps/api/src/leads/leads.service.ts
Normal file
@@ -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<Lead[]> {
|
||||||
|
return listLeads(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<Lead> {
|
||||||
|
const lead = await getLead(id);
|
||||||
|
if (!lead) {
|
||||||
|
throw new NotFoundException(`Lead ${id} not found`);
|
||||||
|
}
|
||||||
|
return lead;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLeadDto, actorId: string | null): Promise<Lead> {
|
||||||
|
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<Lead> {
|
||||||
|
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<ConvertLeadResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/api/src/opportunities/dto/create-opportunity.dto.ts
Normal file
49
apps/api/src/opportunities/dto/create-opportunity.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
32
apps/api/src/opportunities/dto/update-opportunity.dto.ts
Normal file
32
apps/api/src/opportunities/dto/update-opportunity.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
63
apps/api/src/opportunities/opportunities.controller.ts
Normal file
63
apps/api/src/opportunities/opportunities.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/opportunities/opportunities.module.ts
Normal file
10
apps/api/src/opportunities/opportunities.module.ts
Normal file
@@ -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 {}
|
||||||
62
apps/api/src/opportunities/opportunities.service.ts
Normal file
62
apps/api/src/opportunities/opportunities.service.ts
Normal file
@@ -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<Opportunity[]> {
|
||||||
|
return listOpportunities(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await getOpportunity(id);
|
||||||
|
if (!opportunity) {
|
||||||
|
throw new NotFoundException(`Opportunity ${id} not found`);
|
||||||
|
}
|
||||||
|
return opportunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateOpportunityDto, actorId: string | null): Promise<Opportunity> {
|
||||||
|
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<Opportunity> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
packages/database/migrations/1788439012582_crm.cjs
Normal file
93
packages/database/migrations/1788439012582_crm.cjs
Normal file
@@ -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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
232
packages/database/src/repositories/leads.ts
Normal file
232
packages/database/src/repositories/leads.ts
Normal file
@@ -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<Lead[]> {
|
||||||
|
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<LeadRow>(
|
||||||
|
`SELECT * FROM leads ${where} ORDER BY created_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return result.rows.map(mapLead);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLead(id: string): Promise<Lead | null> {
|
||||||
|
const result = await query<LeadRow>(`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<Lead> {
|
||||||
|
const result = await query<LeadRow>(
|
||||||
|
`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<Lead | null> {
|
||||||
|
const result = await query<LeadRow>(
|
||||||
|
`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<Lead | null> {
|
||||||
|
const result = await client.query<LeadRow>(`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<Lead | null> {
|
||||||
|
const result = await client.query<LeadRow>(
|
||||||
|
`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<ConvertLeadResult | null> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
192
packages/database/src/repositories/opportunities.ts
Normal file
192
packages/database/src/repositories/opportunities.ts
Normal file
@@ -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<Opportunity[]> {
|
||||||
|
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<OpportunityRow>(
|
||||||
|
`SELECT * FROM opportunities ${where} ORDER BY created_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return result.rows.map(mapOpportunity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpportunity(id: string): Promise<Opportunity | null> {
|
||||||
|
const result = await query<OpportunityRow>(`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<Opportunity> {
|
||||||
|
const result = await query<OpportunityRow>(
|
||||||
|
`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<Opportunity> {
|
||||||
|
const result = await client.query<OpportunityRow>(
|
||||||
|
`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<Opportunity | null> {
|
||||||
|
const result = await query<OpportunityRow>(
|
||||||
|
`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;
|
||||||
|
}
|
||||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -22,16 +22,22 @@ importers:
|
|||||||
version: link:../../packages/database
|
version: link:../../packages/database
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^10.4.15
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^10.4.15
|
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':
|
'@nestjs/platform-express':
|
||||||
specifier: ^10.4.15
|
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':
|
'@nestjs/terminus':
|
||||||
specifier: ^10.2.3
|
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:
|
express:
|
||||||
specifier: ^4.21.2
|
specifier: ^4.21.2
|
||||||
version: 4.22.2(supports-color@7.2.0)
|
version: 4.22.2(supports-color@7.2.0)
|
||||||
@@ -1017,6 +1023,9 @@ packages:
|
|||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
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':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -1141,6 +1150,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
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:
|
cli-boxes@2.2.1:
|
||||||
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
|
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1463,6 +1478,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
libphonenumber-js@1.13.12:
|
||||||
|
resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==}
|
||||||
|
|
||||||
lilconfig@3.1.3:
|
lilconfig@3.1.3:
|
||||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1994,6 +2012,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
validator@13.15.35:
|
||||||
|
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2376,7 +2398,7 @@ snapshots:
|
|||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
file-type: 20.4.1(supports-color@7.2.0)
|
file-type: 20.4.1(supports-color@7.2.0)
|
||||||
iterare: 1.2.1
|
iterare: 1.2.1
|
||||||
@@ -2384,12 +2406,15 @@ snapshots:
|
|||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.14.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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
|
'@nuxtjs/opencollective': 0.3.2
|
||||||
fast-safe-stringify: 2.1.1
|
fast-safe-stringify: 2.1.1
|
||||||
iterare: 1.2.1
|
iterare: 1.2.1
|
||||||
@@ -2399,14 +2424,14 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- 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:
|
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)
|
||||||
'@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)
|
||||||
body-parser: 1.20.4(supports-color@7.2.0)
|
body-parser: 1.20.4(supports-color@7.2.0)
|
||||||
cors: 2.8.5
|
cors: 2.8.5
|
||||||
express: 4.22.1(supports-color@7.2.0)
|
express: 4.22.1(supports-color@7.2.0)
|
||||||
@@ -2415,10 +2440,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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)
|
||||||
'@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)
|
||||||
boxen: 5.1.2
|
boxen: 5.1.2
|
||||||
check-disk-space: 3.4.0
|
check-disk-space: 3.4.0
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
@@ -2630,6 +2655,8 @@ snapshots:
|
|||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 22.20.1
|
'@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))':
|
'@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:
|
dependencies:
|
||||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||||
@@ -2789,6 +2816,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
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: {}
|
cli-boxes@2.2.1: {}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
@@ -3181,6 +3216,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
libphonenumber-js@1.13.12: {}
|
||||||
|
|
||||||
lilconfig@3.1.3: {}
|
lilconfig@3.1.3: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
@@ -3712,6 +3749,8 @@ snapshots:
|
|||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
validator@13.15.35: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13):
|
vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13):
|
||||||
|
|||||||
Reference in New Issue
Block a user