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 */}
+ 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
*/