Implemented dashboard and reminders

X-Lovable-Edit-ID: edt-6c5b24a3-2b17-4c32-a88e-60bc75a18c6c
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-23 21:34:18 +00:00
co-authored by renee-png
14 changed files with 1274 additions and 32 deletions
BIN
View File
Binary file not shown.
+7
View File
@@ -45,6 +45,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^1.0.12",
"@supabase/supabase-js": "^2.104.1",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.83.0",
@@ -83,6 +84,7 @@
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-email": "^6.0.0",
"react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.5",
"recharts": "^2.15.4",
@@ -112,5 +114,10 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
},
"pnpm": {
"overrides": {
"entities": "4.5.0"
}
}
}
+56 -2
View File
@@ -29,7 +29,14 @@ import {
import { Calendar } from "@/components/ui/calendar";
import { toast } from "sonner";
import { spawnNextWorkflowTask } from "@/lib/workflow-chain";
import { Loader2, Plus, Trash2, X, CalendarIcon, UserPlus } from "lucide-react";
import { Loader2, Plus, Trash2, X, CalendarIcon, UserPlus, Bell } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { format } from "date-fns";
import type { Database } from "@/integrations/supabase/types";
import { cn } from "@/lib/utils";
@@ -111,17 +118,25 @@ export function TaskDetailPanel({
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [showAssignPicker, setShowAssignPicker] = useState(false);
const [reminders, setReminders] = useState<any[]>([]);
const [reminderOpen, setReminderOpen] = useState(false);
const [reminderDate, setReminderDate] = useState<string>("");
const [reminderTime, setReminderTime] = useState<string>("09:00");
const [reminderEmail, setReminderEmail] = useState<string>("");
const [reminderNote, setReminderNote] = useState<string>("");
const [savingReminder, setSavingReminder] = useState(false);
const load = useCallback(async () => {
if (!taskId) return;
setLoading(true);
const [t, a, s, c, h, pr] = await Promise.all([
const [t, a, s, c, h, pr, rem] = await Promise.all([
supabase.from("tasks").select("*").eq("id", taskId).maybeSingle(),
supabase.from("task_assignees").select("user_id").eq("task_id", taskId),
supabase.from("tasks").select("*").eq("parent_task_id", taskId).order("sort_order"),
supabase.from("task_comments").select("*").eq("task_id", taskId).order("created_at"),
supabase.from("task_history").select("*").eq("task_id", taskId).order("created_at", { ascending: false }).limit(50),
supabase.from("profiles").select("id, full_name, email").order("full_name"),
supabase.from("task_reminders").select("*").eq("task_id", taskId).order("remind_at"),
]);
if (t.data) {
setTask(t.data as TaskRow);
@@ -144,6 +159,7 @@ export function TaskDetailPanel({
setHistory((h.data ?? []) as HistoryRow[]);
setAllProfiles((pr.data ?? []) as Profile[]);
setProfiles((pr.data ?? []) as Profile[]);
setReminders((rem.data ?? []) as any[]);
setLoading(false);
}, [taskId]);
@@ -349,6 +365,44 @@ export function TaskDetailPanel({
await supabase.from("task_comments").delete().eq("id", id);
};
const openReminderDialog = () => {
setReminderDate(format(new Date(), "yyyy-MM-dd"));
setReminderTime("09:00");
setReminderEmail(user?.email || "");
setReminderNote("");
setReminderOpen(true);
};
const saveReminder = async () => {
if (!task || !user) return;
if (!reminderDate || !reminderTime || !reminderEmail.trim()) {
toast.error("Date, time, and email are required");
return;
}
setSavingReminder(true);
const remindAt = new Date(`${reminderDate}T${reminderTime}:00`).toISOString();
const { error } = await supabase.from("task_reminders").insert({
task_id: task.id,
recipient_email: reminderEmail.trim(),
remind_at: remindAt,
note: reminderNote.trim() || null,
created_by: user.id,
});
setSavingReminder(false);
if (error) {
toast.error(error.message);
return;
}
toast.success("Reminder scheduled");
setReminderOpen(false);
load();
};
const deleteReminder = async (id: string) => {
await supabase.from("task_reminders").delete().eq("id", id);
load();
};
if (!task && !loading) return null;
const completedSubtasks = subtasks.filter((s) => s.status === "complete").length;
+61
View File
@@ -2993,6 +2993,67 @@ export type Database = {
},
]
}
task_reminders: {
Row: {
created_at: string
created_by: string | null
id: string
note: string | null
recipient_email: string
recipient_user_id: string | null
remind_at: string
sent_at: string | null
task_id: string
updated_at: string
}
Insert: {
created_at?: string
created_by?: string | null
id?: string
note?: string | null
recipient_email: string
recipient_user_id?: string | null
remind_at: string
sent_at?: string | null
task_id: string
updated_at?: string
}
Update: {
created_at?: string
created_by?: string | null
id?: string
note?: string | null
recipient_email?: string
recipient_user_id?: string | null
remind_at?: string
sent_at?: string | null
task_id?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "task_reminders_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "task_reminders_recipient_user_id_fkey"
columns: ["recipient_user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "task_reminders_task_id_fkey"
columns: ["task_id"]
isOneToOne: false
referencedRelation: "tasks"
referencedColumns: ["id"]
},
]
}
tasks: {
Row: {
case_id: string | null
+19
View File
@@ -0,0 +1,19 @@
import type { ComponentType } from 'react'
export interface TemplateEntry {
component: ComponentType<any>
subject: string | ((data: Record<string, any>) => string)
displayName?: string
previewData?: Record<string, any>
/** Fixed recipient — overrides caller-provided recipientEmail when set. */
to?: string
}
import { template as taskReminder } from './task-reminder'
/**
* Template registry — maps template names to their React Email components.
*/
export const TEMPLATES: Record<string, TemplateEntry> = {
'task-reminder': taskReminder,
}
+70
View File
@@ -0,0 +1,70 @@
import React from 'react'
import {
Body, Container, Head, Heading, Html, Preview, Section, Text,
} from '@react-email/components'
import type { TemplateEntry } from './registry'
const SITE_NAME = 'StageLaw'
interface TaskReminderProps {
taskTitle?: string
caseTitle?: string | null
dueDate?: string | null
note?: string | null
}
const TaskReminderEmail = ({ taskTitle, caseTitle, dueDate, note }: TaskReminderProps) => (
<Html lang="en" dir="ltr">
<Head />
<Preview>Reminder: {taskTitle || 'Task reminder'}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Task reminder</Heading>
<Text style={text}>
This is a reminder for the following task:
</Text>
<Section style={card}>
<Text style={taskTitleStyle}>{taskTitle || 'Untitled task'}</Text>
{caseTitle && (
<Text style={metaText}>Case: {caseTitle}</Text>
)}
{dueDate && (
<Text style={metaText}>Due: {dueDate}</Text>
)}
</Section>
{note && (
<Section style={noteCard}>
<Text style={noteLabel}>Note</Text>
<Text style={noteText}>{note}</Text>
</Section>
)}
<Text style={footer}>— {SITE_NAME}</Text>
</Container>
</Body>
</Html>
)
export const template = {
component: TaskReminderEmail,
subject: (data: Record<string, any>) =>
`Reminder: ${data?.taskTitle || 'Task'}`,
displayName: 'Task reminder',
previewData: {
taskTitle: 'Review settlement agreement',
caseTitle: 'CASE-2025-0042 — Smith v. HOA',
dueDate: 'Apr 25, 2026',
note: 'Make sure to confirm signatures with opposing counsel before sending.',
},
} satisfies TemplateEntry
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
const container = { padding: '24px 28px', maxWidth: '560px' }
const h1 = { fontSize: '22px', fontWeight: 'bold', color: '#0f172a', margin: '0 0 16px' }
const text = { fontSize: '14px', color: '#334155', lineHeight: '1.5', margin: '0 0 16px' }
const card = { backgroundColor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '16px 18px', margin: '0 0 16px' }
const taskTitleStyle = { fontSize: '16px', fontWeight: 'bold', color: '#0f172a', margin: '0 0 8px' }
const metaText = { fontSize: '13px', color: '#64748b', margin: '2px 0' }
const noteCard = { backgroundColor: '#fef3c7', border: '1px solid #fde68a', borderRadius: '8px', padding: '12px 16px', margin: '0 0 16px' }
const noteLabel = { fontSize: '11px', fontWeight: 'bold', color: '#92400e', textTransform: 'uppercase' as const, letterSpacing: '0.05em', margin: '0 0 4px' }
const noteText = { fontSize: '13px', color: '#78350f', margin: '0', whiteSpace: 'pre-wrap' as const }
const footer = { fontSize: '12px', color: '#94a3b8', margin: '24px 0 0' }
+108
View File
@@ -44,6 +44,7 @@ import { Route as PayIdRouteImport } from './routes/pay.$id'
import { Route as InvoicesNewRouteImport } from './routes/invoices.new'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
import { Route as EmailUnsubscribeRouteImport } from './routes/email/unsubscribe'
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
import { Route as CollectionsCollectionIdRouteImport } from './routes/collections.$collectionId'
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
@@ -52,11 +53,15 @@ import { Route as CasesCaseIdRouteImport } from './routes/cases.$caseId'
import { Route as ApiStripeWebhookRouteImport } from './routes/api.stripe-webhook'
import { Route as AdminUsersRouteImport } from './routes/admin.users'
import { Route as DocumentsTemplatesIndexRouteImport } from './routes/documents.templates.index'
import { Route as LovableEmailSuppressionRouteImport } from './routes/lovable/email/suppression'
import { Route as InvoicesNewClientIdRouteImport } from './routes/invoices.new.$clientId'
import { Route as DocumentsTemplatesNewRouteImport } from './routes/documents.templates.new'
import { Route as DocumentsTemplatesTemplateIdRouteImport } from './routes/documents.templates.$templateId'
import { Route as DocumentsPleadingNewRouteImport } from './routes/documents.pleading.new'
import { Route as ClientsClientIdFeesRouteImport } from './routes/clients.$clientId.fees'
import { Route as ApiPublicProcessTaskRemindersRouteImport } from './routes/api/public/process-task-reminders'
import { Route as LovableEmailTransactionalSendRouteImport } from './routes/lovable/email/transactional/send'
import { Route as LovableEmailTransactionalPreviewRouteImport } from './routes/lovable/email/transactional/preview'
import { Route as LovableEmailQueueProcessRouteImport } from './routes/lovable/email/queue/process'
const SetupRoute = SetupRouteImport.update({
@@ -234,6 +239,11 @@ const HooksPollImapRoute = HooksPollImapRouteImport.update({
path: '/hooks/poll-imap',
getParentRoute: () => rootRouteImport,
} as any)
const EmailUnsubscribeRoute = EmailUnsubscribeRouteImport.update({
id: '/email/unsubscribe',
path: '/email/unsubscribe',
getParentRoute: () => rootRouteImport,
} as any)
const ContactsContactIdRoute = ContactsContactIdRouteImport.update({
id: '/contacts/$contactId',
path: '/contacts/$contactId',
@@ -274,6 +284,11 @@ const DocumentsTemplatesIndexRoute = DocumentsTemplatesIndexRouteImport.update({
path: '/documents/templates/',
getParentRoute: () => rootRouteImport,
} as any)
const LovableEmailSuppressionRoute = LovableEmailSuppressionRouteImport.update({
id: '/lovable/email/suppression',
path: '/lovable/email/suppression',
getParentRoute: () => rootRouteImport,
} as any)
const InvoicesNewClientIdRoute = InvoicesNewClientIdRouteImport.update({
id: '/$clientId',
path: '/$clientId',
@@ -300,6 +315,24 @@ const ClientsClientIdFeesRoute = ClientsClientIdFeesRouteImport.update({
path: '/fees',
getParentRoute: () => ClientsClientIdRoute,
} as any)
const ApiPublicProcessTaskRemindersRoute =
ApiPublicProcessTaskRemindersRouteImport.update({
id: '/api/public/process-task-reminders',
path: '/api/public/process-task-reminders',
getParentRoute: () => rootRouteImport,
} as any)
const LovableEmailTransactionalSendRoute =
LovableEmailTransactionalSendRouteImport.update({
id: '/lovable/email/transactional/send',
path: '/lovable/email/transactional/send',
getParentRoute: () => rootRouteImport,
} as any)
const LovableEmailTransactionalPreviewRoute =
LovableEmailTransactionalPreviewRouteImport.update({
id: '/lovable/email/transactional/preview',
path: '/lovable/email/transactional/preview',
getParentRoute: () => rootRouteImport,
} as any)
const LovableEmailQueueProcessRoute =
LovableEmailQueueProcessRouteImport.update({
id: '/lovable/email/queue/process',
@@ -319,6 +352,7 @@ export interface FileRoutesByFullPath {
'/clients/$clientId': typeof ClientsClientIdRouteWithChildren
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/contacts/$contactId': typeof ContactsContactIdRoute
'/email/unsubscribe': typeof EmailUnsubscribeRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
@@ -350,13 +384,17 @@ export interface FileRoutesByFullPath {
'/settings/': typeof SettingsIndexRoute
'/status/': typeof StatusIndexRoute
'/tasks/': typeof TasksIndexRoute
'/api/public/process-task-reminders': typeof ApiPublicProcessTaskRemindersRoute
'/clients/$clientId/fees': typeof ClientsClientIdFeesRoute
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
'/documents/templates/': typeof DocumentsTemplatesIndexRoute
'/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute
'/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute
'/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -369,6 +407,7 @@ export interface FileRoutesByTo {
'/clients/$clientId': typeof ClientsClientIdRouteWithChildren
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/contacts/$contactId': typeof ContactsContactIdRoute
'/email/unsubscribe': typeof EmailUnsubscribeRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
@@ -400,13 +439,17 @@ export interface FileRoutesByTo {
'/settings': typeof SettingsIndexRoute
'/status': typeof StatusIndexRoute
'/tasks': typeof TasksIndexRoute
'/api/public/process-task-reminders': typeof ApiPublicProcessTaskRemindersRoute
'/clients/$clientId/fees': typeof ClientsClientIdFeesRoute
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
'/documents/templates': typeof DocumentsTemplatesIndexRoute
'/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute
'/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute
'/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -421,6 +464,7 @@ export interface FileRoutesById {
'/clients/$clientId': typeof ClientsClientIdRouteWithChildren
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/contacts/$contactId': typeof ContactsContactIdRoute
'/email/unsubscribe': typeof EmailUnsubscribeRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/invoices/new': typeof InvoicesNewRouteWithChildren
@@ -452,13 +496,17 @@ export interface FileRoutesById {
'/settings/': typeof SettingsIndexRoute
'/status/': typeof StatusIndexRoute
'/tasks/': typeof TasksIndexRoute
'/api/public/process-task-reminders': typeof ApiPublicProcessTaskRemindersRoute
'/clients/$clientId/fees': typeof ClientsClientIdFeesRoute
'/documents/pleading/new': typeof DocumentsPleadingNewRoute
'/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute
'/documents/templates/new': typeof DocumentsTemplatesNewRoute
'/invoices/new/$clientId': typeof InvoicesNewClientIdRoute
'/lovable/email/suppression': typeof LovableEmailSuppressionRoute
'/documents/templates/': typeof DocumentsTemplatesIndexRoute
'/lovable/email/queue/process': typeof LovableEmailQueueProcessRoute
'/lovable/email/transactional/preview': typeof LovableEmailTransactionalPreviewRoute
'/lovable/email/transactional/send': typeof LovableEmailTransactionalSendRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -474,6 +522,7 @@ export interface FileRouteTypes {
| '/clients/$clientId'
| '/collections/$collectionId'
| '/contacts/$contactId'
| '/email/unsubscribe'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
@@ -505,13 +554,17 @@ export interface FileRouteTypes {
| '/settings/'
| '/status/'
| '/tasks/'
| '/api/public/process-task-reminders'
| '/clients/$clientId/fees'
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/lovable/email/suppression'
| '/documents/templates/'
| '/lovable/email/queue/process'
| '/lovable/email/transactional/preview'
| '/lovable/email/transactional/send'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -524,6 +577,7 @@ export interface FileRouteTypes {
| '/clients/$clientId'
| '/collections/$collectionId'
| '/contacts/$contactId'
| '/email/unsubscribe'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
@@ -555,13 +609,17 @@ export interface FileRouteTypes {
| '/settings'
| '/status'
| '/tasks'
| '/api/public/process-task-reminders'
| '/clients/$clientId/fees'
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/lovable/email/suppression'
| '/documents/templates'
| '/lovable/email/queue/process'
| '/lovable/email/transactional/preview'
| '/lovable/email/transactional/send'
id:
| '__root__'
| '/'
@@ -575,6 +633,7 @@ export interface FileRouteTypes {
| '/clients/$clientId'
| '/collections/$collectionId'
| '/contacts/$contactId'
| '/email/unsubscribe'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/invoices/new'
@@ -606,13 +665,17 @@ export interface FileRouteTypes {
| '/settings/'
| '/status/'
| '/tasks/'
| '/api/public/process-task-reminders'
| '/clients/$clientId/fees'
| '/documents/pleading/new'
| '/documents/templates/$templateId'
| '/documents/templates/new'
| '/invoices/new/$clientId'
| '/lovable/email/suppression'
| '/documents/templates/'
| '/lovable/email/queue/process'
| '/lovable/email/transactional/preview'
| '/lovable/email/transactional/send'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -627,6 +690,7 @@ export interface RootRouteChildren {
ClientsClientIdRoute: typeof ClientsClientIdRouteWithChildren
CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute
ContactsContactIdRoute: typeof ContactsContactIdRoute
EmailUnsubscribeRoute: typeof EmailUnsubscribeRoute
HooksPollImapRoute: typeof HooksPollImapRoute
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
InvoicesNewRoute: typeof InvoicesNewRouteWithChildren
@@ -647,11 +711,15 @@ export interface RootRouteChildren {
ReportsIndexRoute: typeof ReportsIndexRoute
StatusIndexRoute: typeof StatusIndexRoute
TasksIndexRoute: typeof TasksIndexRoute
ApiPublicProcessTaskRemindersRoute: typeof ApiPublicProcessTaskRemindersRoute
DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute
DocumentsTemplatesTemplateIdRoute: typeof DocumentsTemplatesTemplateIdRoute
DocumentsTemplatesNewRoute: typeof DocumentsTemplatesNewRoute
LovableEmailSuppressionRoute: typeof LovableEmailSuppressionRoute
DocumentsTemplatesIndexRoute: typeof DocumentsTemplatesIndexRoute
LovableEmailQueueProcessRoute: typeof LovableEmailQueueProcessRoute
LovableEmailTransactionalPreviewRoute: typeof LovableEmailTransactionalPreviewRoute
LovableEmailTransactionalSendRoute: typeof LovableEmailTransactionalSendRoute
}
declare module '@tanstack/react-router' {
@@ -901,6 +969,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HooksPollImapRouteImport
parentRoute: typeof rootRouteImport
}
'/email/unsubscribe': {
id: '/email/unsubscribe'
path: '/email/unsubscribe'
fullPath: '/email/unsubscribe'
preLoaderRoute: typeof EmailUnsubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/contacts/$contactId': {
id: '/contacts/$contactId'
path: '/contacts/$contactId'
@@ -957,6 +1032,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocumentsTemplatesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/lovable/email/suppression': {
id: '/lovable/email/suppression'
path: '/lovable/email/suppression'
fullPath: '/lovable/email/suppression'
preLoaderRoute: typeof LovableEmailSuppressionRouteImport
parentRoute: typeof rootRouteImport
}
'/invoices/new/$clientId': {
id: '/invoices/new/$clientId'
path: '/$clientId'
@@ -992,6 +1074,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ClientsClientIdFeesRouteImport
parentRoute: typeof ClientsClientIdRoute
}
'/api/public/process-task-reminders': {
id: '/api/public/process-task-reminders'
path: '/api/public/process-task-reminders'
fullPath: '/api/public/process-task-reminders'
preLoaderRoute: typeof ApiPublicProcessTaskRemindersRouteImport
parentRoute: typeof rootRouteImport
}
'/lovable/email/transactional/send': {
id: '/lovable/email/transactional/send'
path: '/lovable/email/transactional/send'
fullPath: '/lovable/email/transactional/send'
preLoaderRoute: typeof LovableEmailTransactionalSendRouteImport
parentRoute: typeof rootRouteImport
}
'/lovable/email/transactional/preview': {
id: '/lovable/email/transactional/preview'
path: '/lovable/email/transactional/preview'
fullPath: '/lovable/email/transactional/preview'
preLoaderRoute: typeof LovableEmailTransactionalPreviewRouteImport
parentRoute: typeof rootRouteImport
}
'/lovable/email/queue/process': {
id: '/lovable/email/queue/process'
path: '/lovable/email/queue/process'
@@ -1070,6 +1173,7 @@ const rootRouteChildren: RootRouteChildren = {
ClientsClientIdRoute: ClientsClientIdRouteWithChildren,
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
ContactsContactIdRoute: ContactsContactIdRoute,
EmailUnsubscribeRoute: EmailUnsubscribeRoute,
HooksPollImapRoute: HooksPollImapRoute,
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
InvoicesNewRoute: InvoicesNewRouteWithChildren,
@@ -1090,11 +1194,15 @@ const rootRouteChildren: RootRouteChildren = {
ReportsIndexRoute: ReportsIndexRoute,
StatusIndexRoute: StatusIndexRoute,
TasksIndexRoute: TasksIndexRoute,
ApiPublicProcessTaskRemindersRoute: ApiPublicProcessTaskRemindersRoute,
DocumentsPleadingNewRoute: DocumentsPleadingNewRoute,
DocumentsTemplatesTemplateIdRoute: DocumentsTemplatesTemplateIdRoute,
DocumentsTemplatesNewRoute: DocumentsTemplatesNewRoute,
LovableEmailSuppressionRoute: LovableEmailSuppressionRoute,
DocumentsTemplatesIndexRoute: DocumentsTemplatesIndexRoute,
LovableEmailQueueProcessRoute: LovableEmailQueueProcessRoute,
LovableEmailTransactionalPreviewRoute: LovableEmailTransactionalPreviewRoute,
LovableEmailTransactionalSendRoute: LovableEmailTransactionalSendRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -0,0 +1,105 @@
import { createClient } from '@supabase/supabase-js'
import { createFileRoute } from '@tanstack/react-router'
// Cron-callable endpoint that finds due task reminders and triggers the
// transactional email send route for each. Authenticated by service-role key
// passed as Bearer token (configured in pg_cron job).
export const Route = createFileRoute('/api/public/process-task-reminders')({
server: {
handlers: {
POST: async ({ request }) => {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceKey) {
return Response.json({ error: 'config' }, { status: 500 })
}
const auth = request.headers.get('Authorization') ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
if (token !== serviceKey) {
return Response.json({ error: 'unauthorized' }, { status: 401 })
}
const supabase = createClient<any, any, any>(supabaseUrl, serviceKey)
// Fetch due reminders (limit batch to avoid timeout).
const { data: reminders, error } = await supabase
.from('task_reminders')
.select(
'id, task_id, recipient_email, remind_at, note, ' +
'task:tasks(id, title, due_date, case_id, case:cases(case_number, title))'
)
.is('sent_at', null)
.lte('remind_at', new Date().toISOString())
.order('remind_at', { ascending: true })
.limit(50)
if (error) {
console.error('Failed to load task reminders', error)
return Response.json({ error: 'load_failed' }, { status: 500 })
}
if (!reminders?.length) {
return Response.json({ processed: 0 })
}
const url = new URL(request.url)
const sendUrl = `${url.origin}/lovable/email/transactional/send`
let processed = 0
for (const r of reminders as any[]) {
const task = r.task
const caseInfo = task?.case
const caseTitle = caseInfo
? `${caseInfo.case_number} — ${caseInfo.title}`
: null
const dueDate = task?.due_date
? new Date(task.due_date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
: null
try {
const res = await fetch(sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${serviceKey}`,
},
body: JSON.stringify({
templateName: 'task-reminder',
recipientEmail: r.recipient_email,
idempotencyKey: `task-reminder-${r.id}`,
templateData: {
taskTitle: task?.title ?? 'Task',
caseTitle,
dueDate,
note: r.note,
},
}),
})
if (!res.ok) {
const body = await res.text()
console.error('Reminder send failed', { id: r.id, status: res.status, body })
continue
}
await supabase
.from('task_reminders')
.update({ sent_at: new Date().toISOString() })
.eq('id', r.id)
processed++
} catch (e) {
console.error('Reminder dispatch error', { id: r.id, error: e })
}
}
return Response.json({ processed })
},
},
},
})
+152
View File
@@ -0,0 +1,152 @@
import { createClient } from '@supabase/supabase-js'
import { createFileRoute } from '@tanstack/react-router'
function redactEmail(email: string | null | undefined): string {
if (!email) return '***'
const [localPart, domain] = email.split('@')
if (!localPart || !domain) return '***'
return `${localPart[0]}***@${domain}`
}
export const Route = createFileRoute("/email/unsubscribe")({
server: {
handlers: {
GET: async ({ request }) => {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return Response.json({ error: 'Server configuration error' }, { status: 500 })
}
// Extract token from query params
const url = new URL(request.url)
const token = url.searchParams.get('token')
if (!token) {
return Response.json({ error: 'Token is required' }, { status: 400 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Look up the token
const { data: tokenRecord, error: lookupError } = await supabase
.from('email_unsubscribe_tokens')
.select('*')
.eq('token', token)
.maybeSingle()
if (lookupError || !tokenRecord) {
return Response.json({ error: 'Invalid or expired token' }, { status: 404 })
}
if (tokenRecord.used_at) {
return Response.json({ valid: false, reason: 'already_unsubscribed' })
}
return Response.json({ valid: true })
},
POST: async ({ request }) => {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return Response.json({ error: 'Server configuration error' }, { status: 500 })
}
// Extract token from query params (always present for RFC 8058 one-click)
const url = new URL(request.url)
let token: string | null = url.searchParams.get('token')
// Detect RFC 8058 one-click unsubscribe: POST with form-encoded body
// containing "List-Unsubscribe=One-Click". Email clients (Gmail, Apple Mail,
// etc.) send this when the user clicks "Unsubscribe" in the mail UI.
const contentType = request.headers.get('content-type') ?? ''
if (contentType.includes('application/x-www-form-urlencoded')) {
const formText = await request.text()
const params = new URLSearchParams(formText)
// For one-click, token comes from query param (already set above).
// Otherwise, token may be in the form body.
if (!params.get('List-Unsubscribe')) {
const formToken = params.get('token')
if (formToken) {
token = formToken
}
}
} else {
// JSON body (from the app's unsubscribe page)
try {
const body = await request.json()
if (body.token) {
token = body.token
}
} catch {
// Fall through — token stays from query param
}
}
if (!token) {
return Response.json({ error: 'Token is required' }, { status: 400 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Look up the token
const { data: tokenRecord, error: lookupError } = await supabase
.from('email_unsubscribe_tokens')
.select('*')
.eq('token', token)
.maybeSingle()
if (lookupError || !tokenRecord) {
return Response.json({ error: 'Invalid or expired token' }, { status: 404 })
}
if (tokenRecord.used_at) {
return Response.json({ success: false, reason: 'already_unsubscribed' })
}
// Atomic check-and-update to avoid TOCTOU race
const { data: updated, error: updateError } = await supabase
.from('email_unsubscribe_tokens')
.update({ used_at: new Date().toISOString() })
.eq('token', token)
.is('used_at', null)
.select()
.maybeSingle()
if (updateError) {
console.error('Failed to mark token as used', { error: updateError, token })
return Response.json({ error: 'Failed to process unsubscribe' }, { status: 500 })
}
if (!updated) {
return Response.json({ success: false, reason: 'already_unsubscribed' })
}
// Add email to suppressed list (upsert to handle duplicates)
const { error: suppressError } = await supabase
.from('suppressed_emails')
.upsert(
{ email: tokenRecord.email.toLowerCase(), reason: 'unsubscribe' },
{ onConflict: 'email' },
)
if (suppressError) {
console.error('Failed to suppress email', {
error: suppressError,
email_redacted: redactEmail(tokenRecord.email),
})
return Response.json({ error: 'Failed to process unsubscribe' }, { status: 500 })
}
console.log('Email unsubscribed', {
email_redacted: redactEmail(tokenRecord.email),
})
return Response.json({ success: true })
},
},
},
})
+69 -30
View File
@@ -9,7 +9,7 @@ import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import {
Briefcase, Receipt, Clock, ArrowRight, CalendarDays, FileText,
CheckSquare, UserPlus, DollarSign, Wallet, AlertCircle, CreditCard,
CheckSquare, UserPlus, DollarSign, Wallet, AlertCircle, CreditCard, Bell,
} from "lucide-react";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { format, startOfDay, startOfMonth, endOfMonth, subDays } from "date-fns";
@@ -48,6 +48,7 @@ function Dashboard() {
const [recentCases, setRecentCases] = useState<any[]>([]);
const [recentInvoices, setRecentInvoices] = useState<any[]>([]);
const [recentPayments, setRecentPayments] = useState<any[]>([]);
const [upcomingReminders, setUpcomingReminders] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -66,6 +67,7 @@ function Dashboard() {
openCasesRes, newCasesRes, closedCasesRes,
tasksTodayRes, hearingsTodayRes,
recentCasesRes, recentInvRes, recentPayRes,
remindersRes,
] = await Promise.all([
supabase.from("trust_ledger_entries").select("entry_type, amount"),
supabase.from("invoice_payments").select("amount, paid_on").gte("paid_on", monthStart).lte("paid_on", monthEnd),
@@ -79,6 +81,13 @@ function Dashboard() {
supabase.from("cases").select("id, case_number, title, status, updated_at, client:clients(name)").order("updated_at", { ascending: false }).limit(5),
supabase.from("invoices").select("id, invoice_number, total, status, issue_date, client:clients(name)").order("created_at", { ascending: false }).limit(5),
supabase.from("payment_requests").select("id, recipient_name, description, total_amount_cents, status, created_at, paid_at").order("created_at", { ascending: false }).limit(5),
supabase
.from("task_reminders")
.select("id, remind_at, recipient_email, note, task:tasks(id, title, case_id)")
.is("sent_at", null)
.gte("remind_at", new Date().toISOString())
.order("remind_at", { ascending: true })
.limit(5),
]);
// Trust balance
@@ -135,6 +144,7 @@ function Dashboard() {
setRecentCases(recentCasesRes.data ?? []);
setRecentInvoices(recentInvRes.data ?? []);
setRecentPayments(recentPayRes.data ?? []);
setUpcomingReminders(remindersRes.data ?? []);
setLoading(false);
})();
}, [user?.id]);
@@ -173,36 +183,65 @@ function Dashboard() {
</CardContent>
</Card>
{/* Recent payments (moved to top) */}
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Recent payments
</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link to="/payments">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
</Button>
</CardHeader>
<CardContent className="space-y-1">
{recentPayments.length === 0 && <p className="text-sm text-muted-foreground">No payment requests yet.</p>}
{recentPayments.map((p) => (
<Link key={p.id} to="/pay/$id" params={{ id: p.id }} search={{} as never}
className="flex items-center justify-between py-2.5 px-2 rounded-md hover:bg-muted/60 -mx-2">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{p.recipient_name}</div>
<div className="text-xs text-muted-foreground truncate">
{p.description || "Payment request"} · {formatDate(p.paid_at || p.created_at)}
{/* Recent payments + Upcoming reminders */}
<div className="grid lg:grid-cols-[2fr_1fr] gap-6 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Recent payments
</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link to="/payments">View all <ArrowRight className="h-3 w-3 ml-1" /></Link>
</Button>
</CardHeader>
<CardContent className="space-y-1">
{recentPayments.length === 0 && <p className="text-sm text-muted-foreground">No payment requests yet.</p>}
{recentPayments.map((p) => (
<Link key={p.id} to="/pay/$id" params={{ id: p.id }} search={{} as never}
className="flex items-center justify-between py-2.5 px-2 rounded-md hover:bg-muted/60 -mx-2">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{p.recipient_name}</div>
<div className="text-xs text-muted-foreground truncate">
{p.description || "Payment request"} · {formatDate(p.paid_at || p.created_at)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{formatCurrency((p.total_amount_cents ?? 0) / 100)}</span>
<Badge variant="outline" className={statusBadgeClass(p.status)}>{p.status}</Badge>
</div>
</Link>
))}
</CardContent>
</Card>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{formatCurrency((p.total_amount_cents ?? 0) / 100)}</span>
<Badge variant="outline" className={statusBadgeClass(p.status)}>{p.status}</Badge>
</div>
</Link>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Bell className="h-4 w-4" />
Upcoming reminders
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{upcomingReminders.length === 0 && (
<p className="text-sm text-muted-foreground">No upcoming reminders.</p>
)}
{upcomingReminders.map((r: any) => (
<Link
key={r.id}
to="/tasks"
search={{ task: r.task?.id } as never}
className="block py-2 px-2 -mx-2 rounded-md hover:bg-muted/60"
>
<div className="text-sm font-medium truncate">{r.task?.title ?? "Task"}</div>
<div className="text-xs text-muted-foreground truncate">
{format(new Date(r.remind_at), "MMM d, h:mm a")} · {r.recipient_email}
</div>
</Link>
))}
</CardContent>
</Card>
</div>
{/* Financial + My tasks */}
<div className="grid lg:grid-cols-[2fr_1fr] gap-6 mb-6">
+158
View File
@@ -0,0 +1,158 @@
import { createClient } from '@supabase/supabase-js'
import { WebhookError, verifyWebhookRequest } from '@lovable.dev/webhooks-js'
import { createFileRoute } from '@tanstack/react-router'
// Suppression event payload sent by the Go API when Mailgun reports
// a bounce, complaint, or unsubscribe.
interface SuppressionPayload {
email: string
reason: 'bounce' | 'complaint' | 'unsubscribe'
message_id?: string
metadata?: Record<string, unknown>
is_retry: boolean
retry_count: number
}
function parseSuppressionPayload(body: string): SuppressionPayload {
const parsed = JSON.parse(body)
if (!parsed.data) {
throw new Error('Missing data field in payload')
}
const data = parsed.data as SuppressionPayload
if (!data.email || !data.reason) {
throw new Error('Missing required fields: email, reason')
}
return data
}
function mapReasonToStatus(
reason: string,
): 'bounced' | 'complained' | 'suppressed' {
switch (reason) {
case 'bounce':
return 'bounced'
case 'complaint':
return 'complained'
default:
return 'suppressed'
}
}
function mapReasonToMessage(reason: string): string {
switch (reason) {
case 'bounce':
return 'Permanent bounce — email address is invalid or rejected'
case 'complaint':
return 'Spam complaint — recipient marked email as spam'
case 'unsubscribe':
return 'Recipient unsubscribed'
default:
return 'Email suppressed'
}
}
export const Route = createFileRoute("/lovable/email/suppression")({
server: {
handlers: {
POST: async ({ request }) => {
const apiKey = process.env.LOVABLE_API_KEY
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!apiKey || !supabaseUrl || !supabaseServiceKey) {
console.error('Missing required environment variables')
return Response.json({ error: 'Server configuration error' }, { status: 500 })
}
// Verify HMAC signature using the Lovable API Key (same as auth-email-hook)
let payload: SuppressionPayload
try {
const verified = await verifyWebhookRequest({
req: request,
secret: apiKey,
parser: parseSuppressionPayload,
})
payload = verified.payload
} catch (error) {
if (error instanceof WebhookError) {
switch (error.code) {
case 'invalid_signature':
console.error('Invalid webhook signature')
return Response.json({ error: 'Invalid signature' }, { status: 401 })
case 'stale_timestamp':
console.error('Stale webhook timestamp')
return Response.json({ error: 'Stale timestamp' }, { status: 401 })
case 'invalid_payload':
case 'invalid_json':
console.error('Invalid payload', { code: error.code })
return Response.json({ error: 'Invalid payload' }, { status: 400 })
default:
console.error('Webhook verification failed', {
code: error.code,
message: error.message,
})
return Response.json({ error: 'Verification failed' }, { status: 401 })
}
}
console.error('Unexpected error during verification', { error })
return Response.json({ error: 'Internal error' }, { status: 500 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const normalizedEmail = payload.email.toLowerCase()
// 1. Upsert to suppressed_emails (idempotent — safe for retries)
const { error: suppressError } = await supabase
.from('suppressed_emails')
.upsert(
{
email: normalizedEmail,
reason: payload.reason,
metadata: payload.metadata ?? null,
},
{ onConflict: 'email' },
)
if (suppressError) {
console.error('Failed to upsert suppressed email', {
error: suppressError,
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
})
return Response.json({ error: 'Failed to write suppression' }, { status: 500 })
}
// 2. Append a new log entry for the suppression event (never update existing rows)
const sendLogStatus = mapReasonToStatus(payload.reason)
const sendLogMessage = mapReasonToMessage(payload.reason)
const { error: insertError } = await supabase
.from('email_send_log')
.insert({
message_id: payload.message_id ?? null,
template_name: 'system',
recipient_email: normalizedEmail,
status: sendLogStatus,
error_message: sendLogMessage,
metadata: payload.metadata ?? null,
})
if (insertError) {
// Non-fatal — log and continue. The suppression was already recorded.
console.warn('Failed to insert email_send_log', {
error: insertError,
})
}
console.log('Suppression processed', {
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
reason: payload.reason,
is_retry: payload.is_retry,
retry_count: payload.retry_count,
has_message_id: !!payload.message_id,
})
return Response.json({ success: true })
},
},
},
})
@@ -0,0 +1,89 @@
import * as React from 'react'
import { render } from '@react-email/components'
import { createFileRoute } from '@tanstack/react-router'
import { TEMPLATES } from '@/lib/email-templates/registry'
// Renders all registered templates with their previewData.
// Gated by LOVABLE_API_KEY — only the Go API calls this.
export const Route = createFileRoute("/lovable/email/transactional/preview")({
server: {
handlers: {
POST: async ({ request }) => {
const apiKey = process.env.LOVABLE_API_KEY
if (!apiKey) {
return Response.json(
{ error: 'Server configuration error' },
{ status: 500 }
)
}
// Verify the caller is authorized with LOVABLE_API_KEY
const authHeader = request.headers.get('Authorization')
const token = authHeader?.replace(/^Bearer\s+/i, '')
if (token !== apiKey) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const templateNames = Object.keys(TEMPLATES)
const results: Array<{
templateName: string
displayName: string
subject: string
html: string
status: 'ready' | 'preview_data_required' | 'render_failed'
errorMessage?: string
}> = []
for (const name of templateNames) {
const entry = TEMPLATES[name]
const displayName = entry.displayName || name
if (!entry.previewData) {
results.push({
templateName: name,
displayName,
subject: '',
html: '',
status: 'preview_data_required',
})
continue
}
try {
const html = await render(
React.createElement(entry.component, entry.previewData)
)
const resolvedSubject =
typeof entry.subject === 'function'
? entry.subject(entry.previewData)
: entry.subject
results.push({
templateName: name,
displayName,
subject: resolvedSubject,
html,
status: 'ready',
})
} catch (err) {
console.error('Failed to render template for preview', {
template: name,
error: err,
})
results.push({
templateName: name,
displayName,
subject: '',
html: '',
status: 'render_failed',
errorMessage: err instanceof Error ? err.message : String(err),
})
}
}
return Response.json({ templates: results })
},
},
},
})
@@ -0,0 +1,326 @@
import * as React from 'react'
import { render } from '@react-email/components'
import { createClient } from '@supabase/supabase-js'
import { createFileRoute } from '@tanstack/react-router'
import { TEMPLATES } from '@/lib/email-templates/registry'
// Configuration baked in at scaffold time
const SITE_NAME = "hoa-guard-desk"
// SENDER_DOMAIN is the verified sender subdomain FQDN (e.g., "notify.example.com").
// It MUST match the subdomain delegated to Lovable's nameservers. NEVER use the root domain.
const SENDER_DOMAIN = "notify.stagelaw.com"
// FROM_DOMAIN is the domain shown in the From: header (e.g., "example.com").
// Can be the root domain when display_from_root is enabled — this is cosmetic only.
const FROM_DOMAIN = "notify.stagelaw.com"
function redactEmail(email: string | null | undefined): string {
if (!email) return '***'
const [localPart, domain] = email.split('@')
if (!localPart || !domain) return '***'
return `${localPart[0]}***@${domain}`
}
// Generate a cryptographically random 32-byte hex token
function generateToken(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
export const Route = createFileRoute("/lovable/email/transactional/send")({
server: {
handlers: {
POST: async ({ request }) => {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
console.error('Missing required environment variables')
return Response.json(
{ error: 'Server configuration error' },
{ status: 500 }
)
}
// Verify the caller has a valid Supabase auth token.
// In TanStack, there is no Supabase gateway — we validate the JWT ourselves.
const authHeader = request.headers.get('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const token = authHeader.slice('Bearer '.length).trim()
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Allow service-role key for server-to-server calls (cron, internal).
if (token !== supabaseServiceKey) {
const { data: { user }, error: authError } = await supabase.auth.getUser(token)
if (authError || !user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
}
// Parse request body
let templateName: string
let recipientEmail: string
let idempotencyKey: string
let messageId: string
let templateData: Record<string, any> = {}
try {
const body = await request.json()
templateName = body.templateName || body.template_name
recipientEmail = body.recipientEmail || body.recipient_email
messageId = crypto.randomUUID()
idempotencyKey = body.idempotencyKey || body.idempotency_key || messageId
if (body.templateData && typeof body.templateData === 'object') {
templateData = body.templateData
}
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
)
}
if (!templateName) {
return Response.json(
{ error: 'templateName is required' },
{ status: 400 }
)
}
// 1. Look up template from registry (early — needed to resolve recipient)
const template = TEMPLATES[templateName]
if (!template) {
console.error('Template not found in registry', { templateName })
return Response.json(
{
error: `Template '${templateName}' not found. Available: ${Object.keys(TEMPLATES).join(', ')}`,
},
{ status: 404 }
)
}
// Resolve effective recipient: template-level `to` takes precedence over
// the caller-provided recipientEmail. This allows notification templates
// to always send to a fixed address (e.g., site owner from env var).
const effectiveRecipient = template.to || recipientEmail
if (!effectiveRecipient) {
return Response.json(
{
error: 'recipientEmail is required (unless the template defines a fixed recipient)',
},
{ status: 400 }
)
}
// 2. Check suppression list (fail-closed: if we can't verify, don't send)
const { data: suppressed, error: suppressionError } = await supabase
.from('suppressed_emails')
.select('id')
.eq('email', effectiveRecipient.toLowerCase())
.maybeSingle()
if (suppressionError) {
console.error('Suppression check failed — refusing to send', {
error: suppressionError,
recipient_redacted: redactEmail(effectiveRecipient),
})
return Response.json(
{ error: 'Failed to verify suppression status' },
{ status: 500 }
)
}
if (suppressed) {
// Log the suppressed attempt
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'suppressed',
})
console.log('Email suppressed', {
templateName,
recipient_redacted: redactEmail(effectiveRecipient),
})
return Response.json({ success: false, reason: 'email_suppressed' })
}
// 3. Get or create unsubscribe token (one token per email address)
const normalizedEmail = effectiveRecipient.toLowerCase()
let unsubscribeToken: string
// Check for existing token for this email
const { data: existingToken, error: tokenLookupError } = await supabase
.from('email_unsubscribe_tokens')
.select('token, used_at')
.eq('email', normalizedEmail)
.maybeSingle()
if (tokenLookupError) {
console.error('Token lookup failed', {
error: tokenLookupError,
email_redacted: redactEmail(normalizedEmail),
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to look up unsubscribe token',
})
return Response.json(
{ error: 'Failed to prepare email' },
{ status: 500 }
)
}
if (existingToken && !existingToken.used_at) {
// Reuse existing unused token
unsubscribeToken = existingToken.token
} else if (!existingToken) {
// Create new token — upsert handles concurrent inserts gracefully
unsubscribeToken = generateToken()
const { error: tokenError } = await supabase
.from('email_unsubscribe_tokens')
.upsert(
{ token: unsubscribeToken, email: normalizedEmail },
{ onConflict: 'email', ignoreDuplicates: true }
)
if (tokenError) {
console.error('Failed to create unsubscribe token', {
error: tokenError,
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to create unsubscribe token',
})
return Response.json(
{ error: 'Failed to prepare email' },
{ status: 500 }
)
}
// If another request raced us, our upsert was silently ignored.
// Re-read to get the actual stored token.
const { data: storedToken, error: reReadError } = await supabase
.from('email_unsubscribe_tokens')
.select('token')
.eq('email', normalizedEmail)
.maybeSingle()
if (reReadError || !storedToken) {
console.error('Failed to read back unsubscribe token after upsert', {
error: reReadError,
email_redacted: redactEmail(normalizedEmail),
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to confirm unsubscribe token storage',
})
return Response.json(
{ error: 'Failed to prepare email' },
{ status: 500 }
)
}
unsubscribeToken = storedToken.token
} else {
// Token exists but is already used — email should have been caught by suppression check above.
// This is a safety fallback; log and skip sending.
console.warn('Unsubscribe token already used but email not suppressed', {
email_redacted: redactEmail(normalizedEmail),
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'suppressed',
error_message:
'Unsubscribe token used but email missing from suppressed list',
})
return Response.json({ success: false, reason: 'email_suppressed' })
}
// 4. Render React Email template to HTML and plain text
const element = React.createElement(template.component, templateData)
const html = await render(element)
const plainText = await render(element, { plainText: true })
// Resolve subject — supports static string or dynamic function
const resolvedSubject =
typeof template.subject === 'function'
? template.subject(templateData)
: template.subject
// 5. Enqueue the pre-rendered email for async processing by the dispatcher.
// The dispatcher (process-email-queue) handles sending, retries, and rate-limit backoff.
// Log pending BEFORE enqueue so we have a record even if enqueue crashes
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'pending',
})
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
queue_name: 'transactional_emails',
payload: {
message_id: messageId,
to: effectiveRecipient,
from: `${SITE_NAME} <noreply@${FROM_DOMAIN}>`,
sender_domain: SENDER_DOMAIN,
subject: resolvedSubject,
html,
text: plainText,
purpose: 'transactional',
label: templateName,
idempotency_key: idempotencyKey,
unsubscribe_token: unsubscribeToken,
queued_at: new Date().toISOString(),
},
})
if (enqueueError) {
console.error('Failed to enqueue email', {
error: enqueueError,
templateName,
recipient_redacted: redactEmail(effectiveRecipient),
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to enqueue email',
})
return Response.json(
{ error: 'Failed to enqueue email' },
{ status: 500 }
)
}
console.log('Transactional email enqueued', {
templateName,
recipient_redacted: redactEmail(effectiveRecipient),
})
return Response.json({ success: true, queued: true })
},
},
},
})
@@ -0,0 +1,54 @@
CREATE TABLE public.task_reminders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE,
recipient_email text NOT NULL,
recipient_user_id uuid REFERENCES public.profiles(id),
remind_at timestamptz NOT NULL,
note text,
sent_at timestamptz,
created_by uuid REFERENCES public.profiles(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX task_reminders_due_idx ON public.task_reminders (remind_at) WHERE sent_at IS NULL;
CREATE INDEX task_reminders_task_id_idx ON public.task_reminders (task_id);
CREATE TRIGGER task_reminders_set_updated_at
BEFORE UPDATE ON public.task_reminders
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
ALTER TABLE public.task_reminders ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view task reminders if task accessible"
ON public.task_reminders FOR SELECT
TO authenticated
USING (public.can_access_task(task_id, auth.uid()));
CREATE POLICY "creator or admin can insert task reminders"
ON public.task_reminders FOR INSERT
TO authenticated
WITH CHECK (
public.can_access_task(task_id, auth.uid())
AND created_by = auth.uid()
);
CREATE POLICY "creator or admin can update task reminders"
ON public.task_reminders FOR UPDATE
TO authenticated
USING (
public.is_admin(auth.uid())
OR created_by = auth.uid()
)
WITH CHECK (
public.is_admin(auth.uid())
OR created_by = auth.uid()
);
CREATE POLICY "creator or admin can delete task reminders"
ON public.task_reminders FOR DELETE
TO authenticated
USING (
public.is_admin(auth.uid())
OR created_by = auth.uid()
);