diff --git a/src/components/admin/brochures-admin-panel.tsx b/src/components/admin/brochures-admin-panel.tsx index 45847d4..bcdaec9 100644 --- a/src/components/admin/brochures-admin-panel.tsx +++ b/src/components/admin/brochures-admin-panel.tsx @@ -29,6 +29,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface BrochureRow { id: string; @@ -123,8 +124,7 @@ function BrochureCard({ brochure, onChange }: { brochure: BrochureRow; onChange: toast.success('Default brochure updated'); onChange(); }, - onError: (e) => - toast.error(e instanceof Error ? e.message : 'Could not update default brochure'), + onError: (e) => toastError(e), }); const archiveMutation = useMutation({ @@ -133,7 +133,7 @@ function BrochureCard({ brochure, onChange }: { brochure: BrochureRow; onChange: toast.success('Brochure archived'); onChange(); }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Archive failed'), + onError: (e) => toastError(e), }); async function handleUpload(file: File) { @@ -168,7 +168,7 @@ function BrochureCard({ brochure, onChange }: { brochure: BrochureRow; onChange: toast.success('New version uploaded'); onChange(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Upload failed'); + toastError(err); } finally { setUploading(false); } @@ -287,7 +287,7 @@ function CreateBrochureDialog({ onCreated(); onOpenChange(false); }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Could not create brochure'), + onError: (e) => toastError(e), }); return ( diff --git a/src/components/admin/documenso/documenso-test-button.tsx b/src/components/admin/documenso/documenso-test-button.tsx index 6248c57..aca6db8 100644 --- a/src/components/admin/documenso/documenso-test-button.tsx +++ b/src/components/admin/documenso/documenso-test-button.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface HealthResponse { ok: boolean; @@ -35,7 +36,7 @@ export function DocumensoTestButton() { } catch (err) { const message = err instanceof Error ? err.message : 'Test failed'; setResult({ ok: false, error: message }); - toast.error(message); + toastError(err); } finally { setPending(false); } diff --git a/src/components/admin/duplicates/duplicates-review-queue.tsx b/src/components/admin/duplicates/duplicates-review-queue.tsx index bf6aeb4..c84a23a 100644 --- a/src/components/admin/duplicates/duplicates-review-queue.tsx +++ b/src/components/admin/duplicates/duplicates-review-queue.tsx @@ -10,6 +10,7 @@ import { PageHeader } from '@/components/shared/page-header'; import { EmptyState } from '@/components/shared/empty-state'; import { Skeleton } from '@/components/ui/skeleton'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; interface CandidatePair { @@ -104,7 +105,7 @@ function CandidateRow({ queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] }); queryClient.invalidateQueries({ queryKey: ['clients'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Merge failed'), + onError: (err) => toastError(err), onSettled: () => setBusy(null), }); @@ -114,7 +115,7 @@ function CandidateRow({ toast.message('Dismissed'); queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Dismiss failed'), + onError: (err) => toastError(err), onSettled: () => setBusy(null), }); diff --git a/src/components/admin/forms/form-template-form.tsx b/src/components/admin/forms/form-template-form.tsx index 7fbab9b..6ddf53d 100644 --- a/src/components/admin/forms/form-template-form.tsx +++ b/src/components/admin/forms/form-template-form.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/select'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import type { FormField } from '@/lib/validators/form-templates'; interface FormTemplate { @@ -97,7 +98,7 @@ export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Prop onSaved(); onOpenChange(false); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'), + onError: (err) => toastError(err), }); function updateField(idx: number, patch: Partial) { diff --git a/src/components/admin/forms/form-template-list.tsx b/src/components/admin/forms/form-template-list.tsx index 444a850..190b48f 100644 --- a/src/components/admin/forms/form-template-list.tsx +++ b/src/components/admin/forms/form-template-list.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { PageHeader } from '@/components/shared/page-header'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import type { FormField } from '@/lib/validators/form-templates'; import { FormTemplateForm } from './form-template-form'; @@ -41,7 +42,7 @@ export function FormTemplateList() { toast.success('Template deleted'); qc.invalidateQueries({ queryKey: ['admin', 'form-templates'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Delete failed'), + onError: (err) => toastError(err), }); return ( diff --git a/src/components/admin/invitations/invitations-manager.tsx b/src/components/admin/invitations/invitations-manager.tsx index d555dfb..285ad5d 100644 --- a/src/components/admin/invitations/invitations-manager.tsx +++ b/src/components/admin/invitations/invitations-manager.tsx @@ -12,6 +12,7 @@ import { Switch } from '@/components/ui/switch'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface Invite { id: string; @@ -56,7 +57,7 @@ export function InvitationsManager() { setIsSuperAdmin(false); qc.invalidateQueries({ queryKey: ['admin', 'invitations'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to send invite'), + onError: (err) => toastError(err), }); const resendMutation = useMutation({ @@ -66,7 +67,7 @@ export function InvitationsManager() { toast.success('Invite resent'); qc.invalidateQueries({ queryKey: ['admin', 'invitations'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to resend'), + onError: (err) => toastError(err), }); const revokeMutation = useMutation({ @@ -75,7 +76,7 @@ export function InvitationsManager() { toast.success('Invite revoked'); qc.invalidateQueries({ queryKey: ['admin', 'invitations'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to revoke'), + onError: (err) => toastError(err), }); return ( diff --git a/src/components/admin/sales-email-config-card.tsx b/src/components/admin/sales-email-config-card.tsx index 8173374..b8d63d6 100644 --- a/src/components/admin/sales-email-config-card.tsx +++ b/src/components/admin/sales-email-config-card.tsx @@ -23,6 +23,7 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface SalesConfigResponse { data: { @@ -160,7 +161,7 @@ export function SalesEmailConfigCard() { toast.success('Sales email settings saved'); await refresh(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Save failed'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/admin/shared/settings-form-card.tsx b/src/components/admin/shared/settings-form-card.tsx index f4b899a..a330fb2 100644 --- a/src/components/admin/shared/settings-form-card.tsx +++ b/src/components/admin/shared/settings-form-card.tsx @@ -18,6 +18,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; export type SettingFieldType = | 'string' @@ -116,7 +117,7 @@ export function SettingsFormCard({ title, description, fields, extra }: Settings toast.success(`Saved ${changedFields.length} setting(s)`); setOriginals(values); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Save failed'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/admin/storage-admin-panel.tsx b/src/components/admin/storage-admin-panel.tsx index 49b6a61..f04cccc 100644 --- a/src/components/admin/storage-admin-panel.tsx +++ b/src/components/admin/storage-admin-panel.tsx @@ -17,6 +17,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; type BackendName = 's3' | 'filesystem'; @@ -57,8 +58,7 @@ export function StorageAdminPanel() { setDryRun(result.data); setConfirmOpen(true); }, - onError: (e) => - toast.error(e instanceof Error ? e.message : 'Storage migration dry-run failed'), + onError: (e) => toastError(e), }); const migrateMutation = useMutation({ @@ -74,7 +74,7 @@ export function StorageAdminPanel() { toast.success(`Storage migration complete (${copied} file${copied === 1 ? '' : 's'} copied)`); queryClient.invalidateQueries({ queryKey: ['admin', 'storage', 'status'] }); }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Storage migration failed'), + onError: (e) => toastError(e), }); const testMutation = useMutation({ diff --git a/src/components/admin/website-analytics/umami-test-button.tsx b/src/components/admin/website-analytics/umami-test-button.tsx index f482f6f..7638bb1 100644 --- a/src/components/admin/website-analytics/umami-test-button.tsx +++ b/src/components/admin/website-analytics/umami-test-button.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface TestResponse { ok: boolean; @@ -38,7 +39,7 @@ export function UmamiTestButton() { } catch (err) { const message = err instanceof Error ? err.message : 'Test failed'; setResult({ ok: false, error: message }); - toast.error(message); + toastError(err); } finally { setPending(false); } diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index 4ac0c7b..514acea 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -28,6 +28,7 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { PermissionGate } from '@/components/shared/permission-gate'; import { BerthForm } from './berth-form'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths'; import { BERTH_STATUSES } from '@/lib/constants'; @@ -123,8 +124,7 @@ function StatusChangeDialog({ reset(); onOpenChange(false); } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to update status'; - toast.error(message); + toastError(err); } } diff --git a/src/components/berths/berth-documents-tab.tsx b/src/components/berths/berth-documents-tab.tsx index 2c42177..af1facd 100644 --- a/src/components/berths/berth-documents-tab.tsx +++ b/src/components/berths/berth-documents-tab.tsx @@ -23,6 +23,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -83,7 +84,7 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) { toast.success('Rolled back to selected version.'); }, onError: (err: Error) => { - toast.error('Rollback failed', { description: err.message }); + toastError(err); }, }); @@ -141,7 +142,7 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) { toast.success('PDF uploaded.'); }, onError: (err: Error) => { - toast.error('Upload failed', { description: err.message }); + toastError(err); }, }); diff --git a/src/components/berths/berth-form.tsx b/src/components/berths/berth-form.tsx index 83483a6..853a086 100644 --- a/src/components/berths/berth-form.tsx +++ b/src/components/berths/berth-form.tsx @@ -21,6 +21,7 @@ import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { TagPicker } from '@/components/shared/tag-picker'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths'; import { BERTH_AREAS, @@ -172,8 +173,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { toast.success('Berth updated'); onOpenChange(false); } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to update berth'; - toast.error(message); + toastError(err); } } diff --git a/src/components/berths/pdf-reconcile-dialog.tsx b/src/components/berths/pdf-reconcile-dialog.tsx index a356cb9..bb5d410 100644 --- a/src/components/berths/pdf-reconcile-dialog.tsx +++ b/src/components/berths/pdf-reconcile-dialog.tsx @@ -21,6 +21,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { @@ -93,7 +94,7 @@ export function PdfReconcileDialog({ onClose(); }, onError: (err: Error) => { - toast.error('Apply failed', { description: err.message }); + toastError(err); }, }); diff --git a/src/components/clients/contacts-editor.tsx b/src/components/clients/contacts-editor.tsx index 13e74a8..dc80348 100644 --- a/src/components/clients/contacts-editor.tsx +++ b/src/components/clients/contacts-editor.tsx @@ -27,6 +27,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; interface Contact { @@ -161,7 +162,7 @@ function ContactRow({ try { await onUpdate({ isPrimary: !contact.isPrimary }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update'); + toastError(err); } } @@ -170,7 +171,7 @@ function ContactRow({ try { await onUpdate({ channel: next }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update'); + toastError(err); } } @@ -343,7 +344,7 @@ function NewContactForm({ label: label.trim() || undefined, }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to add contact'); + toastError(err); } finally { setSaving(false); } @@ -354,7 +355,7 @@ function NewContactForm({ try { await onSave({ channel, value: value.trim(), label: label.trim() || undefined }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to add contact'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/clients/gdpr-export-button.tsx b/src/components/clients/gdpr-export-button.tsx index b550bfe..e2f96a2 100644 --- a/src/components/clients/gdpr-export-button.tsx +++ b/src/components/clients/gdpr-export-button.tsx @@ -22,6 +22,7 @@ import { import { Badge } from '@/components/ui/badge'; import { usePermissions } from '@/hooks/use-permissions'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface ExportRow { id: string; @@ -79,7 +80,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) { setEmailOverride(''); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to queue export'); + toastError(err); }, }); @@ -92,7 +93,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) { ); window.open(res.data.url, '_blank', 'noopener'); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to fetch download URL'); + toastError(err); } } diff --git a/src/components/companies/company-detail-header.tsx b/src/components/companies/company-detail-header.tsx index 228e31a..e22d1f5 100644 --- a/src/components/companies/company-detail-header.tsx +++ b/src/components/companies/company-detail-header.tsx @@ -13,6 +13,7 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { PermissionGate } from '@/components/shared/permission-gate'; import { CompanyForm } from '@/components/companies/company-form'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface CompanyDetailHeaderCompany { id: string; @@ -66,7 +67,7 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) { router.push(`/${portSlug}/companies` as any); }, onError: (err: Error) => { - toast.error(err.message || 'Failed to archive company'); + toastError(err); }, }); diff --git a/src/components/companies/company-members-tab.tsx b/src/components/companies/company-members-tab.tsx index 8399481..df5d5c8 100644 --- a/src/components/companies/company-members-tab.tsx +++ b/src/components/companies/company-members-tab.tsx @@ -25,6 +25,7 @@ import { import { EmptyState } from '@/components/shared/empty-state'; import { PermissionGate } from '@/components/shared/permission-gate'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { AddMembershipDialog } from './add-membership-dialog'; interface MembershipRow { @@ -112,7 +113,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp toast.success('Membership ended'); }, onError: (err: Error) => { - toast.error(err.message || 'Failed to end membership'); + toastError(err); }, }); @@ -126,7 +127,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp toast.success('Primary contact updated'); }, onError: (err: Error) => { - toast.error(err.message || 'Failed to set primary'); + toastError(err); }, }); diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index cadd6ed..4581907 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -18,6 +18,7 @@ import { } from '@/components/ui/select'; import { PageHeader } from '@/components/shared/page-header'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { DOCUMENT_TYPES } from '@/lib/constants'; const SIGNER_ROLES = ['client', 'sales', 'approver', 'developer', 'other'] as const; @@ -158,7 +159,7 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { toast.success('Document created'); router.push(`/${portSlug}/documents/${res.data.id}`); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to create document'); + toastError(err); setSubmitting(false); } }; diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index af4df7f..b30535d 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -13,6 +13,7 @@ import { PageHeader } from '@/components/shared/page-header'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface DetailDoc { id: string; @@ -151,7 +152,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { toast.success('Reminder sent'); queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to send reminder'); + toastError(err); } }; @@ -163,7 +164,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { toast.success('Document cancelled'); router.push(`/${portSlug}/documents`); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Cancel failed'); + toastError(err); setIsCancelling(false); } }; @@ -177,7 +178,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { `Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`, ); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to prepare email'); + toastError(err); } }; @@ -357,9 +358,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { queryKey: ['document-detail', documentId], }); } catch (err) { - toast.error( - err instanceof Error ? err.message : 'Failed to remove watcher', - ); + toastError(err); } }} className="text-muted-foreground hover:text-destructive" diff --git a/src/components/email/compose-dialog.tsx b/src/components/email/compose-dialog.tsx index 90bac32..ca04baa 100644 --- a/src/components/email/compose-dialog.tsx +++ b/src/components/email/compose-dialog.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface Account { id: string; @@ -86,7 +87,7 @@ export function ComposeDialog({ setSubject(''); setBody(''); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Send failed'), + onError: (err) => toastError(err), }); return ( diff --git a/src/components/email/email-accounts-list.tsx b/src/components/email/email-accounts-list.tsx index 8b14d13..8ad9967 100644 --- a/src/components/email/email-accounts-list.tsx +++ b/src/components/email/email-accounts-list.tsx @@ -19,6 +19,7 @@ import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface Account { id: string; @@ -73,7 +74,7 @@ export function EmailAccountsList() { setSheetOpen(false); qc.invalidateQueries({ queryKey: ['email', 'accounts'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to connect account'), + onError: (err) => toastError(err), }); const toggleMutation = useMutation({ diff --git a/src/components/expenses/expense-detail.tsx b/src/components/expenses/expense-detail.tsx index 0b9cda1..125e198 100644 --- a/src/components/expenses/expense-detail.tsx +++ b/src/components/expenses/expense-detail.tsx @@ -12,6 +12,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog import { PermissionGate } from '@/components/shared/permission-gate'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import type { ExpenseRow } from './expense-columns'; import { ExpenseDuplicateBanner } from './expense-duplicate-banner'; @@ -122,7 +123,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr onArchived?.(); }, onError: (e) => { - toast.error(e instanceof Error ? e.message : 'Archive failed'); + toastError(e); setArchiveOpen(false); }, }); diff --git a/src/components/interests/inline-stage-picker.tsx b/src/components/interests/inline-stage-picker.tsx index acfcd60..f385b86 100644 --- a/src/components/interests/inline-stage-picker.tsx +++ b/src/components/interests/inline-stage-picker.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; import { PIPELINE_STAGES, @@ -65,7 +66,7 @@ export function InlineStagePicker({ }, onError: (err) => { setPendingStage(null); - toast.error(err instanceof Error ? err.message : 'Failed to change stage'); + toastError(err); }, }); diff --git a/src/components/invoices/invoice-detail.tsx b/src/components/invoices/invoice-detail.tsx index cb8ee46..827f886 100644 --- a/src/components/invoices/invoice-detail.tsx +++ b/src/components/invoices/invoice-detail.tsx @@ -25,6 +25,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { InvoicePdfPreview } from './invoice-pdf-preview'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { recordPaymentSchema, type RecordPaymentInput } from '@/lib/validators/invoices'; @@ -99,7 +100,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Could not send invoice'), + onError: (e) => toastError(e), }); const paymentForm = useForm({ @@ -118,7 +119,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Could not record payment'), + onError: (e) => toastError(e), }); if (isLoading) { diff --git a/src/components/notifications/notification-preferences-form.tsx b/src/components/notifications/notification-preferences-form.tsx index 27cdbd9..2556a69 100644 --- a/src/components/notifications/notification-preferences-form.tsx +++ b/src/components/notifications/notification-preferences-form.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface Pref { notificationType: string; @@ -72,7 +73,7 @@ export function NotificationPreferencesForm() { toast.success('Preferences saved'); qc.invalidateQueries({ queryKey: ['notifications', 'preferences'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'), + onError: (err) => toastError(err), }); function update(type: string, field: 'inApp' | 'email', value: boolean) { diff --git a/src/components/notifications/reminder-digest-form.tsx b/src/components/notifications/reminder-digest-form.tsx index 9a746f8..cd41777 100644 --- a/src/components/notifications/reminder-digest-form.tsx +++ b/src/components/notifications/reminder-digest-form.tsx @@ -17,6 +17,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface ReminderPrefs { delivery: 'immediate' | 'daily' | 'weekly' | 'off'; @@ -81,7 +82,7 @@ export function ReminderDigestForm() { toast.success('Reminder preferences saved'); qc.invalidateQueries({ queryKey: ['user', 'preferences'] }); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Save failed'), + onError: (err) => toastError(err), }); if (isLoading) { diff --git a/src/components/reservations/reservation-detail.tsx b/src/components/reservations/reservation-detail.tsx index 289de3d..32e2b46 100644 --- a/src/components/reservations/reservation-detail.tsx +++ b/src/components/reservations/reservation-detail.tsx @@ -22,6 +22,7 @@ import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { EmptyState } from '@/components/ui/empty-state'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list'; interface ReservationDoc { @@ -80,7 +81,7 @@ function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservat toast.success('Reservation ended'); onOpenChange(false); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to end reservation'); + toastError(err); } finally { setSubmitting(false); } @@ -245,7 +246,7 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail }); toast.success('Reminder sent'); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed'); + toastError(err); } }} > diff --git a/src/components/residential/residential-client-detail.tsx b/src/components/residential/residential-client-detail.tsx index beab8d2..ae2aa02 100644 --- a/src/components/residential/residential-client-detail.tsx +++ b/src/components/residential/residential-client-detail.tsx @@ -18,6 +18,7 @@ import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import type { CountryCode } from '@/lib/i18n/countries'; interface ResidentialInterestSummary { @@ -312,7 +313,7 @@ function NewInterestSheet({ setNotes(''); toast.success('Interest added'); }, - onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to add'), + onError: (err) => toastError(err), }); return ( diff --git a/src/components/residential/residential-clients-list.tsx b/src/components/residential/residential-clients-list.tsx index 0bbc94f..2e1e3d3 100644 --- a/src/components/residential/residential-clients-list.tsx +++ b/src/components/residential/residential-clients-list.tsx @@ -18,6 +18,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; import type { CountryCode } from '@/lib/i18n/countries'; @@ -246,7 +247,7 @@ function NewResidentialClientSheet({ toast.success('Residential client added'); }, onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Failed to create'); + toastError(err); }, }); diff --git a/src/components/shared/addresses-editor.tsx b/src/components/shared/addresses-editor.tsx index 72ade65..28c83c9 100644 --- a/src/components/shared/addresses-editor.tsx +++ b/src/components/shared/addresses-editor.tsx @@ -11,6 +11,7 @@ import { CountryCombobox } from '@/components/shared/country-combobox'; import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import type { CountryCode } from '@/lib/i18n/countries'; import { getCountryName } from '@/lib/i18n/countries'; import { getSubdivisionName } from '@/lib/i18n/subdivisions'; @@ -119,7 +120,7 @@ function AddressCard({ try { await onUpdate({ isPrimary: true }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to update'); + toastError(err); } } @@ -362,7 +363,7 @@ function NewAddressForm({ isPrimary: makePrimary, }); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to add address'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/shared/inline-country-field.tsx b/src/components/shared/inline-country-field.tsx index 0894e2e..85bde21 100644 --- a/src/components/shared/inline-country-field.tsx +++ b/src/components/shared/inline-country-field.tsx @@ -2,9 +2,9 @@ import { useRef, useState } from 'react'; import { Loader2, Pencil } from 'lucide-react'; -import { toast } from 'sonner'; import { CountryCombobox } from '@/components/shared/country-combobox'; +import { toastError } from '@/lib/api/toast-error'; import { getCountryName, type CountryCode } from '@/lib/i18n/countries'; import { cn } from '@/lib/utils'; @@ -46,7 +46,7 @@ export function InlineCountryField({ await onSave(next); setEditing(false); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to save'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/shared/inline-editable-field.tsx b/src/components/shared/inline-editable-field.tsx index 2ac7f9a..37f231a 100644 --- a/src/components/shared/inline-editable-field.tsx +++ b/src/components/shared/inline-editable-field.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { Loader2, Pencil } from 'lucide-react'; -import { toast } from 'sonner'; +import { toastError } from '@/lib/api/toast-error'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { @@ -85,7 +85,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) { await onSave(trimmed === '' ? null : trimmed); setEditing(false); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to save'); + toastError(err); setDraft(value ?? ''); } finally { setSaving(false); diff --git a/src/components/shared/inline-phone-field.tsx b/src/components/shared/inline-phone-field.tsx index e191889..c8c126d 100644 --- a/src/components/shared/inline-phone-field.tsx +++ b/src/components/shared/inline-phone-field.tsx @@ -2,9 +2,9 @@ import { useState } from 'react'; import { Loader2, Pencil } from 'lucide-react'; -import { toast } from 'sonner'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; +import { toastError } from '@/lib/api/toast-error'; import { parsePhone } from '@/lib/i18n/phone'; import type { CountryCode } from '@/lib/i18n/countries'; import { cn } from '@/lib/utils'; @@ -66,7 +66,7 @@ export function InlinePhoneField({ await onSave(next); setEditing(false); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to save'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/shared/inline-tag-editor.tsx b/src/components/shared/inline-tag-editor.tsx index c5ead44..a0a5f5b 100644 --- a/src/components/shared/inline-tag-editor.tsx +++ b/src/components/shared/inline-tag-editor.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, X, Check } from 'lucide-react'; -import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; interface Tag { @@ -45,7 +45,7 @@ export function InlineTagEditor({ const setTags = useMutation({ mutationFn: (tagIds: string[]) => apiFetch(endpoint, { method: 'PUT', body: { tagIds } }), onSuccess: () => qc.invalidateQueries({ queryKey: invalidateKey }), - onError: (err) => toast.error(err instanceof Error ? err.message : 'Failed to update tags'), + onError: (err) => toastError(err), }); function toggleTag(tagId: string) { diff --git a/src/components/shared/inline-timezone-field.tsx b/src/components/shared/inline-timezone-field.tsx index a351b1b..81591dc 100644 --- a/src/components/shared/inline-timezone-field.tsx +++ b/src/components/shared/inline-timezone-field.tsx @@ -2,9 +2,9 @@ import { useRef, useState } from 'react'; import { Loader2, Pencil } from 'lucide-react'; -import { toast } from 'sonner'; import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; +import { toastError } from '@/lib/api/toast-error'; import { formatTimezoneLabel } from '@/lib/i18n/timezones'; import type { CountryCode } from '@/lib/i18n/countries'; import { cn } from '@/lib/utils'; @@ -46,7 +46,7 @@ export function InlineTimezoneField({ await onSave(next); setEditing(false); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to save'); + toastError(err); } finally { setSaving(false); } diff --git a/src/components/shared/send-document-dialog.tsx b/src/components/shared/send-document-dialog.tsx index 6a0f70b..5a363b6 100644 --- a/src/components/shared/send-document-dialog.tsx +++ b/src/components/shared/send-document-dialog.tsx @@ -33,6 +33,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; const BODY_MAX = 50_000; @@ -149,7 +150,7 @@ export function SendDocumentDialog({ } }, onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Send failed'); + toastError(err); }, }); diff --git a/src/components/yachts/yacht-detail-header.tsx b/src/components/yachts/yacht-detail-header.tsx index 189956e..cc07c95 100644 --- a/src/components/yachts/yacht-detail-header.tsx +++ b/src/components/yachts/yacht-detail-header.tsx @@ -15,6 +15,7 @@ import { PermissionGate } from '@/components/shared/permission-gate'; import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface YachtDetailHeaderYacht { id: string; @@ -131,7 +132,7 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) { router.push(`/${portSlug}/yachts` as any); }, onError: (err: Error) => { - toast.error(err.message || 'Failed to archive yacht'); + toastError(err); }, }); diff --git a/src/lib/error-codes.ts b/src/lib/error-codes.ts index 044b045..a51bc20 100644 --- a/src/lib/error-codes.ts +++ b/src/lib/error-codes.ts @@ -207,6 +207,55 @@ export const ERROR_CODES = { status: 403, userMessage: 'This request was rejected by the security check.', }, + + // ─── Upstream integrations ────────────────────────────────────────── + DOCUMENSO_UPSTREAM_ERROR: { + status: 502, + userMessage: + "The signing service didn't respond as expected. Please retry, and if it keeps happening, ping an admin.", + hint: 'Documenso returned a non-2xx; check Documenso health + auth.', + }, + DOCUMENSO_AUTH_FAILURE: { + status: 502, + userMessage: + 'The signing service rejected our request. An admin will need to refresh the API key.', + hint: 'Documenso 401/403 — API key likely revoked or rotated.', + }, + DOCUMENSO_TIMEOUT: { + status: 504, + userMessage: 'The signing service is taking too long to respond. Please try again in a moment.', + }, + OCR_UPSTREAM_ERROR: { + status: 502, + userMessage: + "The receipt scanner didn't respond as expected. Please retry, or fill the fields manually.", + }, + IMAP_UPSTREAM_ERROR: { + status: 502, + userMessage: + "We couldn't fetch your inbox just now. Please retry, and check your IMAP credentials if it persists.", + }, + UMAMI_UPSTREAM_ERROR: { + status: 502, + userMessage: "Analytics data isn't available right now. Please try again shortly.", + }, + UMAMI_NOT_CONFIGURED: { + status: 409, + userMessage: + 'Analytics has not been configured for this port. Ask an admin to set up the integration.', + }, + + // ─── Internal post-insert guards ──────────────────────────────────── + // Surfaced as a generic "something went wrong" toast because the cause + // is always a programmer / DB-state issue (returning row absent after a + // successful insert, etc.) — the rep can't action it but support can, + // via the request-id lookup. Use only with `internalMessage`. + INSERT_RETURNING_EMPTY: { + status: 500, + userMessage: + 'Something went wrong on our end. Please try again, and quote the error ID below if it keeps happening.', + hint: 'A db.insert(...).returning() came back empty — DB constraint or transaction-rollback bug.', + }, } as const satisfies Record; export type ErrorCode = keyof typeof ERROR_CODES; diff --git a/src/lib/services/ai-budget.service.ts b/src/lib/services/ai-budget.service.ts index cce2bf9..619bbad 100644 --- a/src/lib/services/ai-budget.service.ts +++ b/src/lib/services/ai-budget.service.ts @@ -15,6 +15,7 @@ import { and, eq, gte, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { aiUsageLedger } from '@/lib/db/schema/ai-usage'; import { systemSettings } from '@/lib/db/schema/system'; +import { ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; export type BudgetPeriod = 'day' | 'week' | 'month'; @@ -70,10 +71,10 @@ export async function setAiBudget( period: input.period ?? existing.period, }; if (next.softCapTokens < 0 || next.hardCapTokens < 0) { - throw new Error('Token caps must be non-negative'); + throw new ValidationError('Token caps must be non-negative'); } if (next.softCapTokens > next.hardCapTokens) { - throw new Error('softCapTokens cannot exceed hardCapTokens'); + throw new ValidationError('softCapTokens cannot exceed hardCapTokens'); } await db .delete(systemSettings) diff --git a/src/lib/services/berth-pdf-parser.ts b/src/lib/services/berth-pdf-parser.ts index c61f28d..b64a656 100644 --- a/src/lib/services/berth-pdf-parser.ts +++ b/src/lib/services/berth-pdf-parser.ts @@ -22,6 +22,8 @@ import { PDFDocument } from 'pdf-lib'; +import { ValidationError } from '@/lib/errors'; + // ─── shared types ──────────────────────────────────────────────────────────── export type ParserEngine = 'acroform' | 'ocr' | 'ai'; @@ -445,7 +447,7 @@ export async function parseBerthPdf( opts: ParseBerthPdfOptions = {}, ): Promise { if (!isPdfMagic(buffer)) { - throw new Error('PDF magic-byte check failed: file does not begin with %PDF-'); + throw new ValidationError('PDF magic-byte check failed: file does not begin with %PDF-'); } const acro = await tryAcroForm(buffer); if (acro && Object.keys(acro.fields).length > 0) return acro; diff --git a/src/lib/services/brochures.service.ts b/src/lib/services/brochures.service.ts index e1c5f45..9df5be3 100644 --- a/src/lib/services/brochures.service.ts +++ b/src/lib/services/brochures.service.ts @@ -15,7 +15,7 @@ import { and, asc, desc, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { brochures, brochureVersions, ports } from '@/lib/db/schema'; import type { Brochure, BrochureVersion } from '@/lib/db/schema'; -import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; +import { CodedError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { getStorageBackend } from '@/lib/storage'; import { buildStoragePath } from '@/lib/minio'; import { logger } from '@/lib/logger'; @@ -140,7 +140,10 @@ export async function createBrochure(input: CreateBrochureInput): Promise { if (opts.winnerId === opts.loserId) { - throw new Error('Cannot merge a client into itself'); + throw new ValidationError('Cannot merge a client into itself'); } return await db.transaction(async (tx) => { @@ -99,16 +100,16 @@ export async function mergeClients(opts: MergeOptions): Promise { .where(eq(clients.id, opts.loserId)) .for('update'); - if (!winnerRow) throw new Error(`Winner client ${opts.winnerId} not found`); - if (!loserRow) throw new Error(`Loser client ${opts.loserId} not found`); + if (!winnerRow) throw new NotFoundError('client'); + if (!loserRow) throw new NotFoundError('client'); if (winnerRow.portId !== loserRow.portId) { - throw new Error('Cannot merge clients across different ports'); + throw new ValidationError('Cannot merge clients across different ports'); } if (loserRow.mergedIntoClientId) { - throw new Error(`Loser ${opts.loserId} already merged into ${loserRow.mergedIntoClientId}`); + throw new ConflictError('That client has already been merged into another record.'); } if (winnerRow.archivedAt) { - throw new Error('Cannot merge into an archived client'); + throw new ConflictError('Cannot merge into an archived client'); } // ── Snapshot the loser's full state before any mutation. Used by diff --git a/src/lib/services/crm-invite.service.ts b/src/lib/services/crm-invite.service.ts index e476f64..b699198 100644 --- a/src/lib/services/crm-invite.service.ts +++ b/src/lib/services/crm-invite.service.ts @@ -9,7 +9,7 @@ import { userProfiles } from '@/lib/db/schema/users'; import { env } from '@/lib/env'; import { sendEmail } from '@/lib/email'; import { crmInviteEmail } from '@/lib/email/templates/crm-invite'; -import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; +import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { hashToken, mintToken } from '@/lib/portal/passwords'; const INVITE_TTL_HOURS = 72; @@ -61,7 +61,10 @@ export async function createCrmInvite(args: { }) .returning({ id: crmUserInvites.id }); - if (!row) throw new Error('Failed to create CRM invite'); + if (!row) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create CRM invite', + }); const link = `${env.APP_URL}/set-password?token=${raw}`; const { subject, html, text } = crmInviteEmail({ diff --git a/src/lib/services/currency.ts b/src/lib/services/currency.ts index 7f77dca..31a3c0b 100644 --- a/src/lib/services/currency.ts +++ b/src/lib/services/currency.ts @@ -1,6 +1,7 @@ import { db } from '@/lib/db'; import { currencyRates } from '@/lib/db/schema/system'; import { eq, and } from 'drizzle-orm'; +import { CodedError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { fetchWithTimeout } from '@/lib/fetch-with-timeout'; @@ -25,7 +26,10 @@ export async function convert( export async function refreshRates(): Promise { try { const res = await fetchWithTimeout('https://api.frankfurter.dev/v1/latest?base=USD'); - if (!res.ok) throw new Error(`Frankfurter API error: ${res.status}`); + if (!res.ok) + throw new CodedError('INTERNAL', { + internalMessage: `Frankfurter API error: ${res.status}`, + }); const data = await res.json(); const rates = data.rates as Record; diff --git a/src/lib/services/custom-fields.service.ts b/src/lib/services/custom-fields.service.ts index 1f3cd45..1cd21b3 100644 --- a/src/lib/services/custom-fields.service.ts +++ b/src/lib/services/custom-fields.service.ts @@ -3,7 +3,7 @@ import { and, eq, count } from 'drizzle-orm'; import { db } from '@/lib/db'; import { customFieldDefinitions, customFieldValues } from '@/lib/db/schema/system'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; -import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors'; +import { CodedError, NotFoundError, ValidationError, ConflictError } from '@/lib/errors'; import type { CreateFieldInput, UpdateFieldInput } from '@/lib/validators/custom-fields'; import type { CustomFieldDefinition } from '@/lib/db/schema/system'; @@ -89,7 +89,10 @@ export async function createDefinition( .returning(); const created = rows[0]; - if (!created) throw new Error('Insert failed - no row returned'); + if (!created) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Custom field definition insert returned no row', + }); void createAuditLog({ userId, @@ -141,7 +144,10 @@ export async function updateDefinition( .returning(); const updated = updateRows[0]; - if (!updated) throw new Error('Update failed - no row returned'); + if (!updated) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Custom field definition update returned no row', + }); void createAuditLog({ userId, diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index 5e9d145..4b2f6a9 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -1,7 +1,8 @@ import { env } from '@/lib/env'; +import { CodedError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config'; -import { fetchWithTimeout } from '@/lib/fetch-with-timeout'; +import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout'; interface DocumensoCreds { baseUrl: string; @@ -27,19 +28,36 @@ async function documensoFetch( portId?: string, ): Promise { const { baseUrl, apiKey } = await resolveCreds(portId); - const res = await fetchWithTimeout(`${baseUrl}${path}`, { - ...options, - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); + let res: Response; + try { + res = await fetchWithTimeout(`${baseUrl}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + } catch (err) { + if (err instanceof FetchTimeoutError) { + throw new CodedError('DOCUMENSO_TIMEOUT', { + internalMessage: `${path} timed out after ${err.timeoutMs}ms`, + }); + } + throw err; + } if (!res.ok) { const err = await res.text(); logger.error({ path, status: res.status, err, portId }, 'Documenso API error'); - throw new Error(`Documenso API error: ${res.status}`); + if (res.status === 401 || res.status === 403) { + throw new CodedError('DOCUMENSO_AUTH_FAILURE', { + internalMessage: `${path} → ${res.status}`, + }); + } + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `${path} → ${res.status}: ${err}`, + }); } return res.json(); @@ -242,14 +260,32 @@ export async function sendReminder( export async function downloadSignedPdf(docId: string, portId?: string): Promise { const { baseUrl, apiKey } = await resolveCreds(portId); - const res = await fetchWithTimeout(`${baseUrl}/api/v1/documents/${docId}/download`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); + const path = `/api/v1/documents/${docId}/download`; + let res: Response; + try { + res = await fetchWithTimeout(`${baseUrl}${path}`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + } catch (err) { + if (err instanceof FetchTimeoutError) { + throw new CodedError('DOCUMENSO_TIMEOUT', { + internalMessage: `${path} timed out after ${err.timeoutMs}ms`, + }); + } + throw err; + } if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso download error'); - throw new Error(`Documenso download error: ${res.status}`); + if (res.status === 401 || res.status === 403) { + throw new CodedError('DOCUMENSO_AUTH_FAILURE', { + internalMessage: `${path} → ${res.status}`, + }); + } + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `${path} → ${res.status}: ${err}`, + }); } const arrayBuffer = await res.arrayBuffer(); @@ -367,7 +403,14 @@ export async function placeFields( if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso v2 placeFields error'); - throw new Error(`Documenso v2 placeFields error: ${res.status}`); + if (res.status === 401 || res.status === 403) { + throw new CodedError('DOCUMENSO_AUTH_FAILURE', { + internalMessage: `v2 placeFields ${docId} → ${res.status}`, + }); + } + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `v2 placeFields ${docId} → ${res.status}: ${err}`, + }); } return; } @@ -414,7 +457,14 @@ export async function placeFields( { docId, status: lastError.status, err: lastError.body, portId }, 'Documenso v1 placeField error', ); - throw new Error(`Documenso v1 placeField error: ${lastError.status}`); + if (lastError.status === 401 || lastError.status === 403) { + throw new CodedError('DOCUMENSO_AUTH_FAILURE', { + internalMessage: `v1 placeField ${docId} → ${lastError.status}`, + }); + } + throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { + internalMessage: `v1 placeField ${docId} → ${lastError.status}: ${lastError.body}`, + }); } } } @@ -482,6 +532,13 @@ export async function voidDocument(docId: string, portId?: string): Promise 0) { await assertWatchersInPort(portId, data.watchers); @@ -1498,7 +1501,10 @@ export async function createFromUpload( createdBy: meta.userId, }) .returning(); - if (!doc) throw new Error('Failed to insert document'); + if (!doc) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to insert document', + }); await db.insert(documentSigners).values( data.signers.map((s) => ({ diff --git a/src/lib/services/email-accounts.service.ts b/src/lib/services/email-accounts.service.ts index 349d880..281980d 100644 --- a/src/lib/services/email-accounts.service.ts +++ b/src/lib/services/email-accounts.service.ts @@ -4,7 +4,7 @@ import { db } from '@/lib/db'; import { emailAccounts } from '@/lib/db/schema/email'; import { encrypt, decrypt } from '@/lib/utils/encryption'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; -import { NotFoundError, ForbiddenError } from '@/lib/errors'; +import { CodedError, NotFoundError, ForbiddenError } from '@/lib/errors'; import type { ConnectAccountInput, ToggleAccountInput } from '@/lib/validators/email'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -62,7 +62,10 @@ export async function connectAccount( .returning(); const account = inserted[0]; - if (!account) throw new Error('Failed to insert email account'); + if (!account) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to insert email account', + }); void createAuditLog({ userId: audit.userId, @@ -104,7 +107,10 @@ export async function toggleAccount( .returning(); const updated = updatedRows[0]; - if (!updated) throw new Error('Failed to update email account'); + if (!updated) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to update email account', + }); return stripCredentials(updated); } diff --git a/src/lib/services/email-compose.service.ts b/src/lib/services/email-compose.service.ts index a7c4b01..82d62be 100644 --- a/src/lib/services/email-compose.service.ts +++ b/src/lib/services/email-compose.service.ts @@ -6,7 +6,7 @@ import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/emai import { documents, documentEvents, files } from '@/lib/db/schema/documents'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { env } from '@/lib/env'; -import { NotFoundError, ForbiddenError } from '@/lib/errors'; +import { CodedError, NotFoundError, ForbiddenError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getDecryptedCredentials } from '@/lib/services/email-accounts.service'; import { getPortEmailConfig } from '@/lib/services/port-config'; @@ -195,7 +195,10 @@ export async function sendEmail( }) .returning(); const newThread = newThreadRows[0]; - if (!newThread) throw new Error('Failed to create email thread'); + if (!newThread) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create email thread', + }); threadId = newThread.id; } @@ -222,7 +225,10 @@ export async function sendEmail( .returning(); const message = messageRows[0]; - if (!message) throw new Error('Failed to persist outbound email message'); + if (!message) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to persist outbound email message', + }); // Update thread metadata await db diff --git a/src/lib/services/email-threads.service.ts b/src/lib/services/email-threads.service.ts index cb9bade..34167e8 100644 --- a/src/lib/services/email-threads.service.ts +++ b/src/lib/services/email-threads.service.ts @@ -3,7 +3,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email'; import { clientContacts, clients } from '@/lib/db/schema/clients'; -import { NotFoundError } from '@/lib/errors'; +import { CodedError, NotFoundError } from '@/lib/errors'; import { getDecryptedCredentials } from '@/lib/services/email-accounts.service'; import { logger } from '@/lib/logger'; import type { ListThreadsInput } from '@/lib/validators/email'; @@ -160,7 +160,10 @@ export async function ingestMessage(portId: string, parsedEmail: ParsedEmail) { }) .returning(); const newThread = newThreadRows[0]; - if (!newThread) throw new Error('Failed to create email thread'); + if (!newThread) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create email thread (per-client path)', + }); threadId = newThread.id; } } @@ -197,7 +200,10 @@ export async function ingestMessage(portId: string, parsedEmail: ParsedEmail) { }) .returning(); const newThread = newThreadRows[0]; - if (!newThread) throw new Error('Failed to create email thread'); + if (!newThread) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create email thread (orphan ingest path)', + }); threadId = newThread.id; } @@ -219,7 +225,10 @@ export async function ingestMessage(portId: string, parsedEmail: ParsedEmail) { .returning(); const message = messageRows[0]; - if (!message) throw new Error('Failed to insert email message'); + if (!message) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to insert email message', + }); // Update thread's lastMessageAt and messageCount await db diff --git a/src/lib/services/expense-dedup.service.ts b/src/lib/services/expense-dedup.service.ts index f82d2a4..83b2e96 100644 --- a/src/lib/services/expense-dedup.service.ts +++ b/src/lib/services/expense-dedup.service.ts @@ -8,6 +8,7 @@ import { and, between, eq, ne, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { expenses } from '@/lib/db/schema/financial'; +import { NotFoundError, ValidationError } from '@/lib/errors'; const DEDUP_WINDOW_DAYS = 3; @@ -93,7 +94,7 @@ export async function mergeDuplicate( portId: string, ): Promise { if (sourceId === targetId) { - throw new Error('Cannot merge an expense into itself'); + throw new ValidationError('Cannot merge an expense into itself'); } await db.transaction(async (tx) => { @@ -106,7 +107,7 @@ export async function mergeDuplicate( .from(expenses) .where(and(eq(expenses.id, targetId), eq(expenses.portId, portId))); if (!source || !target) { - throw new Error('Source or target expense not found in this port'); + throw new NotFoundError('expense'); } const mergedReceipts = Array.from( diff --git a/src/lib/services/expenses.ts b/src/lib/services/expenses.ts index b573cc0..c715654 100644 --- a/src/lib/services/expenses.ts +++ b/src/lib/services/expenses.ts @@ -160,7 +160,10 @@ export async function createExpense(portId: string, data: CreateExpenseInput, me }) .returning(); - if (!expense) throw new Error('Insert failed'); + if (!expense) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Expense insert returned no row', + }); void createAuditLog({ userId: meta.userId, diff --git a/src/lib/services/form-templates.service.ts b/src/lib/services/form-templates.service.ts index 9ee8ce7..1ba6798 100644 --- a/src/lib/services/form-templates.service.ts +++ b/src/lib/services/form-templates.service.ts @@ -3,7 +3,7 @@ import { and, desc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { formTemplates } from '@/lib/db/schema/documents'; import { createAuditLog } from '@/lib/audit'; -import { NotFoundError } from '@/lib/errors'; +import { CodedError, NotFoundError } from '@/lib/errors'; import type { CreateFormTemplateInput, UpdateFormTemplateInput, @@ -50,7 +50,10 @@ export async function createFormTemplate( }) .returning(); - if (!tpl) throw new Error('Insert failed'); + if (!tpl) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Form template insert returned no row', + }); void createAuditLog({ userId: meta.userId, diff --git a/src/lib/services/gdpr-export.service.ts b/src/lib/services/gdpr-export.service.ts index 7392cc2..b103374 100644 --- a/src/lib/services/gdpr-export.service.ts +++ b/src/lib/services/gdpr-export.service.ts @@ -18,7 +18,7 @@ import { db } from '@/lib/db'; import { gdprExports, type GdprExport } from '@/lib/db/schema/gdpr'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { ports } from '@/lib/db/schema/ports'; -import { NotFoundError, ValidationError } from '@/lib/errors'; +import { CodedError, NotFoundError, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getStorageBackend, presignDownloadUrl } from '@/lib/storage'; import { getQueue } from '@/lib/queue'; @@ -82,7 +82,10 @@ export async function requestGdprExport(input: RequestExportInput): Promise MAX_BUNDLE_BYTES) { - throw new Error( - `GDPR bundle exceeded ${MAX_BUNDLE_BYTES} bytes (got ${buffer.length}); refusing to upload`, - ); + throw new CodedError('INTERNAL', { + internalMessage: `GDPR bundle exceeded ${MAX_BUNDLE_BYTES} bytes (got ${buffer.length}); refusing to upload`, + }); } const port = await db.query.ports.findFirst({ where: eq(ports.id, input.portId) }); diff --git a/src/lib/services/interest-scoring.service.ts b/src/lib/services/interest-scoring.service.ts index 751bf12..5e07b9d 100644 --- a/src/lib/services/interest-scoring.service.ts +++ b/src/lib/services/interest-scoring.service.ts @@ -7,6 +7,7 @@ import { reminders } from '@/lib/db/schema/operations'; import { emailThreads } from '@/lib/db/schema/email'; import { logger } from '@/lib/logger'; import { PIPELINE_STAGES } from '@/lib/constants'; +import { NotFoundError } from '@/lib/errors'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -108,7 +109,7 @@ export async function calculateInterestScore( }); if (!interest) { - throw new Error(`Interest not found: ${interestId}`); + throw new NotFoundError('interest'); } // Try cache (port-scoped key) diff --git a/src/lib/services/invoices.ts b/src/lib/services/invoices.ts index 02bb48c..63dbbd0 100644 --- a/src/lib/services/invoices.ts +++ b/src/lib/services/invoices.ts @@ -12,7 +12,7 @@ import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { withTransaction } from '@/lib/db/utils'; -import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; +import { CodedError, NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; import { getCountryName } from '@/lib/i18n/countries'; import { getSubdivisionName } from '@/lib/i18n/subdivisions'; import { emitToRoom } from '@/lib/socket/server'; @@ -337,7 +337,10 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me }) .returning(); - if (!newInvoice) throw new Error('Insert failed'); + if (!newInvoice) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Invoice insert returned no row', + }); // Insert line items if (lineItemsData.length > 0) { @@ -618,7 +621,10 @@ export async function generateInvoicePdf(id: string, portId: string, meta: Audit }) .returning(); - if (!fileRecord) throw new Error('File record insert failed'); + if (!fileRecord) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Invoice PDF file record insert returned no row', + }); await db .update(invoices) diff --git a/src/lib/services/notes.service.ts b/src/lib/services/notes.service.ts index 643fe68..ea1665c 100644 --- a/src/lib/services/notes.service.ts +++ b/src/lib/services/notes.service.ts @@ -6,7 +6,7 @@ import { interestNotes, interests } from '@/lib/db/schema/interests'; import { yachtNotes, yachts } from '@/lib/db/schema/yachts'; import { companyNotes, companies } from '@/lib/db/schema/companies'; import { userProfiles } from '@/lib/db/schema/users'; -import { NotFoundError, ValidationError } from '@/lib/errors'; +import { CodedError, NotFoundError, ValidationError } from '@/lib/errors'; import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes'; const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes @@ -141,7 +141,10 @@ export async function create( .insert(yachtNotes) .values({ yachtId: entityId, authorId, content: data.content }) .returning(); - if (!note) throw new Error('Insert failed'); + if (!note) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Yacht note insert returned no row', + }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) @@ -154,7 +157,10 @@ export async function create( .insert(companyNotes) .values({ companyId: entityId, authorId, content: data.content }) .returning(); - if (!note) throw new Error('Insert failed'); + if (!note) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Company note insert returned no row', + }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) @@ -168,7 +174,10 @@ export async function create( .values({ clientId: entityId, authorId, content: data.content }) .returning(); - if (!note) throw new Error('Insert failed'); + if (!note) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Client note insert returned no row', + }); const profile = await db .select({ displayName: userProfiles.displayName }) @@ -204,7 +213,10 @@ export async function create( .values({ interestId: entityId, authorId, content: data.content }) .returning(); - if (!note) throw new Error('Insert failed'); + if (!note) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Interest note insert returned no row', + }); const profile = await db .select({ displayName: userProfiles.displayName }) @@ -235,7 +247,9 @@ export async function create( return { ...note, authorName }; } - throw new Error(`Unsupported entityType: ${entityType as string}`); + throw new CodedError('INTERNAL', { + internalMessage: `Unsupported entityType: ${entityType as string}`, + }); } export async function update( diff --git a/src/lib/services/ocr-providers.ts b/src/lib/services/ocr-providers.ts index 52bdd25..4b75770 100644 --- a/src/lib/services/ocr-providers.ts +++ b/src/lib/services/ocr-providers.ts @@ -6,6 +6,7 @@ import OpenAI from 'openai'; +import { CodedError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { fetchWithTimeout } from '@/lib/fetch-with-timeout'; @@ -140,7 +141,9 @@ async function runClaude({ imageBuffer, mimeType, apiKey, model }: RunArgs): Pro }); if (!res.ok) { const detail = await res.text().catch(() => ''); - throw new Error(`Claude API ${res.status}: ${detail.slice(0, 200)}`); + throw new CodedError('OCR_UPSTREAM_ERROR', { + internalMessage: `Claude API ${res.status}: ${detail.slice(0, 200)}`, + }); } const body = (await res.json()) as { id?: string; diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index ce2bd94..780096e 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -8,7 +8,13 @@ import { systemSettings } from '@/lib/db/schema/system'; import { env } from '@/lib/env'; import { sendEmail } from '@/lib/email'; import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth'; -import { ConflictError, NotFoundError, UnauthorizedError, ValidationError } from '@/lib/errors'; +import { + CodedError, + ConflictError, + NotFoundError, + UnauthorizedError, + ValidationError, +} from '@/lib/errors'; import { logger } from '@/lib/logger'; import { createPortalToken } from '@/lib/portal/auth'; import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal/passwords'; @@ -71,7 +77,9 @@ export async function createPortalUser(args: { .returning({ id: portalUsers.id }); if (!user) { - throw new Error('Failed to create portal user'); + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create portal user', + }); } await issueActivationToken(user.id, normalizedEmail, args.portId); diff --git a/src/lib/services/reports.service.ts b/src/lib/services/reports.service.ts index 7284838..9f019e9 100644 --- a/src/lib/services/reports.service.ts +++ b/src/lib/services/reports.service.ts @@ -12,7 +12,7 @@ import { emitToRoom } from '@/lib/socket/server'; import { getQueue } from '@/lib/queue'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; -import { NotFoundError } from '@/lib/errors'; +import { CodedError, ConflictError, NotFoundError } from '@/lib/errors'; import { fetchPipelineData, @@ -82,7 +82,9 @@ export async function requestReport(portId: string, userId: string, data: Reques .returning(); if (!report) { - throw new Error('Failed to create report record'); + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create report record', + }); } await getQueue('reports').add('generate-report', { reportJobId: report.id }); @@ -149,7 +151,7 @@ export async function getDownloadUrl(reportId: string, portId: string) { } if (report.status !== 'ready' || !report.fileId) { - throw new Error('Report is not ready for download'); + throw new ConflictError('Report is not ready for download'); } const file = await db.query.files.findFirst({ @@ -173,7 +175,7 @@ export async function generateReport(reportJobId: string): Promise { }); if (!report) { - throw new Error(`Report job not found: ${reportJobId}`); + throw new NotFoundError('report job'); } const { portId, reportType, name, parameters, requestedBy } = report; @@ -189,7 +191,9 @@ export async function generateReport(reportJobId: string): Promise { const typeKey = reportType as ReportType; const config = REPORT_TYPE_MAP[typeKey]; if (!config) { - throw new Error(`Unknown report type: ${reportType}`); + throw new CodedError('VALIDATION_ERROR', { + internalMessage: `Unknown report type: ${reportType}`, + }); } const params = (parameters ?? {}) as Record; @@ -242,7 +246,9 @@ export async function generateReport(reportJobId: string): Promise { .returning(); if (!fileRecord) { - throw new Error('Failed to insert file record'); + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to insert file record for generated report', + }); } // 11. Update generatedReports: status='ready', fileId, completedAt diff --git a/src/lib/services/residential.service.ts b/src/lib/services/residential.service.ts index f27ab5e..6e8b6fb 100644 --- a/src/lib/services/residential.service.ts +++ b/src/lib/services/residential.service.ts @@ -3,7 +3,7 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { residentialClients, residentialInterests } from '@/lib/db/schema/residential'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; -import { NotFoundError } from '@/lib/errors'; +import { CodedError, NotFoundError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; @@ -78,7 +78,10 @@ export async function createResidentialClient( .insert(residentialClients) .values({ portId, ...data }) .returning(); - if (!row) throw new Error('Failed to create residential client'); + if (!row) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create residential client', + }); void createAuditLog({ userId: meta.userId, @@ -249,7 +252,10 @@ export async function createResidentialInterest( .insert(residentialInterests) .values({ portId, ...data }) .returning(); - if (!row) throw new Error('Failed to create residential interest'); + if (!row) + throw new CodedError('INSERT_RETURNING_EMPTY', { + internalMessage: 'Failed to create residential interest', + }); void createAuditLog({ userId: meta.userId, diff --git a/src/lib/services/sales-email-config.service.ts b/src/lib/services/sales-email-config.service.ts index ebd14a0..755161f 100644 --- a/src/lib/services/sales-email-config.service.ts +++ b/src/lib/services/sales-email-config.service.ts @@ -20,6 +20,7 @@ import nodemailer, { type Transporter } from 'nodemailer'; import { env } from '@/lib/env'; +import { ConflictError } from '@/lib/errors'; import { decrypt, encrypt } from '@/lib/utils/encryption'; import { getSetting, upsertSetting } from '@/lib/services/settings.service'; import type { AuditMeta } from '@/lib/audit'; @@ -323,7 +324,7 @@ export async function createSalesTransporter(portId: string): Promise<{ }> { const cfg = await getSalesEmailConfig(portId); if (!cfg.smtpHost) { - throw new Error( + throw new ConflictError( 'Sales SMTP not configured for this port. Configure in /admin/email before sending.', ); } diff --git a/src/lib/services/system-monitoring.service.ts b/src/lib/services/system-monitoring.service.ts index a581086..f8e1ce7 100644 --- a/src/lib/services/system-monitoring.service.ts +++ b/src/lib/services/system-monitoring.service.ts @@ -6,6 +6,7 @@ import { getQueue, QUEUE_CONFIGS, type QueueName } from '@/lib/queue'; import { createAuditLog } from '@/lib/audit'; import { env } from '@/lib/env'; import { sql, desc, eq } from 'drizzle-orm'; +import { NotFoundError } from '@/lib/errors'; import { logger } from '@/lib/logger'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -269,7 +270,7 @@ export async function getQueueJobs( export async function retryJob(queueName: QueueName, jobId: string, userId: string): Promise { const queue = getQueue(queueName); const job = await queue.getJob(jobId); - if (!job) throw new Error(`Job ${jobId} not found in queue ${queueName}`); + if (!job) throw new NotFoundError('queue job'); await job.retry(); @@ -294,7 +295,7 @@ export async function deleteJob( ): Promise { const queue = getQueue(queueName); const job = await queue.getJob(jobId); - if (!job) throw new Error(`Job ${jobId} not found in queue ${queueName}`); + if (!job) throw new NotFoundError('queue job'); await job.remove(); diff --git a/src/lib/services/umami.service.ts b/src/lib/services/umami.service.ts index af91377..5261992 100644 --- a/src/lib/services/umami.service.ts +++ b/src/lib/services/umami.service.ts @@ -21,6 +21,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { rangeToBounds, type DateRange } from '@/lib/analytics/range'; +import { CodedError } from '@/lib/errors'; import { fetchWithTimeout } from '@/lib/fetch-with-timeout'; // ─── Settings access ──────────────────────────────────────────────────────── @@ -87,10 +88,15 @@ async function loginAndCache(apiUrl: string, username: string, password: string) body: JSON.stringify({ username, password }), }); if (!res.ok) { - throw new Error(`Umami login failed: ${res.status} ${res.statusText}`); + throw new CodedError('UMAMI_UPSTREAM_ERROR', { + internalMessage: `Umami login failed: ${res.status} ${res.statusText}`, + }); } const body = (await res.json()) as { token?: string }; - if (!body.token) throw new Error('Umami login response missing token'); + if (!body.token) + throw new CodedError('UMAMI_UPSTREAM_ERROR', { + internalMessage: 'Umami login response missing token', + }); jwtCache.set(`${apiUrl}::${username}`, { token: body.token, expiresAt: Date.now() + JWT_TTL_MS, @@ -101,7 +107,9 @@ async function loginAndCache(apiUrl: string, username: string, password: string) async function resolveBearer(config: UmamiPortConfig): Promise { if (config.apiToken) return config.apiToken; if (!config.username || !config.password) { - throw new Error('Umami is misconfigured: no API token and no username/password.'); + throw new CodedError('UMAMI_NOT_CONFIGURED', { + internalMessage: 'Umami is misconfigured: no API token and no username/password.', + }); } const cacheKey = `${config.apiUrl}::${config.username}`; const cached = jwtCache.get(cacheKey); @@ -136,13 +144,17 @@ async function umamiFetch( if (res.status === 401 || res.status === 403) { // Bearer rejected - drop cached JWT so next call re-logs in. if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`); - throw new Error(`Umami unauthorized: ${res.status}`); + throw new CodedError('UMAMI_UPSTREAM_ERROR', { + internalMessage: `Umami unauthorized: ${res.status}`, + }); } if (!res.ok) { const text = await res.text().catch(() => ''); - throw new Error( - `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`, - ); + throw new CodedError('UMAMI_UPSTREAM_ERROR', { + internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${ + text ? ` - ${text}` : '' + }`, + }); } return (await res.json()) as T; } @@ -248,7 +260,9 @@ export async function getActiveVisitors(portId: string): Promise { const config = await loadUmamiConfig(portId); if (!config) { - throw new Error('Umami is not configured for this port.'); + throw new CodedError('UMAMI_NOT_CONFIGURED', { + internalMessage: 'Umami is not configured for this port.', + }); } const result = await umamiFetch( config, diff --git a/tests/integration/dedup/client-merge.test.ts b/tests/integration/dedup/client-merge.test.ts index ecad124..160270e 100644 --- a/tests/integration/dedup/client-merge.test.ts +++ b/tests/integration/dedup/client-merge.test.ts @@ -130,7 +130,7 @@ describe('mergeClients', () => { const winner2 = await makeClient({ portId: port.id }); await expect( mergeClients({ winnerId: winner2.id, loserId: loser.id, mergedBy: 'u' }), - ).rejects.toThrow(/already merged/i); + ).rejects.toThrow(/already been merged/i); }); it('drops duplicate contact rows during reattach', async () => { diff --git a/tests/integration/expense-dedup.test.ts b/tests/integration/expense-dedup.test.ts index da8f060..8afffda 100644 --- a/tests/integration/expense-dedup.test.ts +++ b/tests/integration/expense-dedup.test.ts @@ -197,6 +197,8 @@ describe('expense dedup', () => { expenseDate: new Date('2026-04-15T12:00:00Z'), }); await expect(mergeDuplicate(a.id, a.id, portA.id)).rejects.toThrow(/itself/); - await expect(mergeDuplicate(a.id, b.id, portA.id)).rejects.toThrow(/not found/); + await expect(mergeDuplicate(a.id, b.id, portA.id)).rejects.toThrow( + /couldn't find that expense/, + ); }); }); diff --git a/tests/unit/interest-scoring.test.ts b/tests/unit/interest-scoring.test.ts index 1fe891c..bcda28d 100644 --- a/tests/unit/interest-scoring.test.ts +++ b/tests/unit/interest-scoring.test.ts @@ -306,7 +306,9 @@ describe('calculateInterestScore', () => { it('throws when interest not found', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue(null); - await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found'); + await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow( + /couldn't find that interest/, + ); }); it('returns cached result when redis has a hit (after port-scope DB check)', async () => { diff --git a/tests/unit/services/documenso-place-fields.test.ts b/tests/unit/services/documenso-place-fields.test.ts index ee725e2..e848ec5 100644 --- a/tests/unit/services/documenso-place-fields.test.ts +++ b/tests/unit/services/documenso-place-fields.test.ts @@ -147,7 +147,7 @@ describe('placeFields v2 dispatch', () => { ], 'port-1', ), - ).rejects.toThrow(/v2 placeFields/); + ).rejects.toThrow(/signing service didn't respond/); }); }); @@ -235,7 +235,7 @@ describe('placeFields v1 dispatch', () => { ], 'port-1', ), - ).rejects.toThrow(/v1 placeField/); + ).rejects.toThrow(/signing service didn't respond/); }); }); @@ -322,6 +322,6 @@ describe('voidDocument', () => { it('throws on other non-2xx responses', async () => { configurePort('v2'); fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })); - await expect(voidDocument('env-1', 'port-1')).rejects.toThrow(/voidDocument/); + await expect(voidDocument('env-1', 'port-1')).rejects.toThrow(/signing service didn't respond/); }); });