backend/apps/core-api/src/modules/crm/ (crm.routes.ts, crm.controller.ts, crm.service.ts, crm.repository.ts, crm.schema.ts). Prisma models: Lead, LeadStatus, LeadNote, LeadActivity, and the LeadSource enum.
Mounting and auth
Every handler reads
req.auth.studioId and throws UNAUTHORIZED (401) when it is missing. The routes file applies no requireRole guard and no coach access scoping, so every role that reaches the web lane can read and change every lead in the studio. See API overview for the web lane.
Actor. Write endpoints record who acted. The controller looks up the Coach row with the caller’s studioId and externalUserId and uses its id and name. When no coach row exists the actor is { id: null, name: null }.
Data model
Lead
Every lead response uses the same field set (leadSelect in the repository).
Deletes are soft. A deleted lead keeps its row with
deletedAt set and disappears from every read.
Statuses
Statuses are per studio. They are seeded lazily: the first call to the board, to create a lead, to create a status or to convert a lead creates the six built-in statuses when the studio has none.
The seeded labels are in Hebrew and each status has a hex
color. Code addresses NEW, WON and LOST by key. A locked status can be renamed and recolored but not deleted.
A status row has id, studioId, key, label, color, locked, order, createdAt, updatedAt.
Activity types
LeadActivity.type is a free string. This module writes these values.
Writing an activity never fails the request. The repository swallows any error from
leadActivity.create.
Board
GET /v1/web/crm/board
Loads the whole CRM page in one call: statuses, leads, KPIs and per-status counts.
Auth: web lane, any role.
string
Search text, trimmed, at most 200 characters. Matches
name, email and city case-insensitively and phone as a plain substring.string
Only leads in this status.
string
One of
SITE, INSTAGRAM, FACEBOOK, GOOGLE, REFERRAL, WHATSAPP.string
Only leads owned by this coach.
date
Coerced to a date. Leads created at or after it.
date
Coerced to a date. Leads created at or before it.
leads. The kpis and countsByStatus values always cover every non-deleted lead in the studio. Leads come back newest first with no pagination.
KPI definitions, from kpiCounts:
activeis total leads minus leads in WON minus leads in LOST.newThisMonthcounts leads created since the first day of the current month.wonandlostcount leads in those statuses.remindersTodaycounts leads whoseremindAtis before the end of today. Overdue reminders are included.
200.
countsByStatus only has keys for statuses that hold at least one lead.
Errors: VALIDATION (422).
Leads
POST /v1/web/crm/leads
Creates a lead.
Auth: web lane, any role.
string
required
Trimmed, 1 to 200 characters.
string
required
Trimmed, 3 to 40 characters.
string | null
Trimmed, at most 200 characters. An empty string becomes
null. The format is not validated.string | null
Same rules as
email.string | null
Same rules as
email.string | null
A
LeadSource value.string | null
A
Coach id. The service does not check that it belongs to the studio. A value that is not a coach id fails the foreign key.number | null
Non-negative integer, in agorot.
string
Status to create the lead in. Must be one of the studio’s statuses. Defaults to the status with key
NEW.created activity.
Response: 201 with the lead.
Errors: VALIDATION (422), BAD_REQUEST (400) unknown status.
GET /v1/web/crm/leads/:id
Returns a lead with its notes and activity log.
Auth: web lane, any role.
string
required
Lead id.
200.
NOT_FOUND (404) lead not found.
PATCH /v1/web/crm/leads/:id
Updates lead fields. Built for field-by-field autosave: only the fields sent are changed, and null clears a nullable field.
Auth: web lane, any role.
string
required
Lead id.
string
Trimmed, 1 to 200 characters.
string
Trimmed, 3 to 40 characters.
string | null
At most 200 characters. Empty string becomes
null.string | null
At most 200 characters. Empty string becomes
null.string | null
At most 200 characters. Empty string becomes
null.string | null
A
LeadSource value.string | null
A
Coach id.number | null
Non-negative integer.
200 with the updated lead.
Errors: VALIDATION (422), NOT_FOUND (404).
DELETE /v1/web/crm/leads/:id
Soft deletes a lead by setting deletedAt. Notes and activities stay in the database.
Auth: web lane, any role.
Response: 200.
NOT_FOUND (404).
POST /v1/web/crm/leads/:id/duplicate
Creates a copy of a lead.
Auth: web lane, any role.
The copy keeps statusId, phone, email, business, city, source, ownerCoachId and valueAgorot. Its name is the original name with a Hebrew “(copy)” suffix. The reminder, notes, activity log, lost reason and clientId are not copied. A duplicated activity is recorded on the new lead.
Response: 201 with the new lead.
Errors: NOT_FOUND (404).
PUT /v1/web/crm/leads/:id/status
Moves a lead to a status.
Auth: web lane, any role.
string
required
Lead id.
string
required
A status of the same studio.
string | null
Trimmed, at most 200 characters. Only allowed when the target status has key
LOST.- Moving into LOST stores
lostReason, ornullwhen none is sent. - Moving into any other status clears
lostReason. - Sending a non-empty
lostReasonwith a non-LOST status is rejected. - A
status_changedactivity is recorded only when the status or the lost reason changed.
200 with the updated lead.
Errors:
PUT /v1/web/crm/leads/:id/reminder
Sets or clears a lead’s reminder.
Auth: web lane, any role.
date | null
required
Coerced to a date.
null clears the reminder.reminder_set or reminder_cleared. The reminder is only a stored date that feeds the remindersToday KPI. This module sends no notification and queues no job for it.
Response: 200 with the updated lead.
Errors: VALIDATION (422), NOT_FOUND (404).
POST /v1/web/crm/leads/:id/notes
Adds a note to a lead.
Auth: web lane, any role.
string
required
Trimmed, 1 to 4000 characters.
coachId and coachName. A note_added activity is recorded.
Response: 201 with the LeadNote row.
VALIDATION (422), NOT_FOUND (404).
There are no endpoints to edit or delete a note.
POST /v1/web/crm/leads/:id/convert
Turns a lead into a real trainee and moves the lead to WON.
Auth: web lane, any role.
string
required
Lead id.
boolean
default:"false"
Passed to the clients service, which can send the trainee a WhatsApp invite.
- If the lead already has a
clientId, it returns that client withcreated: falseand changes nothing. Converting twice never creates a second trainee. - Splits the lead’s
nameon whitespace. The first word becomesfirstName, the restlastName. - Calls the same
clients.createthe trainee pages use, with the lead’s phone, its email when present, no tags and the invite flag. The router builds a full clients service for this, wired with the trainee notifier, R2 storage, the subscriptions service, SmartSend and the billing plan limits. - Updates the lead: sets
clientId, moves it to the WON status and clearslostReason. - Records a
convertedactivity.
200.
Any other error thrown by the clients service passes through unchanged. If the trainee is created but the lead update then fails, the trainee stays and the lead is left without a
clientId. The two writes are not in one transaction.
Statuses
POST /v1/web/crm/statuses
Adds a custom status at the end of the list.
Auth: web lane, any role.
string
required
Trimmed, 1 to 60 characters.
string
required
Hex color in the form
#rrggbb.key: null, locked: false and order one above the current maximum.
Response: 201 with the status row.
VALIDATION (422).
PATCH /v1/web/crm/statuses/:id
Renames or recolors a status. Locked statuses can be changed too.
Auth: web lane, any role.
string
required
Status id.
string
Trimmed, 1 to 60 characters.
string
Hex color in the form
#rrggbb.200 with the updated status.
Errors: VALIDATION (422), NOT_FOUND (404) status not found.
DELETE /v1/web/crm/statuses/:id
Deletes a custom status.
Auth: web lane, any role.
Response: 200.
Soft-deleted leads do not block the check, but they still reference the status row. In that case the database foreign key rejects the delete and the shared error mapper returns
CONFLICT (409) with related resource constraint failed.