feat(automation): move task auto-generate to post-sync + admin config UI
Extracts the cron-only auto-generate logic into shared modules (lib/sync/auto-generate.ts, automation-config.ts) and adds group-auto-assign.ts, which attaches ungrouped policies to an existing renewal group when their renewal dates are close. Both now also run automatically after every AMS sync (sync-engine.ts), guarded by a Postgres advisory lock so overlapping syncs cannot double-generate. The external /api/cron/auto-generate endpoint remains for manual/ out-of-band triggering, delegating to the same shared engine. Adds an admin settings UI + /api/admin/automation-settings route to configure grace periods and toggles without a redeploy, and surfaces the new audit actions (AUTO_GENERATE_TASKS_CLIENT, AUTO_ASSIGN_POLICY_GROUP) in the audit log filters. Includes a fixed AUTOMATION_GO_LIVE_AT floor (2026-07-07) in automation-config.ts, applied to every candidate query in auto-generate.ts and group-auto-assign.ts. Without it, the first run after enabling this treated every historical policy/client/group that happened to be old enough as newly eligible, generating tasks anchored to old renewal/expiration dates in one burst (incident: 6,552 tasks created 2026-07-07 02:01, most already 1-2 years overdue for Jeanne Strong and Luke Billman; cleaned up same day after a verified backup). The floor ensures automation only ever processes records created from its go-live date forward.
This commit is contained in:
parent
db37313631
commit
835267c333
10 changed files with 984 additions and 329 deletions
103
ondeck/docs/task-automation-alignment-plan.md
Normal file
103
ondeck/docs/task-automation-alignment-plan.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Task Automation — Alignment Plan
|
||||||
|
|
||||||
|
**Date:** 2026-06-25
|
||||||
|
**Source:** Code review of `/opt/projects/OnDeck/ondeck` against the Seubert task-automation meeting (Lorentz Hinrichsen, Luke Billman, Dawn Boland).
|
||||||
|
**Status:** Planning — to be executed at a later date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The meeting reaffirmed the design for SHAPE task automation: tasks should only generate for **active SHAPE / SHAPE 2 clients with active policies**, the manager setup queue should be drivable **to zero**, and client active/inactive status changes should be **logged and surfaced in a daily briefing**.
|
||||||
|
|
||||||
|
The Horizon data model and the core task mechanics already match the agreed design. **Tasks are policy-specific (not carrier-specific), loss-run tasks follow the policy renewal date, and clients are never auto-deactivated** — the advocate stays on the profile so returning clients resume cleanly. These need no change.
|
||||||
|
|
||||||
|
Three decisions from the call are **not yet built**, and one **likely defect** can mask half the SHAPE population. In priority order:
|
||||||
|
|
||||||
|
1. **Generation is not gated on active policies.** Tasks generate for any client that *has* policies, regardless of whether those policies are Cancelled/Expired/Non-Renewed. The dead-policy filter only hides tasks at display time — they are still created. This contradicts the meeting's central rule: *"no active policies → no tasks of any kind."*
|
||||||
|
2. **No way to clear non-P&C-SHAPE accounts from the manager queue.** Client setup still *requires* a claims advocate before it can be completed, with no "NA / no advocate / other department" option. Accounts like surety/bond-shape (in-transit example) therefore cannot be cleared, so the queue can never reach zero.
|
||||||
|
3. **No status-change logging or daily briefing.** Client active↔inactive transitions are not recorded anywhere, and no briefing/digest feature exists.
|
||||||
|
4. **(Likely bug) `Shape2` vs `Shape 2` name mismatch.** The secondary designation is created/synced as `Shape2` (no space) but nearly every read query filters for `Shape 2` (with a space). If the stored name is `Shape2`, all SHAPE-2 clients are silently excluded from the manager queue, dashboard, and metrics.
|
||||||
|
|
||||||
|
None of the proposed work changes the database schema meaningfully — it is logic-only — so it is safe to deploy in the agreed 7 PM–5 AM window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What already matches (no action needed)
|
||||||
|
|
||||||
|
| Meeting decision | Code reality |
|
||||||
|
|---|---|
|
||||||
|
| Tasks are policy-specific, not carrier-specific | `Task.policyId` / `policyGroupId`; carrier only on `Policy.carrierName`; generation filters by `policyTypeFilter`, never carrier (`src/lib/sync/auto-generate.ts:179`) |
|
||||||
|
| Loss-run tasks follow the policy renewal date | Negative `daysOffset` anchored to `policy.expirationDate + 1` (`auto-generate.ts:175-185`) |
|
||||||
|
| Don't auto-mark clients inactive; advocate persists | No `isActive` flag on `Client`; nothing auto-deactivates; `claimsAdvocateId` persists |
|
||||||
|
| Logic-only change, safe maintenance window | Auto-generation writes no schema; scheduled sync runs 02:00 (`0 2 * * *`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items to address (priority order)
|
||||||
|
|
||||||
|
### Priority 1 — Gate task generation on active policies
|
||||||
|
**Problem:** `src/lib/sync/auto-generate.ts` selects clients/policies/groups by *existence* of policies, not status. Dead-policy filtering (`['Cancelled','Expired','Non-Renewed','Rewritten','Not taken']`) only runs at display time (`api/clients/[id]/tasks/route.ts:29`), so tasks for hibernating/departed clients are still created and merely hidden.
|
||||||
|
**Target behavior:** A client with no active policy generates **no** client-, policy-, or group-level tasks.
|
||||||
|
|
||||||
|
### Priority 2 — Client-level "NA / no advocate" path
|
||||||
|
**Problem:** Setup completion requires an advocate (`src/components/renewal-groups/setup-wizard.tsx:422-426`, disabled button at `:436`). A client leaves the manager queue only when **both** `claimsAdvocateId` and `setupCompletedAt` are set (`manager/setup/page.tsx:40-43`). There is no client-level NA concept (the existing `NA` is a *task* status).
|
||||||
|
**Target behavior:** A manager can mark a client as NA / no-advocate / other-department, which clears it from the setup queue without assigning an advocate.
|
||||||
|
|
||||||
|
### Priority 3 — Status-change logging + daily briefing
|
||||||
|
**Problem:** No client active↔inactive transition is written to `AuditLog`; `Notification.create` is never called; no briefing/digest/summary feature exists.
|
||||||
|
**Target behavior:** When a client crosses the active/inactive line (e.g. a new policy reactivates them, or their last active policy lapses), log it; roll those events into a daily briefing for managers.
|
||||||
|
|
||||||
|
### Priority 4 (verify first) — `Shape2` vs `Shape 2` naming
|
||||||
|
**Problem:** Created/synced as `Shape2` (`api/admin/sync-designations/route.ts:22,84,92`; `scripts/import-shape-tasks.ts:68`) but read as `Shape 2` across manager queue, dashboard, metrics, workload. Import scripts hedge with both forms; runtime read queries do not.
|
||||||
|
**Target behavior:** One canonical name used consistently everywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-step plan (for later execution)
|
||||||
|
|
||||||
|
### Phase 0 — Verify the designation name (do this first; ~15 min)
|
||||||
|
1. Confirm the stored name with: `SELECT name FROM designations WHERE name ILIKE 'shape%';`
|
||||||
|
2. If it returns `Shape2`: either (a) rename the row to `Shape 2`, **or** (b) normalize every read filter to match the import scripts' `['Shape','Shape 2','Shape2']`. Pick one canonical form and apply it everywhere.
|
||||||
|
3. Re-check manager queue / dashboard / metrics counts before vs. after to confirm SHAPE-2 clients now appear.
|
||||||
|
|
||||||
|
### Phase 1 — Active-policy gate on task generation (Priority 1)
|
||||||
|
1. Define a single shared `ACTIVE_POLICY_STATUSES` (or reuse the existing `DEAD_STATUSES` exclusion) in one module so generation and display agree.
|
||||||
|
2. In `auto-generate.ts`:
|
||||||
|
- Client-level query (`~:228-232`): require at least one **active** policy (status not in dead set), not merely any policy/group.
|
||||||
|
- Policy-level generation (`~:149-221`): skip policies whose status is dead.
|
||||||
|
- Group-level generation (`~:80-146`): skip groups with no active policies.
|
||||||
|
3. Write tests covering: all-dead-policies client → 0 tasks; mixed active/dead → tasks only for active; reactivation (dead → active policy added) → tasks resume.
|
||||||
|
4. Decide and document the policy on **already-generated** tasks for now-inactive clients (leave hidden vs. mark NA/cancelled). Confirm with Luke/Lorentz.
|
||||||
|
|
||||||
|
### Phase 2 — Client NA / no-advocate path (Priority 2)
|
||||||
|
1. Decide the data representation (recommend a nullable `Client.setupStatus` enum or a `clearedReason` + boolean, avoiding a heavy schema change). Confirm naming with stakeholders.
|
||||||
|
2. Update the setup wizard so a manager can choose "NA / no advocate / other department" instead of an advocate, and allow completion in that case (relax `setup-wizard.tsx:422-426` / the `canFinish` guard).
|
||||||
|
3. Update the manager setup queue (`manager/setup/page.tsx:38-43`, `api/clients/setup-queue/route.ts`) so NA-marked clients drop off the queue.
|
||||||
|
4. Ensure NA-marked clients are excluded from (or clearly distinguished in) generation per the Phase 1 rules.
|
||||||
|
5. Tests: NA client clears the queue; NA client generates no advocate-assigned tasks; queue can reach zero.
|
||||||
|
|
||||||
|
### Phase 3 — Status-change logging + daily briefing (Priority 3)
|
||||||
|
1. Add active↔inactive transition detection at the point policies change (post-sync and/or on policy create/cancel). Derive status from active-policy presence (consistent with Phase 1).
|
||||||
|
2. Write transitions to `AuditLog` (and/or `Notification`) with old/new status and trigger.
|
||||||
|
3. Build the daily briefing aggregation (new clients needing setup, status changes, queue size) and choose delivery: in-app notification first; Teams/email later (explicitly deferred in the meeting).
|
||||||
|
4. Schedule within the 7 PM–5 AM window; align with the existing 02:00 sync.
|
||||||
|
5. Tests: reactivation logs an event; lapse logs an event; briefing aggregates a day's events.
|
||||||
|
|
||||||
|
### Phase 4 — Non-SHAPE generation gate (follow-on to Priority 1/2)
|
||||||
|
1. Decide whether non-SHAPE clients should generate **zero** tasks (meeting implied yes) or only generic templates.
|
||||||
|
2. If zero: apply the SHAPE/SHAPE 2 designation gate (already used by the manager queue) to `auto-generate.ts` as well.
|
||||||
|
3. Tests: non-SHAPE client → 0 generated tasks.
|
||||||
|
|
||||||
|
### Cross-cutting
|
||||||
|
- All changes are logic-only (Phase 2/3 may add a small column/enum — coordinate a `prisma db push` per `CLAUDE.md`). Deploy in the 7 PM–5 AM window.
|
||||||
|
- After each phase: rebuild and restart per `CLAUDE.md` (`docker compose build horizon-app && docker compose up -d horizon-app`).
|
||||||
|
- Validate against a sanitized/dev copy before production where possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions for Luke / Lorentz
|
||||||
|
1. For clients that go inactive, what happens to **tasks already generated** — leave hidden, auto-NA, or cancel?
|
||||||
|
2. Exact set of "NA" reasons for the setup queue (other department? surety/bond? no rep?) and whether each should still appear anywhere for reference.
|
||||||
|
3. Daily briefing delivery channel and recipients for v1 (in-app vs. Teams vs. email).
|
||||||
|
4. Should **non-SHAPE** clients generate *no* tasks at all, or just no SHAPE-specific tasks?
|
||||||
|
|
@ -48,6 +48,8 @@ const ACTION_OPTIONS = [
|
||||||
'CLIENT_TEAM_MEMBER_REMOVED',
|
'CLIENT_TEAM_MEMBER_REMOVED',
|
||||||
'AUTO_GENERATE_TASKS_GROUP',
|
'AUTO_GENERATE_TASKS_GROUP',
|
||||||
'AUTO_GENERATE_TASKS_POLICY',
|
'AUTO_GENERATE_TASKS_POLICY',
|
||||||
|
'AUTO_GENERATE_TASKS_CLIENT',
|
||||||
|
'AUTO_ASSIGN_POLICY_GROUP',
|
||||||
'GENERATE_TASKS_FROM_GROUP',
|
'GENERATE_TASKS_FROM_GROUP',
|
||||||
'UPDATE',
|
'UPDATE',
|
||||||
'CREATE',
|
'CREATE',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ArrowLeft, ArrowRight, CalendarRange, Database, Shield } from 'lucide-r
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { SystemSettingsForm } from './system-settings-form'
|
import { SystemSettingsForm } from './system-settings-form'
|
||||||
|
import { getAutomationConfig } from '@/lib/sync/automation-config'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
|
@ -17,9 +18,10 @@ export default async function SystemSettingsPage() {
|
||||||
const userRoles = (session.user as any).roles || []
|
const userRoles = (session.user as any).roles || []
|
||||||
if (!userRoles.includes('Admin')) redirect('/dashboard')
|
if (!userRoles.includes('Admin')) redirect('/dashboard')
|
||||||
|
|
||||||
const [syncConfigs, appSettings] = await Promise.all([
|
const [syncConfigs, appSettings, automationConfig] = await Promise.all([
|
||||||
prisma.syncConfig.findMany(),
|
prisma.syncConfig.findMany(),
|
||||||
prisma.appSetting.findMany(),
|
prisma.appSetting.findMany(),
|
||||||
|
getAutomationConfig(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const syncEnabled = syncConfigs.find((c) => c.key === 'sync_enabled')?.value ?? 'false'
|
const syncEnabled = syncConfigs.find((c) => c.key === 'sync_enabled')?.value ?? 'false'
|
||||||
|
|
@ -67,7 +69,12 @@ export default async function SystemSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sync config inline editor */}
|
{/* Sync config inline editor */}
|
||||||
<SystemSettingsForm syncEnabled={syncEnabled === 'true'} syncSchedule={syncSchedule} overdueWindowDays={overdueWindowDays} />
|
<SystemSettingsForm
|
||||||
|
syncEnabled={syncEnabled === 'true'}
|
||||||
|
syncSchedule={syncSchedule}
|
||||||
|
overdueWindowDays={overdueWindowDays}
|
||||||
|
automation={automationConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Links to subsection settings pages */}
|
{/* Links to subsection settings pages */}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -7,21 +7,68 @@ import { Label } from '@/components/ui/label'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Database, Save, BarChart3 } from 'lucide-react'
|
import { Database, Save, BarChart3, ListChecks, FolderTree } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AutomationSettings {
|
||||||
|
taskAutogenEnabled: boolean
|
||||||
|
taskGracePolicyDays: number
|
||||||
|
taskGraceClientDays: number
|
||||||
|
groupAutoassignEnabled: boolean
|
||||||
|
groupAutoassignWindowDays: number
|
||||||
|
}
|
||||||
|
|
||||||
interface SystemSettingsFormProps {
|
interface SystemSettingsFormProps {
|
||||||
syncEnabled: boolean
|
syncEnabled: boolean
|
||||||
syncSchedule: string
|
syncSchedule: string
|
||||||
overdueWindowDays: number
|
overdueWindowDays: number
|
||||||
|
automation: AutomationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SystemSettingsForm({ syncEnabled: initialEnabled, syncSchedule: initialSchedule, overdueWindowDays: initialOverdueWindow }: SystemSettingsFormProps) {
|
export function SystemSettingsForm({ syncEnabled: initialEnabled, syncSchedule: initialSchedule, overdueWindowDays: initialOverdueWindow, automation }: SystemSettingsFormProps) {
|
||||||
const [syncEnabled, setSyncEnabled] = useState(initialEnabled)
|
const [syncEnabled, setSyncEnabled] = useState(initialEnabled)
|
||||||
const [syncSchedule, setSyncSchedule] = useState(initialSchedule)
|
const [syncSchedule, setSyncSchedule] = useState(initialSchedule)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [overdueWindowDays, setOverdueWindowDays] = useState(initialOverdueWindow)
|
const [overdueWindowDays, setOverdueWindowDays] = useState(initialOverdueWindow)
|
||||||
const [savingOverdue, setSavingOverdue] = useState(false)
|
const [savingOverdue, setSavingOverdue] = useState(false)
|
||||||
|
|
||||||
|
// Automation settings (task generation + group auto-assignment)
|
||||||
|
const [taskAutogenEnabled, setTaskAutogenEnabled] = useState(automation.taskAutogenEnabled)
|
||||||
|
const [taskGracePolicyDays, setTaskGracePolicyDays] = useState(automation.taskGracePolicyDays)
|
||||||
|
const [taskGraceClientDays, setTaskGraceClientDays] = useState(automation.taskGraceClientDays)
|
||||||
|
const [groupAutoassignEnabled, setGroupAutoassignEnabled] = useState(automation.groupAutoassignEnabled)
|
||||||
|
const [groupAutoassignWindowDays, setGroupAutoassignWindowDays] = useState(automation.groupAutoassignWindowDays)
|
||||||
|
const [savingTaskGen, setSavingTaskGen] = useState(false)
|
||||||
|
const [savingGroupAssign, setSavingGroupAssign] = useState(false)
|
||||||
|
|
||||||
|
// Both automation cards persist atomically through one endpoint. The `section`
|
||||||
|
// arg only controls which button shows the spinner.
|
||||||
|
async function handleSaveAutomation(section: 'task' | 'group') {
|
||||||
|
const setSaving = section === 'task' ? setSavingTaskGen : setSavingGroupAssign
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/automation-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
taskAutogenEnabled,
|
||||||
|
taskGracePolicyDays,
|
||||||
|
taskGraceClientDays,
|
||||||
|
groupAutoassignEnabled,
|
||||||
|
groupAutoassignWindowDays,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.error || 'Save failed')
|
||||||
|
}
|
||||||
|
toast.success('Automation settings saved')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -138,6 +185,126 @@ export function SystemSettingsForm({ syncEnabled: initialEnabled, syncSchedule:
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ListChecks className="h-4 w-4" /> Task Auto-Generation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="autogen-enabled" className="font-medium">Auto-generate tasks after sync</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Creates template tasks for new policies, groups, and clients once they pass the grace period below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="autogen-enabled"
|
||||||
|
checked={taskAutogenEnabled}
|
||||||
|
onCheckedChange={setTaskAutogenEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="grace-policy">Grace period — new policies (days)</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="grace-policy"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
value={taskGracePolicyDays}
|
||||||
|
onChange={(e) => setTaskGracePolicyDays(Number(e.target.value))}
|
||||||
|
className="w-24 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">days after a policy is synced</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
How long a newly-synced policy or group waits before policy-level and group-level tasks are generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="grace-client">Grace period — new clients (days)</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="grace-client"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
value={taskGraceClientDays}
|
||||||
|
onChange={(e) => setTaskGraceClientDays(Number(e.target.value))}
|
||||||
|
className="w-24 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">days after a client is synced</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
How long a newly-synced client waits before client-level tasks are generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => handleSaveAutomation('task')} disabled={savingTaskGen || savingGroupAssign} size="sm" className="gap-1.5">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{savingTaskGen ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saving applies all automation settings (both sections) together.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FolderTree className="h-4 w-4" /> Renewal Group Auto-Assignment
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="autoassign-enabled" className="font-medium">Auto-assign policies to groups</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Attaches an ungrouped policy to an existing renewal group on the same client when their renewal dates are close.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="autoassign-enabled"
|
||||||
|
checked={groupAutoassignEnabled}
|
||||||
|
onCheckedChange={setGroupAutoassignEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="autoassign-window">Match window (days)</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="autoassign-window"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
value={groupAutoassignWindowDays}
|
||||||
|
onChange={(e) => setGroupAutoassignWindowDays(Number(e.target.value))}
|
||||||
|
className="w-24 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">days from a group's renewal date</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A policy is attached to the nearest existing group whose renewal date is within this many days of the policy's
|
||||||
|
renewal (expiration + 1 day). Existing groupings are never changed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => handleSaveAutomation('group')} disabled={savingTaskGen || savingGroupAssign} size="sm" className="gap-1.5">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{savingGroupAssign ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saving applies all automation settings (both sections) together.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
ondeck/src/app/api/admin/automation-settings/route.ts
Normal file
107
ondeck/src/app/api/admin/automation-settings/route.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import {
|
||||||
|
getAutomationConfig,
|
||||||
|
TASK_GRACE_POLICY_KEY,
|
||||||
|
TASK_GRACE_CLIENT_KEY,
|
||||||
|
GROUP_AUTOASSIGN_ENABLED_KEY,
|
||||||
|
GROUP_AUTOASSIGN_WINDOW_KEY,
|
||||||
|
TASK_AUTOGEN_ENABLED_KEY,
|
||||||
|
} from '@/lib/sync/automation-config'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const config = await getAutomationConfig()
|
||||||
|
return NextResponse.json(config)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Automation settings GET error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const userRoles = (session.user as any).roles || []
|
||||||
|
if (!userRoles.includes('Admin')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const {
|
||||||
|
taskAutogenEnabled,
|
||||||
|
taskGracePolicyDays,
|
||||||
|
taskGraceClientDays,
|
||||||
|
groupAutoassignEnabled,
|
||||||
|
groupAutoassignWindowDays,
|
||||||
|
} = await request.json()
|
||||||
|
|
||||||
|
if (typeof taskAutogenEnabled !== 'boolean' || typeof groupAutoassignEnabled !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'Enabled flags must be booleans' }, { status: 400 })
|
||||||
|
}
|
||||||
|
for (const [label, value] of [
|
||||||
|
['taskGracePolicyDays', taskGracePolicyDays],
|
||||||
|
['taskGraceClientDays', taskGraceClientDays],
|
||||||
|
['groupAutoassignWindowDays', groupAutoassignWindowDays],
|
||||||
|
] as const) {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 365) {
|
||||||
|
return NextResponse.json({ error: `${label} must be an integer between 0 and 365` }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSettingUpserts = (
|
||||||
|
[
|
||||||
|
[TASK_GRACE_POLICY_KEY, taskGracePolicyDays],
|
||||||
|
[TASK_GRACE_CLIENT_KEY, taskGraceClientDays],
|
||||||
|
[GROUP_AUTOASSIGN_ENABLED_KEY, groupAutoassignEnabled],
|
||||||
|
[GROUP_AUTOASSIGN_WINDOW_KEY, groupAutoassignWindowDays],
|
||||||
|
] as const
|
||||||
|
).map(([key, value]) =>
|
||||||
|
prisma.appSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value: String(value) },
|
||||||
|
create: { key, value: String(value) },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.syncConfig.upsert({
|
||||||
|
where: { key: TASK_AUTOGEN_ENABLED_KEY },
|
||||||
|
update: { value: String(taskAutogenEnabled) },
|
||||||
|
create: { key: TASK_AUTOGEN_ENABLED_KEY, value: String(taskAutogenEnabled) },
|
||||||
|
}),
|
||||||
|
...appSettingUpserts,
|
||||||
|
])
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: (session.user as any).id,
|
||||||
|
action: 'AUTOMATION_SETTINGS_UPDATED',
|
||||||
|
// Spans both AppSetting and SyncConfig (taskAutogenEnabled).
|
||||||
|
entityType: 'SystemConfig',
|
||||||
|
newValues: {
|
||||||
|
taskAutogenEnabled,
|
||||||
|
taskGracePolicyDays,
|
||||||
|
taskGraceClientDays,
|
||||||
|
groupAutoassignEnabled,
|
||||||
|
groupAutoassignWindowDays,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
taskAutogenEnabled,
|
||||||
|
taskGracePolicyDays,
|
||||||
|
taskGraceClientDays,
|
||||||
|
groupAutoassignEnabled,
|
||||||
|
groupAutoassignWindowDays,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Automation settings PUT error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/db'
|
import { getAutomationConfig } from '@/lib/sync/automation-config'
|
||||||
|
import { runAutoGenerate } from '@/lib/sync/auto-generate'
|
||||||
const TWENTY_DAYS_MS = 20 * 24 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/cron/auto-generate
|
* POST /api/cron/auto-generate
|
||||||
* Called by a cron scheduler. Protected by CRON_SECRET header.
|
* Called by an external cron scheduler. Protected by CRON_SECRET header.
|
||||||
*
|
*
|
||||||
* Rules:
|
* The same generation engine also runs automatically after each AMS sync
|
||||||
* - Policy must be >= 20 days old in the system
|
* (see runSync in src/lib/sync/sync-engine.ts); this endpoint remains available
|
||||||
* - If policy is in a group, use group renewalDate; otherwise use policy expirationDate
|
* for out-of-band / manual triggering.
|
||||||
* - Skip policies/groups that already have tasks generated from templates
|
*
|
||||||
* - Auto-assign to client's claimsAdvocate if set
|
* Grace periods and the master toggle are read from configuration
|
||||||
|
* (see getAutomationConfig). Tasks generate once records age past their grace.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const secret = request.headers.get('x-cron-secret')
|
const secret = request.headers.get('x-cron-secret')
|
||||||
|
|
@ -19,326 +19,16 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const killSwitch = await prisma.syncConfig.findUnique({
|
const config = await getAutomationConfig()
|
||||||
where: { key: 'task_auto_generate_enabled' },
|
if (!config.taskAutogenEnabled) {
|
||||||
})
|
|
||||||
if (killSwitch?.value === 'false') {
|
|
||||||
return NextResponse.json({ message: 'Auto-generate disabled via SyncConfig' })
|
return NextResponse.json({ message: 'Auto-generate disabled via SyncConfig' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const cutoff = new Date(Date.now() - TWENTY_DAYS_MS)
|
|
||||||
|
|
||||||
let groupTasksCreated = 0
|
|
||||||
let policyTasksCreated = 0
|
|
||||||
let errors: string[] = []
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ─── 1. Policy Groups ───────────────────────────────────────────────────
|
const result = await runAutoGenerate(config)
|
||||||
// Find groups whose policies are all >= 20 days old and have no template-generated tasks yet
|
|
||||||
const groups = await prisma.policyGroup.findMany({
|
|
||||||
where: {
|
|
||||||
policies: {
|
|
||||||
every: { createdAt: { lte: cutoff } },
|
|
||||||
some: {}, // group must have at least one policy
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
none: { templateId: { not: null } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
client: {
|
|
||||||
select: {
|
|
||||||
designationId: true,
|
|
||||||
designation2Id: true,
|
|
||||||
claimsAdvocateId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const group of groups) {
|
|
||||||
try {
|
|
||||||
const designationIds = [
|
|
||||||
group.client.designationId,
|
|
||||||
group.client.designation2Id,
|
|
||||||
].filter(Boolean) as string[]
|
|
||||||
|
|
||||||
const templates = await prisma.taskTemplate.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true,
|
|
||||||
level: { in: ['BOTH', 'RENEWAL_GROUP'] },
|
|
||||||
OR: [
|
|
||||||
{ designationId: null },
|
|
||||||
...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (templates.length === 0) continue
|
|
||||||
|
|
||||||
const renewalDate = new Date(group.renewalDate)
|
|
||||||
|
|
||||||
const tasksToCreate = templates.map((template) => {
|
|
||||||
const dueDate = new Date(renewalDate)
|
|
||||||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
|
||||||
return {
|
|
||||||
title: template.name,
|
|
||||||
description: template.description,
|
|
||||||
department: template.department,
|
|
||||||
timing: template.timing,
|
|
||||||
daysOffset: template.daysOffset,
|
|
||||||
dueDate,
|
|
||||||
status: 'NOT_STARTED' as const,
|
|
||||||
priority: template.defaultPriority,
|
|
||||||
clientId: group.clientId,
|
|
||||||
policyGroupId: group.id,
|
|
||||||
templateId: template.id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
|
||||||
groupTasksCreated += created.count
|
|
||||||
|
|
||||||
// Auto-assign to claims advocate
|
|
||||||
if (group.client.claimsAdvocateId && created.count > 0) {
|
|
||||||
const newTasks = await prisma.task.findMany({
|
|
||||||
where: { policyGroupId: group.id, templateId: { in: templates.map((t) => t.id) } },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (newTasks.length > 0) {
|
|
||||||
await prisma.taskAssignment.createMany({
|
|
||||||
data: newTasks.map((t) => ({
|
|
||||||
taskId: t.id,
|
|
||||||
userId: group.client.claimsAdvocateId!,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'AUTO_GENERATE_TASKS_GROUP',
|
|
||||||
entityType: 'PolicyGroup',
|
|
||||||
entityId: group.id,
|
|
||||||
newValues: { tasksCreated: created.count, renewalDate: group.renewalDate },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
errors.push(`Group ${group.id}: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 2. Individual Policies (not in a group) ────────────────────────────
|
|
||||||
const policies = await prisma.policy.findMany({
|
|
||||||
where: {
|
|
||||||
createdAt: { lte: cutoff },
|
|
||||||
policyGroupId: null,
|
|
||||||
tasks: {
|
|
||||||
none: { templateId: { not: null } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
client: {
|
|
||||||
select: {
|
|
||||||
designationId: true,
|
|
||||||
designation2Id: true,
|
|
||||||
claimsAdvocateId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const policy of policies) {
|
|
||||||
try {
|
|
||||||
const designationIds = [
|
|
||||||
policy.client.designationId,
|
|
||||||
policy.client.designation2Id,
|
|
||||||
].filter(Boolean) as string[]
|
|
||||||
|
|
||||||
const templates = await prisma.taskTemplate.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true,
|
|
||||||
level: { in: ['BOTH', 'POLICY'] },
|
|
||||||
OR: [
|
|
||||||
{ designationId: null },
|
|
||||||
...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (templates.length === 0) continue
|
|
||||||
|
|
||||||
const anchorDate = new Date(policy.expirationDate)
|
|
||||||
anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 1
|
|
||||||
|
|
||||||
const tasksToCreate = templates
|
|
||||||
.filter((template) => {
|
|
||||||
if (template.policyTypeFilter && template.policyTypeFilter !== policy.policyType) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((template) => {
|
|
||||||
const dueDate = new Date(anchorDate)
|
|
||||||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
|
||||||
return {
|
|
||||||
title: template.name,
|
|
||||||
description: template.description,
|
|
||||||
department: template.department,
|
|
||||||
timing: template.timing,
|
|
||||||
daysOffset: template.daysOffset,
|
|
||||||
dueDate,
|
|
||||||
status: 'NOT_STARTED' as const,
|
|
||||||
priority: template.defaultPriority,
|
|
||||||
clientId: policy.clientId,
|
|
||||||
policyId: policy.id,
|
|
||||||
templateId: template.id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (tasksToCreate.length === 0) continue
|
|
||||||
const created = await prisma.task.createMany({ data: tasksToCreate })
|
|
||||||
policyTasksCreated += created.count
|
|
||||||
|
|
||||||
// Auto-assign to claims advocate
|
|
||||||
if (policy.client.claimsAdvocateId && created.count > 0) {
|
|
||||||
const newTasks = await prisma.task.findMany({
|
|
||||||
where: { policyId: policy.id, templateId: { in: templates.map((t) => t.id) } },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (newTasks.length > 0) {
|
|
||||||
await prisma.taskAssignment.createMany({
|
|
||||||
data: newTasks.map((t) => ({
|
|
||||||
taskId: t.id,
|
|
||||||
userId: policy.client.claimsAdvocateId!,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
action: 'AUTO_GENERATE_TASKS_POLICY',
|
|
||||||
entityType: 'Policy',
|
|
||||||
entityId: policy.id,
|
|
||||||
newValues: { tasksCreated: created.count, expirationDate: policy.expirationDate },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
errors.push(`Policy ${policy.id}: ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 3. CLIENT-level tasks (once per client) ────────────────────────────
|
|
||||||
// Collect all unique clients touched above, find their earliest renewal anchor
|
|
||||||
let clientTasksCreated = 0
|
|
||||||
|
|
||||||
const allClientIds = [...new Set([
|
|
||||||
...groups.map((g) => g.clientId),
|
|
||||||
...policies.map((p) => p.clientId),
|
|
||||||
])]
|
|
||||||
|
|
||||||
for (const clientId of allClientIds) {
|
|
||||||
try {
|
|
||||||
const clientRecord = await prisma.client.findUnique({
|
|
||||||
where: { id: clientId },
|
|
||||||
select: {
|
|
||||||
designationId: true,
|
|
||||||
designation2Id: true,
|
|
||||||
claimsAdvocateId: true,
|
|
||||||
policyGroups: { select: { renewalDate: true } },
|
|
||||||
policies: {
|
|
||||||
where: { policyGroupId: null },
|
|
||||||
select: { expirationDate: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!clientRecord) continue
|
|
||||||
|
|
||||||
const designationIds = [
|
|
||||||
clientRecord.designationId,
|
|
||||||
clientRecord.designation2Id,
|
|
||||||
].filter(Boolean) as string[]
|
|
||||||
|
|
||||||
const clientTemplates = await prisma.taskTemplate.findMany({
|
|
||||||
where: {
|
|
||||||
isActive: true,
|
|
||||||
level: 'CLIENT',
|
|
||||||
OR: [
|
|
||||||
{ designationId: null },
|
|
||||||
...(designationIds.length > 0 ? [{ designationId: { in: designationIds } }] : []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (clientTemplates.length === 0) continue
|
|
||||||
|
|
||||||
// Find earliest renewal date across groups and ungrouped policies
|
|
||||||
const groupDates = clientRecord.policyGroups.map((g) => new Date(g.renewalDate).getTime())
|
|
||||||
const policyDates = clientRecord.policies.map((p) => {
|
|
||||||
const d = new Date(p.expirationDate)
|
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
return d.getTime()
|
|
||||||
})
|
|
||||||
const allDates = [...groupDates, ...policyDates]
|
|
||||||
if (allDates.length === 0) continue
|
|
||||||
const anchorDate = new Date(Math.min(...allDates))
|
|
||||||
|
|
||||||
for (const template of clientTemplates) {
|
|
||||||
// Skip if this client already has a CLIENT-level task from this template
|
|
||||||
const existing = await prisma.task.findFirst({
|
|
||||||
where: {
|
|
||||||
clientId,
|
|
||||||
templateId: template.id,
|
|
||||||
policyId: null,
|
|
||||||
policyGroupId: null,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (existing) continue
|
|
||||||
|
|
||||||
const dueDate = new Date(anchorDate)
|
|
||||||
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
|
||||||
|
|
||||||
const created = await prisma.task.create({
|
|
||||||
data: {
|
|
||||||
title: template.name,
|
|
||||||
description: template.description,
|
|
||||||
department: template.department,
|
|
||||||
timing: template.timing,
|
|
||||||
daysOffset: template.daysOffset,
|
|
||||||
dueDate,
|
|
||||||
status: 'NOT_STARTED',
|
|
||||||
priority: template.defaultPriority,
|
|
||||||
clientId,
|
|
||||||
templateId: template.id,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
clientTasksCreated++
|
|
||||||
|
|
||||||
if (clientRecord.claimsAdvocateId) {
|
|
||||||
await prisma.taskAssignment.create({
|
|
||||||
data: { taskId: created.id, userId: clientRecord.claimsAdvocateId },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
errors.push(`Client ${clientId} (CLIENT tasks): ${err.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
groupTasksCreated,
|
...result,
|
||||||
policyTasksCreated,
|
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||||
clientTasksCreated,
|
|
||||||
totalCreated: groupTasksCreated + policyTasksCreated + clientTasksCreated,
|
|
||||||
groupsProcessed: groups.length,
|
|
||||||
policiesProcessed: policies.length,
|
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Auto-generate cron error:', error)
|
console.error('Auto-generate cron error:', error)
|
||||||
|
|
|
||||||
318
ondeck/src/lib/sync/auto-generate.ts
Normal file
318
ondeck/src/lib/sync/auto-generate.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { AUTOMATION_GO_LIVE_AT, type AutomationConfig } from './automation-config'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Designation IDs (primary + secondary) of a client, with nulls dropped. */
|
||||||
|
function designationIdsOf(entity: {
|
||||||
|
designationId: string | null
|
||||||
|
designation2Id: string | null
|
||||||
|
}): string[] {
|
||||||
|
return [entity.designationId, entity.designation2Id].filter(Boolean) as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma `OR` clause matching templates that apply to a client: templates with
|
||||||
|
* no designation, plus templates scoped to one of the client's designations.
|
||||||
|
*/
|
||||||
|
function designationFilter(ids: string[]) {
|
||||||
|
return [
|
||||||
|
{ designationId: null },
|
||||||
|
...(ids.length > 0 ? [{ designationId: { in: ids } }] : []),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign the client's claims advocate to the just-created tasks matched by
|
||||||
|
* `where`. No-op when there is no advocate or nothing was created. Safe because
|
||||||
|
* callers only enter task-creation branches when the entity had no template
|
||||||
|
* tasks yet, so `where` matches exactly the freshly-created rows.
|
||||||
|
*/
|
||||||
|
async function assignAdvocate(
|
||||||
|
advocateId: string | null,
|
||||||
|
createdCount: number,
|
||||||
|
where: NonNullable<Parameters<typeof prisma.task.findMany>[0]>['where']
|
||||||
|
): Promise<void> {
|
||||||
|
if (!advocateId || createdCount === 0) return
|
||||||
|
const tasks = await prisma.task.findMany({ where, select: { id: true } })
|
||||||
|
if (tasks.length === 0) return
|
||||||
|
await prisma.taskAssignment.createMany({
|
||||||
|
data: tasks.map((t) => ({ taskId: t.id, userId: advocateId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoGenerateResult {
|
||||||
|
groupTasksCreated: number
|
||||||
|
policyTasksCreated: number
|
||||||
|
clientTasksCreated: number
|
||||||
|
totalCreated: number
|
||||||
|
groupsProcessed: number
|
||||||
|
policiesProcessed: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate template-derived tasks for renewal groups, standalone policies, and
|
||||||
|
* clients whose records have aged past their configured grace period.
|
||||||
|
*
|
||||||
|
* Idempotent: groups/policies that already have template tasks are skipped, and
|
||||||
|
* client-level tasks are only generated for clients that have none yet. Safe to
|
||||||
|
* run on every sync.
|
||||||
|
*
|
||||||
|
* Grace mapping:
|
||||||
|
* - group- and policy-level tasks use `taskGracePolicyDays`
|
||||||
|
* - client-level tasks use `taskGraceClientDays`
|
||||||
|
*/
|
||||||
|
export async function runAutoGenerate(config: AutomationConfig): Promise<AutoGenerateResult> {
|
||||||
|
const now = Date.now()
|
||||||
|
const policyCutoff = new Date(now - config.taskGracePolicyDays * DAY_MS)
|
||||||
|
const clientCutoff = new Date(now - config.taskGraceClientDays * DAY_MS)
|
||||||
|
|
||||||
|
let groupTasksCreated = 0
|
||||||
|
let policyTasksCreated = 0
|
||||||
|
let clientTasksCreated = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// ─── 1. Policy Groups ─────────────────────────────────────────────────────
|
||||||
|
// Groups whose policies are all aged past the policy grace period and that
|
||||||
|
// have no template-generated tasks yet.
|
||||||
|
const groups = await prisma.policyGroup.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: AUTOMATION_GO_LIVE_AT },
|
||||||
|
policies: {
|
||||||
|
every: { createdAt: { lte: policyCutoff } },
|
||||||
|
some: {}, // group must have at least one policy
|
||||||
|
},
|
||||||
|
tasks: { none: { templateId: { not: null } } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
client: {
|
||||||
|
select: { designationId: true, designation2Id: true, claimsAdvocateId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
try {
|
||||||
|
const templates = await prisma.taskTemplate.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
level: { in: ['BOTH', 'RENEWAL_GROUP'] },
|
||||||
|
OR: designationFilter(designationIdsOf(group.client)),
|
||||||
|
},
|
||||||
|
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (templates.length === 0) continue
|
||||||
|
|
||||||
|
const renewalDate = new Date(group.renewalDate)
|
||||||
|
const tasksToCreate = templates.map((template) => {
|
||||||
|
const dueDate = new Date(renewalDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||||||
|
return {
|
||||||
|
title: template.name,
|
||||||
|
description: template.description,
|
||||||
|
department: template.department,
|
||||||
|
timing: template.timing,
|
||||||
|
daysOffset: template.daysOffset,
|
||||||
|
dueDate,
|
||||||
|
status: 'NOT_STARTED' as const,
|
||||||
|
priority: template.defaultPriority,
|
||||||
|
clientId: group.clientId,
|
||||||
|
policyGroupId: group.id,
|
||||||
|
templateId: template.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||||||
|
groupTasksCreated += created.count
|
||||||
|
|
||||||
|
await assignAdvocate(group.client.claimsAdvocateId, created.count, {
|
||||||
|
policyGroupId: group.id,
|
||||||
|
templateId: { in: templates.map((t) => t.id) },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'AUTO_GENERATE_TASKS_GROUP',
|
||||||
|
entityType: 'PolicyGroup',
|
||||||
|
entityId: group.id,
|
||||||
|
newValues: { tasksCreated: created.count, renewalDate: group.renewalDate },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Group ${group.id}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Individual Policies (not in a group) ──────────────────────────────
|
||||||
|
const policies = await prisma.policy.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: AUTOMATION_GO_LIVE_AT, lte: policyCutoff },
|
||||||
|
policyGroupId: null,
|
||||||
|
tasks: { none: { templateId: { not: null } } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
client: {
|
||||||
|
select: { designationId: true, designation2Id: true, claimsAdvocateId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
try {
|
||||||
|
const templates = await prisma.taskTemplate.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
level: { in: ['BOTH', 'POLICY'] },
|
||||||
|
OR: designationFilter(designationIdsOf(policy.client)),
|
||||||
|
},
|
||||||
|
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (templates.length === 0) continue
|
||||||
|
|
||||||
|
const anchorDate = new Date(policy.expirationDate)
|
||||||
|
anchorDate.setDate(anchorDate.getDate() + 1) // renewal date = expiration + 1
|
||||||
|
|
||||||
|
const tasksToCreate = templates
|
||||||
|
.filter((template) => {
|
||||||
|
if (template.policyTypeFilter && template.policyTypeFilter !== policy.policyType) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((template) => {
|
||||||
|
const dueDate = new Date(anchorDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||||||
|
return {
|
||||||
|
title: template.name,
|
||||||
|
description: template.description,
|
||||||
|
department: template.department,
|
||||||
|
timing: template.timing,
|
||||||
|
daysOffset: template.daysOffset,
|
||||||
|
dueDate,
|
||||||
|
status: 'NOT_STARTED' as const,
|
||||||
|
priority: template.defaultPriority,
|
||||||
|
clientId: policy.clientId,
|
||||||
|
policyId: policy.id,
|
||||||
|
templateId: template.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tasksToCreate.length === 0) continue
|
||||||
|
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||||||
|
policyTasksCreated += created.count
|
||||||
|
|
||||||
|
await assignAdvocate(policy.client.claimsAdvocateId, created.count, {
|
||||||
|
policyId: policy.id,
|
||||||
|
templateId: { in: templates.map((t) => t.id) },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'AUTO_GENERATE_TASKS_POLICY',
|
||||||
|
entityType: 'Policy',
|
||||||
|
entityId: policy.id,
|
||||||
|
newValues: { tasksCreated: created.count, expirationDate: policy.expirationDate },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Policy ${policy.id}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. CLIENT-level tasks (once per client) ──────────────────────────────
|
||||||
|
// Clients aged past the client grace period that have at least one renewal
|
||||||
|
// anchor (a group or an ungrouped policy) and no CLIENT-level template tasks
|
||||||
|
// yet. New CLIENT templates added after a client is processed are applied via
|
||||||
|
// the manual "Generate & Assign" admin tool, not this auto path.
|
||||||
|
const clients = await prisma.client.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: AUTOMATION_GO_LIVE_AT, lte: clientCutoff },
|
||||||
|
tasks: { none: { templateId: { not: null }, policyId: null, policyGroupId: null } },
|
||||||
|
OR: [{ policyGroups: { some: {} } }, { policies: { some: { policyGroupId: null } } }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
designationId: true,
|
||||||
|
designation2Id: true,
|
||||||
|
claimsAdvocateId: true,
|
||||||
|
policyGroups: { select: { renewalDate: true } },
|
||||||
|
policies: { where: { policyGroupId: null }, select: { expirationDate: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
const clientTemplates = await prisma.taskTemplate.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
level: 'CLIENT',
|
||||||
|
OR: designationFilter(designationIdsOf(client)),
|
||||||
|
},
|
||||||
|
orderBy: [{ department: 'asc' }, { daysOffset: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (clientTemplates.length === 0) continue
|
||||||
|
|
||||||
|
// Earliest renewal date across groups and ungrouped policies.
|
||||||
|
const groupDates = client.policyGroups.map((g) => new Date(g.renewalDate).getTime())
|
||||||
|
const policyDates = client.policies.map((p) => {
|
||||||
|
const d = new Date(p.expirationDate)
|
||||||
|
d.setDate(d.getDate() + 1)
|
||||||
|
return d.getTime()
|
||||||
|
})
|
||||||
|
const allDates = [...groupDates, ...policyDates]
|
||||||
|
if (allDates.length === 0) continue
|
||||||
|
const anchorDate = new Date(Math.min(...allDates))
|
||||||
|
|
||||||
|
const tasksToCreate = clientTemplates.map((template) => {
|
||||||
|
const dueDate = new Date(anchorDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + template.daysOffset)
|
||||||
|
return {
|
||||||
|
title: template.name,
|
||||||
|
description: template.description,
|
||||||
|
department: template.department,
|
||||||
|
timing: template.timing,
|
||||||
|
daysOffset: template.daysOffset,
|
||||||
|
dueDate,
|
||||||
|
status: 'NOT_STARTED' as const,
|
||||||
|
priority: template.defaultPriority,
|
||||||
|
clientId: client.id,
|
||||||
|
templateId: template.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await prisma.task.createMany({ data: tasksToCreate })
|
||||||
|
clientTasksCreated += created.count
|
||||||
|
|
||||||
|
await assignAdvocate(client.claimsAdvocateId, created.count, {
|
||||||
|
clientId: client.id,
|
||||||
|
policyId: null,
|
||||||
|
policyGroupId: null,
|
||||||
|
templateId: { in: clientTemplates.map((t) => t.id) },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'AUTO_GENERATE_TASKS_CLIENT',
|
||||||
|
entityType: 'Client',
|
||||||
|
entityId: client.id,
|
||||||
|
newValues: { tasksCreated: created.count, anchorDate },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Client ${client.id} (CLIENT tasks): ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupTasksCreated,
|
||||||
|
policyTasksCreated,
|
||||||
|
clientTasksCreated,
|
||||||
|
totalCreated: groupTasksCreated + policyTasksCreated + clientTasksCreated,
|
||||||
|
groupsProcessed: groups.length,
|
||||||
|
policiesProcessed: policies.length,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ondeck/src/lib/sync/automation-config.ts
Normal file
95
ondeck/src/lib/sync/automation-config.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for post-sync automation: task auto-generation and
|
||||||
|
* policy → renewal-group auto-assignment.
|
||||||
|
*
|
||||||
|
* Values are persisted as strings in the `app_settings` (AppSetting) key-value
|
||||||
|
* table, except the task-generation master toggle which lives in `sync_config`
|
||||||
|
* (SyncConfig) under the pre-existing `task_auto_generate_enabled` key so the
|
||||||
|
* external cron endpoint and the post-sync hook share a single switch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// AppSetting keys
|
||||||
|
export const TASK_GRACE_POLICY_KEY = 'task_grace_period_policy_days'
|
||||||
|
export const TASK_GRACE_CLIENT_KEY = 'task_grace_period_client_days'
|
||||||
|
export const GROUP_AUTOASSIGN_ENABLED_KEY = 'group_autoassign_enabled'
|
||||||
|
export const GROUP_AUTOASSIGN_WINDOW_KEY = 'group_autoassign_window_days'
|
||||||
|
|
||||||
|
// SyncConfig key (shared with /api/cron/auto-generate kill switch)
|
||||||
|
export const TASK_AUTOGEN_ENABLED_KEY = 'task_auto_generate_enabled'
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
export const TASK_GRACE_POLICY_DEFAULT = 20
|
||||||
|
export const TASK_GRACE_CLIENT_DEFAULT = 20
|
||||||
|
export const GROUP_AUTOASSIGN_WINDOW_DEFAULT = 35
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed floor below which post-sync automation (task auto-generate and group
|
||||||
|
* auto-assign) will never process a record, regardless of how long it has
|
||||||
|
* been eligible by age. Policies, clients, and groups created before this
|
||||||
|
* date predate the automation and must be handled manually — never backfilled.
|
||||||
|
*
|
||||||
|
* Without this floor, the first run after enabling automation treats every
|
||||||
|
* historical record that happens to be old enough as newly eligible and
|
||||||
|
* generates tasks for all of it at once, anchored to old renewal/expiration
|
||||||
|
* dates (see 2026-07-07 incident: 6,552 tasks generated in one burst, most
|
||||||
|
* already years overdue). This is intentionally a fixed constant, not a
|
||||||
|
* rolling "N days ago" window — it must never move forward on its own.
|
||||||
|
*/
|
||||||
|
export const AUTOMATION_GO_LIVE_AT = new Date('2026-07-07T00:00:00Z')
|
||||||
|
|
||||||
|
export interface AutomationConfig {
|
||||||
|
/** Master switch for post-sync task generation. */
|
||||||
|
taskAutogenEnabled: boolean
|
||||||
|
/** Days a policy/group must age before policy- and group-level tasks generate. */
|
||||||
|
taskGracePolicyDays: number
|
||||||
|
/** Days a client must age before client-level tasks generate. */
|
||||||
|
taskGraceClientDays: number
|
||||||
|
/** Master switch for policy → group auto-assignment. */
|
||||||
|
groupAutoassignEnabled: boolean
|
||||||
|
/** Max distance (days) between a policy's renewal and a group's renewalDate to match. */
|
||||||
|
groupAutoassignWindowDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntOr(value: string | undefined, fallback: number): number {
|
||||||
|
const n = parseInt(value ?? '', 10)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all automation configuration in a single pass, applying defaults for
|
||||||
|
* any key that has not been set yet.
|
||||||
|
*/
|
||||||
|
export async function getAutomationConfig(): Promise<AutomationConfig> {
|
||||||
|
const [appSettings, autogenToggle] = await Promise.all([
|
||||||
|
prisma.appSetting.findMany({
|
||||||
|
where: {
|
||||||
|
key: {
|
||||||
|
in: [
|
||||||
|
TASK_GRACE_POLICY_KEY,
|
||||||
|
TASK_GRACE_CLIENT_KEY,
|
||||||
|
GROUP_AUTOASSIGN_ENABLED_KEY,
|
||||||
|
GROUP_AUTOASSIGN_WINDOW_KEY,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.syncConfig.findUnique({ where: { key: TASK_AUTOGEN_ENABLED_KEY } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const map = Object.fromEntries(appSettings.map((s) => [s.key, s.value]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Defaults to enabled unless explicitly set to 'false' (matches cron route).
|
||||||
|
taskAutogenEnabled: autogenToggle?.value !== 'false',
|
||||||
|
taskGracePolicyDays: parseIntOr(map[TASK_GRACE_POLICY_KEY], TASK_GRACE_POLICY_DEFAULT),
|
||||||
|
taskGraceClientDays: parseIntOr(map[TASK_GRACE_CLIENT_KEY], TASK_GRACE_CLIENT_DEFAULT),
|
||||||
|
// New behaviour: defaults to enabled, opt-out via 'false'.
|
||||||
|
groupAutoassignEnabled: map[GROUP_AUTOASSIGN_ENABLED_KEY] !== 'false',
|
||||||
|
groupAutoassignWindowDays: parseIntOr(
|
||||||
|
map[GROUP_AUTOASSIGN_WINDOW_KEY],
|
||||||
|
GROUP_AUTOASSIGN_WINDOW_DEFAULT
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ondeck/src/lib/sync/group-auto-assign.ts
Normal file
106
ondeck/src/lib/sync/group-auto-assign.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { AUTOMATION_GO_LIVE_AT, type AutomationConfig } from './automation-config'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export interface GroupAutoAssignResult {
|
||||||
|
assigned: number
|
||||||
|
candidatesConsidered: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach ungrouped policies to an existing renewal group on the same client when
|
||||||
|
* the policy's renewal date (expirationDate + 1 day) falls within the configured
|
||||||
|
* window of the group's renewalDate. When several groups match, the nearest
|
||||||
|
* renewalDate wins.
|
||||||
|
*
|
||||||
|
* Constraints (by design):
|
||||||
|
* - Only ungrouped policies that have NO template-generated tasks yet are
|
||||||
|
* considered, so attaching never strands standalone tasks.
|
||||||
|
* - Never creates groups and never moves a policy already in a group.
|
||||||
|
*
|
||||||
|
* Intended to run before task generation so freshly-attached policies generate
|
||||||
|
* group-level tasks rather than standalone policy-level tasks.
|
||||||
|
*/
|
||||||
|
export async function runGroupAutoAssign(config: AutomationConfig): Promise<GroupAutoAssignResult> {
|
||||||
|
const errors: string[] = []
|
||||||
|
let assigned = 0
|
||||||
|
|
||||||
|
const windowMs = config.groupAutoassignWindowDays * DAY_MS
|
||||||
|
|
||||||
|
// Candidate policies: ungrouped, no template tasks yet.
|
||||||
|
const candidates = await prisma.policy.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: AUTOMATION_GO_LIVE_AT },
|
||||||
|
policyGroupId: null,
|
||||||
|
tasks: { none: { templateId: { not: null } } },
|
||||||
|
},
|
||||||
|
select: { id: true, clientId: true, expirationDate: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { assigned: 0, candidatesConsidered: 0, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all groups for the affected clients once and index by client.
|
||||||
|
const clientIds = [...new Set(candidates.map((p) => p.clientId))]
|
||||||
|
const groups = await prisma.policyGroup.findMany({
|
||||||
|
where: { clientId: { in: clientIds } },
|
||||||
|
select: { id: true, clientId: true, renewalDate: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupsByClient = new Map<string, { id: string; renewalDate: Date }[]>()
|
||||||
|
for (const g of groups) {
|
||||||
|
const list = groupsByClient.get(g.clientId) ?? []
|
||||||
|
list.push({ id: g.id, renewalDate: g.renewalDate })
|
||||||
|
groupsByClient.set(g.clientId, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const policy of candidates) {
|
||||||
|
try {
|
||||||
|
const clientGroups = groupsByClient.get(policy.clientId)
|
||||||
|
if (!clientGroups || clientGroups.length === 0) continue
|
||||||
|
if (!policy.expirationDate) continue
|
||||||
|
|
||||||
|
// Renewal date for a standalone policy = expiration + 1 day.
|
||||||
|
const policyRenewal = new Date(policy.expirationDate).getTime() + DAY_MS
|
||||||
|
|
||||||
|
let best: { id: string; diff: number } | null = null
|
||||||
|
for (const g of clientGroups) {
|
||||||
|
const diff = Math.abs(new Date(g.renewalDate).getTime() - policyRenewal)
|
||||||
|
if (diff <= windowMs && (best === null || diff < best.diff)) {
|
||||||
|
best = { id: g.id, diff }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best) continue
|
||||||
|
|
||||||
|
// Guard on policyGroupId: null so a concurrent run that already attached
|
||||||
|
// this policy results in zero rows updated — we then skip counting/logging.
|
||||||
|
const { count } = await prisma.policy.updateMany({
|
||||||
|
where: { id: policy.id, policyGroupId: null },
|
||||||
|
data: { policyGroupId: best.id },
|
||||||
|
})
|
||||||
|
if (count === 0) continue
|
||||||
|
assigned++
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'AUTO_ASSIGN_POLICY_GROUP',
|
||||||
|
entityType: 'Policy',
|
||||||
|
entityId: policy.id,
|
||||||
|
newValues: {
|
||||||
|
policyGroupId: best.id,
|
||||||
|
matchDistanceDays: Math.round(best.diff / DAY_MS),
|
||||||
|
windowDays: config.groupAutoassignWindowDays,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Policy ${policy.id}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { assigned, candidatesConsidered: candidates.length, errors }
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,9 @@ import {
|
||||||
shouldUpdateRecord,
|
shouldUpdateRecord,
|
||||||
} from './mappers'
|
} from './mappers'
|
||||||
import { sleep } from '@/lib/utils'
|
import { sleep } from '@/lib/utils'
|
||||||
|
import { getAutomationConfig } from './automation-config'
|
||||||
|
import { runGroupAutoAssign } from './group-auto-assign'
|
||||||
|
import { runAutoGenerate } from './auto-generate'
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|
@ -139,6 +142,10 @@ export async function runSync(
|
||||||
console.log('✅ Sync completed successfully')
|
console.log('✅ Sync completed successfully')
|
||||||
console.log('📊 Stats:', stats)
|
console.log('📊 Stats:', stats)
|
||||||
|
|
||||||
|
// Post-sync automation: auto-assign newly-synced policies to renewal groups,
|
||||||
|
// then generate template-derived tasks. Failures here never fail the sync.
|
||||||
|
await runPostSyncAutomation()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
syncLogId: syncLog.id,
|
syncLogId: syncLog.id,
|
||||||
|
|
@ -172,6 +179,59 @@ export async function runSync(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arbitrary fixed key for the Postgres advisory lock guarding post-sync
|
||||||
|
// automation, so overlapping syncs (e.g. manual + scheduled) can't both
|
||||||
|
// generate tasks and create duplicates.
|
||||||
|
const AUTOMATION_LOCK_KEY = 4317201
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run post-sync automation: policy → renewal-group auto-assignment followed by
|
||||||
|
* task auto-generation. Each step respects its own enabled toggle and grace
|
||||||
|
* periods. Wrapped so a failure is logged but never fails the sync itself.
|
||||||
|
*
|
||||||
|
* Serialized via a Postgres advisory lock: if another automation pass is already
|
||||||
|
* running, this one is skipped (it would find nothing new to do anyway).
|
||||||
|
*/
|
||||||
|
async function runPostSyncAutomation(): Promise<void> {
|
||||||
|
let lockAcquired = false
|
||||||
|
try {
|
||||||
|
const [{ locked }] = await prisma.$queryRaw<{ locked: boolean }[]>`
|
||||||
|
SELECT pg_try_advisory_lock(${AUTOMATION_LOCK_KEY}) AS locked
|
||||||
|
`
|
||||||
|
lockAcquired = locked
|
||||||
|
if (!lockAcquired) {
|
||||||
|
console.log('⏭️ Post-sync automation already running elsewhere — skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getAutomationConfig()
|
||||||
|
|
||||||
|
if (config.groupAutoassignEnabled) {
|
||||||
|
const assign = await runGroupAutoAssign(config)
|
||||||
|
console.log(
|
||||||
|
`🔗 Group auto-assign: ${assign.assigned} of ${assign.candidatesConsidered} ungrouped policies attached`
|
||||||
|
)
|
||||||
|
if (assign.errors.length > 0) console.warn('⚠️ Group auto-assign errors:', assign.errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.taskAutogenEnabled) {
|
||||||
|
const gen = await runAutoGenerate(config)
|
||||||
|
console.log(
|
||||||
|
`🧩 Task auto-generate: ${gen.totalCreated} tasks created (group ${gen.groupTasksCreated}, policy ${gen.policyTasksCreated}, client ${gen.clientTasksCreated})`
|
||||||
|
)
|
||||||
|
if (gen.errors.length > 0) console.warn('⚠️ Task auto-generate errors:', gen.errors)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Post-sync automation failed (sync itself succeeded):', error)
|
||||||
|
} finally {
|
||||||
|
if (lockAcquired) {
|
||||||
|
await prisma.$queryRaw`SELECT pg_advisory_unlock(${AUTOMATION_LOCK_KEY})`.catch((e) =>
|
||||||
|
console.error('⚠️ Failed to release automation advisory lock:', e)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync employees from AFW with retry logic
|
* Sync employees from AFW with retry logic
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue