From fc3435dc80fcfd5ce9fefc2cff21a72142520ef8 Mon Sep 17 00:00:00 2001 From: d0zingcat Date: Thu, 15 Jan 2026 11:48:01 +0800 Subject: [PATCH 1/4] feat: make db migration automatically Signed-off-by: d0zingcat --- CHANGELOG.md | 12 ++++++++ Dockerfile | 2 +- README.md | 6 ++-- ...s_lionheart.sql => 0000_stiff_electro.sql} | 29 +++++++++++++++++-- apps/server/drizzle/0001_bumpy_orphan.sql | 18 ------------ apps/server/package.json | 3 +- apps/server/src/db/migrate.ts | 26 +++++++++++++++++ docs/copilot-context.md | 3 +- todo.md | 1 + 9 files changed, 73 insertions(+), 27 deletions(-) rename apps/server/drizzle/{0000_famous_lionheart.sql => 0000_stiff_electro.sql} (57%) delete mode 100644 apps/server/drizzle/0001_bumpy_orphan.sql create mode 100644 apps/server/src/db/migrate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c690d..8b95440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 +## [1.2.3] - 2026-01-15 + +### 新增 +- **自动化数据库迁移**: 引入了自动化数据库初始化与迁移机制。 + - 添加了 `src/db/migrate.ts` 脚本,使用 Drizzle Migrator 自动应用挂起的迁移。 + - 更新了 `Dockerfile`,使容器启动时自动执行数据库迁移。 + - 在 `package.json` 中新增了 `db:migrate:deploy` 脚本。 + +### 修复 +- **初始化错误**: 修复了在全新环境下启动时因缺少数据库表导致的 `relation "users" does not exist` 错误。 +- **迁移历史**: 清理并重新生成了初始迁移文件,确保所有表在全新部署时能正确创建。 + ## [1.2.2] - 2026-01-14 ### 变更 diff --git a/Dockerfile b/Dockerfile index 3d0a432..9ab3d6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ WORKDIR /app/apps/server EXPOSE 3000 -CMD ["bun", "run", "start"] +CMD ["sh", "-c", "bun run db:migrate:deploy && bun run start"] diff --git a/README.md b/README.md index 502d979..a63663e 100644 --- a/README.md +++ b/README.md @@ -71,15 +71,15 @@ FEISHU_APP_ID="cli_xxx" FEISHU_APP_SECRET="xxx" ADMIN_EMAILS="user1@example.com,user2@example.com" # 管理员列表 -# 数据库推送 -cd apps/server && bun run db:push +# 数据库推送/迁移 +cd apps/server && bun run db:migrate:deploy # 启动开发环境 bun run dev ``` ### 3. Docker 部署 -项目支持使用 Docker Compose 快速部署: +项目支持使用 Docker Compose 快速部署,且**数据库会自动进行初始化与迁移**: ```bash # 复制并填写环境变量 diff --git a/apps/server/drizzle/0000_famous_lionheart.sql b/apps/server/drizzle/0000_stiff_electro.sql similarity index 57% rename from apps/server/drizzle/0000_famous_lionheart.sql rename to apps/server/drizzle/0000_stiff_electro.sql index 68b54f4..14bfa11 100644 --- a/apps/server/drizzle/0000_famous_lionheart.sql +++ b/apps/server/drizzle/0000_stiff_electro.sql @@ -9,7 +9,8 @@ CREATE TABLE "alert_logs" ( --> statement-breakpoint CREATE TABLE "alert_tasks" ( "id" text PRIMARY KEY NOT NULL, - "topic_slug" text NOT NULL, + "topic_slug" text, + "sender_id" text, "status" text DEFAULT 'pending' NOT NULL, "recipient_count" integer DEFAULT 0, "success_count" integer DEFAULT 0, @@ -19,6 +20,12 @@ CREATE TABLE "alert_tasks" ( "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint +CREATE TABLE "known_group_chats" ( + "chat_id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "last_active_at" timestamp DEFAULT now() +); +--> statement-breakpoint CREATE TABLE "subscriptions" ( "user_id" text NOT NULL, "topic_id" text NOT NULL, @@ -26,6 +33,15 @@ CREATE TABLE "subscriptions" ( CONSTRAINT "subscriptions_user_id_topic_id_pk" PRIMARY KEY("user_id","topic_id") ); --> statement-breakpoint +CREATE TABLE "topic_group_chats" ( + "id" text PRIMARY KEY NOT NULL, + "topic_id" text NOT NULL, + "chat_id" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" text +); +--> statement-breakpoint CREATE TABLE "topics" ( "id" text PRIMARY KEY NOT NULL, "slug" text NOT NULL, @@ -33,6 +49,7 @@ CREATE TABLE "topics" ( "description" text, "status" text DEFAULT 'approved' NOT NULL, "created_by" text, + "approved_by" text, "created_at" timestamp DEFAULT now() NOT NULL, CONSTRAINT "topics_slug_unique" UNIQUE("slug") ); @@ -43,10 +60,16 @@ CREATE TABLE "users" ( "feishu_user_id" text NOT NULL, "email" text, "is_admin" boolean DEFAULT false, - CONSTRAINT "users_email_unique" UNIQUE("email") + "personal_token" text NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email"), + CONSTRAINT "users_personal_token_unique" UNIQUE("personal_token") ); --> statement-breakpoint ALTER TABLE "alert_logs" ADD CONSTRAINT "alert_logs_task_id_alert_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."alert_tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_tasks" ADD CONSTRAINT "alert_tasks_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "topics" ADD CONSTRAINT "topics_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file +ALTER TABLE "topic_group_chats" ADD CONSTRAINT "topic_group_chats_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "topic_group_chats" ADD CONSTRAINT "topic_group_chats_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "topics" ADD CONSTRAINT "topics_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "topics" ADD CONSTRAINT "topics_approved_by_users_id_fk" FOREIGN KEY ("approved_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/0001_bumpy_orphan.sql b/apps/server/drizzle/0001_bumpy_orphan.sql deleted file mode 100644 index 039c725..0000000 --- a/apps/server/drizzle/0001_bumpy_orphan.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE "topic_group_webhooks" ( - "id" text PRIMARY KEY NOT NULL, - "topic_id" text NOT NULL, - "name" text NOT NULL, - "url" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "created_by" text -); ---> statement-breakpoint -ALTER TABLE "alert_tasks" ALTER COLUMN "topic_slug" DROP NOT NULL;--> statement-breakpoint -ALTER TABLE "alert_tasks" ADD COLUMN "sender_id" text;--> statement-breakpoint -ALTER TABLE "topics" ADD COLUMN "approved_by" text;--> statement-breakpoint -ALTER TABLE "users" ADD COLUMN "personal_token" text NOT NULL;--> statement-breakpoint -ALTER TABLE "topic_group_webhooks" ADD CONSTRAINT "topic_group_webhooks_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "topic_group_webhooks" ADD CONSTRAINT "topic_group_webhooks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "alert_tasks" ADD CONSTRAINT "alert_tasks_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "topics" ADD CONSTRAINT "topics_approved_by_users_id_fk" FOREIGN KEY ("approved_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "users" ADD CONSTRAINT "users_personal_token_unique" UNIQUE("personal_token"); \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index f9966c7..30fea03 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,6 +6,7 @@ "start": "bun run src/index.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:migrate:deploy": "bun run src/db/migrate.ts", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" }, @@ -24,4 +25,4 @@ "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3" } -} +} \ No newline at end of file diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts new file mode 100644 index 0000000..4d2851d --- /dev/null +++ b/apps/server/src/db/migrate.ts @@ -0,0 +1,26 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; +import * as schema from "./schema"; + +const connectionString = + process.env.DATABASE_URL || + "postgres://postgres:password@localhost:5432/alert_message_center"; + +async function main() { + console.log("⏳ Running migrations..."); + const sql = postgres(connectionString, { max: 1 }); + const db = drizzle(sql, { schema }); + + try { + await migrate(db, { migrationsFolder: "./drizzle" }); + console.log("✅ Migrations completed!"); + } catch (error) { + console.error("❌ Migration failed:", error); + process.exit(1); + } finally { + await sql.end(); + } +} + +main(); diff --git a/docs/copilot-context.md b/docs/copilot-context.md index 25b0882..88a5eb0 100644 --- a/docs/copilot-context.md +++ b/docs/copilot-context.md @@ -1,4 +1,4 @@ -# Project Context for GitHub Copilot (v1.2.2) +# Project Context for GitHub Copilot (v1.2.3) This document provides technical context, architectural decisions, and code conventions for the **Alert Message Center** project. It is intended to help AI assistants understand the codebase. @@ -216,6 +216,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`. - GitHub Actions automates building a multi-stage Docker image and pushing it to GitHub Container Registry (GHCR). - Image path: `ghcr.io/${USER}/alert-message-center`. - Deployment Architecture: A single container runs the Bun server, which serves API requests and static frontend assets (via `hono/bun`'s `serveStatic`). + - **Database Initialization**: The Docker entrypoint automatically runs `bun run db:migrate:deploy` before starting the server to ensure the schema is up-to-date in new environments. ## 8. Core Documents diff --git a/todo.md b/todo.md index 7d251e1..70a9122 100644 --- a/todo.md +++ b/todo.md @@ -27,4 +27,5 @@ - [x] **Long Connection**: WebSocket support for intranet deployments. - [x] **Structured Logging**: Integrated `pino` for better observability. - [x] **Linting**: Tightened Biome rules and resolved all a11y/correctness issues. +- [x] **Automated Migrations**: Automatically initialize database schema on startup (especially in Docker). From 9965b3e1ce33bfa7cdd9d2e7f6bc432e682587a5 Mon Sep 17 00:00:00 2001 From: d0zingcat Date: Thu, 15 Jan 2026 19:32:24 +0800 Subject: [PATCH 2/4] feat: 1. db init 2. fault tolerance 3. lint fix Signed-off-by: d0zingcat --- CHANGELOG.md | 18 +++ apps/server/package.json | 2 +- apps/server/src/event-handler.ts | 13 +- apps/server/src/feishu.ts | 17 ++- apps/server/src/verify_permissions.ts | 23 ++-- apps/server/src/webhook.ts | 87 ++++++++----- .../web/src/components/GroupBindingsModal.tsx | 12 +- apps/web/src/views/AdminView.tsx | 71 +++++++--- apps/web/src/views/SystemLoadView.tsx | 40 +++++- apps/web/src/views/TopicsView.tsx | 122 ++++++++++++------ apps/web/src/views/UsersView.tsx | 14 +- docs/copilot-context.md | 11 +- todo.md | 2 +- 13 files changed, 310 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b95440..3d49051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/), 并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。 +## [1.2.5] - 2026-01-15 + +### 修复 +- **前端鲁棒性**: 修复了当数据库为空或 API 返回错误对象时页面发生崩溃(白屏)的问题。 + - 为 `TopicsView`, `SystemLoadView` 和 `AdminView` 中的所有 API 请求增加了 `res.ok` 和 `Array.isArray` 校验。 + - 增加了防御性逻辑,确保在数据未加载或加载失败时显示友好的提示而非崩溃。 +- **Vite 环境变量**: 修复了 `TypeError: Cannot read properties of undefined (reading 'VITE_WEBHOOK_BASE_URL')`。 + - 在 `TopicsView.tsx` 中使用可选链 (`meta.env?.`) 安全地访问 Vite 环境变量,防止由于环境未完全初始化导致的崩溃。 + +## [1.2.4] - 2026-01-15 + +### 变更 +- **类型安全**: 全面重构了服务端与前端的代码,消除了绝大部分 `any` 类型的使用。 + - 在 `webhook.ts`, `verify_permissions.ts`, `feishu.ts` 等核心文件中引入了显式接口。 + - 改进了 Webhook Body 的处理逻辑,在保持灵活性的同时增强了类型校验。 + - 修复了多处 Non-null Assertion 为更安全的可选链或显式空值检查。 +- **Linting**: 严格执行 Biome 的 `noExplicitAny` 规则。 + ## [1.2.3] - 2026-01-15 ### 新增 diff --git a/apps/server/package.json b/apps/server/package.json index 30fea03..4aaa10e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,4 +25,4 @@ "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3" } -} \ No newline at end of file +} diff --git a/apps/server/src/event-handler.ts b/apps/server/src/event-handler.ts index ef221c4..0ddc8f5 100644 --- a/apps/server/src/event-handler.ts +++ b/apps/server/src/event-handler.ts @@ -4,12 +4,21 @@ import { db } from "./db"; import { knownGroupChats, topicGroupChats } from "./db/schema"; import { logger } from "./lib/logger"; +interface BotAddedEvent { + chat_id: string; + name?: string; +} + +interface BotDeletedEvent { + chat_id: string; +} + export const eventDispatcher = new lark.EventDispatcher({ encryptKey: process.env.FEISHU_ENCRYPT_KEY, verificationToken: process.env.FEISHU_VERIFICATION_TOKEN, }).register({ "im.chat.member.bot.added_v1": async (data) => { - const { chat_id, name } = data as any; + const { chat_id, name } = data as unknown as BotAddedEvent; logger.info({ chat_id, name }, "[Feishu Event] Bot added to group"); if (chat_id) { @@ -30,7 +39,7 @@ export const eventDispatcher = new lark.EventDispatcher({ } }, "im.chat.member.bot.deleted_v1": async (data) => { - const { chat_id } = data as any; + const { chat_id } = data as unknown as BotDeletedEvent; logger.info({ chat_id }, "[Feishu Event] Bot removed from group"); if (chat_id) { diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts index b69ee11..ed7f9b5 100644 --- a/apps/server/src/feishu.ts +++ b/apps/server/src/feishu.ts @@ -1,6 +1,15 @@ import * as lark from "@larksuiteoapi/node-sdk"; import { logger } from "./lib/logger"; +export interface UserAccessTokenData { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + refresh_expires_in: number; + scope: string; +} + export class FeishuClient { public client: lark.Client; public appId: string; @@ -20,7 +29,7 @@ export class FeishuClient { receiveId: string, receiveIdType: "open_id" | "user_id" | "email" | "chat_id", msgType: string, - content: any, + content: Record | string, ) { // Content needs to be stringified for 'text' type in API, but SDK might handle it differently? // Actually SDK expects 'content' as string JSON for 'im.v1.messages.create' @@ -50,7 +59,9 @@ export class FeishuClient { } } - async getUserAccessToken(code: string): Promise { + async getUserAccessToken( + code: string, + ): Promise { try { const response = await this.client.authen.accessToken.create({ data: { @@ -63,7 +74,7 @@ export class FeishuClient { logger.error({ response }, "Feishu get user access token error"); throw new Error(`Failed to get user access token: ${response.msg}`); } - return response.data; + return response.data as UserAccessTokenData; } catch (e) { console.error("Feishu SDK error:", e); throw e; diff --git a/apps/server/src/verify_permissions.ts b/apps/server/src/verify_permissions.ts index 6632813..be09689 100644 --- a/apps/server/src/verify_permissions.ts +++ b/apps/server/src/verify_permissions.ts @@ -3,6 +3,11 @@ import { db } from "./db"; import { subscriptions, topics, users } from "./db/schema"; import app from "./index"; +interface TopicWithSubscriptions { + id: string; + subscriptions: { userId: string }[]; +} + async function verify() { console.log("Starting Verification..."); let errors = 0; @@ -112,9 +117,9 @@ async function verify() { }, }); const res3 = await app.request(req3); - const data3 = await res3.json(); + const data3 = (await res3.json()) as TopicWithSubscriptions[]; - const targetTopic = (data3 as any).find((t: any) => t.id === topic.id); + const targetTopic = data3.find((t) => t.id === topic.id); if (targetTopic) { if ( targetTopic.subscriptions.length === 1 && @@ -135,23 +140,19 @@ async function verify() { errors++; } - // Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too) - // Actually, let's just check that Admin sees the User's subscription. - // In my logic: isAdmin ? undefined (all) : ... - // So Admin should see User's subscription. - + // Test as Admin (Should see ALL subscriptions??) const req4 = new Request("http://localhost/api/topics", { headers: { Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`, }, }); const res4 = await app.request(req4); - const data4 = await res4.json(); + const data4 = (await res4.json()) as TopicWithSubscriptions[]; - const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id); + const targetTopicAdmin = data4.find((t) => t.id === topic.id); // Should see the subscription for userUser - const hasUserSub = targetTopicAdmin.subscriptions.some( - (s: any) => s.userId === userUser.id, + const hasUserSub = targetTopicAdmin?.subscriptions.some( + (s) => s.userId === userUser.id, ); if (hasUserSub) { console.log( diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 375cd89..0c43cf9 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -5,6 +5,16 @@ import { alertLogs, alertTasks, topics, users } from "./db/schema"; import { feishuClient } from "./feishu"; import { logger } from "./lib/logger"; +type FeishuReceiveIdType = "open_id" | "user_id" | "email" | "chat_id"; + +interface Recipient { + type: "user" | "group"; + id: string; + name: string; + feishuId: string; + idType: FeishuReceiveIdType; +} + const webhook = new Hono(); webhook.post("/:token/topic/:slug", async (c) => { @@ -21,7 +31,8 @@ webhook.post("/:token/topic/:slug", async (c) => { logger.warn({ token }, "[Webhook] Invalid personal token"); return c.json({ error: "Invalid personal token" }, 401); } - let body: any; + // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON + let body: Record; try { const rawBody = await c.req.text(); logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body"); @@ -55,26 +66,31 @@ webhook.post("/:token/topic/:slug", async (c) => { logger.info({ topicName: topic.name }, "[Webhook] Found topic"); // 2. Collect recipients - const userRecipients = topic.subscriptions + const userRecipients: Recipient[] = topic.subscriptions .map((sub) => sub.user) - .filter((u) => !!u && !!u.feishuUserId) - .map((u) => ({ - type: "user", - id: u.id, - name: u.name, - feishuId: u.feishuUserId, - idType: u.feishuUserId.startsWith("ou_") ? "open_id" : "user_id", - })); + .map((u) => { + if (!u || !u.feishuUserId) return null; + return { + type: "user" as const, + id: u.id, + name: u.name, + feishuId: u.feishuUserId, + idType: (u.feishuUserId.startsWith("ou_") + ? "open_id" + : "user_id") as FeishuReceiveIdType, + }; + }) + .filter((u): u is NonNullable => u !== null); - const groupRecipients = topic.groupChats.map((g) => ({ + const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({ type: "group", id: g.id, // Binding ID name: g.name, feishuId: g.chatId, - idType: "chat_id", + idType: "chat_id" as FeishuReceiveIdType, })); - const allRecipients = [...userRecipients, ...groupRecipients]; + const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients]; const [task] = await db .insert(alertTasks) @@ -137,13 +153,15 @@ webhook.post("/:token/topic/:slug", async (c) => { await feishuClient.sendMessage( recipient.feishuId, - recipient.idType as any, + recipient.idType, msgType, content, ); return { recipientId: recipient.id, status: "sent", error: null }; - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); logger.error( { err: error, @@ -155,22 +173,25 @@ webhook.post("/:token/topic/:slug", async (c) => { return { recipientId: recipient.id, status: "failed", - error: error.message, + error: errorMessage, }; } }), ).then(async (results) => { const successCount = results.filter( - (r) => r.status === "fulfilled" && (r.value as any).status === "sent", + (r) => + r.status === "fulfilled" && + (r.value as { status: string }).status === "sent", ).length; const failures = results.filter( (r) => r.status === "rejected" || - (r.status === "fulfilled" && (r.value as any).status === "failed"), + (r.status === "fulfilled" && + (r.value as { status: string }).status === "failed"), ).length; // Determine final status - const finalStatus = + const finalStatus: "completed" | "failed" = failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed"; // Update Task @@ -189,26 +210,29 @@ webhook.post("/:token/topic/:slug", async (c) => { const logs = results.map((r, index) => { const recipient = allRecipients[index]; if (r.status === "fulfilled") { - const val = r.value as any; + const val = r.value as { + status: "sent" | "failed"; + error: string | null; + }; return { taskId: task.id, userId: recipient.type === "user" ? recipient.id : null, // Only link users // We could add connection to group binding if we altered schema, but for now log it - status: val.status, + status: val.status as "sent" | "failed", error: val.error, }; } else { return { taskId: task.id, userId: recipient.type === "user" ? recipient.id : null, - status: "failed", - error: r.reason ? String(r.reason) : "Unknown error", + status: "failed" as const, + error: r.status === "rejected" ? String(r.reason) : "Unknown error", }; } }); if (logs.length > 0) { - await db.insert(alertLogs).values(logs as any); + await db.insert(alertLogs).values(logs); } logger.info( @@ -248,7 +272,8 @@ webhook.post("/:token/dm", async (c) => { return c.json({ error: "User has no Feishu ID linked" }, 400); } - let body: any; + // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON + let body: Record; try { const rawBody = await c.req.text(); if (!rawBody || rawBody.trim() === "") { @@ -315,24 +340,26 @@ webhook.post("/:token/dm", async (c) => { await db.insert(alertLogs).values({ taskId: task.id, userId: user.id, - status: "sent", + status: "sent" as const, }); - } catch (error: any) { + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); logger.error({ err: error, userName: user.name }, "Failed to send DM"); await db .update(alertTasks) .set({ status: "failed", updatedAt: new Date(), - error: error.message, + error: errorMessage, }) .where(eq(alertTasks.id, task.id)); await db.insert(alertLogs).values({ taskId: task.id, userId: user.id, - status: "failed", - error: error.message, + status: "failed" as const, + error: errorMessage, }); } })(); diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx index bb138aa..12d43ab 100644 --- a/apps/web/src/components/GroupBindingsModal.tsx +++ b/apps/web/src/components/GroupBindingsModal.tsx @@ -48,8 +48,10 @@ export default function GroupBindingsModal({ init: { credentials: "include" }, }, ); - const data = await res.json(); - setBindings(data as any); + const data = (await res.json()) as GroupBinding[]; + if (Array.isArray(data)) { + setBindings(data); + } } catch (err) { console.error(err); } @@ -60,8 +62,10 @@ export default function GroupBindingsModal({ const res = await client.api.groups.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setKnownGroups(data as any); + const data = (await res.json()) as KnownGroup[]; + if (Array.isArray(data)) { + setKnownGroups(data); + } } catch (err) { console.error(err); } diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx index 83b39b9..aa2bd00 100644 --- a/apps/web/src/views/AdminView.tsx +++ b/apps/web/src/views/AdminView.tsx @@ -2,6 +2,24 @@ import { useCallback, useEffect, useState } from "react"; import { client } from "../lib/client"; import SystemLoadView from "./SystemLoadView"; +interface TopicUser { + id: string; + name: string; + email?: string | null; +} + +interface Topic { + id: string; + name: string; + slug: string; + description?: string; + status: "pending" | "approved" | "rejected"; + subscriptions?: { id: string }[]; + creator?: TopicUser; + approver?: TopicUser; + createdAt: string; +} + export default function AdminView() { const [activeTab, setActiveTab] = useState("load"); @@ -17,33 +35,30 @@ export default function AdminView() { @@ -59,7 +74,7 @@ export default function AdminView() { } function TopicsManagement() { - const [topics, setTopics] = useState([]); + const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); const fetchAllTopics = useCallback(async () => { @@ -68,10 +83,21 @@ function TopicsManagement() { const res = await client.api.topics.all.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setTopics(data); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setTopics(data as unknown as Topic[]); + } else { + console.error("All topics data is not an array:", data); + setTopics([]); + } + } else { + console.error("Failed to fetch all topics:", res.status); + setTopics([]); + } } catch (error) { console.error(error); + setTopics([]); } finally { setLoading(false); } @@ -141,13 +167,12 @@ function TopicsManagement() { {topic.status} @@ -179,7 +204,7 @@ function TopicsManagement() { } function TopicRequestsList() { - const [requests, setRequests] = useState([]); + const [requests, setRequests] = useState([]); const [loading, setLoading] = useState(true); const fetchRequests = useCallback(async () => { @@ -188,10 +213,20 @@ function TopicRequestsList() { const res = await client.api.topics.requests.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setRequests(data); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setRequests(data as unknown as Topic[]); + } else { + setRequests([]); + } + } else { + console.error("Failed to fetch requests:", res.status); + setRequests([]); + } } catch (error) { console.error(error); + setRequests([]); } finally { setLoading(false); } diff --git a/apps/web/src/views/SystemLoadView.tsx b/apps/web/src/views/SystemLoadView.tsx index d96f660..623346e 100644 --- a/apps/web/src/views/SystemLoadView.tsx +++ b/apps/web/src/views/SystemLoadView.tsx @@ -2,6 +2,19 @@ import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { client } from "../lib/client"; +interface Task { + id: string; + createdAt: string; + topicSlug: string | null; + sender: { + name: string; + email: string | null; + } | null; + successCount: number; + recipientCount: number; + status: string; +} + interface Stats { topics: { topicSlug: string; @@ -16,7 +29,7 @@ interface Stats { failedCount: number; successRate: number; }; - tasks: any[]; + tasks: Task[]; } export default function SystemLoadView() { @@ -29,7 +42,13 @@ export default function SystemLoadView() { const res = await client.api.stats.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); + if (!res.ok) { + console.error("Failed to fetch stats:", res.status); + return; + } + const data = (await res.json()) as Omit & { + topics: unknown; + }; // Fetch recent tasks as well const tasksRes = await client.api.alerts.tasks.$get( @@ -38,9 +57,19 @@ export default function SystemLoadView() { init: { credentials: "include" }, }, ); - const tasks = await tasksRes.json(); + if (!tasksRes.ok) { + console.error("Failed to fetch tasks:", tasksRes.status); + return; + } + const tasks = (await tasksRes.json()) as unknown; - setStats({ ...data, tasks } as Stats); + setStats({ + topics: Array.isArray(data.topics) + ? (data.topics as Stats["topics"]) + : [], + recent: data.recent as Stats["recent"], + tasks: Array.isArray(tasks) ? (tasks as Task[]) : [], + }); setLastUpdated(new Date()); } catch (error) { console.error("Failed to fetch stats:", error); @@ -237,7 +266,7 @@ export default function SystemLoadView() { - {stats.tasks.map((task: any) => ( + {stats.tasks.map((task) => ( + Success Rate Gauge ([]); - const [myRequests, setMyRequests] = useState([]); + const [myRequests, setMyRequests] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); @@ -65,10 +67,21 @@ export default function TopicsView() { const res = await client.api.topics.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setTopics(data as unknown as Topic[]); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setTopics(data as unknown as Topic[]); + } else { + console.error("Topics data is not an array:", data); + setTopics([]); + } + } else { + console.error("Failed to fetch topics:", res.status); + setTopics([]); + } } catch (err) { console.error(err); + setTopics([]); } finally { setLoading(false); } @@ -79,8 +92,12 @@ export default function TopicsView() { const res = await client.api.topics["my-requests"].$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setMyRequests(data); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setMyRequests(data as unknown as Topic[]); + } + } } catch (err) { console.error(err); } @@ -91,8 +108,12 @@ export default function TopicsView() { const res = await client.api.users.$get(undefined, { init: { credentials: "include" }, }); - const data = await res.json(); - setUsers(data as unknown as TopicUser[]); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setUsers(data as unknown as TopicUser[]); + } + } } catch (err) { console.error(err); } @@ -112,7 +133,11 @@ export default function TopicsView() { try { const res = await client.api.topics.$post( { - json: formData as any, + json: formData as { + name: string; + slug: string; + description?: string; + }, }, { init: { credentials: "include" }, @@ -134,7 +159,7 @@ export default function TopicsView() { setSubmitStatus(null); }, 1500); } else { - const error = await res.json(); + const error = (await res.json()) as { message?: string }; setSubmitStatus({ type: "error", message: error.message || "Failed to submit request.", @@ -194,12 +219,20 @@ export default function TopicsView() { const updatedSubs = isSubscribed ? t.subscriptions.filter((s) => s.userId !== userId) : [ - ...t.subscriptions, - { - userId, - user: users.find((u) => u.id === userId) || currentUser!, - }, - ]; + ...t.subscriptions, + { + userId, + user: + users.find((u) => u.id === userId) || + (currentUser + ? { + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + } + : { id: "unknown", name: "Unknown" }), + }, + ]; return { ...t, subscriptions: updatedSubs }; } return t; @@ -211,12 +244,20 @@ export default function TopicsView() { const updatedSubs = isSubscribed ? selectedTopic.subscriptions.filter((s) => s.userId !== userId) : [ - ...selectedTopic.subscriptions, - { - userId, - user: users.find((u) => u.id === userId) || currentUser!, - }, - ]; + ...selectedTopic.subscriptions, + { + userId, + user: + users.find((u) => u.id === userId) || + (currentUser + ? { + id: currentUser.id, + name: currentUser.name, + email: currentUser.email, + } + : { id: "unknown", name: "Unknown" }), + }, + ]; setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs }); } @@ -226,13 +267,13 @@ export default function TopicsView() { } }; - const isSubscribed = (topic: Topic) => { + const isSubscribedToTopic = (topic: Topic) => { return topic.subscriptions.some((sub) => sub.userId === currentUser?.id); }; const handleSelfSubscribe = async (topic: Topic) => { if (!currentUser) return; - const subscribed = isSubscribed(topic); + const subscribed = isSubscribedToTopic(topic); await toggleSubscription(topic.id, currentUser.id, subscribed); }; @@ -245,16 +286,20 @@ export default function TopicsView() { const getWebhookUrl = (topicSlug: string) => { if (!currentUser?.personalToken) return ""; // Use an environment variable if available, otherwise fallback to current origin + // biome-ignore lint/suspicious/noExplicitAny: Vite env access + const meta = import.meta as any; const baseUrl = ( - (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin + meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin ).replace(/\/$/, ""); return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; }; const getDmWebhookUrl = () => { if (!currentUser?.personalToken) return ""; + // biome-ignore lint/suspicious/noExplicitAny: Vite env access + const meta = import.meta as any; const baseUrl = ( - (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin + meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin ).replace(/\/$/, ""); return `${baseUrl}/webhook/${currentUser.personalToken}/dm`; }; @@ -384,13 +429,12 @@ export default function TopicsView() {
{req.status === "approved" ? "Approved" @@ -565,7 +608,9 @@ export default function TopicsView() { )}

Requested on:{" "} - {new Date(req.createdAt).toLocaleDateString()} + {req.createdAt + ? new Date(req.createdAt).toLocaleDateString() + : "Unknown"} {req.approver && ( | Approved by: {req.approver.name} @@ -600,7 +645,7 @@ export default function TopicsView() { id="topic-name" type="text" required - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value }) @@ -618,7 +663,7 @@ export default function TopicsView() { id="topic-slug" type="text" required - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900" value={formData.slug} onChange={(e) => setFormData({ ...formData, slug: e.target.value }) @@ -634,7 +679,7 @@ export default function TopicsView() {