Implement reminders system with full CRUD and background processors
- Reminders service: create, update, delete, complete, snooze, dismiss - List with filters (status, priority, assignee, entity, date range) - My/overdue/upcoming convenience endpoints - BullMQ processors: auto-follow-up creation (BR-060) and overdue notifications - Snooze with presets (1h, 4h, tomorrow, next week) and custom datetime - Un-snooze logic: snoozed reminders auto-revert to pending when snooze expires - UI: filterable list with my/all toggle, priority badges, overdue indicators - Permission-gated: view_own, view_all, create, assign_others - Entity linking: reminders can link to clients, interests, or berths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,10 +24,17 @@ export const notificationsWorker = new Worker(
|
||||
break;
|
||||
}
|
||||
case 'reminder-check': {
|
||||
const { processDocumentReminders } = await import(
|
||||
'@/jobs/processors/document-reminder'
|
||||
);
|
||||
// Document signing reminders (EOI)
|
||||
const { processDocumentReminders } = await import('@/jobs/processors/document-reminder');
|
||||
await processDocumentReminders();
|
||||
// CRM follow-up reminders (BR-060)
|
||||
const { processFollowUpReminders } = await import('@/lib/services/reminders.service');
|
||||
await processFollowUpReminders();
|
||||
break;
|
||||
}
|
||||
case 'reminder-overdue-check': {
|
||||
const { processOverdueReminders } = await import('@/lib/services/reminders.service');
|
||||
await processOverdueReminders();
|
||||
break;
|
||||
}
|
||||
case 'send-notification-email': {
|
||||
@@ -57,9 +64,7 @@ export const notificationsWorker = new Worker(
|
||||
authUser.email,
|
||||
`[Port Nimara] ${notif.title}`,
|
||||
`<p>${notif.description ?? notif.title}</p>${
|
||||
notif.link
|
||||
? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>`
|
||||
: ''
|
||||
notif.link ? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>` : ''
|
||||
}`,
|
||||
);
|
||||
|
||||
|
||||
468
src/lib/services/reminders.service.ts
Normal file
468
src/lib/services/reminders.service.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
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<number>`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<string, unknown> = { 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');
|
||||
}
|
||||
47
src/lib/validators/reminders.ts
Normal file
47
src/lib/validators/reminders.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
import { baseListQuerySchema } from '@/lib/api/route-helpers';
|
||||
|
||||
export const createReminderSchema = z.object({
|
||||
title: z.string().min(1).max(300),
|
||||
note: z.string().max(2000).optional(),
|
||||
dueAt: z.string().datetime(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
|
||||
assignedTo: z.string().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
interestId: z.string().uuid().optional(),
|
||||
berthId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export type CreateReminderInput = z.infer<typeof createReminderSchema>;
|
||||
|
||||
export const updateReminderSchema = z.object({
|
||||
title: z.string().min(1).max(300).optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
dueAt: z.string().datetime().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
assignedTo: z.string().nullable().optional(),
|
||||
clientId: z.string().uuid().nullable().optional(),
|
||||
interestId: z.string().uuid().nullable().optional(),
|
||||
berthId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdateReminderInput = z.infer<typeof updateReminderSchema>;
|
||||
|
||||
export const snoozeReminderSchema = z.object({
|
||||
snoozeUntil: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type SnoozeReminderInput = z.infer<typeof snoozeReminderSchema>;
|
||||
|
||||
export const reminderListQuerySchema = baseListQuerySchema.extend({
|
||||
status: z.enum(['pending', 'snoozed', 'completed', 'dismissed']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
interestId: z.string().uuid().optional(),
|
||||
berthId: z.string().uuid().optional(),
|
||||
dueBefore: z.string().datetime().optional(),
|
||||
dueAfter: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export type ReminderListQuery = z.infer<typeof reminderListQuerySchema>;
|
||||
Reference in New Issue
Block a user