Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,26 @@ NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX
# Resend console.
RESEND_API_KEY=re_xxxxxxxxx
TRADE_INQUIRY_TO_EMAIL=your-email@example.com
# Canonical staff notification mailbox for the trade pipeline (new inquiry
# alerts + customer-reply alerts on unassigned leads). Falls back to
# TRADE_INQUIRY_TO_EMAIL when unset — set this and remove the legacy name.
# TRADE_NOTIFICATION_EMAIL=your-email@example.com
# Sender for trade-inquiry emails; also the fallback sender for admin
# invitations. Optional — defaults to noreply@mail.deepdivebrewing.com
# (the verified Resend sending domain).
RESEND_FROM_EMAIL=deepdive@mail.deepdivebrewing.com
# Sender for customer-facing lead email sent from /admin/trade. Optional —
# falls back to RESEND_FROM_EMAIL, then trade@mail.deepdivebrewing.com
# (verified domain).
# TRADE_FROM_EMAIL=trade@mail.deepdivebrewing.com
# Domain for per-lead inbound addresses (<token>@<domain>) used in Reply-To
# and the "attach email to this lead" feature. Requires an inbound-configured
# domain in Resend (MX records) — defaults to reply.deepdivebrewing.com.
# TRADE_REPLY_DOMAIN=reply.deepdivebrewing.com
# Signing secret (whsec_…) for POST /api/webhooks/resend — required for the
# endpoint to accept inbound email and delivery events. Configure the same
# secret on the webhook in the Resend console.
# RESEND_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
# Optional preferred sender for admin invitation emails.
ADMIN_INVITE_FROM_EMAIL=invitations@mail.deepdivebrewing.com
# Optional cooldown in milliseconds before an invitation email can be resent (default 60,000).
Expand Down
22 changes: 4 additions & 18 deletions app/api/admin/trade-leads/[id]/activities/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,10 @@ import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId, logInfo } from "@/lib/log";
import {
addTradeLeadNote,
getTradeLead,
listTradeLeadActivities,
getTradeLeadDetail,
tradeLeadActorOf,
} from "@/lib/trade-leads-admin";
import {
serializeTradeLead,
serializeTradeLeadActivity,
validateNoteBody,
} from "@/lib/trade-leads-admin-common";
import { validateNoteBody } from "@/lib/trade-leads-admin-common";

interface RouteParams {
params: Promise<{ id: string }>;
Expand Down Expand Up @@ -45,17 +40,8 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
await addTradeLeadNote(id, parsed.note, tradeLeadActorOf(actor));
logInfo("trade_lead.note_added", { leadId: id, requestId });

const [lead, activities] = await Promise.all([
getTradeLead(id),
listTradeLeadActivities(id),
]);
return NextResponse.json({
ok: true,
lead: lead ? serializeTradeLead(lead.id, lead.data) : null,
activities: activities.map((a) =>
serializeTradeLeadActivity(a.id, a.data)
),
});
const detail = await getTradeLeadDetail(id);
return NextResponse.json({ ok: true, ...detail });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Failed to add note.",
Expand Down
88 changes: 88 additions & 0 deletions app/api/admin/trade-leads/[id]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import {
badRequestResponse,
getBearerToken,
unauthorizedResponse,
} from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId, logInfo } from "@/lib/log";
import {
getTradeLeadDetail,
tradeLeadActorOf,
} from "@/lib/trade-leads-admin";
import {
parseOutboundMessageBody,
sendLeadEmail,
TradeLeadEmailSendError,
} from "@/lib/trade-leads-email";

interface RouteParams {
params: Promise<{ id: string }>;
}

// Sends an admin-composed email to the lead's contact address (Issue #152).
// The recipient is always the lead's stored email — the request cannot aim
// the message at an arbitrary address, so the endpoint is not a mail relay.
// The send is recorded on the lead (communication doc + timeline entry)
// whether or not the provider accepts it, so the history never claims a
// message that did not happen.
export async function POST(req: NextRequest, { params }: RouteParams) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

let body: unknown;
try {
body = await req.json();
} catch {
return badRequestResponse("Invalid JSON body.");
}

const { id } = await params;

