feat: add logging to next api routes
This commit is contained in:
@@ -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" });
|
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" });
|
||||||
},
|
}
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
64
packages/api/src/utils/apiLogging.ts
Normal file
64
packages/api/src/utils/apiLogging.ts
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user