diff --git a/src/app/(dashboard)/[portSlug]/reminders/page.tsx b/src/app/(dashboard)/[portSlug]/reminders/page.tsx
index b501217..ad93699 100644
--- a/src/app/(dashboard)/[portSlug]/reminders/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/reminders/page.tsx
@@ -1,16 +1,5 @@
+import { ReminderList } from '@/components/reminders/reminder-list';
+
export default function RemindersPage() {
- return (
-
-
-
Reminders
-
Manage tasks and follow-up reminders
-
-
-
Coming in Layer 2
-
- This feature will be implemented in the next phase.
-
-
-
- );
+ return ;
}
diff --git a/src/app/api/v1/reminders/[id]/complete/route.ts b/src/app/api/v1/reminders/[id]/complete/route.ts
new file mode 100644
index 0000000..077eaa3
--- /dev/null
+++ b/src/app/api/v1/reminders/[id]/complete/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { completeReminder } from '@/lib/services/reminders.service';
+import { errorResponse } from '@/lib/errors';
+
+export const POST = withAuth(
+ withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
+ try {
+ const data = await completeReminder(params.id!, ctx.portId, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/[id]/dismiss/route.ts b/src/app/api/v1/reminders/[id]/dismiss/route.ts
new file mode 100644
index 0000000..48ef647
--- /dev/null
+++ b/src/app/api/v1/reminders/[id]/dismiss/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { dismissReminder } from '@/lib/services/reminders.service';
+import { errorResponse } from '@/lib/errors';
+
+export const POST = withAuth(
+ withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
+ try {
+ const data = await dismissReminder(params.id!, ctx.portId, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/[id]/route.ts b/src/app/api/v1/reminders/[id]/route.ts
new file mode 100644
index 0000000..81cd346
--- /dev/null
+++ b/src/app/api/v1/reminders/[id]/route.ts
@@ -0,0 +1,51 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseBody } from '@/lib/api/route-helpers';
+import { getReminder, updateReminder, deleteReminder } from '@/lib/services/reminders.service';
+import { updateReminderSchema } from '@/lib/validators/reminders';
+import { errorResponse } from '@/lib/errors';
+
+export const GET = withAuth(
+ withPermission('reminders', 'view_own', async (_req, ctx, params) => {
+ try {
+ const data = await getReminder(params.id!, ctx.portId);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const PATCH = withAuth(
+ withPermission('reminders', 'edit_own', async (req, ctx, params) => {
+ try {
+ const body = await parseBody(req, updateReminderSchema);
+ const data = await updateReminder(params.id!, ctx.portId, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const DELETE = withAuth(
+ withPermission('reminders', 'edit_own', async (_req, ctx, params) => {
+ try {
+ await deleteReminder(params.id!, ctx.portId, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/[id]/snooze/route.ts b/src/app/api/v1/reminders/[id]/snooze/route.ts
new file mode 100644
index 0000000..2dc7cd6
--- /dev/null
+++ b/src/app/api/v1/reminders/[id]/snooze/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseBody } from '@/lib/api/route-helpers';
+import { snoozeReminder } from '@/lib/services/reminders.service';
+import { snoozeReminderSchema } from '@/lib/validators/reminders';
+import { errorResponse } from '@/lib/errors';
+
+export const POST = withAuth(
+ withPermission('reminders', 'edit_own', async (req, ctx, params) => {
+ try {
+ const body = await parseBody(req, snoozeReminderSchema);
+ const data = await snoozeReminder(params.id!, ctx.portId, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/my/route.ts b/src/app/api/v1/reminders/my/route.ts
new file mode 100644
index 0000000..8f01074
--- /dev/null
+++ b/src/app/api/v1/reminders/my/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { getMyReminders } from '@/lib/services/reminders.service';
+import { errorResponse } from '@/lib/errors';
+
+export const GET = withAuth(
+ withPermission('reminders', 'view_own', async (_req, ctx) => {
+ try {
+ const data = await getMyReminders(ctx.userId, ctx.portId);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/overdue/route.ts b/src/app/api/v1/reminders/overdue/route.ts
new file mode 100644
index 0000000..ad85728
--- /dev/null
+++ b/src/app/api/v1/reminders/overdue/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { getOverdueReminders } from '@/lib/services/reminders.service';
+import { errorResponse } from '@/lib/errors';
+
+export const GET = withAuth(
+ withPermission('reminders', 'view_all', async (_req, ctx) => {
+ try {
+ const data = await getOverdueReminders(ctx.portId);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/route.ts b/src/app/api/v1/reminders/route.ts
new file mode 100644
index 0000000..ae2abf2
--- /dev/null
+++ b/src/app/api/v1/reminders/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseBody, parseQuery } from '@/lib/api/route-helpers';
+import { listReminders, createReminder } from '@/lib/services/reminders.service';
+import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders';
+import { errorResponse } from '@/lib/errors';
+
+export const GET = withAuth(
+ withPermission('reminders', 'view_own', async (req, ctx) => {
+ try {
+ const query = parseQuery(req, reminderListQuerySchema);
+ const result = await listReminders(ctx.portId, query);
+ return NextResponse.json(result);
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
+
+export const POST = withAuth(
+ withPermission('reminders', 'create', async (req, ctx) => {
+ try {
+ const body = await parseBody(req, createReminderSchema);
+
+ // Check assign_others permission if assigning to someone else
+ if (body.assignedTo && body.assignedTo !== ctx.userId) {
+ if (!ctx.isSuperAdmin) {
+ const perms = ctx.permissions?.reminders;
+ if (!perms?.assign_others) {
+ return NextResponse.json(
+ { error: 'Cannot assign reminders to other users' },
+ { status: 403 },
+ );
+ }
+ }
+ }
+
+ const data = await createReminder(ctx.portId, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data }, { status: 201 });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/reminders/upcoming/route.ts b/src/app/api/v1/reminders/upcoming/route.ts
new file mode 100644
index 0000000..f7dffbe
--- /dev/null
+++ b/src/app/api/v1/reminders/upcoming/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { getUpcomingReminders } from '@/lib/services/reminders.service';
+import { errorResponse } from '@/lib/errors';
+
+export const GET = withAuth(
+ withPermission('reminders', 'view_own', async (_req, ctx) => {
+ try {
+ const data = await getUpcomingReminders(ctx.portId);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/components/reminders/reminder-form.tsx b/src/components/reminders/reminder-form.tsx
new file mode 100644
index 0000000..1546faa
--- /dev/null
+++ b/src/components/reminders/reminder-form.tsx
@@ -0,0 +1,267 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
+import { apiFetch } from '@/lib/api/client';
+import { usePermissions } from '@/hooks/use-permissions';
+
+interface UserOption {
+ id: string;
+ displayName: string;
+}
+
+interface ReminderFormProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ reminder?: {
+ id: string;
+ title: string;
+ note: string | null;
+ dueAt: string;
+ priority: string;
+ assignedTo: string | null;
+ clientId: string | null;
+ interestId: string | null;
+ berthId: string | null;
+ } | null;
+ // Pre-fill entity link when creating from entity detail pages
+ defaultClientId?: string;
+ defaultInterestId?: string;
+ defaultBerthId?: string;
+ onSuccess: () => void;
+}
+
+export function ReminderForm({
+ open,
+ onOpenChange,
+ reminder,
+ defaultClientId,
+ defaultInterestId,
+ defaultBerthId,
+ onSuccess,
+}: ReminderFormProps) {
+ const [title, setTitle] = useState('');
+ const [note, setNote] = useState('');
+ const [dueAt, setDueAt] = useState('');
+ const [priority, setPriority] = useState('medium');
+ const [assignedTo, setAssignedTo] = useState('');
+ const [clientId, setClientId] = useState('');
+ const [interestId, setInterestId] = useState('');
+ const [berthId, setBerthId] = useState('');
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const { can } = usePermissions();
+ const canAssignOthers = can('reminders', 'assign_others');
+
+ const isEdit = !!reminder;
+
+ useEffect(() => {
+ if (open && canAssignOthers) {
+ void apiFetch<{ data: UserOption[] }>('/api/v1/admin/users/options').then((res) =>
+ setUsers(res.data),
+ );
+ }
+ }, [open, canAssignOthers]);
+
+ useEffect(() => {
+ if (open) {
+ if (reminder) {
+ setTitle(reminder.title);
+ setNote(reminder.note ?? '');
+ setDueAt(reminder.dueAt.slice(0, 16)); // datetime-local format
+ setPriority(reminder.priority);
+ setAssignedTo(reminder.assignedTo ?? '');
+ setClientId(reminder.clientId ?? '');
+ setInterestId(reminder.interestId ?? '');
+ setBerthId(reminder.berthId ?? '');
+ } else {
+ setTitle('');
+ setNote('');
+ // Default to tomorrow 9 AM
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ tomorrow.setHours(9, 0, 0, 0);
+ setDueAt(tomorrow.toISOString().slice(0, 16));
+ setPriority('medium');
+ setAssignedTo('');
+ setClientId(defaultClientId ?? '');
+ setInterestId(defaultInterestId ?? '');
+ setBerthId(defaultBerthId ?? '');
+ }
+ setError(null);
+ }
+ }, [open, reminder, defaultClientId, defaultInterestId, defaultBerthId]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ try {
+ const body = {
+ title,
+ note: note || undefined,
+ dueAt: new Date(dueAt).toISOString(),
+ priority,
+ assignedTo: assignedTo || undefined,
+ clientId: clientId || undefined,
+ interestId: interestId || undefined,
+ berthId: berthId || undefined,
+ };
+
+ if (isEdit) {
+ await apiFetch(`/api/v1/reminders/${reminder.id}`, {
+ method: 'PATCH',
+ body,
+ });
+ } else {
+ await apiFetch('/api/v1/reminders', {
+ method: 'POST',
+ body,
+ });
+ }
+ onSuccess();
+ onOpenChange(false);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Something went wrong';
+ setError(message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+ {isEdit ? 'Edit Reminder' : 'New Reminder'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/reminders/reminder-list.tsx b/src/components/reminders/reminder-list.tsx
new file mode 100644
index 0000000..ab15613
--- /dev/null
+++ b/src/components/reminders/reminder-list.tsx
@@ -0,0 +1,328 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { type ColumnDef } from '@tanstack/react-table';
+import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
+import { formatDistanceToNow } from 'date-fns';
+
+import { DataTable } from '@/components/shared/data-table';
+import { PageHeader } from '@/components/shared/page-header';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { apiFetch } from '@/lib/api/client';
+import { usePermissions } from '@/hooks/use-permissions';
+import { ReminderForm } from './reminder-form';
+import { SnoozeDialog } from './snooze-dialog';
+
+interface Reminder {
+ id: string;
+ title: string;
+ note: string | null;
+ dueAt: string;
+ priority: 'low' | 'medium' | 'high' | 'urgent';
+ status: 'pending' | 'snoozed' | 'completed' | 'dismissed';
+ assignedTo: string | null;
+ createdBy: string;
+ clientId: string | null;
+ interestId: string | null;
+ berthId: string | null;
+ autoGenerated: boolean;
+ snoozedUntil: string | null;
+ completedAt: string | null;
+ createdAt: string;
+ client?: { id: string; fullName: string } | null;
+ interest?: { id: string; pipelineStage: string } | null;
+ berth?: { id: string; mooringNumber: string } | null;
+}
+
+const PRIORITY_CONFIG = {
+ urgent: { label: 'Urgent', className: 'bg-red-600 text-white' },
+ high: { label: 'High', className: 'bg-orange-500 text-white' },
+ medium: { label: 'Medium', className: 'bg-blue-500 text-white' },
+ low: { label: 'Low', className: 'bg-gray-400 text-white' },
+} as const;
+
+const STATUS_CONFIG = {
+ pending: { label: 'Pending', icon: Bell },
+ snoozed: { label: 'Snoozed', icon: Clock },
+ completed: { label: 'Completed', icon: CheckCircle2 },
+ dismissed: { label: 'Dismissed', icon: XCircle },
+} as const;
+
+export function ReminderList() {
+ const [reminders, setReminders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [formOpen, setFormOpen] = useState(false);
+ const [editingReminder, setEditingReminder] = useState(null);
+ const [snoozingId, setSnoozingId] = useState(null);
+ const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
+ const [statusFilter, setStatusFilter] = useState('active');
+ const [priorityFilter, setPriorityFilter] = useState('all');
+ const [total, setTotal] = useState(0);
+ const { can } = usePermissions();
+ const canViewAll = can('reminders', 'view_all');
+
+ const fetchReminders = useCallback(async () => {
+ setLoading(true);
+ try {
+ if (viewMode === 'my') {
+ const res = await apiFetch<{ data: Reminder[] }>('/api/v1/reminders/my');
+ let filtered = res.data;
+ if (priorityFilter !== 'all') {
+ filtered = filtered.filter((r) => r.priority === priorityFilter);
+ }
+ setReminders(filtered);
+ setTotal(filtered.length);
+ } else {
+ const params = new URLSearchParams({ limit: '50', order: 'asc', sort: 'dueAt' });
+ if (statusFilter === 'active') {
+ params.set('status', 'pending');
+ } else if (statusFilter !== 'all') {
+ params.set('status', statusFilter);
+ }
+ if (priorityFilter !== 'all') {
+ params.set('priority', priorityFilter);
+ }
+ const res = await apiFetch<{
+ data: Reminder[];
+ pagination: { total: number };
+ }>(`/api/v1/reminders?${params}`);
+ setReminders(res.data);
+ setTotal(res.pagination.total);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [viewMode, statusFilter, priorityFilter]);
+
+ useEffect(() => {
+ void fetchReminders();
+ }, [fetchReminders]);
+
+ async function handleComplete(id: string) {
+ await apiFetch(`/api/v1/reminders/${id}/complete`, { method: 'POST' });
+ await fetchReminders();
+ }
+
+ async function handleDismiss(id: string) {
+ await apiFetch(`/api/v1/reminders/${id}/dismiss`, { method: 'POST' });
+ await fetchReminders();
+ }
+
+ function isOverdue(dueAt: string, status: string): boolean {
+ return (status === 'pending' || status === 'snoozed') && new Date(dueAt) < new Date();
+ }
+
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: 'priority',
+ header: '',
+ cell: ({ row }) => {
+ const config = PRIORITY_CONFIG[row.original.priority];
+ return {config.label};
+ },
+ size: 70,
+ },
+ {
+ accessorKey: 'title',
+ header: 'Reminder',
+ cell: ({ row }) => {
+ const overdue = isOverdue(row.original.dueAt, row.original.status);
+ return (
+
+
+
{row.original.title}
+ {row.original.autoGenerated && (
+
+ Auto
+
+ )}
+ {overdue &&
}
+
+ {row.original.client && (
+
+ Client: {row.original.client.fullName}
+
+ )}
+ {row.original.berth && (
+
+ Berth: {row.original.berth.mooringNumber}
+
+ )}
+
+ );
+ },
+ },
+ {
+ accessorKey: 'dueAt',
+ header: 'Due',
+ cell: ({ row }) => {
+ const overdue = isOverdue(row.original.dueAt, row.original.status);
+ const date = new Date(row.original.dueAt);
+ return (
+
+
{date.toLocaleDateString()}
+
+ {formatDistanceToNow(date, { addSuffix: true })}
+
+
+ );
+ },
+ },
+ {
+ accessorKey: 'status',
+ header: 'Status',
+ cell: ({ row }) => {
+ const config = STATUS_CONFIG[row.original.status];
+ const Icon = config.icon;
+ return (
+
+
+ {config.label}
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ header: '',
+ cell: ({ row }) => {
+ if (row.original.status === 'completed' || row.original.status === 'dismissed') {
+ return null;
+ }
+ return (
+
+
+
+
+
+ );
+ },
+ enableSorting: false,
+ size: 120,
+ },
+ ];
+
+ return (
+
+
{
+ setEditingReminder(null);
+ setFormOpen(true);
+ }}
+ >
+
+ New Reminder
+
+ }
+ />
+
+
+ {canViewAll && (
+ setViewMode(v as 'my' | 'all')}>
+
+ My Reminders
+ All Reminders
+
+
+ )}
+
+ {viewMode === 'all' && (
+
+ )}
+
+
+
+
+ row.id}
+ emptyState={
+
+
+
No reminders.
+
+
+ }
+ />
+
+
+
+ {
+ if (!open) setSnoozingId(null);
+ }}
+ reminderId={snoozingId}
+ onSuccess={fetchReminders}
+ />
+
+ );
+}
diff --git a/src/components/reminders/snooze-dialog.tsx b/src/components/reminders/snooze-dialog.tsx
new file mode 100644
index 0000000..d6b52ac
--- /dev/null
+++ b/src/components/reminders/snooze-dialog.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { apiFetch } from '@/lib/api/client';
+
+interface SnoozeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ reminderId: string | null;
+ onSuccess: () => void;
+}
+
+const PRESETS = [
+ { label: '1 hour', hours: 1 },
+ { label: '4 hours', hours: 4 },
+ { label: 'Tomorrow 9 AM', hours: -1 }, // special case
+ { label: 'Next week', hours: -2 }, // special case
+] as const;
+
+function getPresetDate(preset: (typeof PRESETS)[number]): Date {
+ const now = new Date();
+ if (preset.hours === -1) {
+ // Tomorrow 9 AM
+ const tomorrow = new Date(now);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ tomorrow.setHours(9, 0, 0, 0);
+ return tomorrow;
+ }
+ if (preset.hours === -2) {
+ // Next Monday 9 AM
+ const next = new Date(now);
+ const daysUntilMonday = (8 - next.getDay()) % 7 || 7;
+ next.setDate(next.getDate() + daysUntilMonday);
+ next.setHours(9, 0, 0, 0);
+ return next;
+ }
+ return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
+}
+
+export function SnoozeDialog({ open, onOpenChange, reminderId, onSuccess }: SnoozeDialogProps) {
+ const [customDate, setCustomDate] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSnooze(snoozeUntil: string) {
+ if (!reminderId) return;
+ setLoading(true);
+ try {
+ await apiFetch(`/api/v1/reminders/${reminderId}/snooze`, {
+ method: 'POST',
+ body: { snoozeUntil },
+ });
+ onSuccess();
+ onOpenChange(false);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/queue/workers/notifications.ts b/src/lib/queue/workers/notifications.ts
index 021dc05..b249544 100644
--- a/src/lib/queue/workers/notifications.ts
+++ b/src/lib/queue/workers/notifications.ts
@@ -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}`,
`${notif.description ?? notif.title}
${
- notif.link
- ? `View in CRM
`
- : ''
+ notif.link ? `View in CRM
` : ''
}`,
);
diff --git a/src/lib/services/reminders.service.ts b/src/lib/services/reminders.service.ts
new file mode 100644
index 0000000..fb9563a
--- /dev/null
+++ b/src/lib/services/reminders.service.ts
@@ -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`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');
+}
diff --git a/src/lib/validators/reminders.ts b/src/lib/validators/reminders.ts
new file mode 100644
index 0000000..545e2ec
--- /dev/null
+++ b/src/lib/validators/reminders.ts
@@ -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;
+
+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;
+
+export const snoozeReminderSchema = z.object({
+ snoozeUntil: z.string().datetime(),
+});
+
+export type SnoozeReminderInput = z.infer;
+
+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;