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:
@@ -1,16 +1,5 @@
|
|||||||
|
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||||
|
|
||||||
export default function RemindersPage() {
|
export default function RemindersPage() {
|
||||||
return (
|
return <ReminderList />;
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Reminders</h1>
|
|
||||||
<p className="text-muted-foreground">Manage tasks and follow-up reminders</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
|
||||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This feature will be implemented in the next phase.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/app/api/v1/reminders/[id]/complete/route.ts
Normal file
21
src/app/api/v1/reminders/[id]/complete/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
21
src/app/api/v1/reminders/[id]/dismiss/route.ts
Normal file
21
src/app/api/v1/reminders/[id]/dismiss/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
51
src/app/api/v1/reminders/[id]/route.ts
Normal file
51
src/app/api/v1/reminders/[id]/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
24
src/app/api/v1/reminders/[id]/snooze/route.ts
Normal file
24
src/app/api/v1/reminders/[id]/snooze/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
16
src/app/api/v1/reminders/my/route.ts
Normal file
16
src/app/api/v1/reminders/my/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
16
src/app/api/v1/reminders/overdue/route.ts
Normal file
16
src/app/api/v1/reminders/overdue/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
50
src/app/api/v1/reminders/route.ts
Normal file
50
src/app/api/v1/reminders/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
16
src/app/api/v1/reminders/upcoming/route.ts
Normal file
16
src/app/api/v1/reminders/upcoming/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
267
src/components/reminders/reminder-form.tsx
Normal file
267
src/components/reminders/reminder-form.tsx
Normal file
@@ -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<UserOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Follow up with client..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-note">Note</Label>
|
||||||
|
<Textarea
|
||||||
|
id="reminder-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="Additional details..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-due">Due Date & Time</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-due"
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueAt}
|
||||||
|
onChange={(e) => setDueAt(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-priority">Priority</Label>
|
||||||
|
<Select value={priority} onValueChange={setPriority}>
|
||||||
|
<SelectTrigger id="reminder-priority">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="urgent">Urgent</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canAssignOthers && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-assign">Assign To</Label>
|
||||||
|
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||||
|
<SelectTrigger id="reminder-assign">
|
||||||
|
<SelectValue placeholder="Myself" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Myself</SelectItem>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
Link to Entity (optional — paste UUIDs, or leave blank)
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Client ID"
|
||||||
|
value={clientId}
|
||||||
|
onChange={(e) => setClientId(e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Interest ID"
|
||||||
|
value={interestId}
|
||||||
|
onChange={(e) => setInterestId(e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Berth ID"
|
||||||
|
value={berthId}
|
||||||
|
onChange={(e) => setBerthId(e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading || !title.trim() || !dueAt}>
|
||||||
|
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Reminder'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
src/components/reminders/reminder-list.tsx
Normal file
328
src/components/reminders/reminder-list.tsx
Normal file
@@ -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<Reminder[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
||||||
|
const [snoozingId, setSnoozingId] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('active');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState<string>('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<Reminder, unknown>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'priority',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const config = PRIORITY_CONFIG[row.original.priority];
|
||||||
|
return <Badge className={`${config.className} text-[10px] px-1.5`}>{config.label}</Badge>;
|
||||||
|
},
|
||||||
|
size: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
header: 'Reminder',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const overdue = isOverdue(row.original.dueAt, row.original.status);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{row.original.title}</span>
|
||||||
|
{row.original.autoGenerated && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
Auto
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{overdue && <AlertTriangle className="h-3.5 w-3.5 text-destructive" />}
|
||||||
|
</div>
|
||||||
|
{row.original.client && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Client: {row.original.client.fullName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.original.berth && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Berth: {row.original.berth.mooringNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'dueAt',
|
||||||
|
header: 'Due',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const overdue = isOverdue(row.original.dueAt, row.original.status);
|
||||||
|
const date = new Date(row.original.dueAt);
|
||||||
|
return (
|
||||||
|
<div className={overdue ? 'text-destructive font-medium' : ''}>
|
||||||
|
<div className="text-sm">{date.toLocaleDateString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(date, { addSuffix: true })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const config = STATUS_CONFIG[row.original.status];
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{config.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (row.original.status === 'completed' || row.original.status === 'dismissed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-green-600 hover:text-green-700"
|
||||||
|
onClick={() => handleComplete(row.original.id)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => handleDismiss(row.original.id)}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Reminders"
|
||||||
|
description={`${total} reminder${total !== 1 ? 's' : ''}`}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingReminder(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
New Reminder
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
{canViewAll && (
|
||||||
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="my">My Reminders</TabsTrigger>
|
||||||
|
<TabsTrigger value="all">All Reminders</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'all' && (
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="snoozed">Snoozed</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="dismissed">Dismissed</SelectItem>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Priorities</SelectItem>
|
||||||
|
<SelectItem value="urgent">Urgent</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={reminders}
|
||||||
|
isLoading={loading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
emptyState={
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Bell className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
|
||||||
|
<p className="text-muted-foreground">No reminders.</p>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingReminder(null);
|
||||||
|
setFormOpen(true);
|
||||||
|
}}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Create your first reminder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReminderForm
|
||||||
|
open={formOpen}
|
||||||
|
onOpenChange={setFormOpen}
|
||||||
|
reminder={editingReminder}
|
||||||
|
onSuccess={fetchReminders}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SnoozeDialog
|
||||||
|
open={!!snoozingId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSnoozingId(null);
|
||||||
|
}}
|
||||||
|
reminderId={snoozingId}
|
||||||
|
onSuccess={fetchReminders}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/components/reminders/snooze-dialog.tsx
Normal file
119
src/components/reminders/snooze-dialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Snooze Reminder</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
variant="outline"
|
||||||
|
className="justify-start"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleSnooze(getPresetDate(preset).toISOString())}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label htmlFor="custom-snooze">Custom date & time</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="custom-snooze"
|
||||||
|
type="datetime-local"
|
||||||
|
value={customDate}
|
||||||
|
onChange={(e) => setCustomDate(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={loading || !customDate}
|
||||||
|
onClick={() => handleSnooze(new Date(customDate).toISOString())}
|
||||||
|
>
|
||||||
|
Snooze
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,10 +24,17 @@ export const notificationsWorker = new Worker(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'reminder-check': {
|
case 'reminder-check': {
|
||||||
const { processDocumentReminders } = await import(
|
// Document signing reminders (EOI)
|
||||||
'@/jobs/processors/document-reminder'
|
const { processDocumentReminders } = await import('@/jobs/processors/document-reminder');
|
||||||
);
|
|
||||||
await processDocumentReminders();
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'send-notification-email': {
|
case 'send-notification-email': {
|
||||||
@@ -57,9 +64,7 @@ export const notificationsWorker = new Worker(
|
|||||||
authUser.email,
|
authUser.email,
|
||||||
`[Port Nimara] ${notif.title}`,
|
`[Port Nimara] ${notif.title}`,
|
||||||
`<p>${notif.description ?? notif.title}</p>${
|
`<p>${notif.description ?? notif.title}</p>${
|
||||||
notif.link
|
notif.link ? `<p><a href="${process.env.APP_URL}${notif.link}">View in CRM</a></p>` : ''
|
||||||
? `<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