task-f.5
This commit is contained in:
173
docs/implementation/task-f5-approval-automation-foundation.md
Normal file
173
docs/implementation/task-f5-approval-automation-foundation.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Task F5: Approval Automation Foundation
|
||||||
|
|
||||||
|
## Scope Delivered
|
||||||
|
|
||||||
|
- Added approval automation schema for reminder policies and escalation policies.
|
||||||
|
- Added step timing state to approval requests so automation can reason about the current step safely.
|
||||||
|
- Added automation scheduler and pending queue monitor on top of the existing approval and notification foundations.
|
||||||
|
- Added admin APIs and settings pages for reminder and escalation policy management.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Approval request timing
|
||||||
|
|
||||||
|
`crm_approval_requests` now stores:
|
||||||
|
|
||||||
|
- `current_step_started_at`
|
||||||
|
|
||||||
|
This becomes the source of truth for waiting-time, reminder, escalation, and timeout calculations.
|
||||||
|
|
||||||
|
### Reminder policies
|
||||||
|
|
||||||
|
`crm_approval_reminder_policies`
|
||||||
|
|
||||||
|
One policy per workflow step:
|
||||||
|
|
||||||
|
- `workflow_id`
|
||||||
|
- `step_number`
|
||||||
|
- `sla_hours`
|
||||||
|
- `reminder_offsets_hours`
|
||||||
|
- `calendar_mode`
|
||||||
|
- `timeout_action`
|
||||||
|
- `is_active`
|
||||||
|
|
||||||
|
This lets each step define SLA, reminder schedule, and timeout behavior without changing runtime approval ownership.
|
||||||
|
|
||||||
|
### Escalation policies
|
||||||
|
|
||||||
|
`crm_approval_escalation_policies`
|
||||||
|
|
||||||
|
Supports one or more escalation rules per workflow step:
|
||||||
|
|
||||||
|
- `workflow_id`
|
||||||
|
- `step_number`
|
||||||
|
- `trigger_after_hours`
|
||||||
|
- `target_type`
|
||||||
|
- `target_value`
|
||||||
|
- `is_active`
|
||||||
|
|
||||||
|
Supported targets:
|
||||||
|
|
||||||
|
- `manager`
|
||||||
|
- `requester`
|
||||||
|
- `explicit_user`
|
||||||
|
- `role`
|
||||||
|
- `permission_group`
|
||||||
|
|
||||||
|
## Scheduler Model
|
||||||
|
|
||||||
|
Core service:
|
||||||
|
|
||||||
|
- `processApprovalAutomation()`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
1. Load pending approval requests.
|
||||||
|
2. Resolve the current workflow step and timing baseline from `current_step_started_at`.
|
||||||
|
3. Send reminder events for every configured reminder threshold crossed.
|
||||||
|
4. Send escalation events for every active escalation threshold crossed.
|
||||||
|
5. Execute timeout action when SLA is reached.
|
||||||
|
|
||||||
|
The scheduler is safe to rerun because notification publication uses deterministic `dedupeKey` values per request, step, and threshold.
|
||||||
|
|
||||||
|
## Timeout Model
|
||||||
|
|
||||||
|
Supported timeout actions:
|
||||||
|
|
||||||
|
- `none`
|
||||||
|
- `auto_reject`
|
||||||
|
- `auto_cancel`
|
||||||
|
- `auto_escalate`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `auto_reject` and `auto_cancel` create approval action history and synchronize entity status.
|
||||||
|
- `auto_escalate` keeps the approval pending and publishes a timeout notification event to resolved escalation targets.
|
||||||
|
- This foundation intentionally does not reassign approver ownership or introduce delegation.
|
||||||
|
|
||||||
|
## Notification Integration
|
||||||
|
|
||||||
|
F.5 reuses `publishNotificationEvent()` from the F.4 notification foundation.
|
||||||
|
|
||||||
|
New event types:
|
||||||
|
|
||||||
|
- `approval.reminder`
|
||||||
|
- `approval.escalated`
|
||||||
|
- `approval.timeout`
|
||||||
|
|
||||||
|
Channels remain in-app only for now.
|
||||||
|
|
||||||
|
## Queue Monitor
|
||||||
|
|
||||||
|
Core service:
|
||||||
|
|
||||||
|
- `getPendingApprovalQueue()`
|
||||||
|
|
||||||
|
Current queue output includes:
|
||||||
|
|
||||||
|
- pending count
|
||||||
|
- due today count
|
||||||
|
- overdue count
|
||||||
|
- escalated count
|
||||||
|
- per-request waiting hours
|
||||||
|
- SLA hours
|
||||||
|
- remaining hours
|
||||||
|
- due timestamp
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- `GET /api/crm/approval/automation/pending`
|
||||||
|
- `POST /api/crm/approval/automation/run`
|
||||||
|
- `GET /api/crm/approval/reminder-policies`
|
||||||
|
- `POST /api/crm/approval/reminder-policies`
|
||||||
|
- `PATCH /api/crm/approval/reminder-policies/[id]`
|
||||||
|
- `DELETE /api/crm/approval/reminder-policies/[id]`
|
||||||
|
- `GET /api/crm/approval/escalation-policies`
|
||||||
|
- `POST /api/crm/approval/escalation-policies`
|
||||||
|
- `PATCH /api/crm/approval/escalation-policies/[id]`
|
||||||
|
- `DELETE /api/crm/approval/escalation-policies/[id]`
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Added settings pages:
|
||||||
|
|
||||||
|
- `/dashboard/crm/settings/approval-reminder-policies`
|
||||||
|
- `/dashboard/crm/settings/approval-escalation-policies`
|
||||||
|
|
||||||
|
The reminder page also includes:
|
||||||
|
|
||||||
|
- queue monitor cards
|
||||||
|
- a compact pending queue view
|
||||||
|
- a manual “Run Automation” action for verification and operations
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- `crm.approval.automation.read`
|
||||||
|
- `crm.approval.automation.run`
|
||||||
|
- `crm.approval.reminder.manage`
|
||||||
|
- `crm.approval.escalation.manage`
|
||||||
|
|
||||||
|
These are grouped under the approval permission section and added to the default CRM admin configuration surface.
|
||||||
|
|
||||||
|
## Audit Events
|
||||||
|
|
||||||
|
F.5 records:
|
||||||
|
|
||||||
|
- `approval_reminder_sent`
|
||||||
|
- `approval_escalated`
|
||||||
|
- `approval_timeout`
|
||||||
|
- `approval_policy_created`
|
||||||
|
- `approval_policy_updated`
|
||||||
|
- `approval_policy_deleted`
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
- calendar support is `calendar_days` only
|
||||||
|
- no holiday calendar yet
|
||||||
|
- no working-hours window yet
|
||||||
|
- no delegation or vacation replacement yet
|
||||||
|
- no background cron orchestration yet; manual run endpoint is the current operator entrypoint
|
||||||
38
drizzle/0005_furry_maelstrom.sql
Normal file
38
drizzle/0005_furry_maelstrom.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE "crm_approval_escalation_policies" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"workflow_id" text NOT NULL,
|
||||||
|
"step_number" integer NOT NULL,
|
||||||
|
"trigger_after_hours" integer NOT NULL,
|
||||||
|
"target_type" text NOT NULL,
|
||||||
|
"target_value" text,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"updated_by" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "crm_approval_reminder_policies" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"workflow_id" text NOT NULL,
|
||||||
|
"step_number" integer NOT NULL,
|
||||||
|
"sla_hours" integer DEFAULT 24 NOT NULL,
|
||||||
|
"reminder_offsets_hours" integer[] DEFAULT '{}' NOT NULL,
|
||||||
|
"calendar_mode" text DEFAULT 'calendar_days' NOT NULL,
|
||||||
|
"timeout_action" text DEFAULT 'none' NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"updated_by" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_approval_requests" ADD COLUMN "current_step_started_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_approval_steps" ADD COLUMN "sla_hours" integer DEFAULT 24 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_approval_steps" ADD COLUMN "calendar_mode" text DEFAULT 'calendar_days' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_approval_steps" ADD COLUMN "timeout_action" text DEFAULT 'none' NOT NULL;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_approval_reminder_policies_workflow_step_idx" ON "crm_approval_reminder_policies" USING btree ("workflow_id","step_number");
|
||||||
5157
drizzle/meta/0005_snapshot.json
Normal file
5157
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1782455991734,
|
"when": 1782455991734,
|
||||||
"tag": "0004_unusual_hairball",
|
"tag": "0004_unusual_hairball",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782464526434,
|
||||||
|
"tag": "0005_furry_maelstrom",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
474
plans/task-f.5.md
Normal file
474
plans/task-f.5.md
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
# Task F.5: Approval Automation (Reminder & Escalation) Foundation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Planned
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build the Approval Automation Foundation that manages reminder scheduling, SLA monitoring, escalation policies, timeout handling, and automatic notification for approval workflows.
|
||||||
|
|
||||||
|
This task extends the Notification Framework (F.4) by introducing time-based automation while keeping approval business logic unchanged.
|
||||||
|
|
||||||
|
The goal is to support:
|
||||||
|
|
||||||
|
* Approval SLA
|
||||||
|
* Reminder policies
|
||||||
|
* Escalation policies
|
||||||
|
* Approval timeout handling
|
||||||
|
* Automation scheduler
|
||||||
|
* Approval queue monitoring
|
||||||
|
* Notification integration
|
||||||
|
|
||||||
|
This task does not implement Email, LINE OA, Webhook delivery, Delegation, Vacation replacement, or Analytics Dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mandatory Review
|
||||||
|
|
||||||
|
Review:
|
||||||
|
|
||||||
|
* `AGENTS.md`
|
||||||
|
* `docs/standards/project-foundations.md`
|
||||||
|
* `docs/standards/task-catalog.md`
|
||||||
|
* `docs/implementation/task-f2-approval-matrix-foundation.md`
|
||||||
|
* `docs/implementation/task-f3-approval-workflow-builder-foundation.md`
|
||||||
|
* `docs/implementation/task-f4-notification-framework-foundation.md`
|
||||||
|
* Approval Runtime
|
||||||
|
* Notification Framework
|
||||||
|
* CRM Authorization Foundation
|
||||||
|
* Audit Foundation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Foundation
|
||||||
|
|
||||||
|
Already available:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Matrix
|
||||||
|
Approval Workflow Builder
|
||||||
|
Approval Runtime
|
||||||
|
Notification Framework
|
||||||
|
Audit Foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
Current limitation:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval waits forever until someone approves.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.1 Approval SLA
|
||||||
|
|
||||||
|
Each workflow step may define:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
SLA Hours
|
||||||
|
Due Date
|
||||||
|
Reminder Policy
|
||||||
|
Escalation Policy
|
||||||
|
Timeout Policy
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Sales Manager
|
||||||
|
|
||||||
|
24 Hours
|
||||||
|
|
||||||
|
Department Manager
|
||||||
|
|
||||||
|
48 Hours
|
||||||
|
|
||||||
|
CEO
|
||||||
|
|
||||||
|
72 Hours
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.2 Reminder Policy
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_reminder_policies
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Workflow A
|
||||||
|
|
||||||
|
Reminder
|
||||||
|
|
||||||
|
After 1 day
|
||||||
|
|
||||||
|
After 3 days
|
||||||
|
|
||||||
|
After 5 days
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Multiple reminder points
|
||||||
|
* Prevent duplicate reminders
|
||||||
|
* Configurable per workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.3 Escalation Policy
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_escalation_policies
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported targets:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Manager
|
||||||
|
Requester
|
||||||
|
Explicit User
|
||||||
|
Role
|
||||||
|
Permission Group
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
After 3 days
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Notify Manager
|
||||||
|
|
||||||
|
After 5 days
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Escalate Director
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.4 Timeout Policy
|
||||||
|
|
||||||
|
Supported actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
None
|
||||||
|
|
||||||
|
Auto Reject
|
||||||
|
|
||||||
|
Auto Cancel
|
||||||
|
|
||||||
|
Auto Escalate
|
||||||
|
```
|
||||||
|
|
||||||
|
Timeout actions must be configurable per workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.5 Automation Scheduler
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
processApprovalAutomation()
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Find pending approvals
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Calculate SLA
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Send reminders
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Run escalation
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Execute timeout actions
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Publish notification events
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Idempotent
|
||||||
|
* Safe to execute repeatedly
|
||||||
|
* Transactional where required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.6 Approval Queue Monitor
|
||||||
|
|
||||||
|
Add service:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
getPendingApprovalQueue()
|
||||||
|
```
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Pending
|
||||||
|
|
||||||
|
Due Today
|
||||||
|
|
||||||
|
Overdue
|
||||||
|
|
||||||
|
Escalated
|
||||||
|
|
||||||
|
Waiting Time
|
||||||
|
|
||||||
|
Remaining SLA
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.7 Notification Integration
|
||||||
|
|
||||||
|
Use existing:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
publishNotificationEvent()
|
||||||
|
```
|
||||||
|
|
||||||
|
Events:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
approval.reminder
|
||||||
|
|
||||||
|
approval.escalated
|
||||||
|
|
||||||
|
approval.timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
No direct Email or LINE sending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.8 Calendar Support
|
||||||
|
|
||||||
|
Prepare business calendar support.
|
||||||
|
|
||||||
|
For now support:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Calendar Days
|
||||||
|
```
|
||||||
|
|
||||||
|
Future-ready for:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Working Days
|
||||||
|
|
||||||
|
Holiday Calendar
|
||||||
|
|
||||||
|
Business Hours
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.9 APIs
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/approval/automation/pending
|
||||||
|
|
||||||
|
POST /api/crm/approval/automation/run
|
||||||
|
|
||||||
|
GET /api/crm/approval/reminder-policies
|
||||||
|
|
||||||
|
POST /api/crm/approval/reminder-policies
|
||||||
|
|
||||||
|
GET /api/crm/approval/escalation-policies
|
||||||
|
|
||||||
|
POST /api/crm/approval/escalation-policies
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.10 UI
|
||||||
|
|
||||||
|
Add settings pages:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Reminder Policies
|
||||||
|
|
||||||
|
Approval Escalation Policies
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* CRUD policies
|
||||||
|
* SLA configuration
|
||||||
|
* Reminder schedule
|
||||||
|
* Escalation target
|
||||||
|
* Timeout action
|
||||||
|
|
||||||
|
Use existing design system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.11 Audit
|
||||||
|
|
||||||
|
Audit actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
approval_reminder_sent
|
||||||
|
|
||||||
|
approval_escalated
|
||||||
|
|
||||||
|
approval_timeout
|
||||||
|
|
||||||
|
approval_policy_created
|
||||||
|
|
||||||
|
approval_policy_updated
|
||||||
|
|
||||||
|
approval_policy_deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F.5.12 Permissions
|
||||||
|
|
||||||
|
Verify or add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.approval.automation.read
|
||||||
|
|
||||||
|
crm.approval.automation.run
|
||||||
|
|
||||||
|
crm.approval.reminder.manage
|
||||||
|
|
||||||
|
crm.approval.escalation.manage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-f5-approval-automation-foundation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
SLA model
|
||||||
|
|
||||||
|
Reminder model
|
||||||
|
|
||||||
|
Escalation model
|
||||||
|
|
||||||
|
Timeout model
|
||||||
|
|
||||||
|
Automation scheduler
|
||||||
|
|
||||||
|
Notification integration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
* SMTP Email
|
||||||
|
* LINE OA
|
||||||
|
* Webhook delivery
|
||||||
|
* Vacation replacement
|
||||||
|
* Delegate approval
|
||||||
|
* AI reminder suggestions
|
||||||
|
* Approval Analytics Dashboard
|
||||||
|
* Real-time push notifications
|
||||||
|
|
||||||
|
These belong to later tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual verification:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Create reminder policy
|
||||||
|
|
||||||
|
Create escalation policy
|
||||||
|
|
||||||
|
Submit approval
|
||||||
|
|
||||||
|
Reminder generated after SLA
|
||||||
|
|
||||||
|
Escalation generated after configured threshold
|
||||||
|
|
||||||
|
Timeout action executes correctly
|
||||||
|
|
||||||
|
Notification created
|
||||||
|
|
||||||
|
Approval remains consistent
|
||||||
|
|
||||||
|
Audit logs created
|
||||||
|
|
||||||
|
Scheduler safe to rerun
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task is complete when:
|
||||||
|
|
||||||
|
* Approval SLA exists
|
||||||
|
* Reminder policies exist
|
||||||
|
* Escalation policies exist
|
||||||
|
* Timeout policies exist
|
||||||
|
* Automation scheduler exists
|
||||||
|
* Reminder notifications are generated
|
||||||
|
* Escalation notifications are generated
|
||||||
|
* Notification Framework is reused
|
||||||
|
* Audit logs capture automation
|
||||||
|
* Existing approval flow remains operational
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Automation Foundation = Established
|
||||||
|
|
||||||
|
Reminder Engine = Operational
|
||||||
|
|
||||||
|
Escalation Engine = Operational
|
||||||
|
|
||||||
|
Ready for F.6 Approval Analytics & Dashboard
|
||||||
|
```
|
||||||
33
src/app/api/crm/approval/automation/pending/route.ts
Normal file
33
src/app/api/crm/approval/automation/pending/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPendingApprovalQueue } from '@/features/foundation/approval-automation/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalAutomationRead
|
||||||
|
});
|
||||||
|
const result = await getPendingApprovalQueue(organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Pending approval automation queue loaded successfully',
|
||||||
|
...result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to load pending approval queue' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/crm/approval/automation/run/route.ts
Normal file
29
src/app/api/crm/approval/automation/run/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { processApprovalAutomation } from '@/features/foundation/approval-automation/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalAutomationRun
|
||||||
|
});
|
||||||
|
const result = await processApprovalAutomation(organization.id, session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval automation completed successfully',
|
||||||
|
...result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to run approval automation' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/crm/approval/escalation-policies/[id]/route.ts
Normal file
76
src/app/api/crm/approval/escalation-policies/[id]/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
deleteApprovalEscalationPolicy,
|
||||||
|
updateApprovalEscalationPolicy
|
||||||
|
} from '@/features/foundation/approval-automation/server/service';
|
||||||
|
import { approvalEscalationPolicySchema } from '@/features/foundation/approval-automation/schemas';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalEscalationManage
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
const payload = approvalEscalationPolicySchema.parse(await request.json());
|
||||||
|
await updateApprovalEscalationPolicy(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval escalation policy updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to update approval escalation policy' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalEscalationManage
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
await deleteApprovalEscalationPolicy(id, organization.id, session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval escalation policy deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to delete approval escalation policy' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/api/crm/approval/escalation-policies/route.ts
Normal file
78
src/app/api/crm/approval/escalation-policies/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
createApprovalEscalationPolicy,
|
||||||
|
listApprovalEscalationPolicies
|
||||||
|
} from '@/features/foundation/approval-automation/server/service';
|
||||||
|
import { approvalEscalationPolicySchema } from '@/features/foundation/approval-automation/schemas';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalEscalationManage
|
||||||
|
});
|
||||||
|
const items = await listApprovalEscalationPolicies(organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Approval escalation policies loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to load approval escalation policies' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalEscalationManage
|
||||||
|
});
|
||||||
|
const payload = approvalEscalationPolicySchema.parse(await request.json());
|
||||||
|
const created = await createApprovalEscalationPolicy(
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Approval escalation policy created successfully',
|
||||||
|
item: created
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to create approval escalation policy' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/crm/approval/reminder-policies/[id]/route.ts
Normal file
76
src/app/api/crm/approval/reminder-policies/[id]/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
deleteApprovalReminderPolicy,
|
||||||
|
updateApprovalReminderPolicy
|
||||||
|
} from '@/features/foundation/approval-automation/server/service';
|
||||||
|
import { approvalReminderPolicySchema } from '@/features/foundation/approval-automation/schemas';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalReminderManage
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
const payload = approvalReminderPolicySchema.parse(await request.json());
|
||||||
|
await updateApprovalReminderPolicy(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval reminder policy updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to update approval reminder policy' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalReminderManage
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
await deleteApprovalReminderPolicy(id, organization.id, session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval reminder policy deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to delete approval reminder policy' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/crm/approval/reminder-policies/route.ts
Normal file
75
src/app/api/crm/approval/reminder-policies/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
createApprovalReminderPolicy,
|
||||||
|
listApprovalReminderPolicies
|
||||||
|
} from '@/features/foundation/approval-automation/server/service';
|
||||||
|
import { approvalReminderPolicySchema } from '@/features/foundation/approval-automation/schemas';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalReminderManage
|
||||||
|
});
|
||||||
|
const items = await listApprovalReminderPolicies(organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Approval reminder policies loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to load approval reminder policies' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalReminderManage
|
||||||
|
});
|
||||||
|
const payload = approvalReminderPolicySchema.parse(await request.json());
|
||||||
|
const created = await createApprovalReminderPolicy(organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Approval reminder policy created successfully',
|
||||||
|
item: created
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to create approval reminder policy' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import { EscalationPolicySettings } from '@/features/foundation/approval-automation/components/escalation-policy-settings';
|
||||||
|
import { approvalEscalationPoliciesOptions } from '@/features/foundation/approval-automation/api/queries';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
export default async function ApprovalEscalationPoliciesPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const canRead =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalEscalationManage)));
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
if (canRead) {
|
||||||
|
void queryClient.prefetchQuery(approvalEscalationPoliciesOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Approval Escalation Policies'
|
||||||
|
pageDescription='Configure escalation targets for overdue approval steps without changing the core approval runtime.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to approval escalation policies.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canRead ? (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<EscalationPolicySettings />
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import { ReminderPolicySettings } from '@/features/foundation/approval-automation/components/reminder-policy-settings';
|
||||||
|
import {
|
||||||
|
approvalReminderPoliciesOptions,
|
||||||
|
pendingApprovalAutomationQueueOptions
|
||||||
|
} from '@/features/foundation/approval-automation/api/queries';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
export default async function ApprovalReminderPoliciesPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const canRead =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReminderManage)));
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
if (canRead) {
|
||||||
|
void queryClient.prefetchQuery(approvalReminderPoliciesOptions());
|
||||||
|
void queryClient.prefetchQuery(pendingApprovalAutomationQueueOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Approval Reminder Policies'
|
||||||
|
pageDescription='Configure SLA, reminder timing, timeout actions, and monitor the pending approval automation queue.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to approval reminder policies.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canRead ? (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ReminderPolicySettings />
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,6 +123,22 @@ import { NavGroup } from "@/types";
|
|||||||
permission: "crm.approval.workflow.read",
|
permission: "crm.approval.workflow.read",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Approval Reminder Policies",
|
||||||
|
url: "/dashboard/crm/settings/approval-reminder-policies",
|
||||||
|
access: {
|
||||||
|
requireOrg: true,
|
||||||
|
permission: "crm.approval.reminder.manage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Approval Escalation Policies",
|
||||||
|
url: "/dashboard/crm/settings/approval-escalation-policies",
|
||||||
|
access: {
|
||||||
|
requireOrg: true,
|
||||||
|
permission: "crm.approval.escalation.manage",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Templates",
|
title: "Templates",
|
||||||
url: "/dashboard/crm/settings/templates",
|
url: "/dashboard/crm/settings/templates",
|
||||||
|
|||||||
@@ -766,6 +766,9 @@ export const crmApprovalSteps = pgTable(
|
|||||||
roleName: text('role_name').notNull(),
|
roleName: text('role_name').notNull(),
|
||||||
approvalMode: text('approval_mode').default('sequential').notNull(),
|
approvalMode: text('approval_mode').default('sequential').notNull(),
|
||||||
isRequired: boolean('is_required').default(true).notNull(),
|
isRequired: boolean('is_required').default(true).notNull(),
|
||||||
|
slaHours: integer('sla_hours').default(24).notNull(),
|
||||||
|
calendarMode: text('calendar_mode').default('calendar_days').notNull(),
|
||||||
|
timeoutAction: text('timeout_action').default('none').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
@@ -806,6 +809,9 @@ export const crmApprovalRequests = pgTable('crm_approval_requests', {
|
|||||||
entityType: text('entity_type').notNull(),
|
entityType: text('entity_type').notNull(),
|
||||||
entityId: text('entity_id').notNull(),
|
entityId: text('entity_id').notNull(),
|
||||||
currentStep: integer('current_step').default(1).notNull(),
|
currentStep: integer('current_step').default(1).notNull(),
|
||||||
|
currentStepStartedAt: timestamp('current_step_started_at', { withTimezone: true })
|
||||||
|
.defaultNow()
|
||||||
|
.notNull(),
|
||||||
status: text('status').notNull(),
|
status: text('status').notNull(),
|
||||||
requestedBy: text('requested_by').notNull(),
|
requestedBy: text('requested_by').notNull(),
|
||||||
requestedAt: timestamp('requested_at', { withTimezone: true }).defaultNow().notNull(),
|
requestedAt: timestamp('requested_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
@@ -815,6 +821,48 @@ export const crmApprovalRequests = pgTable('crm_approval_requests', {
|
|||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmApprovalReminderPolicies = pgTable(
|
||||||
|
'crm_approval_reminder_policies',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
workflowId: text('workflow_id').notNull(),
|
||||||
|
stepNumber: integer('step_number').notNull(),
|
||||||
|
slaHours: integer('sla_hours').default(24).notNull(),
|
||||||
|
reminderOffsetsHours: integer('reminder_offsets_hours').array().default([]).notNull(),
|
||||||
|
calendarMode: text('calendar_mode').default('calendar_days').notNull(),
|
||||||
|
timeoutAction: text('timeout_action').default('none').notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
updatedBy: text('updated_by').notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workflowStepIdx: uniqueIndex('crm_approval_reminder_policies_workflow_step_idx').on(
|
||||||
|
table.workflowId,
|
||||||
|
table.stepNumber
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const crmApprovalEscalationPolicies = pgTable('crm_approval_escalation_policies', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
workflowId: text('workflow_id').notNull(),
|
||||||
|
stepNumber: integer('step_number').notNull(),
|
||||||
|
triggerAfterHours: integer('trigger_after_hours').notNull(),
|
||||||
|
targetType: text('target_type').notNull(),
|
||||||
|
targetValue: text('target_value'),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
updatedBy: text('updated_by').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const crmApprovalActions = pgTable('crm_approval_actions', {
|
export const crmApprovalActions = pgTable('crm_approval_actions', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
|
|||||||
@@ -1480,6 +1480,27 @@ async function upsertNotificationTemplatesForOrganization(
|
|||||||
titleTemplate: 'Approval Returned: {{quotationCode}}',
|
titleTemplate: 'Approval Returned: {{quotationCode}}',
|
||||||
bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}',
|
bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}',
|
||||||
linkTemplate: '{{entityLink}}'
|
linkTemplate: '{{entityLink}}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventType: 'approval.reminder',
|
||||||
|
titleTemplate: 'Approval Reminder: {{workflowName}}',
|
||||||
|
bodyTemplate:
|
||||||
|
'{{quotationCode}} is still waiting on {{currentStepRoleName}}. Please review it.',
|
||||||
|
linkTemplate: '{{entityLink}}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventType: 'approval.escalated',
|
||||||
|
titleTemplate: 'Approval Escalated: {{workflowName}}',
|
||||||
|
bodyTemplate:
|
||||||
|
'{{quotationCode}} exceeded its escalation threshold at {{currentStepRoleName}}.',
|
||||||
|
linkTemplate: '{{entityLink}}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventType: 'approval.timeout',
|
||||||
|
titleTemplate: 'Approval Timeout: {{workflowName}}',
|
||||||
|
bodyTemplate:
|
||||||
|
'{{quotationCode}} reached timeout handling at {{currentStepRoleName}}. {{remark}}',
|
||||||
|
linkTemplate: '{{entityLink}}'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
87
src/features/foundation/approval-automation/api/mutations.ts
Normal file
87
src/features/foundation/approval-automation/api/mutations.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import {
|
||||||
|
createApprovalEscalationPolicy,
|
||||||
|
createApprovalReminderPolicy,
|
||||||
|
deleteApprovalEscalationPolicy,
|
||||||
|
deleteApprovalReminderPolicy,
|
||||||
|
runApprovalAutomation,
|
||||||
|
updateApprovalEscalationPolicy,
|
||||||
|
updateApprovalReminderPolicy
|
||||||
|
} from './service';
|
||||||
|
import { approvalAutomationKeys } from './queries';
|
||||||
|
import type { EscalationPolicyPayload, ReminderPolicyPayload } from './types';
|
||||||
|
|
||||||
|
async function invalidateAutomationQueries() {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.pendingQueue() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.reminderPolicies() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.escalationPolicies() })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runApprovalAutomationMutation = mutationOptions({
|
||||||
|
mutationFn: () => runApprovalAutomation(),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createApprovalReminderPolicyMutation = mutationOptions({
|
||||||
|
mutationFn: (data: ReminderPolicyPayload) => createApprovalReminderPolicy(data),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateApprovalReminderPolicyMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ReminderPolicyPayload }) =>
|
||||||
|
updateApprovalReminderPolicy(id, values),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteApprovalReminderPolicyMutation = mutationOptions({
|
||||||
|
mutationFn: (id: string) => deleteApprovalReminderPolicy(id),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createApprovalEscalationPolicyMutation = mutationOptions({
|
||||||
|
mutationFn: (data: EscalationPolicyPayload) => createApprovalEscalationPolicy(data),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateApprovalEscalationPolicyMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: EscalationPolicyPayload }) =>
|
||||||
|
updateApprovalEscalationPolicy(id, values),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteApprovalEscalationPolicyMutation = mutationOptions({
|
||||||
|
mutationFn: (id: string) => deleteApprovalEscalationPolicy(id),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateAutomationQueries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
31
src/features/foundation/approval-automation/api/queries.ts
Normal file
31
src/features/foundation/approval-automation/api/queries.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getApprovalEscalationPolicies,
|
||||||
|
getApprovalReminderPolicies,
|
||||||
|
getPendingApprovalAutomationQueue
|
||||||
|
} from './service';
|
||||||
|
|
||||||
|
export const approvalAutomationKeys = {
|
||||||
|
all: ['crm-approval-automation'] as const,
|
||||||
|
pendingQueue: () => [...approvalAutomationKeys.all, 'pending-queue'] as const,
|
||||||
|
reminderPolicies: () => [...approvalAutomationKeys.all, 'reminder-policies'] as const,
|
||||||
|
escalationPolicies: () => [...approvalAutomationKeys.all, 'escalation-policies'] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pendingApprovalAutomationQueueOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: approvalAutomationKeys.pendingQueue(),
|
||||||
|
queryFn: () => getPendingApprovalAutomationQueue()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approvalReminderPoliciesOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: approvalAutomationKeys.reminderPolicies(),
|
||||||
|
queryFn: () => getApprovalReminderPolicies()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approvalEscalationPoliciesOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: approvalAutomationKeys.escalationPolicies(),
|
||||||
|
queryFn: () => getApprovalEscalationPolicies()
|
||||||
|
});
|
||||||
70
src/features/foundation/approval-automation/api/service.ts
Normal file
70
src/features/foundation/approval-automation/api/service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
ApprovalAutomationRunResponse,
|
||||||
|
EscalationPoliciesResponse,
|
||||||
|
EscalationPolicyPayload,
|
||||||
|
MutationSuccessResponse,
|
||||||
|
PendingApprovalQueueResponse,
|
||||||
|
ReminderPoliciesResponse,
|
||||||
|
ReminderPolicyPayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const BASE = '/crm/approval';
|
||||||
|
|
||||||
|
export async function getPendingApprovalAutomationQueue() {
|
||||||
|
return apiClient<PendingApprovalQueueResponse>(`${BASE}/automation/pending`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runApprovalAutomation() {
|
||||||
|
return apiClient<ApprovalAutomationRunResponse>(`${BASE}/automation/run`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalReminderPolicies() {
|
||||||
|
return apiClient<ReminderPoliciesResponse>(`${BASE}/reminder-policies`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApprovalReminderPolicy(data: ReminderPolicyPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE}/reminder-policies`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApprovalReminderPolicy(id: string, data: ReminderPolicyPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE}/reminder-policies/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApprovalReminderPolicy(id: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE}/reminder-policies/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalEscalationPolicies() {
|
||||||
|
return apiClient<EscalationPoliciesResponse>(`${BASE}/escalation-policies`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApprovalEscalationPolicy(data: EscalationPolicyPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE}/escalation-policies`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApprovalEscalationPolicy(id: string, data: EscalationPolicyPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE}/escalation-policies/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApprovalEscalationPolicy(id: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE}/escalation-policies/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
112
src/features/foundation/approval-automation/api/types.ts
Normal file
112
src/features/foundation/approval-automation/api/types.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type {
|
||||||
|
ApprovalEscalationTargetType,
|
||||||
|
ApprovalTimeoutAction
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export interface ReminderPolicyItem {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
stepNumber: number;
|
||||||
|
slaHours: number;
|
||||||
|
reminderOffsetsHours: number[];
|
||||||
|
calendarMode: 'calendar_days';
|
||||||
|
timeoutAction: ApprovalTimeoutAction;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EscalationPolicyItem {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
stepNumber: number;
|
||||||
|
triggerAfterHours: number;
|
||||||
|
targetType: ApprovalEscalationTargetType;
|
||||||
|
targetValue: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalQueueItem {
|
||||||
|
approvalRequestId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
requestedBy: string;
|
||||||
|
requestedAt: string;
|
||||||
|
currentStep: number;
|
||||||
|
currentStepRoleCode: string | null;
|
||||||
|
currentStepRoleName: string | null;
|
||||||
|
currentStepStartedAt: string;
|
||||||
|
waitingHours: number;
|
||||||
|
slaHours: number | null;
|
||||||
|
remainingHours: number | null;
|
||||||
|
dueAt: string | null;
|
||||||
|
isDueToday: boolean;
|
||||||
|
isOverdue: boolean;
|
||||||
|
isEscalated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalQueueResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: PendingApprovalQueueItem[];
|
||||||
|
summary: {
|
||||||
|
pending: number;
|
||||||
|
dueToday: number;
|
||||||
|
overdue: number;
|
||||||
|
escalated: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderPoliciesResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: ReminderPolicyItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EscalationPoliciesResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: EscalationPolicyItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderPolicyPayload {
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: number;
|
||||||
|
slaHours: number;
|
||||||
|
reminderOffsetsHours: number[];
|
||||||
|
calendarMode?: 'calendar_days';
|
||||||
|
timeoutAction?: ApprovalTimeoutAction;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EscalationPolicyPayload {
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: number;
|
||||||
|
triggerAfterHours: number;
|
||||||
|
targetType: ApprovalEscalationTargetType;
|
||||||
|
targetValue?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalAutomationRunResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
processedCount: number;
|
||||||
|
processedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationSuccessResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries';
|
||||||
|
import {
|
||||||
|
createApprovalEscalationPolicyMutation,
|
||||||
|
deleteApprovalEscalationPolicyMutation,
|
||||||
|
updateApprovalEscalationPolicyMutation
|
||||||
|
} from '../api/mutations';
|
||||||
|
import { approvalEscalationPoliciesOptions } from '../api/queries';
|
||||||
|
import type { EscalationPolicyItem, EscalationPolicyPayload } from '../api/types';
|
||||||
|
|
||||||
|
type EscalationFormState = {
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: string;
|
||||||
|
triggerAfterHours: string;
|
||||||
|
targetType: EscalationPolicyPayload['targetType'];
|
||||||
|
targetValue: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toEscalationState(policy?: EscalationPolicyItem): EscalationFormState {
|
||||||
|
return {
|
||||||
|
workflowId: policy?.workflowId ?? '',
|
||||||
|
stepNumber: String(policy?.stepNumber ?? 1),
|
||||||
|
triggerAfterHours: String(policy?.triggerAfterHours ?? 24),
|
||||||
|
targetType: policy?.targetType ?? 'manager',
|
||||||
|
targetValue: policy?.targetValue ?? '',
|
||||||
|
isActive: policy?.isActive ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function EscalationPolicyDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
policy
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
policy?: EscalationPolicyItem;
|
||||||
|
}) {
|
||||||
|
const workflowsQuery = useSuspenseQuery(approvalWorkflowsQueryOptions());
|
||||||
|
const [state, setState] = React.useState<EscalationFormState>(toEscalationState(policy));
|
||||||
|
const createMutation = useMutation({
|
||||||
|
...createApprovalEscalationPolicyMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Escalation policy created');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
|
||||||
|
});
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
...updateApprovalEscalationPolicyMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Escalation policy updated');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const workflowId = policy?.workflowId ?? workflowsQuery.data.items[0]?.id ?? '';
|
||||||
|
setState({ ...toEscalationState(policy), workflowId });
|
||||||
|
}
|
||||||
|
}, [open, policy, workflowsQuery.data.items]);
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload: EscalationPolicyPayload = {
|
||||||
|
workflowId: state.workflowId,
|
||||||
|
stepNumber: Number(state.stepNumber),
|
||||||
|
triggerAfterHours: Number(state.triggerAfterHours),
|
||||||
|
targetType: state.targetType,
|
||||||
|
targetValue: state.targetValue.trim() || null,
|
||||||
|
isActive: state.isActive
|
||||||
|
};
|
||||||
|
|
||||||
|
if (policy) {
|
||||||
|
await updateMutation.mutateAsync({ id: policy.id, values: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className='sm:max-w-2xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{policy ? 'Edit Escalation Policy' : 'Create Escalation Policy'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className='space-y-4' onSubmit={onSubmit}>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Workflow</div>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
|
||||||
|
value={state.workflowId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, workflowId: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{workflowsQuery.data.items.map((workflow) => (
|
||||||
|
<option key={workflow.id} value={workflow.id}>
|
||||||
|
{workflow.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Step Number</div>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
value={state.stepNumber}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, stepNumber: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Trigger After Hours</div>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
value={state.triggerAfterHours}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
triggerAfterHours: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Target Type</div>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
|
||||||
|
value={state.targetType}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
targetType: event.target.value as EscalationFormState['targetType']
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value='manager'>Manager</option>
|
||||||
|
<option value='requester'>Requester</option>
|
||||||
|
<option value='explicit_user'>Explicit User</option>
|
||||||
|
<option value='role'>Role</option>
|
||||||
|
<option value='permission_group'>Permission Group</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Target Value</div>
|
||||||
|
{state.targetType === 'role' ? (
|
||||||
|
<select
|
||||||
|
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
|
||||||
|
value={state.targetValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, targetValue: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Select role</option>
|
||||||
|
{BUSINESS_ROLES.map((role) => (
|
||||||
|
<option key={role} value={role}>
|
||||||
|
{role}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : state.targetType === 'permission_group' ? (
|
||||||
|
<select
|
||||||
|
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
|
||||||
|
value={state.targetValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, targetValue: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>Select permission</option>
|
||||||
|
{Object.values(PERMISSIONS).map((permission) => (
|
||||||
|
<option key={permission} value={permission}>
|
||||||
|
{permission}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={state.targetValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, targetValue: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
state.targetType === 'manager'
|
||||||
|
? 'Optional role code, otherwise organization admins'
|
||||||
|
: state.targetType === 'explicit_user'
|
||||||
|
? 'User id'
|
||||||
|
: 'Optional'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>Active Policy</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Inactive escalation policies are ignored by the scheduler.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={state.isActive}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setState((current) => ({ ...current, isActive: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' isLoading={isPending}>
|
||||||
|
{policy ? 'Save Policy' : 'Create Policy'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EscalationPolicySettings() {
|
||||||
|
const escalationPoliciesQuery = useSuspenseQuery(approvalEscalationPoliciesOptions());
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false);
|
||||||
|
const [editingPolicy, setEditingPolicy] = React.useState<EscalationPolicyItem | null>(null);
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
...deleteApprovalEscalationPolicyMutation,
|
||||||
|
onSuccess: () => toast.success('Escalation policy deleted'),
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex items-center justify-between rounded-lg border p-4'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>Approval Escalation Policies</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Define who gets notified when a pending step waits too long.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
|
Create Policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{escalationPoliciesQuery.data.items.map((policy) => (
|
||||||
|
<div key={policy.id} className='rounded-lg border p-4'>
|
||||||
|
<div className='flex items-start justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>
|
||||||
|
{policy.workflowName} | Step {policy.stepNumber}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Escalate after {policy.triggerAfterHours} hours to {policy.targetType}
|
||||||
|
{policy.targetValue ? `: ${policy.targetValue}` : ''}
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
|
<Badge variant='outline'>{policy.targetType}</Badge>
|
||||||
|
<Badge variant='outline'>{policy.triggerAfterHours}h</Badge>
|
||||||
|
<Badge variant={policy.isActive ? 'secondary' : 'outline'}>
|
||||||
|
{policy.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button size='sm' variant='outline' onClick={() => setEditingPolicy(policy)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => deleteMutation.mutate(policy.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{escalationPoliciesQuery.data.items.length === 0 ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No escalation policies configured yet.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EscalationPolicyDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
<EscalationPolicyDialog
|
||||||
|
open={editingPolicy !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingPolicy(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
policy={editingPolicy ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries';
|
||||||
|
import {
|
||||||
|
createApprovalReminderPolicyMutation,
|
||||||
|
deleteApprovalReminderPolicyMutation,
|
||||||
|
runApprovalAutomationMutation,
|
||||||
|
updateApprovalReminderPolicyMutation
|
||||||
|
} from '../api/mutations';
|
||||||
|
import {
|
||||||
|
approvalReminderPoliciesOptions,
|
||||||
|
pendingApprovalAutomationQueueOptions
|
||||||
|
} from '../api/queries';
|
||||||
|
import type { ReminderPolicyItem, ReminderPolicyPayload } from '../api/types';
|
||||||
|
|
||||||
|
type ReminderFormState = {
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: string;
|
||||||
|
slaHours: string;
|
||||||
|
reminderOffsets: string;
|
||||||
|
timeoutAction: ReminderPolicyPayload['timeoutAction'];
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toReminderState(policy?: ReminderPolicyItem): ReminderFormState {
|
||||||
|
return {
|
||||||
|
workflowId: policy?.workflowId ?? '',
|
||||||
|
stepNumber: String(policy?.stepNumber ?? 1),
|
||||||
|
slaHours: String(policy?.slaHours ?? 24),
|
||||||
|
reminderOffsets: policy?.reminderOffsetsHours.join(', ') ?? '24, 48',
|
||||||
|
timeoutAction: policy?.timeoutAction ?? 'none',
|
||||||
|
isActive: policy?.isActive ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOffsets(value: string) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => Number(item.trim()))
|
||||||
|
.filter((item) => Number.isFinite(item) && item > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReminderPolicyDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
policy
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
policy?: ReminderPolicyItem;
|
||||||
|
}) {
|
||||||
|
const workflowsQuery = useSuspenseQuery(approvalWorkflowsQueryOptions());
|
||||||
|
const [state, setState] = React.useState<ReminderFormState>(toReminderState(policy));
|
||||||
|
const createMutation = useMutation({
|
||||||
|
...createApprovalReminderPolicyMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Reminder policy created');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
|
||||||
|
});
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
...updateApprovalReminderPolicyMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Reminder policy updated');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const workflowId = policy?.workflowId ?? workflowsQuery.data.items[0]?.id ?? '';
|
||||||
|
setState({ ...toReminderState(policy), workflowId });
|
||||||
|
}
|
||||||
|
}, [open, policy, workflowsQuery.data.items]);
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload: ReminderPolicyPayload = {
|
||||||
|
workflowId: state.workflowId,
|
||||||
|
stepNumber: Number(state.stepNumber),
|
||||||
|
slaHours: Number(state.slaHours),
|
||||||
|
reminderOffsetsHours: parseOffsets(state.reminderOffsets),
|
||||||
|
timeoutAction: state.timeoutAction,
|
||||||
|
isActive: state.isActive,
|
||||||
|
calendarMode: 'calendar_days'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (policy) {
|
||||||
|
await updateMutation.mutateAsync({ id: policy.id, values: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className='sm:max-w-2xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{policy ? 'Edit Reminder Policy' : 'Create Reminder Policy'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className='space-y-4' onSubmit={onSubmit}>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Workflow</div>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
|
||||||
|
value={state.workflowId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, workflowId: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{workflowsQuery.data.items.map((workflow) => (
|
||||||
|
<option key={workflow.id} value={workflow.id}>
|
||||||
|
{workflow.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Step Number</div>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
value={state.stepNumber}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, stepNumber: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>SLA Hours</div>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
value={state.slaHours}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, slaHours: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Timeout Action</div>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
|
||||||
|
value={state.timeoutAction}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
timeoutAction: event.target.value as ReminderFormState['timeoutAction']
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value='none'>None</option>
|
||||||
|
<option value='auto_reject'>Auto Reject</option>
|
||||||
|
<option value='auto_cancel'>Auto Cancel</option>
|
||||||
|
<option value='auto_escalate'>Auto Escalate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='text-sm font-medium'>Reminder Offsets (hours)</div>
|
||||||
|
<Textarea
|
||||||
|
value={state.reminderOffsets}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, reminderOffsets: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='24, 48, 72'
|
||||||
|
/>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
Comma-separated hours after step start.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>Active Policy</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Inactive policies are ignored by the scheduler.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={state.isActive}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setState((current) => ({ ...current, isActive: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' isLoading={isPending}>
|
||||||
|
{policy ? 'Save Policy' : 'Create Policy'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReminderPolicySettings() {
|
||||||
|
const reminderPoliciesQuery = useSuspenseQuery(approvalReminderPoliciesOptions());
|
||||||
|
const pendingQueueQuery = useSuspenseQuery(pendingApprovalAutomationQueueOptions());
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false);
|
||||||
|
const [editingPolicy, setEditingPolicy] = React.useState<ReminderPolicyItem | null>(null);
|
||||||
|
const runAutomation = useMutation({
|
||||||
|
...runApprovalAutomationMutation,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Automation completed. ${result.processedCount} request(s) checked.`);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Run failed')
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
...deleteApprovalReminderPolicyMutation,
|
||||||
|
onSuccess: () => toast.success('Reminder policy deleted'),
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
||||||
|
});
|
||||||
|
|
||||||
|
const queue = pendingQueueQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-4'>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Pending</div>
|
||||||
|
<div className='text-2xl font-semibold'>{queue.summary.pending}</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Due Today</div>
|
||||||
|
<div className='text-2xl font-semibold'>{queue.summary.dueToday}</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Overdue</div>
|
||||||
|
<div className='text-2xl font-semibold'>{queue.summary.overdue}</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Escalated</div>
|
||||||
|
<div className='text-2xl font-semibold'>{queue.summary.escalated}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between rounded-lg border p-4'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>Approval Automation Scheduler</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Manually execute reminder, escalation, and timeout checks for all pending approvals.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button variant='outline' onClick={() => setCreateOpen(true)}>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
|
Create Policy
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => runAutomation.mutate()} isLoading={runAutomation.isPending}>
|
||||||
|
Run Automation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='mb-4 font-medium'>Pending Queue Monitor</div>
|
||||||
|
{queue.items.length === 0 ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>No pending approvals right now.</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{queue.items.slice(0, 10).map((item) => (
|
||||||
|
<div key={item.approvalRequestId} className='rounded-lg border p-3'>
|
||||||
|
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>
|
||||||
|
{item.workflowName} | Step {item.currentStep}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{item.currentStepRoleName ?? '-'} | Waiting {item.waitingHours}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{item.isOverdue ? <Badge variant='destructive'>Overdue</Badge> : null}
|
||||||
|
{item.isDueToday ? <Badge variant='secondary'>Due Today</Badge> : null}
|
||||||
|
{item.isEscalated ? <Badge variant='outline'>Escalated</Badge> : null}
|
||||||
|
{item.slaHours !== null ? (
|
||||||
|
<Badge variant='outline'>SLA {item.slaHours}h</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{reminderPoliciesQuery.data.items.map((policy) => (
|
||||||
|
<div key={policy.id} className='rounded-lg border p-4'>
|
||||||
|
<div className='flex items-start justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>
|
||||||
|
{policy.workflowName} | Step {policy.stepNumber}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Reminders at {policy.reminderOffsetsHours.join(', ') || '-'} hours
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
|
<Badge variant='outline'>SLA {policy.slaHours}h</Badge>
|
||||||
|
<Badge variant='outline'>{policy.timeoutAction}</Badge>
|
||||||
|
<Badge variant={policy.isActive ? 'secondary' : 'outline'}>
|
||||||
|
{policy.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button size='sm' variant='outline' onClick={() => setEditingPolicy(policy)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => deleteMutation.mutate(policy.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{reminderPoliciesQuery.data.items.length === 0 ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No reminder policies configured yet.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReminderPolicyDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
<ReminderPolicyDialog
|
||||||
|
open={editingPolicy !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingPolicy(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
policy={editingPolicy ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/features/foundation/approval-automation/schemas.ts
Normal file
28
src/features/foundation/approval-automation/schemas.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const approvalReminderPolicySchema = z.object({
|
||||||
|
workflowId: z.string().min(1),
|
||||||
|
stepNumber: z.number().int().min(1),
|
||||||
|
slaHours: z.number().int().min(1),
|
||||||
|
reminderOffsetsHours: z.array(z.number().int().min(1)).default([]),
|
||||||
|
calendarMode: z.literal('calendar_days').default('calendar_days'),
|
||||||
|
timeoutAction: z
|
||||||
|
.enum(['none', 'auto_reject', 'auto_cancel', 'auto_escalate'])
|
||||||
|
.default('none'),
|
||||||
|
isActive: z.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approvalEscalationPolicySchema = z.object({
|
||||||
|
workflowId: z.string().min(1),
|
||||||
|
stepNumber: z.number().int().min(1),
|
||||||
|
triggerAfterHours: z.number().int().min(1),
|
||||||
|
targetType: z.enum([
|
||||||
|
'manager',
|
||||||
|
'requester',
|
||||||
|
'explicit_user',
|
||||||
|
'role',
|
||||||
|
'permission_group'
|
||||||
|
]),
|
||||||
|
targetValue: z.string().trim().optional().nullable(),
|
||||||
|
isActive: z.boolean().default(true)
|
||||||
|
});
|
||||||
1025
src/features/foundation/approval-automation/server/service.ts
Normal file
1025
src/features/foundation/approval-automation/server/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
95
src/features/foundation/approval-automation/types.ts
Normal file
95
src/features/foundation/approval-automation/types.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export type ApprovalTimeoutAction = 'none' | 'auto_reject' | 'auto_cancel' | 'auto_escalate';
|
||||||
|
export type ApprovalCalendarMode = 'calendar_days';
|
||||||
|
export type ApprovalEscalationTargetType =
|
||||||
|
| 'manager'
|
||||||
|
| 'requester'
|
||||||
|
| 'explicit_user'
|
||||||
|
| 'role'
|
||||||
|
| 'permission_group';
|
||||||
|
|
||||||
|
export interface ApprovalReminderPolicyRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
stepNumber: number;
|
||||||
|
slaHours: number;
|
||||||
|
reminderOffsetsHours: number[];
|
||||||
|
calendarMode: ApprovalCalendarMode;
|
||||||
|
timeoutAction: ApprovalTimeoutAction;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalEscalationPolicyRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
stepNumber: number;
|
||||||
|
triggerAfterHours: number;
|
||||||
|
targetType: ApprovalEscalationTargetType;
|
||||||
|
targetValue: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalReminderPolicyPayload {
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: number;
|
||||||
|
slaHours: number;
|
||||||
|
reminderOffsetsHours: number[];
|
||||||
|
calendarMode?: ApprovalCalendarMode;
|
||||||
|
timeoutAction?: ApprovalTimeoutAction;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalEscalationPolicyPayload {
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: number;
|
||||||
|
triggerAfterHours: number;
|
||||||
|
targetType: ApprovalEscalationTargetType;
|
||||||
|
targetValue?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalQueueItem {
|
||||||
|
approvalRequestId: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowName: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
requestedBy: string;
|
||||||
|
requestedAt: string;
|
||||||
|
currentStep: number;
|
||||||
|
currentStepRoleCode: string | null;
|
||||||
|
currentStepRoleName: string | null;
|
||||||
|
currentStepStartedAt: string;
|
||||||
|
waitingHours: number;
|
||||||
|
slaHours: number | null;
|
||||||
|
remainingHours: number | null;
|
||||||
|
dueAt: string | null;
|
||||||
|
isDueToday: boolean;
|
||||||
|
isOverdue: boolean;
|
||||||
|
isEscalated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalQueueSummary {
|
||||||
|
pending: number;
|
||||||
|
dueToday: number;
|
||||||
|
overdue: number;
|
||||||
|
escalated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingApprovalQueueResponse {
|
||||||
|
items: PendingApprovalQueueItem[];
|
||||||
|
summary: PendingApprovalQueueSummary;
|
||||||
|
}
|
||||||
@@ -85,6 +85,9 @@ function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepR
|
|||||||
roleName: row.roleName,
|
roleName: row.roleName,
|
||||||
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
|
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
|
||||||
isRequired: row.isRequired,
|
isRequired: row.isRequired,
|
||||||
|
slaHours: row.slaHours,
|
||||||
|
calendarMode: row.calendarMode as ApprovalStepRecord['calendarMode'],
|
||||||
|
timeoutAction: row.timeoutAction as ApprovalStepRecord['timeoutAction'],
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
@@ -99,6 +102,7 @@ function mapRequestRecord(row: typeof crmApprovalRequests.$inferSelect): Approva
|
|||||||
entityType: row.entityType,
|
entityType: row.entityType,
|
||||||
entityId: row.entityId,
|
entityId: row.entityId,
|
||||||
currentStep: row.currentStep,
|
currentStep: row.currentStep,
|
||||||
|
currentStepStartedAt: row.currentStepStartedAt.toISOString(),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
requestedBy: row.requestedBy,
|
requestedBy: row.requestedBy,
|
||||||
requestedAt: row.requestedAt.toISOString(),
|
requestedAt: row.requestedAt.toISOString(),
|
||||||
@@ -682,7 +686,7 @@ async function syncQuotationStatusFromApproval(
|
|||||||
await db.update(crmQuotations).set(nextValues).where(eq(crmQuotations.id, quotationId));
|
await db.update(crmQuotations).set(nextValues).where(eq(crmQuotations.id, quotationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncEntityStatusFromApproval(
|
export async function syncEntityStatusFromApproval(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
entityType: string,
|
entityType: string,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
@@ -1139,6 +1143,7 @@ export async function submitForApproval(
|
|||||||
entityType: payload.entityType,
|
entityType: payload.entityType,
|
||||||
entityId: payload.entityId,
|
entityId: payload.entityId,
|
||||||
currentStep: steps[0].stepNumber,
|
currentStep: steps[0].stepNumber,
|
||||||
|
currentStepStartedAt: new Date(),
|
||||||
status: APPROVAL_REQUEST_STATUSES.pending,
|
status: APPROVAL_REQUEST_STATUSES.pending,
|
||||||
requestedBy: userId
|
requestedBy: userId
|
||||||
})
|
})
|
||||||
@@ -1238,6 +1243,7 @@ const steps = await listWorkflowSteps(request.workflowId, organizationId);
|
|||||||
.update(crmApprovalRequests)
|
.update(crmApprovalRequests)
|
||||||
.set({
|
.set({
|
||||||
currentStep: nextStep?.stepNumber ?? currentStep.stepNumber,
|
currentStep: nextStep?.stepNumber ?? currentStep.stepNumber,
|
||||||
|
currentStepStartedAt: nextStep ? new Date() : request.currentStepStartedAt,
|
||||||
status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved,
|
status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved,
|
||||||
completedAt: nextStep ? null : new Date(),
|
completedAt: nextStep ? null : new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface ApprovalStepRecord {
|
|||||||
roleName: string;
|
roleName: string;
|
||||||
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
|
slaHours: number;
|
||||||
|
calendarMode: 'calendar_days';
|
||||||
|
timeoutAction: 'none' | 'auto_reject' | 'auto_cancel' | 'auto_escalate';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
@@ -56,6 +59,7 @@ export interface ApprovalRequestRecord {
|
|||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
|
currentStepStartedAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
requestedBy: string;
|
requestedBy: string;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ const EVENT_RULES: Record<string, EventRule> = {
|
|||||||
'approval.returned': {
|
'approval.returned': {
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
recipients: [{ type: 'approval_requester' }]
|
recipients: [{ type: 'approval_requester' }]
|
||||||
|
},
|
||||||
|
'approval.reminder': {
|
||||||
|
severity: 'warning',
|
||||||
|
recipients: [{ type: 'approval_current_step_approvers', excludeActor: true }]
|
||||||
|
},
|
||||||
|
'approval.escalated': {
|
||||||
|
severity: 'warning',
|
||||||
|
recipients: [
|
||||||
|
{ type: 'explicit_user', excludeActor: true },
|
||||||
|
{ type: 'approval_requester' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'approval.timeout': {
|
||||||
|
severity: 'error',
|
||||||
|
recipients: [
|
||||||
|
{ type: 'explicit_user', excludeActor: true },
|
||||||
|
{ type: 'approval_requester' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,7 +95,9 @@ function mapEventRecord(row: typeof appNotificationEvents.$inferSelect): Notific
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapTemplateRecord(row: typeof appNotificationTemplates.$inferSelect): NotificationTemplateRecord {
|
function mapTemplateRecord(
|
||||||
|
row: typeof appNotificationTemplates.$inferSelect
|
||||||
|
): NotificationTemplateRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
@@ -195,9 +215,7 @@ export async function processNotificationEvent(eventId: string) {
|
|||||||
const recipients = [...new Set(recipientLists.flat())];
|
const recipients = [...new Set(recipientLists.flat())];
|
||||||
const existingRows = recipients.length
|
const existingRows = recipients.length
|
||||||
? await db
|
? await db
|
||||||
.select({
|
.select({ recipientUserId: appNotifications.recipientUserId })
|
||||||
recipientUserId: appNotifications.recipientUserId
|
|
||||||
})
|
|
||||||
.from(appNotifications)
|
.from(appNotifications)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -207,9 +225,10 @@ export async function processNotificationEvent(eventId: string) {
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const existingRecipientIds = new Set(existingRows.map((row) => row.recipientUserId));
|
const existingRecipientIds = new Set(existingRows.map((row) => row.recipientUserId));
|
||||||
const recipientsToCreate = recipients.filter((recipientUserId) => !existingRecipientIds.has(recipientUserId));
|
const recipientsToCreate = recipients.filter((userId) => !existingRecipientIds.has(userId));
|
||||||
|
|
||||||
const createdIds: string[] = [];
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
for (const recipientUserId of recipientsToCreate) {
|
for (const recipientUserId of recipientsToCreate) {
|
||||||
const rendered = renderNotificationTemplate(template, payload);
|
const rendered = renderNotificationTemplate(template, payload);
|
||||||
const notificationId = crypto.randomUUID();
|
const notificationId = crypto.randomUUID();
|
||||||
@@ -250,7 +269,11 @@ export async function processNotificationEvent(eventId: string) {
|
|||||||
entityType: 'app_notification',
|
entityType: 'app_notification',
|
||||||
entityId: notificationId,
|
entityId: notificationId,
|
||||||
action: 'notification_created',
|
action: 'notification_created',
|
||||||
afterData: { recipientUserId, eventId: event.id, eventType: event.eventType }
|
afterData: {
|
||||||
|
recipientUserId,
|
||||||
|
eventId: event.id,
|
||||||
|
eventType: event.eventType
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +315,7 @@ export async function processPendingNotificationEvents() {
|
|||||||
try {
|
try {
|
||||||
await processNotificationEvent(row.id);
|
await processNotificationEvent(row.id);
|
||||||
} catch {
|
} catch {
|
||||||
// Errors are recorded on the event row for later inspection.
|
// Event failures are persisted for inspection.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +374,7 @@ export async function publishNotificationEvent(input: PublishNotificationEventIn
|
|||||||
try {
|
try {
|
||||||
await processNotificationEvent(row.id);
|
await processNotificationEvent(row.id);
|
||||||
} catch {
|
} catch {
|
||||||
// Event failures are persisted on the event row and must not block the caller.
|
// Event failures are recorded and must not block callers.
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapEventRecord(row);
|
return mapEventRecord(row);
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const PERMISSIONS = {
|
|||||||
crmApprovalWorkflowActivate: 'crm.approval.workflow.activate',
|
crmApprovalWorkflowActivate: 'crm.approval.workflow.activate',
|
||||||
crmApprovalWorkflowDeactivate: 'crm.approval.workflow.deactivate',
|
crmApprovalWorkflowDeactivate: 'crm.approval.workflow.deactivate',
|
||||||
crmApprovalWorkflowStepManage: 'crm.approval.workflow.step.manage',
|
crmApprovalWorkflowStepManage: 'crm.approval.workflow.step.manage',
|
||||||
|
crmApprovalAutomationRead: 'crm.approval.automation.read',
|
||||||
|
crmApprovalAutomationRun: 'crm.approval.automation.run',
|
||||||
|
crmApprovalReminderManage: 'crm.approval.reminder.manage',
|
||||||
|
crmApprovalEscalationManage: 'crm.approval.escalation.manage',
|
||||||
crmDashboardRead: 'crm.dashboard.read',
|
crmDashboardRead: 'crm.dashboard.read',
|
||||||
crmDashboardExport: 'crm.dashboard.export',
|
crmDashboardExport: 'crm.dashboard.export',
|
||||||
crmReportRead: 'crm.report.read',
|
crmReportRead: 'crm.report.read',
|
||||||
@@ -460,11 +464,15 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalWorkflowCreate,
|
PERMISSIONS.crmApprovalWorkflowCreate,
|
||||||
PERMISSIONS.crmApprovalWorkflowUpdate,
|
PERMISSIONS.crmApprovalWorkflowUpdate,
|
||||||
PERMISSIONS.crmApprovalWorkflowDelete,
|
PERMISSIONS.crmApprovalWorkflowDelete,
|
||||||
PERMISSIONS.crmApprovalWorkflowClone,
|
PERMISSIONS.crmApprovalWorkflowClone,
|
||||||
PERMISSIONS.crmApprovalWorkflowActivate,
|
PERMISSIONS.crmApprovalWorkflowActivate,
|
||||||
PERMISSIONS.crmApprovalWorkflowDeactivate,
|
PERMISSIONS.crmApprovalWorkflowDeactivate,
|
||||||
PERMISSIONS.crmApprovalWorkflowStepManage,
|
PERMISSIONS.crmApprovalWorkflowStepManage,
|
||||||
PERMISSIONS.crmDocumentTemplateRead,
|
PERMISSIONS.crmApprovalAutomationRead,
|
||||||
|
PERMISSIONS.crmApprovalAutomationRun,
|
||||||
|
PERMISSIONS.crmApprovalReminderManage,
|
||||||
|
PERMISSIONS.crmApprovalEscalationManage,
|
||||||
|
PERMISSIONS.crmDocumentTemplateRead,
|
||||||
PERMISSIONS.crmDocumentTemplateCreate,
|
PERMISSIONS.crmDocumentTemplateCreate,
|
||||||
PERMISSIONS.crmDocumentTemplateUpdate,
|
PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
PERMISSIONS.crmDocumentTemplateDelete,
|
PERMISSIONS.crmDocumentTemplateDelete,
|
||||||
@@ -574,7 +582,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmApprovalSubmit, label: 'Submit approvals' },
|
{ key: PERMISSIONS.crmApprovalSubmit, label: 'Submit approvals' },
|
||||||
{ key: PERMISSIONS.crmApprovalApprove, label: 'Approve quotations' },
|
{ key: PERMISSIONS.crmApprovalApprove, label: 'Approve quotations' },
|
||||||
{ key: PERMISSIONS.crmApprovalReject, label: 'Reject quotations' },
|
{ key: PERMISSIONS.crmApprovalReject, label: 'Reject quotations' },
|
||||||
{ key: PERMISSIONS.crmApprovalReturn, label: 'Return quotations' }
|
{ key: PERMISSIONS.crmApprovalReturn, label: 'Return quotations' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalAutomationRead, label: 'Read approval automation' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalAutomationRun, label: 'Run approval automation' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalReminderManage, label: 'Manage reminder policies' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalEscalationManage, label: 'Manage escalation policies' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user