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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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' }
|
||||
@@ -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 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
Reference in New Issue
Block a user