import { and, eq, lte, gte, desc, asc, inArray, sql, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { reminders, interests, clients } from '@/lib/db/schema'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { createNotification } from '@/lib/services/notifications.service'; import { logger } from '@/lib/logger'; import type { CreateReminderInput, UpdateReminderInput, SnoozeReminderInput, ReminderListQuery, } from '@/lib/validators/reminders'; interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── List ──────────────────────────────────────────────────────────────────── export async function listReminders(portId: string, query: ReminderListQuery) { const conditions = [eq(reminders.portId, portId)]; if (query.status) conditions.push(eq(reminders.status, query.status)); if (query.priority) conditions.push(eq(reminders.priority, query.priority)); if (query.assignedTo) conditions.push(eq(reminders.assignedTo, query.assignedTo)); if (query.clientId) conditions.push(eq(reminders.clientId, query.clientId)); if (query.interestId) conditions.push(eq(reminders.interestId, query.interestId)); if (query.berthId) conditions.push(eq(reminders.berthId, query.berthId)); if (query.dueBefore) conditions.push(lte(reminders.dueAt, new Date(query.dueBefore))); if (query.dueAfter) conditions.push(gte(reminders.dueAt, new Date(query.dueAfter))); if (query.search) { conditions.push(sql`${reminders.title} ILIKE ${'%' + query.search + '%'}`); } const orderDir = query.order === 'asc' ? asc : desc; const orderCol = query.sort === 'priority' ? reminders.priority : reminders.dueAt; const offset = (query.page - 1) * query.limit; const [data, countResult] = await Promise.all([ db .select() .from(reminders) .where(and(...conditions)) .orderBy(orderDir(orderCol)) .limit(query.limit) .offset(offset), db .select({ count: sql`count(*)` }) .from(reminders) .where(and(...conditions)), ]); return { data, pagination: { page: query.page, limit: query.limit, total: Number(countResult[0]?.count ?? 0), }, }; } export async function getMyReminders(userId: string, portId: string) { return db .select() .from(reminders) .where( and( eq(reminders.portId, portId), eq(reminders.assignedTo, userId), inArray(reminders.status, ['pending', 'snoozed']), ), ) .orderBy(asc(reminders.dueAt)); } export async function getOverdueReminders(portId: string) { return db .select() .from(reminders) .where( and( eq(reminders.portId, portId), inArray(reminders.status, ['pending', 'snoozed']), lte(reminders.dueAt, new Date()), ), ) .orderBy(asc(reminders.dueAt)); } export async function getUpcomingReminders(portId: string, days: number = 14) { const until = new Date(); until.setDate(until.getDate() + days); return db .select() .from(reminders) .where( and( eq(reminders.portId, portId), inArray(reminders.status, ['pending', 'snoozed']), lte(reminders.dueAt, until), gte(reminders.dueAt, new Date()), ), ) .orderBy(asc(reminders.dueAt)); } // ─── CRUD ──────────────────────────────────────────────────────────────────── export async function getReminder(id: string, portId: string) { const reminder = await db.query.reminders.findFirst({ where: and(eq(reminders.id, id), eq(reminders.portId, portId)), with: { client: true, interest: true, berth: true }, }); if (!reminder) throw new NotFoundError('Reminder'); return reminder; } export async function createReminder(portId: string, data: CreateReminderInput, meta: AuditMeta) { const [reminder] = await db .insert(reminders) .values({ portId, title: data.title, note: data.note ?? null, dueAt: new Date(data.dueAt), priority: data.priority, assignedTo: data.assignedTo ?? meta.userId, createdBy: meta.userId, clientId: data.clientId ?? null, interestId: data.interestId ?? null, berthId: data.berthId ?? null, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'reminder', entityId: reminder!.id, newValue: { title: reminder!.title, dueAt: reminder!.dueAt, priority: reminder!.priority }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'reminder:created', { reminderId: reminder!.id, title: reminder!.title, dueAt: reminder!.dueAt.toISOString(), assignedTo: reminder!.assignedTo ?? meta.userId, }); if (reminder!.assignedTo) { emitToRoom(`user:${reminder!.assignedTo}`, 'reminder:created', { reminderId: reminder!.id, title: reminder!.title, dueAt: reminder!.dueAt.toISOString(), assignedTo: reminder!.assignedTo, }); } return reminder!; } export async function updateReminder( id: string, portId: string, data: UpdateReminderInput, meta: AuditMeta, ) { const existing = await db.query.reminders.findFirst({ where: and(eq(reminders.id, id), eq(reminders.portId, portId)), }); if (!existing) throw new NotFoundError('Reminder'); const updates: Record = { updatedAt: new Date() }; if (data.title !== undefined) updates.title = data.title; if (data.note !== undefined) updates.note = data.note; if (data.dueAt !== undefined) updates.dueAt = new Date(data.dueAt); if (data.priority !== undefined) updates.priority = data.priority; if (data.assignedTo !== undefined) updates.assignedTo = data.assignedTo; if (data.clientId !== undefined) updates.clientId = data.clientId; if (data.interestId !== undefined) updates.interestId = data.interestId; if (data.berthId !== undefined) updates.berthId = data.berthId; const [updated] = await db .update(reminders) .set(updates) .where(and(eq(reminders.id, id), eq(reminders.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'reminder', entityId: id, oldValue: { title: existing.title, dueAt: existing.dueAt, priority: existing.priority }, newValue: { title: updated!.title, dueAt: updated!.dueAt, priority: updated!.priority }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'reminder:updated', { reminderId: updated!.id, changedFields: Object.keys(data), }); return updated!; } export async function deleteReminder(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.reminders.findFirst({ where: and(eq(reminders.id, id), eq(reminders.portId, portId)), }); if (!existing) throw new NotFoundError('Reminder'); await db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'reminder', entityId: id, oldValue: { title: existing.title }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } // ─── Status Actions ────────────────────────────────────────────────────────── export async function completeReminder(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.reminders.findFirst({ where: and(eq(reminders.id, id), eq(reminders.portId, portId)), }); if (!existing) throw new NotFoundError('Reminder'); if (existing.status === 'completed') throw new ValidationError('Reminder already completed'); const [updated] = await db .update(reminders) .set({ status: 'completed', completedAt: new Date(), updatedAt: new Date(), }) .where(and(eq(reminders.id, id), eq(reminders.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'reminder', entityId: id, oldValue: { status: existing.status }, newValue: { status: 'completed' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'reminder:completed', { reminderId: updated!.id, title: updated!.title, completedBy: meta.userId, }); return updated!; } export async function snoozeReminder( id: string, portId: string, data: SnoozeReminderInput, meta: AuditMeta, ) { const existing = await db.query.reminders.findFirst({ where: and(eq(reminders.id, id), eq(reminders.portId, portId)), }); if (!existing) throw new NotFoundError('Reminder'); const [updated] = await db .update(reminders) .set({ status: 'snoozed', snoozedUntil: new Date(data.snoozeUntil), updatedAt: new Date(), }) .where(and(eq(reminders.id, id), eq(reminders.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'reminder', entityId: id, oldValue: { status: existing.status }, newValue: { status: 'snoozed', snoozedUntil: data.snoozeUntil }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'reminder:snoozed', { reminderId: updated!.id, snoozedUntil: data.snoozeUntil, }); return updated!; } export async function dismissReminder(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.reminders.findFirst({ where: and(eq(reminders.id, id), eq(reminders.portId, portId)), }); if (!existing) throw new NotFoundError('Reminder'); const [updated] = await db .update(reminders) .set({ status: 'dismissed', updatedAt: new Date() }) .where(and(eq(reminders.id, id), eq(reminders.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'reminder', entityId: id, oldValue: { status: existing.status }, newValue: { status: 'dismissed' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return updated!; } // ─── Background Processors ────────────────────────────────────────────────── /** * Hourly check: creates auto-follow-up reminders for interests with * reminderEnabled=true where no activity in reminderDays days (BR-060). */ export async function processFollowUpReminders() { const ports = await db.query.ports.findMany({ where: eq(sql`true`, true) }); for (const port of ports) { const enabledInterests = await db .select({ id: interests.id, clientId: interests.clientId, reminderDays: interests.reminderDays, reminderLastFired: interests.reminderLastFired, updatedAt: interests.updatedAt, }) .from(interests) .where( and( eq(interests.portId, port.id), eq(interests.reminderEnabled, true), isNull(interests.archivedAt), ), ); const now = new Date(); for (const interest of enabledInterests) { if (!interest.reminderDays) continue; // Check if enough days have passed since last activity const lastActivity = interest.reminderLastFired ?? interest.updatedAt; const daysSinceActivity = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24); if (daysSinceActivity < interest.reminderDays) continue; // Get client name for the reminder title const client = interest.clientId ? await db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) }) : null; const title = client ? `Follow up with ${client.fullName}` : 'Follow up on interest'; // Find the assigned user (first userPortRole for this port, or fallback) // For now, leave assignedTo null — the notification goes to the port room await db.insert(reminders).values({ portId: port.id, title, note: 'Auto-generated: no activity detected within the configured follow-up window.', dueAt: now, priority: 'medium', assignedTo: null, createdBy: 'system', interestId: interest.id, clientId: interest.clientId, autoGenerated: true, }); // Update last fired timestamp await db .update(interests) .set({ reminderLastFired: now }) .where(eq(interests.id, interest.id)); // Fire notification to the port room emitToRoom(`port:${port.id}`, 'system:alert', { alertType: 'follow_up_created', message: title, severity: 'info', }); logger.info({ interestId: interest.id, portId: port.id }, 'Auto follow-up reminder created'); } } } /** * Every 15 minutes: checks for past-due reminders and creates overdue notifications. */ 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 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'); }