The CRM module tracks sales leads for a studio. A lead sits in one status, can carry a reminder, notes and an activity log, and can be converted into a real trainee. Source: 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.
Filters apply only to 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:
  • active is total leads minus leads in WON minus leads in LOST.
  • newThisMonth counts leads created since the first day of the current month.
  • won and lost count leads in those statuses.
  • remindersToday counts leads whose remindAt is before the end of today. Overdue reminders are included.
Month and day boundaries use the server’s local time, not the studio timezone. Response: 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.
Side effects: seeds statuses if needed, then records a 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.
Notes come back newest first with no limit. Activities come back newest first, capped at 100. Response: 200.
Errors: 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.
Status, lost reason and reminder are not accepted here. They have their own endpoints. This endpoint writes no activity entry. Response: 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.
Errors: 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.
Rules:
  • Moving into LOST stores lostReason, or null when none is sent.
  • Moving into any other status clears lostReason.
  • Sending a non-empty lostReason with a non-LOST status is rejected.
  • A status_changed activity is recorded only when the status or the lost reason changed.
Moving a lead into WON through this endpoint does not create a trainee. Use the convert endpoint for that. Response: 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.
Records 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.
The note stores the actor as coachId and coachName. A note_added activity is recorded. Response: 201 with the LeadNote row.
Errors: 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.
What the service does:
  1. If the lead already has a clientId, it returns that client with created: false and changes nothing. Converting twice never creates a second trainee.
  2. Splits the lead’s name on whitespace. The first word becomes firstName, the rest lastName.
  3. Calls the same clients.create the 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.
  4. Updates the lead: sets clientId, moves it to the WON status and clears lostReason.
  5. Records a converted activity.
Because the trainee is created by the clients service, its rules apply here too, including phone normalization and the studio’s plan limit on trainees. Response: 200.
Errors: 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.
The new status gets key: null, locked: false and order one above the current maximum. Response: 201 with the status row.
Errors: 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.
There is no endpoint to reorder statuses. Response: 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.
Errors: 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.