Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
import { NextResponse, type NextRequest } from 'next/server';
|
||
import { and, eq, sql } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { documentSendOpens, documentSends } from '@/lib/db/schema/brochures';
|
||
import { logger } from '@/lib/logger';
|
||
import { trackEvent } from '@/lib/services/umami.service';
|
||
|
||
/**
|
||
* GET /api/public/email-pixel/[sendId]
|
||
*
|
||
* Returns a 1×1 transparent GIF and records an open event in
|
||
* `document_send_opens` + bumps the cached aggregates on `document_sends`.
|
||
*
|
||
* Lookups are gated by `track_opens=true` on the send row, so a leaked
|
||
* sendId for an untracked email is a no-op (the pixel still returns
|
||
* 200/GIF so email clients don't surface a broken-image icon).
|
||
*
|
||
* Privacy: we deliberately don't store IP addresses or any data beyond
|
||
* user-agent + referer. Apple Mail privacy proxy pre-fetches images, so
|
||
* opens from iOS users are over-counted; image-blocking clients
|
||
* (Outlook with images disabled) under-count. Standard email-tracking
|
||
* caveats apply.
|
||
*/
|
||
|
||
// 1×1 transparent GIF, base64-encoded. Generated once at module-load so
|
||
// every request returns the same buffer without re-allocating.
|
||
const TRANSPARENT_GIF = Buffer.from(
|
||
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||
'base64',
|
||
);
|
||
|
||
function gifResponse(): NextResponse {
|
||
return new NextResponse(TRANSPARENT_GIF as unknown as BodyInit, {
|
||
status: 200,
|
||
headers: {
|
||
'Content-Type': 'image/gif',
|
||
'Content-Length': String(TRANSPARENT_GIF.length),
|
||
// Tell every upstream cache to keep its hands off - we count opens
|
||
// on the FETCH itself, so any cached response is a missed open.
|
||
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||
Pragma: 'no-cache',
|
||
Expires: '0',
|
||
},
|
||
});
|
||
}
|
||
|
||
export async function GET(
|
||
req: NextRequest,
|
||
ctx: { params: Promise<{ sendId: string }> },
|
||
): Promise<NextResponse> {
|
||
const { sendId } = await ctx.params;
|
||
|
||
try {
|
||
// Look up the send row; ignore unknown / un-tracked sends silently.
|
||
const sendRow = await db.query.documentSends.findFirst({
|
||
where: and(eq(documentSends.id, sendId), eq(documentSends.trackOpens, true)),
|
||
columns: { id: true, portId: true, recipientEmail: true, documentKind: true },
|
||
});
|
||
if (!sendRow) return gifResponse();
|
||
|
||
const userAgent = req.headers.get('user-agent');
|
||
const referer = req.headers.get('referer');
|
||
|
||
// Best-effort write - never block the pixel response on a slow DB.
|
||
// The pixel must return promptly so email clients render normally.
|
||
db.insert(documentSendOpens)
|
||
.values({
|
||
portId: sendRow.portId,
|
||
sendId: sendRow.id,
|
||
userAgent: userAgent ?? null,
|
||
referer: referer ?? null,
|
||
})
|
||
.then(() =>
|
||
db
|
||
.update(documentSends)
|
||
.set({
|
||
openCount: sql`${documentSends.openCount} + 1`,
|
||
firstOpenedAt: sql`COALESCE(${documentSends.firstOpenedAt}, NOW())`,
|
||
})
|
||
.where(eq(documentSends.id, sendRow.id)),
|
||
)
|
||
.catch((err) => {
|
||
logger.warn({ err, sendId: sendRow.id }, 'email-pixel: failed to record open');
|
||
});
|
||
|
||
// Cross-post to Umami so the marketing funnel includes opens. Don't
|
||
// await - fire-and-forget so the pixel response stays fast.
|
||
trackEvent(
|
||
sendRow.portId,
|
||
'email-opened',
|
||
{
|
||
sendId: sendRow.id,
|
||
documentKind: sendRow.documentKind,
|
||
},
|
||
'email://pixel',
|
||
).catch((err) => {
|
||
logger.debug({ err, sendId: sendRow.id }, 'email-pixel: umami cross-post failed');
|
||
});
|
||
|
||
return gifResponse();
|
||
} catch (err) {
|
||
logger.warn({ err, sendId }, 'email-pixel: unexpected error');
|
||
return gifResponse();
|
||
}
|
||
}
|