feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker
Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
threaded through generate-and-sign validator + both pathways
(in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
transaction: useOnlyForThisEoi writes documents.override_* and stops;
setAsDefault demotes the prior primary + promotes (existing contactId)
or inserts + promotes (fresh value); neither flag inserts a non-primary
client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
combobox + manual input + 2 checkboxes per field with mutually
exclusive intent semantics.
Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
opens it as a nested Sheet pre-filled with the linked client as owner.
On save the new yacht is stamped source='eoi-generated' and the
interest is PATCHed with the new yachtId so the EOI context reflows.
Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
(transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
source='eoi-custom-input'.
Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
fired_at IS NULL gate so parallel workers can't double-fire. Uses
the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
value lands in user_profiles.preferences.digestTimeOfDay (validated
HH:MM at the route). <ReminderForm> seeds its dueAt from this
preference via a React-Query me-prefs fetch.
Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
of Google Workspace's 16-char App Password formatted as
"abcd efgh ijkl mnop" still authenticates. Workspace activation
procedure documented in MASTER-PLAN §Phase 6 (was previously written
to CLAUDE.md, which was bloat — moved to the plan).
Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { and, eq, lte, gte, desc, asc, inArray, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNull, lte, gte, desc, asc, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { reminders, interests, clients } from '@/lib/db/schema';
|
||||
@@ -523,38 +523,55 @@ export async function processFollowUpReminders() {
|
||||
export async function processOverdueReminders() {
|
||||
const now = new Date();
|
||||
|
||||
// Find pending reminders past their due date
|
||||
const overdueReminders = await db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now)));
|
||||
|
||||
for (const reminder of overdueReminders) {
|
||||
if (reminder.assignedTo) {
|
||||
void createNotification({
|
||||
portId: reminder.portId,
|
||||
userId: reminder.assignedTo,
|
||||
type: 'reminder_overdue',
|
||||
title: 'Reminder overdue',
|
||||
description: reminder.title,
|
||||
entityType: 'reminder',
|
||||
entityId: reminder.id,
|
||||
link: '/reminders',
|
||||
});
|
||||
|
||||
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
|
||||
reminderId: reminder.id,
|
||||
title: reminder.title,
|
||||
dueAt: reminder.dueAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also un-snooze reminders whose snooze period has passed
|
||||
// Un-snooze reminders whose snooze window has elapsed first, so a
|
||||
// reminder that just transitioned snoozed → pending is eligible in
|
||||
// the same tick (rather than waiting a full 15 minutes for the next
|
||||
// scan).
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
|
||||
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, now)));
|
||||
|
||||
logger.info({ overdueCount: overdueReminders.length }, 'Processed overdue reminders');
|
||||
// Phase 4 — claim due reminders by stamping fired_at in a single
|
||||
// UPDATE...RETURNING. Postgres's row locks guarantee only one worker
|
||||
// wins per row, so parallel maintenance workers can't double-fire the
|
||||
// same reminder. Limited to status='pending' (the un-snooze pass
|
||||
// above already promoted anything that was snoozed-expired).
|
||||
//
|
||||
// Partial index `idx_reminders_due_unfired` from migration 0072
|
||||
// covers (port_id, due_at) WHERE fired_at IS NULL AND status IN
|
||||
// ('pending', 'snoozed') so the scan stays cheap even on a large
|
||||
// backlog of long-fired reminders.
|
||||
const claimed = await db
|
||||
.update(reminders)
|
||||
.set({ firedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now), isNull(reminders.firedAt)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
for (const reminder of claimed) {
|
||||
if (!reminder.assignedTo) continue;
|
||||
void createNotification({
|
||||
portId: reminder.portId,
|
||||
userId: reminder.assignedTo,
|
||||
type: 'reminder_overdue',
|
||||
title: 'Reminder overdue',
|
||||
description: reminder.title,
|
||||
entityType: 'reminder',
|
||||
entityId: reminder.id,
|
||||
link: '/reminders',
|
||||
// Per-reminder dedup is now redundant given fired_at, but keep
|
||||
// the key so a manual re-fire (e.g. ops clears fired_at) still
|
||||
// respects the cooldown.
|
||||
dedupeKey: `reminder:${reminder.id}`,
|
||||
});
|
||||
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
|
||||
reminderId: reminder.id,
|
||||
title: reminder.title,
|
||||
dueAt: reminder.dueAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ firedCount: claimed.length }, 'Reminder cron: claimed + notified');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user