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',
|
||||
'AUTO_GENERATE_TASKS_GROUP',
|
||||
'AUTO_GENERATE_TASKS_POLICY',
|
||||
'AUTO_GENERATE_TASKS_CLIENT',
|
||||
'AUTO_ASSIGN_POLICY_GROUP',
|
||||
'GENERATE_TASKS_FROM_GROUP',
|
||||
'UPDATE',
|
||||
'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 { Badge } from '@/components/ui/badge'
|
||||
import { SystemSettingsForm } from './system-settings-form'
|
||||
import { getAutomationConfig } from '@/lib/sync/automation-config'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
|
|
@ -17,9 +18,10 @@ export default async function SystemSettingsPage() {
|
|||
const userRoles = (session.user as any).roles || []
|
||||
if (!userRoles.includes('Admin')) redirect('/dashboard')
|
||||
|
||||
const [syncConfigs, appSettings] = await Promise.all([
|
||||
const [syncConfigs, appSettings, automationConfig] = await Promise.all([
|
||||
prisma.syncConfig.findMany(),
|
||||
prisma.appSetting.findMany(),
|
||||
getAutomationConfig(),
|
||||
])
|
||||
|
||||
const syncEnabled = syncConfigs.find((c) => c.key === 'sync_enabled')?.value ?? 'false'
|
||||
|
|
@ -67,7 +69,12 @@ export default async function SystemSettingsPage() {
|
|||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -7,21 +7,68 @@ import { Label } from '@/components/ui/label'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
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 {
|
||||
syncEnabled: boolean
|
||||
syncSchedule: string
|
||||
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 [syncSchedule, setSyncSchedule] = useState(initialSchedule)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [overdueWindowDays, setOverdueWindowDays] = useState(initialOverdueWindow)
|
||||
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() {
|
||||
setSaving(true)
|
||||
try {
|
||||
|
|
@ -138,6 +185,126 @@ export function SystemSettingsForm({ syncEnabled: initialEnabled, syncSchedule:
|
|||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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 { prisma } from '@/lib/db'
|
||||
|
||||
const TWENTY_DAYS_MS = 20 * 24 * 60 * 60 * 1000
|
||||
import { getAutomationConfig } from '@/lib/sync/automation-config'
|
||||
import { runAutoGenerate } from '@/lib/sync/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:
|
||||
* - Policy must be >= 20 days old in the system
|
||||
* - If policy is in a group, use group renewalDate; otherwise use policy expirationDate
|
||||
* - Skip policies/groups that already have tasks generated from templates
|
||||
* - Auto-assign to client's claimsAdvocate if set
|
||||
* The same generation engine also runs automatically after each AMS sync
|
||||
* (see runSync in src/lib/sync/sync-engine.ts); this endpoint remains available
|
||||
* for out-of-band / manual triggering.
|
||||
*
|
||||
* 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) {
|
||||
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 })
|
||||
}
|
||||
|
||||
const killSwitch = await prisma.syncConfig.findUnique({
|
||||
where: { key: 'task_auto_generate_enabled' },
|
||||
})
|
||||
if (killSwitch?.value === 'false') {
|
||||
const config = await getAutomationConfig()
|
||||
if (!config.taskAutogenEnabled) {
|
||||
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 {
|
||||
// ─── 1. Policy Groups ───────────────────────────────────────────────────
|
||||
// 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runAutoGenerate(config)
|
||||
return NextResponse.json({
|
||||
groupTasksCreated,
|
||||
policyTasksCreated,
|
||||
clientTasksCreated,
|
||||
totalCreated: groupTasksCreated + policyTasksCreated + clientTasksCreated,
|
||||
groupsProcessed: groups.length,
|
||||
policiesProcessed: policies.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
...result,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
})
|
||||
} catch (error: any) {
|
||||
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,
|
||||
} from './mappers'
|
||||
import { sleep } from '@/lib/utils'
|
||||
import { getAutomationConfig } from './automation-config'
|
||||
import { runGroupAutoAssign } from './group-auto-assign'
|
||||
import { runAutoGenerate } from './auto-generate'
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean
|
||||
|
|
@ -139,6 +142,10 @@ export async function runSync(
|
|||
console.log('✅ Sync completed successfully')
|
||||
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 {
|
||||
success: true,
|
||||
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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue