Files
pn-new-crm/src/components/email/compose-dialog.tsx
Matt Ciaccio fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:

* 38 client components / 56 toast.error sites converted to
  toastError(err) so the new admin error inspector becomes usable from
  user-reported issues — every failed inline-edit, save, send, archive,
  upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
  the existing AppError subclasses.  Adds new error codes:
  DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
  DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
  IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
  UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
  post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
  (client-merge "already been merged", expense/interest "couldn't find
  that …", documenso "signing service didn't respond").

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00

155 lines
4.5 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface Account {
id: string;
emailAddress: string;
isActive: boolean;
}
interface ComposeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTo?: string;
defaultSubject?: string;
}
export function ComposeDialog({
open,
onOpenChange,
defaultTo = '',
defaultSubject = '',
}: ComposeDialogProps) {
const qc = useQueryClient();
const [accountId, setAccountId] = useState('');
const [to, setTo] = useState(defaultTo);
const [cc, setCc] = useState('');
const [subject, setSubject] = useState(defaultSubject);
const [body, setBody] = useState('');
const { data: accounts = [] } = useQuery<Account[]>({
queryKey: ['email', 'accounts'],
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
});
const activeAccounts = accounts.filter((a) => a.isActive);
const sendMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/email/compose', {
method: 'POST',
body: {
accountId,
to: to
.split(',')
.map((s) => s.trim())
.filter(Boolean),
cc: cc
? cc
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined,
subject,
bodyHtml: body.replace(/\n/g, '<br>'),
},
}),
onSuccess: () => {
toast.success('Email sent');
qc.invalidateQueries({ queryKey: ['email', 'threads'] });
onOpenChange(false);
setTo('');
setCc('');
setSubject('');
setBody('');
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Compose email</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label>From</Label>
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger>
<SelectValue placeholder="Choose account" />
</SelectTrigger>
<SelectContent>
{activeAccounts.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No active accounts
</div>
) : (
activeAccounts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.emailAddress}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>To (comma-separated)</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} />
</div>
<div className="space-y-1">
<Label>CC</Label>
<Input value={cc} onChange={(e) => setCc(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Message</Label>
<Textarea rows={10} value={body} onChange={(e) => setBody(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => sendMutation.mutate()}
disabled={
sendMutation.isPending || !accountId || !to.trim() || !subject.trim() || !body.trim()
}
>
{sendMutation.isPending ? 'Sending…' : 'Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}