Skip to main content

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.

ColumnTypeNotes
idUUIDPK
name, slugVARCHARslug is unique among non-deleted types (uq_wr_types_slug), generated from the name
description, icon, colorcolor defaults to #7C3AED
owner_team_idUUID → teamsTeam that receives and works the requests
owner_department_idUUID → departmentsOptional; otherwise taken from the team on create
default_pipeline_idUUID → pipelinesMust be a scope = 'work_requests' pipeline
assignment_modeVARCHAR(20)queue (default) · auto · manual (CHECK constraint)
allowed_entitiesJSONBRecord types requests can be raised on; default ["leads","opportunities"]
request_fieldsJSONBRequest form definition: [{ key, label, type, required, options?, helpText? }]
default_due_daysINTDue date offset applied on create
default_billable, default_hourly_rate, currencyBilling defaults copied onto each request
is_active, sort_order
created_by, created_at, updated_at, deleted_atSoft 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​

ColumnTypeNotes
idUUIDPK
request_numberVARCHAR(20)WR-000001, from sequence work_request_number_seq; unique
title, description
type_idUUID → work_request_types
pipeline_id, stage_idUUIDPipeline and current stage
stage_entered_atTIMESTAMPTZAdded by 089; used to fill time_in_stage in history
entity_type, entity_idPrimary record the request was raised on (drives carry-over). Extra records link through record_associations.
team_id, department_idUUIDOwning team / department, copied from the type
requested_byUUID → users
assigned_to, assigned_by, assigned_atCurrent assignee
statusVARCHAR(20)requested · accepted · declined · in_progress · completed · cancelled (CHECK)
priorityVARCHAR(20)low · medium (default) · high · urgent (CHECK)
due_dateTIMESTAMPTZ
accepted_at, started_at, completed_at, cancelled_at, declined_atTIMESTAMPTZLifecycle stamps
declined_by, decline_reason
estimated_minutesINT
is_billable, hourly_rate, currencyBilling; defaults to time entries on this request
project_idUUID → projectsSet when a project is created from the opportunity the request is on
request_dataJSONBAnswers to the type's request form
custom_fieldsJSONB
tagsTEXT[]
created_by, updated_by, created_at, updated_at, deleted_atSoft 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_won marks a stage that completes the request; is_lost one that cancels it. A stage cannot be both.
  • New pipelines are seeded with New, In Progress, Review, Completed (is_won), Cancelled (is_lost) unless withDefaultStages: false is sent. Closing stages get sort_order 900+ so new open stages sort before them.
  • is_default is always false on 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:

OperationPreconditionSide effects
createType 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 presentStarts 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)
acceptstatus = requested, unassigned; caller is a team member or managerapplyAssignment(caller) → accepted
assignOpen status; caller is a manager; target user activeapplyAssignment(user)
releaseCaller is the assignee; accepted / in_progressClears assignee, status = requested, re-notifies the team
declinestatus = requested; team member or manager; reason requireddeclined, stamps declined_*, notifies requester
startstatus = accepted; assignee or managerin_progress
changeStageaccepted / 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
completeaccepted / in_progress; assignee or managerMoves to the first active is_won stage if not already there; completed
cancelOpen status; requester or managerMoves to the first active is_lost stage; cancelled
reopenClosed status; requester or managerIf 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
updateOpen status; manager, assignee, requester or team memberBilling fields (isBillable, hourlyRate, currency) only by assignee or manager; requestData re-validated against the type
removeManager, or requester while still requested and unassignedSoft 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.

CapabilityWhoImplementation
viewRequester 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 recordassertCanView() = DataAccessService.canAccessEntity('work_requests', id) or canAccessEntity(entity_type, entity_id). Fails with 404, not 403, so existence isn't leaked.
manageAdmins (roleLevel >= 100), users with work_requests record access all, the owning team's team_lead_id, the owning department's head_idcanManage()
workThe assignee or a managerassertCanWork() — stage changes, start, complete
acceptAny member of the owning team (user_teams), while unassignedaccept() / 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:

  1. Explicit pick — assignedTo in the body is honoured only if the caller can manage the new request.
  2. auto — pickAutoAssignee(): if the first stage has a non-inherit owner rule, StageOwnershipService.resolveStageOwner(firstStage, null); else StageOwnershipService.nextTeamMember(firstStageId, teamId) — team round-robin keyed on the first stage's cursor. No result → team notified as in queue.
  3. queue — notifyTeam() to every active member of team_id (or the department head when there is no team).
  4. 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:

  1. If the body carries assignmentMode other than stage_rule (record_owner · user · team_lead, plus assignToUserId / assignToTeamId), StageOwnershipService.resolveManualOwner() — record_owner here means the current assignee.
  2. Otherwise (or if the manual pick can't be honoured), resolveStageOwner(target, wr.assigned_to) — an inherit stage 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​

HookCalled fromBehaviour
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 setSets 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.

TriggerFired bypreviousValues
work_request_createdcreate—
work_request_assignedapplyAssignment (accept, assign, auto-assign, hand-off)assigned_to, status
work_request_stage_changedmoveStage (stage changes, and the stage moves done by complete / cancel / reopen)stage_id
work_request_status_changedsetStatus (start, complete, cancel, reopen, auto in_progress)status
work_request_completedsetStatus('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):

EventRecipients
work_request_receivedNew, 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_assignedThe new assignee, unless they assigned themselves
work_request_updatedRequester 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, alias wr) — 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_id maps to assigned_to.
  • Time Entries (time_entries, alias te) — hours, billable hours/amount, date, source, logged-on type, user, and the work request chain (number, title, type, team, opportunity, deal outcome). owner_id maps to user_id.
Tasks report fix

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​

MethodPathPermission
GET/work-requestswork_requests.view
GET/work-requests/countsview
GET/work-requests/by-entity/:entityType/:entityIdview
POST/work-requestscreate
GET/work-requests/:idview
PUT/work-requests/:idedit
DELETE/work-requests/:iddelete
GET/work-requests/:id/stage-historyview
GET/work-requests/:id/activitiesview
GET/work-requests/:id/documentsview
DELETE/work-requests/:id/documents/:documentIdedit
POST/work-requests/:id/accept · assign · release · decline · start · complete · cancel · reopen · change-stageedit

Files are uploaded through the existing POST /upload/document/work_requests/:id.

/work-request-settings​

MethodPathPermission
GET/work-request-settings/types · /types/:idwork_requests.view
POST / PUT / DELETE/work-request-settings/types[/:id]@AdminOnly()
GET/work-request-settings/pipelines · /pipelines/:id · /pipelines/:id/stageswork_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​

RouteComponent
/work-requestsWorkRequestsPage — views Team Queue, My Work, My Requests, All; list or board layout (?layout=board, drag a card to change stage)
/work-requests/:idWorkRequestDetailPage — actions from can, timer, files, activity
/admin/work-request-settingsWorkRequestSettingsPage — 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).