Work Requests Module
Work Requests let one team ask another to do a piece of work tied to a CRM record (a presales demo on an opportunity, a credit check on a lead). Each request belongs to an admin-defined request type owned by a team, runs on its own pipeline, and collects time entries, files and activity.
apps/api/src/modules/work-requests/
├── work-requests.module.ts
├── work-requests.controller.ts ← /work-requests
├── work-requests.service.ts ← lifecycle, permissions, carry-over
├── work-request-settings.controller.ts ← /work-request-settings
└── work-request-settings.service.ts ← types, pipelines, stages
apps/web/src/
├── api/work-requests.api.ts ← workRequestsApi + workRequestSettingsApi
├── features/work-requests/ ← /work-requests (list, board) + detail page
├── features/admin/WorkRequestSettingsPage.tsx ← /admin/work-request-settings
└── components/shared/work-requests/ ← RequestWorkModal, EntityWorkRequestsTab, TimePanel
Related shared pieces — the entity registry, record associations and generic time tracking — are covered in Associations & Time Entries.
Tables
Created by migration 088_work_requests (plus 089_work_requests_stage_entered_at). See Database Migrations.
work_request_types
Admin-configured kinds of work.
| Column | Type | Notes |
|---|---|---|
id | UUID | PK |
name, slug | VARCHAR | slug is unique among non-deleted types (uq_wr_types_slug), generated from the name |
description, icon, color | color defaults to #7C3AED | |
owner_team_id | UUID → teams | Team that receives and works the requests |
owner_department_id | UUID → departments | Optional; otherwise taken from the team on create |
default_pipeline_id | UUID → pipelines | Must be a scope = 'work_requests' pipeline |
assignment_mode | VARCHAR(20) | queue (default) · auto · manual (CHECK constraint) |
allowed_entities | JSONB | Record types requests can be raised on; default ["leads","opportunities"] |
request_fields | JSONB | Request form definition: [{ key, label, type, required, options?, helpText? }] |
default_due_days | INT | Due date offset applied on create |
default_billable, default_hourly_rate, currency | Billing defaults copied onto each request | |
is_active, sort_order | ||
created_by, created_at, updated_at, deleted_at | Soft delete |
Request form field types: text, textarea, number, date, select, multiselect, checkbox, url (unknown types are coerced to text; select / multiselect require options; keys must be unique).
work_requests
| Column | Type | Notes |
|---|---|---|
id | UUID | PK |
request_number | VARCHAR(20) | WR-000001, from sequence work_request_number_seq; unique |
title, description | ||
type_id | UUID → work_request_types | |
pipeline_id, stage_id | UUID | Pipeline and current stage |
stage_entered_at | TIMESTAMPTZ | Added by 089; used to fill time_in_stage in history |
entity_type, entity_id | Primary record the request was raised on (drives carry-over). Extra records link through record_associations. | |
team_id, department_id | UUID | Owning team / department, copied from the type |
requested_by | UUID → users | |
assigned_to, assigned_by, assigned_at | Current assignee | |
status | VARCHAR(20) | requested · accepted · declined · in_progress · completed · cancelled (CHECK) |
priority | VARCHAR(20) | low · medium (default) · high · urgent (CHECK) |
due_date | TIMESTAMPTZ | |
accepted_at, started_at, completed_at, cancelled_at, declined_at | TIMESTAMPTZ | Lifecycle stamps |
declined_by, decline_reason | ||
estimated_minutes | INT | |
is_billable, hourly_rate, currency | Billing; defaults to time entries on this request | |
project_id | UUID → projects | Set when a project is created from the opportunity the request is on |
request_data | JSONB | Answers to the type's request form |
custom_fields | JSONB | |
tags | TEXT[] | |
created_by, updated_by, created_at, updated_at, deleted_at | Soft delete |
Indexes cover (entity_type, entity_id), type, pipeline, stage, team, department, requester, assignee, status, due date, project, and a partial created_at DESC WHERE deleted_at IS NULL.
work_request_stage_history
One row per stage move: work_request_id (CASCADE), from_stage_id, to_stage_id, changed_by, time_in_stage (INTERVAL, NOW() - stage_entered_at), note, created_at. The initial placement on the first stage is recorded with from_stage_id = NULL.
Pipelines and stages
Work requests reuse the shared pipelines / pipeline_stages tables (see Pipeline System):
pipelines.scope = 'work_requests'(new column, default'sales')pipeline_stages.module = 'work_requests'is_wonmarks a stage that completes the request;is_lostone that cancels it. A stage cannot be both.- New pipelines are seeded with New, In Progress, Review, Completed (
is_won), Cancelled (is_lost) unlesswithDefaultStages: falseis sent. Closing stages getsort_order900+ so new open stages sort before them. is_defaultis alwaysfalseon these pipelines — "default pipeline" lookups elsewhere mean the sales default.
Status and Stage — Two Axes
status (request lifecycle)
requested ──accept/assign──▶ accepted ──start / first stage move──▶ in_progress ──▶ completed
│ │ │
└──decline──▶ declined └──────────────cancel───────────────────┴──▶ cancelled
release (hand back): accepted | in_progress ──▶ requested (assignee cleared)
reopen: declined | completed | cancelled ──▶ accepted (had an assignee) | requested
stage (progress of the work on the type's pipeline)
first open stage ──▶ … open stages … ──▶ is_won stage (⇒ status completed)
└─▶ is_lost stage (⇒ status cancelled)
Rules enforced in WorkRequestsService:
| Operation | Precondition | Side effects |
|---|---|---|
create | Type active; type has a pipeline with an open stage; type (or body teamId) gives a team or department; primary record type in allowed_entities and visible to the caller; required request fields present | Starts on the first open stage (getFirstStage: lowest sort_order, active, not won/lost); due_date from default_due_days if not given; billing from type defaults; history row; activity on the request and on the primary record (work_request_created); optional associations[] linked; assignment (see below) |
accept | status = requested, unassigned; caller is a team member or manager | applyAssignment(caller) → accepted |
assign | Open status; caller is a manager; target user active | applyAssignment(user) |
release | Caller is the assignee; accepted / in_progress | Clears assignee, status = requested, re-notifies the team |
decline | status = requested; team member or manager; reason required | declined, stamps declined_*, notifies requester |
start | status = accepted; assignee or manager | in_progress |
changeStage | accepted / in_progress; assignee or manager; stage in the same pipeline and active; sequential pipelines only allow ±1 sort_order (cancel stages exempt) | is_won → completed; is_lost → cancelled; open stage → in_progress if accepted, then hand-off |
complete | accepted / in_progress; assignee or manager | Moves to the first active is_won stage if not already there; completed |
cancel | Open status; requester or manager | Moves to the first active is_lost stage; cancelled |
reopen | Closed status; requester or manager | If on a closing stage, back to the first open stage; clears closing stamps; accepted if it had an assignee (and was not declined), else requested + team notified |
update | Open status; manager, assignee, requester or team member | Billing fields (isBillable, hourlyRate, currency) only by assignee or manager; requestData re-validated against the type |
remove | Manager, or requester while still requested and unassigned | Soft delete; deletes any active_timers on the request |
Completing or cancelling (by any route) deletes running active_timers on the request and notifies the requester and assignee.
Permission Model
Two layers. The controller checks the RBAC module permission (work_requests.view/create/edit/delete); the service then decides whether this caller may take this action on this request. Every lifecycle endpoint needs only work_requests.edit at the guard.
| Capability | Who | Implementation |
|---|---|---|
| view | Requester or assignee inside the caller's work_requests record scope; members of the owning team; record-team members; or anyone who can view the primary record | assertCanView() = DataAccessService.canAccessEntity('work_requests', id) or canAccessEntity(entity_type, entity_id). Fails with 404, not 403, so existence isn't leaked. |
| manage | Admins (roleLevel >= 100), users with work_requests record access all, the owning team's team_lead_id, the owning department's head_id | canManage() |
| work | The assignee or a manager | assertCanWork() — stage changes, start, complete |
| accept | Any member of the owning team (user_teams), while unassigned | accept() / decline() |
findOne() returns a can object so the UI only renders working actions:
"can": {
"edit": true, "manage": false, "accept": false, "assign": false,
"release": true, "decline": false, "work": true,
"cancel": false, "reopen": false, "logTime": true
}
The caller is passed to the service as a RequestActor built by actorFromJwt() (see RBAC Deep Dive).
Default role seeding in 088: all roles get work_requests view/create/edit and time_entries view/create/edit/delete; roles with level >= 90 or named Admin/Super Admin also get work_requests.delete and export. record_access.work_requests copies the role's leads scope; record_access.time_entries is all for admin roles, own otherwise.
Assignment
On create, in order:
- Explicit pick —
assignedToin the body is honoured only if the caller can manage the new request. auto—pickAutoAssignee(): if the first stage has a non-inheritowner rule,StageOwnershipService.resolveStageOwner(firstStage, null); elseStageOwnershipService.nextTeamMember(firstStageId, teamId)— team round-robin keyed on the first stage's cursor. No result → team notified as inqueue.queue—notifyTeam()to every active member ofteam_id(or the department head when there is no team).manual—notifyTeam(leadOnly = true)to the team lead.
applyAssignment() sets assigned_to/by/at, moves requested → accepted (stamping accepted_at once), audits, logs an assigned activity, fires work_request_assigned, notifies the new assignee (unless self-assigned) and — on first pick-up — the requester.
Stage hand-off
changeStage() to an open stage resolves an owner:
- If the body carries
assignmentModeother thanstage_rule(record_owner·user·team_lead, plusassignToUserId/assignToTeamId),StageOwnershipService.resolveManualOwner()—record_ownerhere means the current assignee. - Otherwise (or if the manual pick can't be honoured),
resolveStageOwner(target, wr.assigned_to)— aninheritstage returns the current assignee.
A record_stage_assignments row is written (entity_type = 'work_requests'), and if the owner differs from the current assignee, applyAssignment() hands the request over (Handed off at stage "…"). Stage owner rules are edited through the shared PUT /lead-settings/stage-ownership/:stageId endpoint.
Carry-over Hooks
| Hook | Called from | Behaviour |
|---|---|---|
moveToEntity(schema, userId, from, to) | LeadsService.convert() (step 7) | Requests whose primary record is the lead move to the new opportunity — or the account, or the contact, when no opportunity was created. Each moved request keeps the lead as a manual association labelled "Raised on" (AssociationsService.linkSystem) and gets a moved activity. Failures are logged, never block conversion. |
linkProject(schema, userId, opportunityId, projectId) | ProjectsService project creation, when dto.opportunityId is set | Sets project_id on that opportunity's requests that have no project yet, so delivery sees the pre-sale work and time. Errors are swallowed. |
Both use queryReturning() because they are UPDATE … RETURNING (see Best Practices). WorkRequestsModule is imported with forwardRef by LeadsModule and ProjectsModule.
Workflow Triggers
Fired fire-and-forget via WorkflowRunnerService.trigger(schema, 'work_requests', type, id, row, previousValues). The entity payload is the raw row with owner_id set to assigned_to, so owner-based actions target the assignee.
| Trigger | Fired by | previousValues |
|---|---|---|
work_request_created | create | — |
work_request_assigned | applyAssignment (accept, assign, auto-assign, hand-off) | assigned_to, status |
work_request_stage_changed | moveStage (stage changes, and the stage moves done by complete / cancel / reopen) | stage_id |
work_request_status_changed | setStatus (start, complete, cancel, reopen, auto in_progress) | status |
work_request_completed | setStatus('completed') | status |
Decline and release update the row directly and fire no trigger. In the runner, tableForModule('work_requests') resolves to work_requests, and assign_owner writes assigned_to instead of owner_id for this module. update_field has no system-column allowlist for work_requests (custom fields only), and the scheduler does not scan this module.
Notifications
Three event types were added to NOTIFICATION_EVENT_TYPES (notification-preferences.service.ts), all with entityType = 'work_requests', icon = 'clipboard', actionUrl = /work-requests/:id, and variables actorName, requestNumber, requestTitle, actionUrl (absolute, from APP_URL):
| Event | Recipients |
|---|---|
work_request_received | New, released or reopened-to-queue request: active team members (queue, auto fallback) or the team lead (manual); the department head when the type has no team |
work_request_assigned | The new assignee, unless they assigned themselves |
work_request_updated | Requester on pick-up and decline; requester and assignee on complete / cancel |
The actor is always filtered out of the recipient list.
Reports
Two report data sources were added in report-data-sources.ts, scoped by the work_requests and time_entries modules respectively (reports.service.ts):
- Work Requests (
work_requests, aliaswr) — status, priority, raised-on type, billable, rate, estimated hours, due/overdue, requested/accepted/completed dates, Hours to Accept, Days to Complete, plus joins for type, stage, team, department, assignee, requester, logged hours and billable amount (time pre-aggregated per request so rows don't multiply), and the opportunity it is on with Deal Outcome (Won / Lost / Open / No deal).owner_idmaps toassigned_to. - Time Entries (
time_entries, aliaste) — hours, billable hours/amount, date, source, logged-on type, user, and the work request chain (number, title, type, team, opportunity, deal outcome).owner_idmaps touser_id.
The same change corrected the Tasks data source: its Status and Priority fields pointed at t.status / t.priority, which do not exist. They now read task_statuses.name / task_priorities.name through joins. Estimated Minutes and Actual Minutes were also added.
Endpoints
All endpoints use JwtAuthGuard + PermissionGuard. Full request/response notes are in the API reference: Work Requests API and Work Request Settings API.
/work-requests
| Method | Path | Permission |
|---|---|---|
| GET | /work-requests | work_requests.view |
| GET | /work-requests/counts | view |
| GET | /work-requests/by-entity/:entityType/:entityId | view |
| POST | /work-requests | create |
| GET | /work-requests/:id | view |
| PUT | /work-requests/:id | edit |
| DELETE | /work-requests/:id | delete |
| GET | /work-requests/:id/stage-history | view |
| GET | /work-requests/:id/activities | view |
| GET | /work-requests/:id/documents | view |
| DELETE | /work-requests/:id/documents/:documentId | edit |
| POST | /work-requests/:id/accept · assign · release · decline · start · complete · cancel · reopen · change-stage | edit |
Files are uploaded through the existing POST /upload/document/work_requests/:id.
/work-request-settings
| Method | Path | Permission |
|---|---|---|
| GET | /work-request-settings/types · /types/:id | work_requests.view |
| POST / PUT / DELETE | /work-request-settings/types[/:id] | @AdminOnly() |
| GET | /work-request-settings/pipelines · /pipelines/:id · /pipelines/:id/stages | work_requests.view |
| POST / PUT / DELETE | /work-request-settings/pipelines[/:id] | @AdminOnly() |
| PUT | /work-request-settings/pipelines/:id/stages/reorder | @AdminOnly() |
| POST | /work-request-settings/pipelines/:id/stages | @AdminOnly() |
| PUT / DELETE | /work-request-settings/stages/:stageId | @AdminOnly() |
Reads only need work_requests.view because the request form and board list types and stages.
Frontend
| Route | Component |
|---|---|
/work-requests | WorkRequestsPage — views Team Queue, My Work, My Requests, All; list or board layout (?layout=board, drag a card to change stage) |
/work-requests/:id | WorkRequestDetailPage — actions from can, timer, files, activity |
/admin/work-request-settings | WorkRequestSettingsPage — Request Types and Pipelines & Stages tabs |
The main sidebar shows Work Requests when the user is an admin or has work_requests.view, with a badge of assignedToMe + teamQueue from GET /work-requests/counts. Lead and opportunity detail pages get a Work Requests tab (EntityWorkRequestsTab) and the Request work modal (RequestWorkModal).