This commit is contained in:
phaichayon
2026-06-27 10:04:43 +07:00
parent 2ace5dbb83
commit 3fdd681dd0
29 changed files with 8593 additions and 15 deletions

View 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

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1782455991734,
"tag": "0004_unusual_hairball",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1782464526434,
"tag": "0005_furry_maelstrom",
"breakpoints": true
}
]
}

474
plans/task-f.5.md Normal file
View 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
```

View 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 }
);
}
}

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -123,6 +123,22 @@ import { NavGroup } from "@/types";
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",
url: "/dashboard/crm/settings/templates",

View File

@@ -766,6 +766,9 @@ export const crmApprovalSteps = pgTable(
roleName: text('role_name').notNull(),
approvalMode: text('approval_mode').default('sequential').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(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
@@ -806,6 +809,9 @@ export const crmApprovalRequests = pgTable('crm_approval_requests', {
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
currentStep: integer('current_step').default(1).notNull(),
currentStepStartedAt: timestamp('current_step_started_at', { withTimezone: true })
.defaultNow()
.notNull(),
status: text('status').notNull(),
requestedBy: text('requested_by').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 })
});
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', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),

View File

@@ -1480,6 +1480,27 @@ async function upsertNotificationTemplatesForOrganization(
titleTemplate: 'Approval Returned: {{quotationCode}}',
bodyTemplate: '{{actorName}} returned {{quotationCode}} for revision. Reason: {{remark}}',
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}}'
}
];

View 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();
}
}
});

View 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()
});

View 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'
});
}

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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)
});

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -85,6 +85,9 @@ function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepR
roleName: row.roleName,
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
isRequired: row.isRequired,
slaHours: row.slaHours,
calendarMode: row.calendarMode as ApprovalStepRecord['calendarMode'],
timeoutAction: row.timeoutAction as ApprovalStepRecord['timeoutAction'],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
@@ -99,6 +102,7 @@ function mapRequestRecord(row: typeof crmApprovalRequests.$inferSelect): Approva
entityType: row.entityType,
entityId: row.entityId,
currentStep: row.currentStep,
currentStepStartedAt: row.currentStepStartedAt.toISOString(),
status: row.status,
requestedBy: row.requestedBy,
requestedAt: row.requestedAt.toISOString(),
@@ -682,7 +686,7 @@ async function syncQuotationStatusFromApproval(
await db.update(crmQuotations).set(nextValues).where(eq(crmQuotations.id, quotationId));
}
async function syncEntityStatusFromApproval(
export async function syncEntityStatusFromApproval(
organizationId: string,
entityType: string,
entityId: string,
@@ -1139,6 +1143,7 @@ export async function submitForApproval(
entityType: payload.entityType,
entityId: payload.entityId,
currentStep: steps[0].stepNumber,
currentStepStartedAt: new Date(),
status: APPROVAL_REQUEST_STATUSES.pending,
requestedBy: userId
})
@@ -1238,6 +1243,7 @@ const steps = await listWorkflowSteps(request.workflowId, organizationId);
.update(crmApprovalRequests)
.set({
currentStep: nextStep?.stepNumber ?? currentStep.stepNumber,
currentStepStartedAt: nextStep ? new Date() : request.currentStepStartedAt,
status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved,
completedAt: nextStep ? null : new Date(),
updatedAt: new Date()

View File

@@ -34,6 +34,9 @@ export interface ApprovalStepRecord {
roleName: string;
approvalMode: 'sequential' | 'any_one' | 'all_required';
isRequired: boolean;
slaHours: number;
calendarMode: 'calendar_days';
timeoutAction: 'none' | 'auto_reject' | 'auto_cancel' | 'auto_escalate';
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -56,6 +59,7 @@ export interface ApprovalRequestRecord {
entityType: string;
entityId: string;
currentStep: number;
currentStepStartedAt: string;
status: string;
requestedBy: string;
requestedAt: string;

View File

@@ -55,6 +55,24 @@ const EVENT_RULES: Record<string, EventRule> = {
'approval.returned': {
severity: 'warning',
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 {
id: row.id,
organizationId: row.organizationId,
@@ -195,9 +215,7 @@ export async function processNotificationEvent(eventId: string) {
const recipients = [...new Set(recipientLists.flat())];
const existingRows = recipients.length
? await db
.select({
recipientUserId: appNotifications.recipientUserId
})
.select({ recipientUserId: appNotifications.recipientUserId })
.from(appNotifications)
.where(
and(
@@ -207,9 +225,10 @@ export async function processNotificationEvent(eventId: string) {
)
: [];
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[] = [];
for (const recipientUserId of recipientsToCreate) {
const rendered = renderNotificationTemplate(template, payload);
const notificationId = crypto.randomUUID();
@@ -250,7 +269,11 @@ export async function processNotificationEvent(eventId: string) {
entityType: 'app_notification',
entityId: notificationId,
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 {
await processNotificationEvent(row.id);
} 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 {
await processNotificationEvent(row.id);
} 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);

View File

@@ -96,6 +96,10 @@ export const PERMISSIONS = {
crmApprovalWorkflowActivate: 'crm.approval.workflow.activate',
crmApprovalWorkflowDeactivate: 'crm.approval.workflow.deactivate',
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',
crmDashboardExport: 'crm.dashboard.export',
crmReportRead: 'crm.report.read',
@@ -460,11 +464,15 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate,
PERMISSIONS.crmApprovalWorkflowDelete,
PERMISSIONS.crmApprovalWorkflowClone,
PERMISSIONS.crmApprovalWorkflowActivate,
PERMISSIONS.crmApprovalWorkflowDeactivate,
PERMISSIONS.crmApprovalWorkflowStepManage,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmApprovalWorkflowClone,
PERMISSIONS.crmApprovalWorkflowActivate,
PERMISSIONS.crmApprovalWorkflowDeactivate,
PERMISSIONS.crmApprovalWorkflowStepManage,
PERMISSIONS.crmApprovalAutomationRead,
PERMISSIONS.crmApprovalAutomationRun,
PERMISSIONS.crmApprovalReminderManage,
PERMISSIONS.crmApprovalEscalationManage,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate,
PERMISSIONS.crmDocumentTemplateDelete,
@@ -574,7 +582,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmApprovalSubmit, label: 'Submit approvals' },
{ key: PERMISSIONS.crmApprovalApprove, label: 'Approve 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' }
]
},
{