feat: add logging to next api routes

This commit is contained in:
Henry
2026-04-05 22:33:06 +01:00
parent e81f5d7fe6
commit 0dc5092ebc
9 changed files with 252 additions and 167 deletions

View File

@@ -1,12 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env"; import { env } from "~/env";
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") { if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" }); return res.status(405).json({ message: "Method not allowed" });
} }
@@ -32,7 +33,9 @@ export default withRateLimit(
try { try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase(); allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch { } catch {
return res.status(500).json({ message: "Storage endpoint misconfigured" }); return res
.status(500)
.json({ message: "Storage endpoint misconfigured" });
} }
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) { if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
@@ -66,10 +69,7 @@ export default withRateLimit(
const buffer = await upstream.arrayBuffer(); const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer)); return res.send(Buffer.from(buffer));
} catch (error) { } catch (error) {
console.error("Error downloading attachment:", error); return res.status(500).json({ message: "Failed to download attachment" });
return res
.status(500)
.json({ message: "Failed to download attachment" });
} }
}, }),
); );

View File

@@ -1,25 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") { if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" }); return res.status(405).json({ message: "Method not allowed" });
}
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
} }
const data = await response.json();
return res.status(200).json(data); try {
} catch (error) { const response = await fetch("https://formbricks.com/api/oss-friends");
console.error("Error fetching OSS friends:", error); if (!response.ok) {
return res.status(500).json({ message: "Failed to fetch OSS friends" }); throw new Error("Failed to fetch from Formbricks");
} }
}, const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}),
); );

View File

@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe"; import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createStripeClient } from "@kan/stripe";
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient(); const stripe = createStripeClient();
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" }); return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
} }
const session = await stripe.billingPortal.sessions.create({ try {
customer: user.stripeCustomerId, const { user } = await createNextApiContext(req);
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
return res.status(200).json({ url: session.url }); if (!user?.stripeCustomerId) {
} catch (error) { return res.status(404).json({ error: "No billing account found" });
console.error("Error:", error); }
return res.status(500).json({ error: "Error creating portal session" });
} const session = await stripe.billingPortal.sessions.create({
}, customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
return res.status(200).json({ url: session.url });
} catch (error) {
return res.status(500).json({ error: "Error creating portal session" });
}
}),
); );

View File

@@ -1,52 +1,58 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { addYears } from "date-fns";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema"; import { withApiLogging } from "@kan/api/utils/apiLogging";
import { addYears } from "date-fns";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { integrations } from "@kan/db/schema";
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" }); return res.status(405).json({ message: "Method not allowed" });
} }
const { user } = await createNextApiContext(req); const { user } = await createNextApiContext(req);
if (!user) if (!user)
return res.status(401).json({ message: "User not authenticated" }); return res.status(401).json({ message: "User not authenticated" });
const apiKey = process.env.TRELLO_APP_API_KEY; const apiKey = process.env.TRELLO_APP_API_KEY;
if (!apiKey) if (!apiKey)
return res.status(500).json({ message: "Trello API key not set in Environment Variables" }); return res
.status(500)
.json({ message: "Trello API key not set in Environment Variables" });
const token = req.body.token; const token = req.body.token;
if (!token) if (!token) return res.status(400).json({ message: "No token found" });
return res.status(400).json({ message: "No token found" });
try { try {
const { db } = await createNextApiContext(req); const { db } = await createNextApiContext(req);
await db.insert(integrations).values({ await db
provider: "trello", .insert(integrations)
userId: user.id, .values({
accessToken: token, provider: "trello",
expiresAt: addYears(new Date(), 1), userId: user.id,
}).onConflictDoUpdate({ accessToken: token,
set: { expiresAt: addYears(new Date(), 1),
accessToken: token, })
expiresAt: addYears(new Date(), 1), .onConflictDoUpdate({
}, set: {
target: [integrations.userId, integrations.provider], accessToken: token,
}); expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
return res.status(200).json({ message: "Trello authentication successful" }); return res
} catch (err) { .status(200)
console.error("Trello authentication error:", err); .json({ message: "Trello authentication successful" });
return res.status(400).json({ message: "Trello authentication failed" }); } catch (err) {
} return res.status(400).json({ message: "Trello authentication failed" });
}, }
); }),
);

View File

