diff --git a/ondeck/docs/task-automation-alignment-plan.md b/ondeck/docs/task-automation-alignment-plan.md new file mode 100644 index 0000000..5b7d706 --- /dev/null +++ b/ondeck/docs/task-automation-alignment-plan.md @@ -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? diff --git a/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx b/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx index d70a77c..b7b5025 100644 --- a/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx +++ b/ondeck/src/app/(dashboard)/admin/audit/page-client.tsx @@ -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', diff --git a/ondeck/src/app/(dashboard)/admin/settings/page.tsx b/ondeck/src/app/(dashboard)/admin/settings/page.tsx index 4be4c64..6cb2552 100644 --- a/ondeck/src/app/(dashboard)/admin/settings/page.tsx +++ b/ondeck/src/app/(dashboard)/admin/settings/page.tsx @@ -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() { {/* Sync config inline editor */} - + {/* Links to subsection settings pages */}
diff --git a/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx b/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx index f082db2..51290f0 100644 --- a/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx +++ b/ondeck/src/app/(dashboard)/admin/settings/system-settings-form.tsx @@ -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: + + + + + Task Auto-Generation + + + +
+
+ +

+ Creates template tasks for new policies, groups, and clients once they pass the grace period below. +

+
+ +
+ +
+ +
+ setTaskGracePolicyDays(Number(e.target.value))} + className="w-24 text-sm" + /> + days after a policy is synced +
+

+ How long a newly-synced policy or group waits before policy-level and group-level tasks are generated. +

+
+ +
+ +
+ setTaskGraceClientDays(Number(e.target.value))} + className="w-24 text-sm" + /> + days after a client is synced +
+

+ How long a newly-synced client waits before client-level tasks are generated. +

+
+ + +

+ Saving applies all automation settings (both sections) together. +

+
+
+ + + + + Renewal Group Auto-Assignment + + + +
+
+ +

+ Attaches an ungrouped policy to an existing renewal group on the same client when their renewal dates are close. +

+
+ +
+ +
+ +
+ setGroupAutoassignWindowDays(Number(e.target.value))} + className="w-24 text-sm" + /> + days from a group's renewal date +
+

+ 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. +

+
+ + +

+ Saving applies all automation settings (both sections) together. +

+
+
) } diff --git a/ondeck/src/app/api/admin/automation-settings/route.ts b/ondeck/src/app/api/admin/automation-settings/route.ts new file mode 100644 index 0000000..f59e218 --- /dev/null +++ b/ondeck/src/app/api/admin/automation-settings/route.ts @@ -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 }) + } +} diff --git a/ondeck/src/app/api/cron/auto-generate/route.ts b/ondeck/src/app/api/cron/auto-generate/route.ts index 7d8a7d5..c527449 100644 --- a/ondeck/src/app/api/cron/auto-generate/route.ts +++ b/ondeck/src/app/api/cron/auto-generate/route.ts @@ -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. - * - * 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 + * Called by an external cron scheduler. Protected by CRON_SECRET header. + * + * 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) diff --git a/ondeck/src/lib/sync/auto-generate.ts b/ondeck/src/lib/sync/auto-generate.ts new file mode 100644 index 0000000..d7ecbba --- /dev/null +++ b/ondeck/src/lib/sync/auto-generate.ts @@ -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[0]>['where'] +): Promise { + 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 { + 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, + } +} diff --git a/ondeck/src/lib/sync/automation-config.ts b/ondeck/src/lib/sync/automation-config.ts new file mode 100644 index 0000000..d72c535 --- /dev/null +++ b/ondeck/src/lib/sync/automation-config.ts @@ -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 { + 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 + ), + } +} diff --git a/ondeck/src/lib/sync/group-auto-assign.ts b/ondeck/src/lib/sync/group-auto-assign.ts new file mode 100644 index 0000000..fceb705 --- /dev/null +++ b/ondeck/src/lib/sync/group-auto-assign.ts @@ -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 { + 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() + 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 } +} diff --git a/ondeck/src/lib/sync/sync-engine.ts b/ondeck/src/lib/sync/sync-engine.ts index c31cd93..cd3e0a1 100644 --- a/ondeck/src/lib/sync/sync-engine.ts +++ b/ondeck/src/lib/sync/sync-engine.ts @@ -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 { + 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 */