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/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"
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user