diff --git a/apps/web/src/pages/api/download/attatchment.ts b/apps/web/src/pages/api/download/attatchment.ts index 4e538eca..c69dd2df 100644 --- a/apps/web/src/pages/api/download/attatchment.ts +++ b/apps/web/src/pages/api/download/attatchment.ts @@ -1,12 +1,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; import { withRateLimit } from "@kan/api/utils/rateLimit"; import { env } from "~/env"; export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "GET") { return res.status(405).json({ message: "Method not allowed" }); } @@ -32,7 +33,9 @@ export default withRateLimit( try { allowedHost = new URL(s3Endpoint).hostname.toLowerCase(); } 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}`)) { @@ -66,10 +69,7 @@ export default withRateLimit( const buffer = await upstream.arrayBuffer(); return res.send(Buffer.from(buffer)); } 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" }); } - }, + }), ); diff --git a/apps/web/src/pages/api/oss-friends.ts b/apps/web/src/pages/api/oss-friends.ts index 5a61f545..c542c6bf 100644 --- a/apps/web/src/pages/api/oss-friends.ts +++ b/apps/web/src/pages/api/oss-friends.ts @@ -1,25 +1,25 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; import { withRateLimit } from "@kan/api/utils/rateLimit"; export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "GET") { - 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"); + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); } - const data = await response.json(); - return res.status(200).json(data); - } catch (error) { - console.error("Error fetching OSS friends:", error); - return res.status(500).json({ message: "Failed to fetch OSS friends" }); - } - }, + 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); + } catch (error) { + return res.status(500).json({ message: "Failed to fetch OSS friends" }); + } + }), ); diff --git a/apps/web/src/pages/api/stripe/create_billing_session.ts b/apps/web/src/pages/api/stripe/create_billing_session.ts index e6363506..27edbeed 100644 --- a/apps/web/src/pages/api/stripe/create_billing_session.ts +++ b/apps/web/src/pages/api/stripe/create_billing_session.ts @@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { env } from "next-runtime-env"; 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 { createStripeClient } from "@kan/stripe"; export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - const stripe = createStripeClient(); + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { + const stripe = createStripeClient(); - if (req.method !== "POST") { - 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" }); + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); } - const session = await stripe.billingPortal.sessions.create({ - customer: user.stripeCustomerId, - return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`, - }); + try { + const { user } = await createNextApiContext(req); - return res.status(200).json({ url: session.url }); - } catch (error) { - console.error("Error:", error); - return res.status(500).json({ error: "Error creating portal session" }); - } - }, + if (!user?.stripeCustomerId) { + return res.status(404).json({ error: "No billing account found" }); + } + + 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" }); + } + }), ); diff --git a/apps/web/src/pages/api/trello/authenticate.ts b/apps/web/src/pages/api/trello/authenticate.ts index 048efdbf..157192d8 100644 --- a/apps/web/src/pages/api/trello/authenticate.ts +++ b/apps/web/src/pages/api/trello/authenticate.ts @@ -1,52 +1,58 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { addYears } from "date-fns"; import { createNextApiContext } from "@kan/api/trpc"; -import { integrations } from "@kan/db/schema"; -import { addYears } from "date-fns"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { integrations } from "@kan/db/schema"; export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") { - return res.status(405).json({ message: "Method not allowed" }); - } + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } - const { user } = await createNextApiContext(req); + const { user } = await createNextApiContext(req); - if (!user) - return res.status(401).json({ message: "User not authenticated" }); + if (!user) + 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) - return res.status(500).json({ message: "Trello API key not set in Environment Variables" }); + if (!apiKey) + 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) - return res.status(400).json({ message: "No token found" }); + if (!token) return res.status(400).json({ message: "No token found" }); - try { - const { db } = await createNextApiContext(req); + try { + const { db } = await createNextApiContext(req); - await db.insert(integrations).values({ - provider: "trello", - userId: user.id, - accessToken: token, - expiresAt: addYears(new Date(), 1), - }).onConflictDoUpdate({ - set: { - accessToken: token, - expiresAt: addYears(new Date(), 1), - }, - target: [integrations.userId, integrations.provider], - }); + await db + .insert(integrations) + .values({ + provider: "trello", + userId: user.id, + accessToken: token, + expiresAt: addYears(new Date(), 1), + }) + .onConflictDoUpdate({ + set: { + accessToken: token, + expiresAt: addYears(new Date(), 1), + }, + target: [integrations.userId, integrations.provider], + }); - return res.status(200).json({ message: "Trello authentication successful" }); - } catch (err) { - console.error("Trello authentication error:", err); - return res.status(400).json({ message: "Trello authentication failed" }); - } - }, -); \ No newline at end of file + return res + .status(200) + .json({ message: "Trello authentication successful" }); + } catch (err) { + return res.status(400).json({ message: "Trello authentication failed" }); + } + }), +); diff --git a/apps/web/src/pages/api/unsubscribe.ts b/apps/web/src/pages/api/unsubscribe.ts index a4a4430e..1b1d56db 100644 --- a/apps/web/src/pages/api/unsubscribe.ts +++ b/apps/web/src/pages/api/unsubscribe.ts @@ -3,9 +3,11 @@ import { Novu } from "@novu/api"; import { jwtVerify } from "jose"; import { z } from "zod"; -import { env } from "~/env"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { env } from "~/env"; + const requestSchema = z.object({ token: z.string().min(1), }); @@ -22,84 +24,85 @@ const textEncoder = new TextEncoder(); export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") { - return res.status(404).json({ - success: false, - error: "Unsubscribe endpoint is not available.", - code: "UNAVAILABLE", - }); - } + withApiLogging( + async (req: NextApiRequest, res: NextApiResponse) => { + if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") { + return res.status(404).json({ + success: false, + error: "Unsubscribe endpoint is not available.", + code: "UNAVAILABLE", + }); + } - if (req.method !== "POST") { - res.setHeader("Allow", "POST"); - return res.status(405).json({ - success: false, - error: "Method not allowed.", - code: "METHOD_NOT_ALLOWED", - }); - } + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ + success: false, + error: "Method not allowed.", + code: "METHOD_NOT_ALLOWED", + }); + } - const parsedBody = requestSchema.safeParse(req.body); + const parsedBody = requestSchema.safeParse(req.body); - if (!parsedBody.success) { - return res.status(400).json({ - success: false, - error: "Invalid request payload.", - code: "BAD_REQUEST", - }); - } + if (!parsedBody.success) { + return res.status(400).json({ + success: false, + error: "Invalid request payload.", + code: "BAD_REQUEST", + }); + } - if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) { - return res.status(500).json({ - success: false, - error: "Unsubscribe service is not configured.", - code: "NOT_CONFIGURED", - }); - } + if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) { + return res.status(500).json({ + success: false, + error: "Unsubscribe service is not configured.", + code: "NOT_CONFIGURED", + }); + } - let payload: z.infer; + let payload: z.infer; - try { - const verified = await jwtVerify( - parsedBody.data.token, - textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET), - { - // We intentionally do not use exp/iat claims – - // tokens are long-lived and validated only by signature + payload. - clockTolerance: "0s", - }, - ); - payload = tokenPayloadSchema.parse(verified.payload); - } catch { - return res.status(401).json({ - success: false, - error: "Your unsubscribe link is invalid or has expired.", - code: "INVALID_TOKEN", - }); - } + try { + const verified = await jwtVerify( + parsedBody.data.token, + textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET), + { + // We intentionally do not use exp/iat claims – + // tokens are long-lived and validated only by signature + payload. + clockTolerance: "0s", + }, + ); + payload = tokenPayloadSchema.parse(verified.payload); + } catch { + return res.status(401).json({ + success: false, + error: "Your unsubscribe link is invalid or has expired.", + code: "INVALID_TOKEN", + }); + } - const novu = new Novu({ secretKey: env.NOVU_API_KEY }); + const novu = new Novu({ secretKey: env.NOVU_API_KEY }); - try { - await novu.subscribers.preferences.update( - { - channels: { - email: false, - }, - }, - payload.subscriberId, - ); - } catch (error) { - console.error("Failed to update Novu preferences", error); - return res.status(502).json({ - success: false, - error: - "We could not update your email preferences right now. Please try again later.", - code: "NOVU_ERROR", - }); - } + try { + await novu.subscribers.preferences.update( + { + channels: { + email: false, + }, + }, + payload.subscriberId, + ); + } catch (error) { + return res.status(502).json({ + success: false, + error: + "We could not update your email preferences right now. Please try again later.", + code: "NOVU_ERROR", + }); + } - return res.status(200).json({ success: true }); - }, + return res.status(200).json({ success: true }); + }, + ), ); diff --git a/apps/web/src/pages/api/upload/attachment.ts b/apps/web/src/pages/api/upload/attachment.ts index 2a08a8be..7c52d1c6 100644 --- a/apps/web/src/pages/api/upload/attachment.ts +++ b/apps/web/src/pages/api/upload/attachment.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { Upload } from "@aws-sdk/lib-storage"; import { createNextApiContext } from "@kan/api/trpc"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; import { assertPermission } from "@kan/api/utils/permissions"; import { withRateLimit } from "@kan/api/utils/rateLimit"; import * as cardRepo from "@kan/db/repository/card.repo"; @@ -22,7 +23,7 @@ export const config = { export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } @@ -36,7 +37,9 @@ export default withRateLimit( const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; 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; @@ -55,7 +58,9 @@ export default withRateLimit( } 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) { @@ -129,8 +134,7 @@ export default withRateLimit( return res.status(200).json({ attachment }); } catch (error) { - console.error("Attachment upload failed", error); return res.status(500).json({ error: "Internal server error" }); } - }, + }), ); diff --git a/apps/web/src/pages/api/upload/avatar.ts b/apps/web/src/pages/api/upload/avatar.ts index 8f5f996a..ac7074f3 100644 --- a/apps/web/src/pages/api/upload/avatar.ts +++ b/apps/web/src/pages/api/upload/avatar.ts @@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import { createNextApiContext } from "@kan/api/trpc"; -import * as userRepo from "@kan/db/repository/user.repo"; - -import { env } from "~/env"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; import { withRateLimit } from "@kan/api/utils/rateLimit"; +import * as userRepo from "@kan/db/repository/user.repo"; 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"]; export const config = { @@ -19,7 +23,7 @@ export const config = { export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } @@ -51,7 +55,9 @@ export default withRateLimit( } 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) { @@ -93,9 +99,7 @@ export default withRateLimit( user: updatedUser, }); } catch (error) { - console.error("Avatar upload failed", error); return res.status(500).json({ error: "Internal server error" }); } - }, + }), ); - diff --git a/packages/api/package.json b/packages/api/package.json index 92ad0486..06d4977f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,6 +28,10 @@ "types": "./dist/utils/rateLimit.d.ts", "default": "./src/utils/rateLimit.ts" }, + "./utils/apiLogging": { + "types": "./dist/utils/apiLogging.d.ts", + "default": "./src/utils/apiLogging.ts" + }, "./utils/permissions": { "types": "./dist/utils/permissions.d.ts", "default": "./src/utils/permissions.ts" diff --git a/packages/api/src/utils/apiLogging.ts b/packages/api/src/utils/apiLogging.ts new file mode 100644 index 00000000..f415a1c4 --- /dev/null +++ b/packages/api/src/utils/apiLogging.ts @@ -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, +) { + 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"); + } + }; +}