diff --git a/bun.lockb b/bun.lockb index 883ed2f..7bf6e32 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b1a82ca..465d66c 100644 --- a/package.json +++ b/package.json @@ -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" + } } } diff --git a/src/components/tasks/task-detail-panel.tsx b/src/components/tasks/task-detail-panel.tsx index cefb154..6315bbb 100644 --- a/src/components/tasks/task-detail-panel.tsx +++ b/src/components/tasks/task-detail-panel.tsx @@ -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([]); + const [reminderOpen, setReminderOpen] = useState(false); + const [reminderDate, setReminderDate] = useState(""); + const [reminderTime, setReminderTime] = useState("09:00"); + const [reminderEmail, setReminderEmail] = useState(""); + const [reminderNote, setReminderNote] = useState(""); + 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; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 0476004..de7924c 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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 diff --git a/src/lib/email-templates/registry.ts b/src/lib/email-templates/registry.ts new file mode 100644 index 0000000..75f7c19 --- /dev/null +++ b/src/lib/email-templates/registry.ts @@ -0,0 +1,19 @@ +import type { ComponentType } from 'react' + +export interface TemplateEntry { + component: ComponentType + subject: string | ((data: Record) => string) + displayName?: string + previewData?: Record + /** 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 = { + 'task-reminder': taskReminder, +} diff --git a/src/lib/email-templates/task-reminder.tsx b/src/lib/email-templates/task-reminder.tsx new file mode 100644 index 0000000..ea803c2 --- /dev/null +++ b/src/lib/email-templates/task-reminder.tsx @@ -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) => ( + + + Reminder: {taskTitle || 'Task reminder'} + + + Task reminder + + This is a reminder for the following task: + +
+ {taskTitle || 'Untitled task'} + {caseTitle && ( + Case: {caseTitle} + )} + {dueDate && ( + Due: {dueDate} + )} +
+ {note && ( +
+ Note + {note} +
+ )} + — {SITE_NAME} +
+ + +) + +export const template = { + component: TaskReminderEmail, + subject: (data: Record) => + `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' } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3caf79d..5d98e10 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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) diff --git a/src/routes/api/public/process-task-reminders.ts b/src/routes/api/public/process-task-reminders.ts new file mode 100644 index 0000000..58d4c20 --- /dev/null +++ b/src/routes/api/public/process-task-reminders.ts @@ -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(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 }) + }, + }, + }, +}) diff --git a/src/routes/email/unsubscribe.ts b/src/routes/email/unsubscribe.ts new file mode 100644 index 0000000..ecf6b6c --- /dev/null +++ b/src/routes/email/unsubscribe.ts @@ -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 }) + }, + }, + }, +}) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index fb73048..8d4fee9 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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([]); const [recentInvoices, setRecentInvoices] = useState([]); const [recentPayments, setRecentPayments] = useState([]); + const [upcomingReminders, setUpcomingReminders] = useState([]); 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() { - {/* Recent payments (moved to top) */} - - - - - Recent payments - - - - - {recentPayments.length === 0 &&

No payment requests yet.

} - {recentPayments.map((p) => ( - -
-
{p.recipient_name}
-
- {p.description || "Payment request"} · {formatDate(p.paid_at || p.created_at)} + {/* Recent payments + Upcoming reminders */} +
+ + + + + Recent payments + + + + + {recentPayments.length === 0 &&

No payment requests yet.

} + {recentPayments.map((p) => ( + +
+
{p.recipient_name}
+
+ {p.description || "Payment request"} · {formatDate(p.paid_at || p.created_at)} +
-
-
- {formatCurrency((p.total_amount_cents ?? 0) / 100)} - {p.status} -
- - ))} - - +
+ {formatCurrency((p.total_amount_cents ?? 0) / 100)} + {p.status} +
+ + ))} + + + + + + + + Upcoming reminders + + + + {upcomingReminders.length === 0 && ( +

No upcoming reminders.

+ )} + {upcomingReminders.map((r: any) => ( + +
{r.task?.title ?? "Task"}
+
+ {format(new Date(r.remind_at), "MMM d, h:mm a")} · {r.recipient_email} +
+ + ))} +
+
+
{/* Financial + My tasks */}
diff --git a/src/routes/lovable/email/suppression.ts b/src/routes/lovable/email/suppression.ts new file mode 100644 index 0000000..402ede4 --- /dev/null +++ b/src/routes/lovable/email/suppression.ts @@ -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 + 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 }) + }, + }, + }, +}) diff --git a/src/routes/lovable/email/transactional/preview.ts b/src/routes/lovable/email/transactional/preview.ts new file mode 100644 index 0000000..14d021d --- /dev/null +++ b/src/routes/lovable/email/transactional/preview.ts @@ -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 }) + }, + }, + }, +}) diff --git a/src/routes/lovable/email/transactional/send.ts b/src/routes/lovable/email/transactional/send.ts new file mode 100644 index 0000000..01c597f --- /dev/null +++ b/src/routes/lovable/email/transactional/send.ts @@ -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 = {} + 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} `, + 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 }) + }, + }, + }, +}) diff --git a/supabase/migrations/20260423213112_c6553e42-f949-41a7-be6f-13f5ae9f987b.sql b/supabase/migrations/20260423213112_c6553e42-f949-41a7-be6f-13f5ae9f987b.sql new file mode 100644 index 0000000..9f2926c --- /dev/null +++ b/supabase/migrations/20260423213112_c6553e42-f949-41a7-be6f-13f5ae9f987b.sql @@ -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() +); \ No newline at end of file