try {
const actor = await requireAdminActor(idToken);

const parsed = parseOutboundMessageBody(body);
if (!parsed.ok) return badRequestResponse(parsed.error);

const result = await sendLeadEmail(
id,
parsed.message,
tradeLeadActorOf(actor)
);
logInfo("trade_lead.email_sent", {
leadId: id,
communicationId: result.communicationId,
requestId,
});

const detail = await getTradeLeadDetail(id);
return NextResponse.json({ ok: true, ...detail });
} catch (error) {
if (error instanceof TradeLeadEmailSendError) {
// The communication id lets the client retry this exact send — the
// server replays the stored payload under the same Resend idempotency
// key, so a provider-accepted attempt can't turn into two emails.
logInfo("trade_lead.email_send_failed", {
leadId: id,
communicationId: error.communicationId,
requestId,
});
return NextResponse.json(
{
ok: false,
error: error.message,
communicationId: error.communicationId,
},
{ status: error.status }
);
}
return apiErrorResponse(error, {
fallback: "Failed to send the email.",
event: "trade_lead.email_send_failed",
context: { requestId },
});
}
}
26 changes: 6 additions & 20 deletions app/api/admin/trade-leads/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,19 @@ import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId, logInfo } from "@/lib/log";
import {
applyLeadPatch,
getTradeLead,
listTradeLeadActivities,
getTradeLeadDetail,
resolveAssignee,
tradeLeadActorOf,
} from "@/lib/trade-leads-admin";
import {
parseLeadPatchBody,
serializeTradeLead,
serializeTradeLeadActivity,
type NormalizedLeadPatch,
} from "@/lib/trade-leads-admin-common";

interface RouteParams {
params: Promise<{ id: string }>;
}

async function leadDetail(id: string) {
const [lead, activities] = await Promise.all([
getTradeLead(id),
listTradeLeadActivities(id),
]);
if (!lead) return null;
return {
lead: serializeTradeLead(lead.id, lead.data),
activities: activities.map((a) => serializeTradeLeadActivity(a.id, a.data)),
};
}

export async function GET(req: NextRequest, { params }: RouteParams) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
Expand All @@ -41,15 +26,15 @@ export async function GET(req: NextRequest, { params }: RouteParams) {
try {
await requireAdminActor(idToken);
const { id } = await params;
const detail = await leadDetail(id);
const detail = await getTradeLeadDetail(id);
if (!detail) {
return NextResponse.json(
{ ok: false, error: "Trade lead not found." },
{ status: 404 }
);
}
// Read-only: viewing a lead must not touch updatedAt/lastActivityAt —
// those timestamps drive the 24-month retention window.
// Read-only apart from first-touch routing-token provisioning, which
// does not advance updatedAt/lastActivityAt — the retention anchors.
return NextResponse.json({ ok: true, ...detail });
} catch (error) {
return apiErrorResponse(error, {
Expand Down Expand Up @@ -81,6 +66,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {

const patch: NormalizedLeadPatch = {
status: parsed.patch.status,
island: parsed.patch.island,
nextFollowUpAt: parsed.patch.nextFollowUpAt,
outcome: parsed.patch.outcome,
};
Expand All @@ -101,7 +87,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
});
}

const detail = await leadDetail(id);
const detail = await getTradeLeadDetail(id);
return NextResponse.json({ ok: true, ...detail });
} catch (error) {
return apiErrorResponse(error, {
Expand Down
64 changes: 64 additions & 0 deletions app/api/webhooks/resend/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type NextRequest, NextResponse } from "next/server";
import { getRequestId, logError, logInfo } from "@/lib/log";
import {
applyDeliveryEvent,
handleInboundEmail,
verifyResendWebhook,
} from "@/lib/trade-leads-email";

// Resend webhook endpoint (Issue #152): inbound customer replies
// (email.received) and outbound delivery-state callbacks (email.delivered,
// email.bounced, …). Not behind admin auth — the caller is Resend — so the
// svix signature on the raw body is the trust boundary, verified before any
// payload is touched.
//
// Failure contract:
// 401/400 → invalid or unverifiable request (no retry desired)
// 200 → handled, ignored, or already recorded (idempotent)
// 500 → transient/internal failure — Resend retries the delivery
export async function POST(req: NextRequest) {
const requestId = getRequestId(req.headers);
const rawPayload = await req.text();

let event;
try {
event = verifyResendWebhook(rawPayload, {
// Resend uses the Standard Webhooks header set.
id: req.headers.get("svix-id") ?? req.headers.get("webhook-id"),
timestamp:
req.headers.get("svix-timestamp") ??
req.headers.get("webhook-timestamp"),
signature:
req.headers.get("svix-signature") ??
req.headers.get("webhook-signature"),
});
} catch (error) {
logError("trade_email.webhook_rejected", error, { requestId });
return NextResponse.json(
{ ok: false, error: "Invalid webhook." },
{ status: 401 }
);
}

try {
if (event.type === "email.received") {
const outcome = await handleInboundEmail(event.data);
logInfo("trade_email.inbound_processed", { requestId, outcome });
return NextResponse.json({ ok: true, outcome });
}

const outcome = await applyDeliveryEvent(event.type, event.data);
if (outcome !== "ignored") {
logInfo("trade_email.delivery_processed", { requestId, outcome });
}
return NextResponse.json({ ok: true, outcome });
} catch (error) {
// Processing failure → 500 so the provider retries rather than silently
// dropping a customer reply.
logError("trade_email.webhook_processing_failed", error, { requestId });
return NextResponse.json(
{ ok: false, error: "Webhook processing failed." },
{ status: 500 }
);
}
}
Loading
Loading