@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env"; import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
const requestSchema = z.object({ const requestSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
}); });
@@ -22,84 +24,85 @@ const textEncoder = new TextEncoder();
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => { withApiLogging(
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") { async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
return res.status(404).json({ if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
success: false, return res.status(404).json({
error: "Unsubscribe endpoint is not available.", success: false,
code: "UNAVAILABLE", error: "Unsubscribe endpoint is not available.",
}); code: "UNAVAILABLE",
} });
}
if (req.method !== "POST") { if (req.method !== "POST") {
res.setHeader("Allow", "POST"); res.setHeader("Allow", "POST");
return res.status(405).json({ return res.status(405).json({
success: false, success: false,
error: "Method not allowed.", error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED", code: "METHOD_NOT_ALLOWED",
}); });
} }
const parsedBody = requestSchema.safeParse(req.body); const parsedBody = requestSchema.safeParse(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Invalid request payload.", error: "Invalid request payload.",
code: "BAD_REQUEST", code: "BAD_REQUEST",
}); });
} }
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) { if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: "Unsubscribe service is not configured.", error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED", code: "NOT_CONFIGURED",
}); });
} }
let payload: z.infer<typeof tokenPayloadSchema>; let payload: z.infer<typeof tokenPayloadSchema>;
try { try {
const verified = await jwtVerify( const verified = await jwtVerify(
parsedBody.data.token, parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET), textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{ {
// We intentionally do not use exp/iat claims // We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload. // tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s", clockTolerance: "0s",
}, },
); );
payload = tokenPayloadSchema.parse(verified.payload); payload = tokenPayloadSchema.parse(verified.payload);
} catch { } catch {
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: "Your unsubscribe link is invalid or has expired.", error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN", code: "INVALID_TOKEN",
}); });
} }
const novu = new Novu({ secretKey: env.NOVU_API_KEY }); const novu = new Novu({ secretKey: env.NOVU_API_KEY });
try { try {
await novu.subscribers.preferences.update( await novu.subscribers.preferences.update(
{ {
channels: { channels: {
email: false, email: false,
}, },
}, },
payload.subscriberId, payload.subscriberId,
); );
} catch (error) { } catch (error) {
console.error("Failed to update Novu preferences", error); return res.status(502).json({
return res.status(502).json({ success: false,
success: false, error:
error: "We could not update your email preferences right now. Please try again later.",
"We could not update your email preferences right now. Please try again later.", code: "NOVU_ERROR",
code: "NOVU_ERROR", });
}); }
}
return res.status(200).json({ success: true }); return res.status(200).json({ success: true });
}, },
),
); );

View File

@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { Upload } from "@aws-sdk/lib-storage"; import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions"; import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as cardRepo from "@kan/db/repository/card.repo"; import * as cardRepo from "@kan/db/repository/card.repo";
@@ -22,7 +23,7 @@ export const config = {
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" }); return res.status(405).json({ error: "Method not allowed" });
} }
@@ -36,7 +37,9 @@ export default withRateLimit(
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) { if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" }); return res
.status(500)
.json({ error: "Attachments bucket not configured" });
} }
const cardPublicId = req.query.cardPublicId; const cardPublicId = req.query.cardPublicId;
@@ -55,7 +58,9 @@ export default withRateLimit(
} }
if (!Number.isFinite(contentLength) || contentLength <= 0) { if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" }); return res
.status(400)
.json({ error: "Missing or invalid content length" });
} }
if (contentLength > MAX_SIZE_BYTES) { if (contentLength > MAX_SIZE_BYTES) {
@@ -129,8 +134,7 @@ export default withRateLimit(
return res.status(200).json({ attachment }); return res.status(200).json({ attachment });
} catch (error) { } catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" }); return res.status(500).json({ error: "Internal server error" });
} }
}, }),
); );

View File

@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc"; import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo"; import { withApiLogging } from "@kan/api/utils/apiLogging";
import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit"; import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as userRepo from "@kan/db/repository/user.repo";
import { createS3Client } from "@kan/shared/utils"; import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB import { env } from "~/env";
const MAX_SIZE_BYTES = parseInt(
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
10,
); // Default 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"]; const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = { export const config = {
@@ -19,7 +23,7 @@ export const config = {
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" }); return res.status(405).json({ error: "Method not allowed" });
} }
@@ -51,7 +55,9 @@ export default withRateLimit(
} }
if (!Number.isFinite(contentLength) || contentLength <= 0) { if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" }); return res
.status(400)
.json({ error: "Missing or invalid content length" });
} }
if (contentLength > MAX_SIZE_BYTES) { if (contentLength > MAX_SIZE_BYTES) {
@@ -93,9 +99,7 @@ export default withRateLimit(
user: updatedUser, user: updatedUser,
}); });
} catch (error) { } catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" }); return res.status(500).json({ error: "Internal server error" });
} }
}, }),
); );

View File

@@ -28,6 +28,10 @@
"types": "./dist/utils/rateLimit.d.ts", "types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts" "default": "./src/utils/rateLimit.ts"
}, },
"./utils/apiLogging": {
"types": "./dist/utils/apiLogging.d.ts",
"default": "./src/utils/apiLogging.ts"
},
"./utils/permissions": { "./utils/permissions": {
"types": "./dist/utils/permissions.d.ts", "types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts" "default": "./src/utils/permissions.ts"

View File

@@ -0,0 +1,64 @@
import { randomUUID } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { createLogger } from "@kan/logger";
import { createNextApiContext } from "../trpc";
const log = createLogger("api");
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
export function withApiLogging(
handler: (
req: NextApiRequest,
res: NextApiResponse,
) => Promise<unknown> | unknown,
) {
return async (req: NextApiRequest, res: NextApiResponse) => {
const start = Date.now();
const requestId = randomUUID();
const route = req.url?.split("?")[0] ?? "unknown";
const input = {
...(req.query && Object.keys(req.query).length > 0 && { query: req.query }),
...(req.body && typeof req.body === "object" && Object.keys(req.body).length > 0 && { body: req.body }),
};
let statusCode = 200;
const originalStatus = res.status.bind(res);
res.status = (code: number) => {
statusCode = code;
return originalStatus(code);
};
let userId: string | undefined;
let email: string | undefined;
try {
const ctx = await createNextApiContext(req);
userId = ctx.user?.id;
email = ctx.user?.email ?? undefined;
} catch {
// unauthenticated or auth unavailable
}
await handler(req, res);
const duration = Date.now() - start;
const meta = {
requestId,
procedure: route,
transport: "rest",
duration,
userId,
...(isCloud && email && { email }),
...(Object.keys(input).length > 0 && { input }),
status: statusCode,
};
if (statusCode < 400) {
log.info(meta, "API OK");
} else {
log.error(meta, "API error");
}
};
}