diff --git a/src/app/api/v1/tracked-links/route.ts b/src/app/api/v1/tracked-links/route.ts new file mode 100644 index 00000000..ad2eae68 --- /dev/null +++ b/src/app/api/v1/tracked-links/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { buildTrackedUrl, createTrackedLink } from '@/lib/services/tracked-links.service'; +import { errorResponse } from '@/lib/errors'; + +/** + * POST /api/v1/tracked-links + * + * Mints a new tracked redirect-link the rep can drop into an outgoing + * email or chat. Body: { targetUrl, sendId? }. Returns the slug + the + * full public URL (`/q/`) — caller pastes the URL into + * the message draft. + * + * Gated on `email.send` since this surface is consumed from compose UIs. + * Tracked links aren't sensitive on their own but reps shouldn't be + * able to mint click-trackers without the underlying send permission. + */ +const createSchema = z.object({ + targetUrl: z.string().url(), + sendId: z.string().min(1).optional(), +}); + +export const POST = withAuth(async (req, ctx) => { + try { + const body = await parseBody(req, createSchema); + const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.email?.send; + if (!allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const row = await createTrackedLink({ + portId: ctx.portId, + targetUrl: body.targetUrl, + sendId: body.sendId, + createdByUserId: ctx.userId, + }); + return NextResponse.json({ + data: { + id: row.id, + slug: row.slug, + targetUrl: row.targetUrl, + url: buildTrackedUrl(row.slug), + }, + }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/components/admin/sends-log.tsx b/src/components/admin/sends-log.tsx index 52b2a8c4..b38141a1 100644 --- a/src/components/admin/sends-log.tsx +++ b/src/components/admin/sends-log.tsx @@ -31,6 +31,12 @@ interface SendRow { bounceStatus: 'hard' | 'soft' | 'ooo' | null; bounceReason: string | null; bounceDetectedAt: string | null; + /** Phase 4b email-open tracking. `openCount` is denormalised on every + * pixel hit; `firstOpenedAt` stamps the first time the recipient + * loaded the email. Both stay 0 / null when `trackOpens` is off. */ + trackOpens: boolean; + openCount: number; + firstOpenedAt: string | null; } interface ListResponse { @@ -137,6 +143,28 @@ export function SendsLog() { : 'Out of office'} ) : null} + {row.trackOpens ? ( + row.openCount > 0 ? ( + + Opened {row.openCount > 1 ? `× ${row.openCount}` : ''} + + ) : ( + + Not opened + + ) + ) : null} void; + /** Display variant — `inline` is a small text button suitable for a + * toolbar; `default` is a sized button suitable for a form footer. */ + variant?: 'inline' | 'default'; +} + +/** + * Compose-surface affordance for minting a trackable redirect link. + * Opens a small dialog: rep pastes the destination URL, clicks Create, + * gets the public `/q/` URL with a Copy button + an "Insert into + * message" action that calls back to the parent. + * + * Permission-gated on `email.send` so reps without send rights don't + * see the affordance — same as the server-side check on the POST route. + */ +export function TrackedLinkComposerButton({ sendId, onInsert, variant = 'inline' }: Props) { + const [open, setOpen] = useState(false); + const [target, setTarget] = useState(''); + const [result, setResult] = useState(null); + + const create = useMutation({ + mutationFn: async () => { + const res = await apiFetch('/api/v1/tracked-links', { + method: 'POST', + body: { targetUrl: target.trim(), sendId }, + }); + return res.data; + }, + onSuccess: (data) => { + setResult(data); + }, + onError: (err) => toastError(err), + }); + + function reset() { + setTarget(''); + setResult(null); + } + + return ( + <> + + + { + setOpen(o); + if (!o) reset(); + }} + > + + + Mint a tracked link + + The recipient clicks the public <APP_URL>/q/<slug> URL and lands on the + destination URL. Each click is counted + attributed to{' '} + {sendId ? 'this send' : 'your account'} in the analytics surface. + + + {result ? ( +
+ +
+ + +
+

+ Redirects to{' '} + + {result.targetUrl} + + . +

+
+ ) : ( +
+ + setTarget(e.target.value)} + placeholder="https://portnimara.com/yachts/sapphire" + /> +
+ )} + + {result ? ( + <> + + {onInsert ? ( + + ) : null} + + ) : ( + <> + + + + )} + +
+
+ + ); +} diff --git a/src/components/shared/send-document-dialog.tsx b/src/components/shared/send-document-dialog.tsx index 8d20981f..9ca4a63b 100644 --- a/src/components/shared/send-document-dialog.tsx +++ b/src/components/shared/send-document-dialog.tsx @@ -32,6 +32,7 @@ import { } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { TrackedLinkComposerButton } from '@/components/email/tracked-link-composer-button'; import { Input } from '@/components/ui/input'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; @@ -221,7 +222,17 @@ function SendDocumentDialogInner({ )}
- +
+ + {/* Tracked-link composer — appends a trackable redirect + URL to the body so click-throughs reconcile back to + the send's analytics. */} + + setCustomBody((prev) => (prev ? `${prev}\n\n${url}` : url).slice(0, BODY_MAX)) + } + /> +