feat: health check and stats endpoints (#290)

This commit is contained in:
Henry
2025-12-18 08:08:30 +00:00
committed by GitHub
parent 57186b6b4d
commit cca8a9d424
22 changed files with 447 additions and 59 deletions

View File

@@ -9,5 +9,15 @@ export const openApiDocument = generateOpenApiDocument(appRouter, {
version: "1.0.0",
baseUrl: `${env("NEXT_PUBLIC_BASE_URL")}/api/v1`,
docsUrl: "docs.kan.bn",
tags: ["Auth", "Users", "Boards", "Lists", "Cards", "Labels", "Imports", "Integrations"],
tags: [
"Auth",
"Users",
"Boards",
"Lists",
"Cards",
"Labels",
"Imports",
"Integrations",
"Health",
],
});

View File

@@ -3,6 +3,7 @@ import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
import { checklistRouter } from "./routers/checklist";
import { feedbackRouter } from "./routers/feedback";
import { healthRouter } from "./routers/health";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
import { labelRouter } from "./routers/label";
@@ -18,6 +19,7 @@ export const appRouter = createTRPCRouter({
card: cardRouter,
checklist: checklistRouter,
feedback: feedbackRouter,
health: healthRouter,
label: labelRouter,
list: listRouter,
member: memberRouter,

View File

@@ -0,0 +1,203 @@
import { HeadBucketCommand } from "@aws-sdk/client-s3";
import { TRPCError } from "@trpc/server";
import { sql } from "drizzle-orm";
import { env } from "next-runtime-env";
import { z } from "zod";
import type { dbClient } from "@kan/db/client";
import * as boardRepo from "@kan/db/repository/board.repo";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
import * as cardCommentRepo from "@kan/db/repository/cardComment.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import * as importRepo from "@kan/db/repository/import.repo";
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
import * as labelRepo from "@kan/db/repository/label.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import {
adminProtectedProcedure,
createTRPCRouter,
publicProcedure,
} from "../trpc";
import { createS3Client } from "../utils/s3";
const checkDatabaseConnection = async (db: dbClient) => {
try {
await db.execute(sql`SELECT 1`);
return true;
} catch {
return false;
}
};
const checkS3Connection = async () => {
try {
// Check if S3 is configured
if (
!process.env.S3_ENDPOINT ||
!process.env.S3_ACCESS_KEY_ID ||
!process.env.S3_SECRET_ACCESS_KEY
) {
// S3 is optional, so return true if not configured
return true;
}
const client = createS3Client();
const avatarBucketName = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
const attachmentsBucketName = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME");
await client.send(new HeadBucketCommand({ Bucket: avatarBucketName }));
await client.send(new HeadBucketCommand({ Bucket: attachmentsBucketName }));
return true;
} catch (error) {
console.error(error);
return false;
}
};
export const healthRouter = createTRPCRouter({
health: publicProcedure
.meta({
openapi: {
method: "GET",
path: "/health",
summary: "Health check",
description:
"Returns the health status of the application and its dependencies",
tags: ["Health"],
protect: false,
},
})
.input(z.void())
.output(
z.object({
status: z.enum(["ok", "error"]),
database: z.enum(["ok", "error"]).optional(),
storage: z.enum(["ok", "error", "not_configured"]).optional(),
}),
)
.query(async ({ ctx }) => {
const dbHealthy = await checkDatabaseConnection(ctx.db);
const s3Healthy = await checkS3Connection();
const s3Configured = !!(
process.env.S3_ENDPOINT &&
process.env.S3_ACCESS_KEY_ID &&
process.env.S3_SECRET_ACCESS_KEY
);
const database = dbHealthy ? "ok" : "error";
const storage = !s3Configured
? "not_configured"
: s3Healthy
? "ok"
: "error";
// Overall status is "ok" only if database is healthy and (storage is not configured or storage is healthy)
const status = dbHealthy && (!s3Configured || s3Healthy) ? "ok" : "error";
return {
status,
database,
storage,
};
}),
stats: adminProtectedProcedure
.meta({
openapi: {
method: "GET",
path: "/stats",
summary: "Get statistics",
description:
"Returns statistics about the application (workspaces, users, cards, etc.)",
tags: ["Health"],
protect: true,
},
})
.input(z.void())
.output(
z.object({
users: z.number(),
workspaces: z.number(),
boards: z.number(),
lists: z.number(),
cards: z.number(),
cardComments: z.number(),
cardAttachments: z.number(),
cardActivityLogs: z.number(),
labels: z.number(),
checklists: z.number(),
checklistItems: z.number(),
activeMembers: z.number(),
activeInviteLinks: z.number(),
imports: z.number(),
}),
)
.query(async ({ ctx }) => {
try {
// Execute all count queries in parallel
const [
usersCount,
workspacesCount,
boardsCount,
listsCount,
cardsCount,
cardCommentsCount,
checklistItemsCount,
checklistsCount,
labelsCount,
cardAttachmentsCount,
cardActivitiesCount,
activeMembersCount,
activeInviteLinksCount,
importsCount,
cardActivityLogsCount,
] = await Promise.all([
userRepo.getCount(ctx.db),
workspaceRepo.getCount(ctx.db),
boardRepo.getCount(ctx.db),
listRepo.getCount(ctx.db),
cardRepo.getCount(ctx.db),
cardCommentRepo.getCount(ctx.db),
checklistRepo.getCountItems(ctx.db),
checklistRepo.getCount(ctx.db),
labelRepo.getCount(ctx.db),
cardAttachmentRepo.getCount(ctx.db),
cardActivityRepo.getCount(ctx.db),
memberRepo.getActiveCount(ctx.db),
inviteLinkRepo.getActiveCount(ctx.db),
importRepo.getCount(ctx.db),
memberRepo.getActiveCount(ctx.db),
cardActivityRepo.getCount(ctx.db),
]);
return {
users: usersCount,
workspaces: workspacesCount,
boards: boardsCount,
lists: listsCount,
cards: cardsCount,
cardComments: cardCommentsCount,
checklistItems: checklistItemsCount,
checklists: checklistsCount,
labels: labelsCount,
cardAttachments: cardAttachmentsCount,
cardActivities: cardActivitiesCount,
activeMembers: activeMembersCount,
activeInviteLinks: activeInviteLinksCount,
imports: importsCount,
cardActivityLogs: cardActivityLogsCount,
};
} catch (error) {
throw new TRPCError({
message: `Failed to retrieve statistics: ${error instanceof Error ? error.message : "Unknown error"}`,
code: "INTERNAL_SERVER_ERROR",
});
}
}),
});

View File

@@ -2,6 +2,7 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { NextApiRequest } from "next";
import type { OpenApiMeta } from "trpc-to-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import { env } from "next-runtime-env";
import superjson from "superjson";
import { ZodError } from "zod";
@@ -45,6 +46,7 @@ interface CreateContextOptions {
user: User | null | undefined;
db: dbClient;
auth: ReturnType<typeof createAuthWithHeaders>;
headers: Headers;
}
export const createInnerTRPCContext = (opts: CreateContextOptions) => {
@@ -52,6 +54,7 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
user: opts.user,
db: opts.db,
auth: opts.auth,
headers: opts.headers,
};
};
@@ -63,7 +66,7 @@ export const createTRPCContext = async ({ req }: CreateNextContextOptions) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth });
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
};
export const createNextApiContext = async (req: NextApiRequest) => {
@@ -74,7 +77,7 @@ export const createNextApiContext = async (req: NextApiRequest) => {
const session = await auth.api.getSession();
return createInnerTRPCContext({ db, user: session?.user, auth });
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
};
export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
@@ -91,7 +94,7 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
throw error;
}
return createInnerTRPCContext({ db, user: session?.user, auth });
return createInnerTRPCContext({ db, user: session?.user, auth, headers });
};
const t = initTRPC
@@ -129,9 +132,28 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
});
});
const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
if (ctx.headers.get("x-admin-api-key") !== env("KAN_ADMIN_API_KEY")) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx,
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
openapi: {
method: "GET",
path: "/protected",
},
});
export const adminProtectedProcedure = t.procedure
.use(enforceUserIsAdmin)
.meta({
openapi: {
method: "GET",
path: "/admin/protected",
},
});