Skip to content
Open
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
40 changes: 31 additions & 9 deletions apps/web/src/app/api/ses_callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,31 @@ export async function GET() {
export async function POST(req: Request) {
const data = await req.json();

console.log(data, data.Message);
logger.info(
{
snsMessageId: data.MessageId,
snsType: data.Type,
topicArn: data.TopicArn,
},
"[SesCallback]: Received an SNS notification",
);

const isEventValid = await checkEventValidity(data);

console.log("Is event valid: ", isEventValid);

if (!isEventValid) {
logger.warn(
{ snsMessageId: data.MessageId, topicArn: data.TopicArn },
"[SesCallback]: Dropped the SES event because the topic is not configured",
);
return Response.json({ data: "Event is not valid" });
}

if (data.Type === "SubscriptionConfirmation") {
return handleSubscription(data);
}

let message = null;

try {
message = JSON.parse(data.Message || "{}");
const message = JSON.parse(data.Message || "{}");
const status = await SesHookParser.queue({
event: message,
messageId: data.MessageId,
Expand All @@ -42,7 +49,10 @@ export async function POST(req: Request) {

return Response.json({ data: "Success" });
} catch (e) {
console.error(e);
logger.error(
{ err: e, snsMessageId: data.MessageId, topicArn: data.TopicArn },
"[SesCallback]: Failed to queue the SES event",
);
return Response.json({ data: "Error is parsing hook" });
}
}
Expand All @@ -51,18 +61,30 @@ export async function POST(req: Request) {
* Handles the subscription confirmation event. called only once for a webhook
*/
async function handleSubscription(message: any) {
await fetch(message.SubscribeURL, {
const topicArn = message.TopicArn as string;

const confirmResponse = await fetch(message.SubscribeURL, {
method: "GET",
});

const topicArn = message.TopicArn as string;
if (!confirmResponse.ok) {
logger.error(
{ status: confirmResponse.status, topicArn },
"[SesCallback]: SNS subscription confirmation returned a non-success status",
);
}

const setting = await db.sesSetting.findFirst({
where: {
topicArn,
},
});

if (!setting) {
logger.warn(
{ topicArn },
"[SesCallback]: No SES setting matches the confirmed topic ARN",
);
return Response.json({ data: "Setting not found" });
}

Expand Down
27 changes: 18 additions & 9 deletions apps/web/src/app/api/trpc/[trpc]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";

import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { logger } from "~/server/logger/log";

/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
Expand All @@ -21,14 +21,23 @@ const handler = (req: NextRequest) =>
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
// Only server-side failures are logged. Rejected input, bad credentials and
// missing records are a normal part of the API surface and stay unlogged.
onError: ({ path, error }) => {
if (error.code !== "INTERNAL_SERVER_ERROR" && error.code !== "TIMEOUT") {
return;
}

logger.error(
{
err: error,
cause: error.cause,
trpcPath: path,
trpcCode: error.code,
},
"[Trpc]: Request failed"
);
},
});

export { handler as GET, handler as POST };
45 changes: 44 additions & 1 deletion apps/web/src/app/api/webhook/stripe/route.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
const { state } = vi.hoisted(() => ({
state: {
signature: null as string | null,
secret: undefined as string | undefined,
},
}));

Expand All @@ -27,11 +28,23 @@ vi.mock("~/server/billing/payments", () => ({

vi.mock("~/env", () => ({
env: {
STRIPE_WEBHOOK_SECRET: undefined,
get STRIPE_WEBHOOK_SECRET() {
return state.secret;
},
},
}));

vi.mock("~/server/logger/log", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));

import { POST } from "~/app/api/webhook/stripe/route";
import { getStripe, syncStripeData } from "~/server/billing/payments";
import { logger } from "~/server/logger/log";

describe("stripe webhook route", () => {
it("returns 400 when signature header is missing", async () => {
Expand All @@ -58,4 +71,34 @@ describe("stripe webhook route", () => {
expect(response.status).toBe(400);
await expect(response.text()).resolves.toBe("No webhook secret");
});

it("logs the stripe event identity when the billing sync fails", async () => {
state.signature = "test-signature";
state.secret = "whsec_test";

vi.mocked(getStripe).mockReturnValue({
webhooks: {
constructEvent: vi.fn(() => ({
id: "evt_123",
type: "invoice.paid",
data: { object: { customer: "cus_123" } },
})),
},
} as never);
vi.mocked(syncStripeData).mockRejectedValue(new Error("stripe API error"));

const response = await POST(
new Request("http://localhost", { method: "POST", body: "{}" }),
);

expect(response.status).toBe(400);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
stripeEventId: "evt_123",
stripeEventType: "invoice.paid",
stripeCustomerId: "cus_123",
}),
expect.any(String),
);
});
});
20 changes: 15 additions & 5 deletions apps/web/src/app/api/webhook/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import Stripe from "stripe";
import { env } from "~/env";
import { getStripe, syncStripeData } from "~/server/billing/payments";
import { logger } from "~/server/logger/log";

const allowedEvents: Stripe.Event.Type[] = [
"checkout.session.completed",
Expand Down Expand Up @@ -41,8 +42,11 @@ export async function POST(req: Request) {

const stripe = getStripe();

let event: Stripe.Event | undefined;
let customerId: string | undefined;

try {
const event = stripe.webhooks.constructEvent(
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
Expand All @@ -53,9 +57,7 @@ export async function POST(req: Request) {
}

// All the events I track have a customerId
const { customer: customerId } = event?.data?.object as {
customer: string; // Sadly TypeScript does not know this
};
customerId = (event.data.object as { customer: string }).customer;

// This helps make it typesafe and also lets me know if my assumption is wrong
if (typeof customerId !== "string") {
Expand All @@ -68,7 +70,15 @@ export async function POST(req: Request) {

return new NextResponse("OK", { status: 200 });
} catch (err) {
console.error("Error processing webhook:", err);
logger.error(
{
err,
stripeEventId: event?.id,
stripeEventType: event?.type,
stripeCustomerId: customerId,
},
"[StripeHook]: Failed to process the billing event"
);
return new NextResponse("Webhook error", { status: 400 });
}
}
5 changes: 4 additions & 1 deletion apps/web/src/server/aws/ses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,10 @@ export async function sendRawEmail({
logger.info({ messageId: response.MessageId }, "Email sent!");
return response.MessageId;
} catch (error) {
logger.error({ err: error }, "Failed to send email");
logger.error(
{ err: error, emailId, region },
"Failed to send email"
);
// It's better to throw the original error or a new error with more context
// throw new Error("Failed to send email");
throw error;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/server/billing/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export async function syncStripeData(customerId: string) {
});

if (!team) {
logger.warn(
{ stripeCustomerId: customerId },
"[Billing]: No team matches the Stripe customer; plan was not synced",
);
return;
}

Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/server/public-api/api/domains/get-domain.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { mockGetTeamFromToken, mockRedis, mockGetDomainService, mockLogger } =
vi.hoisted(() => ({
mockGetTeamFromToken: vi.fn(),
mockRedis: {
incr: vi.fn(),
expire: vi.fn(),
ttl: vi.fn(),
},
mockGetDomainService: vi.fn(),
mockLogger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));

vi.mock("~/server/public-api/auth", () => ({
getTeamFromToken: mockGetTeamFromToken,
}));

vi.mock("~/server/redis", () => ({
getRedis: () => mockRedis,
redisKey: (key: string) => key,
}));

vi.mock("~/server/db", () => ({
db: {
domain: { findUnique: vi.fn() },
team: { findUnique: vi.fn() },
},
}));

vi.mock("~/utils/common", () => ({
isSelfHosted: () => false,
}));

vi.mock("~/server/service/domain-service", () => ({
getDomain: mockGetDomainService,
}));

vi.mock("~/server/logger/log", () => ({
logger: mockLogger,
}));

import { UnsendApiError } from "~/server/public-api/api-error";
import getDomain from "~/server/public-api/api/domains/get-domain";
import { getApp } from "~/server/public-api/hono";

describe("GET /v1/domains/{id}", () => {
beforeEach(() => {
mockGetTeamFromToken.mockReset();
mockGetDomainService.mockReset();
mockLogger.error.mockReset();
mockRedis.incr.mockReset();
mockRedis.expire.mockReset();
mockRedis.ttl.mockReset();

mockGetTeamFromToken.mockResolvedValue({
id: 1,
apiRateLimit: 20,
apiKeyId: 11,
apiKey: { domainId: null },
});

mockRedis.incr.mockResolvedValue(1);
mockRedis.expire.mockResolvedValue(1);
mockRedis.ttl.mockResolvedValue(1);
});

function request() {
const app = getApp();
getDomain(app);
return app.request("http://localhost/api/v1/domains/42", {
headers: { Authorization: "Bearer test-key" },
});
}

it("records the original error, the domain and the team when enrichment fails", async () => {
mockGetDomainService.mockRejectedValue(
new Error("SES identity lookup failed"),
);

const response = await request();

expect(response.status).toBe(500);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
domainId: 42,
teamId: 1,
err: expect.any(Error),
}),
"Failed to enrich domain",
);
});

it("records no enrichment failure for an expected missing domain", async () => {
mockGetDomainService.mockRejectedValue(
new UnsendApiError({ code: "NOT_FOUND", message: "Domain not found" }),
);

const response = await request();

expect(response.status).toBe(500);
const enrichmentFailureLogs = mockLogger.error.mock.calls.filter(
([, message]) => message === "Failed to enrich domain",
);
expect(enrichmentFailureLogs).toHaveLength(0);
});
});
7 changes: 7 additions & 0 deletions apps/web/src/server/public-api/api/domains/get-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRoute, z } from "@hono/zod-openapi";
import { DomainSchema } from "~/lib/zod/domain-schema";
import { PublicAPIApp } from "~/server/public-api/hono";
import { UnsendApiError } from "../../api-error";
import { logger } from "~/server/logger/log";
import { db } from "~/server/db";
import { getDomain as getDomainService } from "~/server/service/domain-service";

Expand Down Expand Up @@ -46,6 +47,12 @@ function getDomain(app: PublicAPIApp) {
try {
enriched = await getDomainService(id, team.id);
} catch (e) {
if (!(e instanceof UnsendApiError) || e.status >= 500) {
logger.error(
{ err: e, domainId: id, teamId: team.id },
"Failed to enrich domain",
);
}
throw new UnsendApiError({
code: "INTERNAL_SERVER_ERROR",
message: e instanceof Error ? e.message : "Internal server error",
Expand Down
Loading
Loading