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:
lorentz 2026-07-07 18:26:12 +00:00
parent db37313631
commit 835267c333
10 changed files with 984 additions and 329 deletions

View 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 PM5 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 PM5 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 PM5 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?

View file

@ -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',

View file

@ -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>

View file

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

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

View file

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

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

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

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

View file

@ -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
*/