Merge pull request #9 from d0zingcat/feature/db_init

This commit is contained in:
2026-01-15 19:58:45 +08:00
committed by GitHub
19 changed files with 370 additions and 116 deletions

View File

@@ -5,6 +5,40 @@
本文件的格式基于 [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 环境变量,防止由于环境未完全初始化导致的崩溃。
- **CI & 类型安全**: 修复了破坏 CI 流水的类型错误与格式问题。
- 运行 `biome check --write` 统一了全局代码格式。
- 完善了 `feishu.ts` 中的 `UserAccessTokenData` 接口定义,补充了飞书 API 返回的用户基础信息字段。
-`auth.ts` 中增加了对 `feishuClient.getUserAccessToken` 返回值的空值校验,确保 OAuth 回调流程更健壮。
## [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
### 新增
- **自动化数据库迁移**: 引入了自动化数据库初始化与迁移机制。
- 添加了 `src/db/migrate.ts` 脚本,使用 Drizzle Migrator 自动应用挂起的迁移。
- 更新了 `Dockerfile`,使容器启动时自动执行数据库迁移。
-`package.json` 中新增了 `db:migrate:deploy` 脚本。
### 修复
- **初始化错误**: 修复了在全新环境下启动时因缺少数据库表导致的 `relation "users" does not exist` 错误。
- **迁移历史**: 清理并重新生成了初始迁移文件,确保所有表在全新部署时能正确创建。
## [1.2.2] - 2026-01-14
### 变更

View File

@@ -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"]

View File

@@ -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
# 复制并填写环境变量

View File

@@ -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;
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;

View File

@@ -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");

View File

@@ -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"
},

View File

@@ -53,6 +53,11 @@ auth.get("/callback", async (c) => {
// Exchange code for user access token and user info
const userData = await feishuClient.getUserAccessToken(code);
if (!userData) {
logger.error("[Auth] Failed to get user data from code");
return c.json({ error: "Failed to get user info from Feishu" }, 500);
}
// Check if user exists, otherwise create
let user = await db.query.users.findFirst({
where: eq(users.feishuUserId, userData.open_id),

View File

@@ -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();

View File

@@ -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) {

View File

@@ -1,6 +1,18 @@
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;
name: string;
open_id: string;
email?: string;
}
export class FeishuClient {
public client: lark.Client;
public appId: string;
@@ -20,7 +32,7 @@ export class FeishuClient {
receiveId: string,
receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
msgType: string,
content: any,
content: Record<string, unknown> | 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 +62,9 @@ export class FeishuClient {
}
}
async getUserAccessToken(code: string): Promise<any> {
async getUserAccessToken(
code: string,
): Promise<UserAccessTokenData | undefined> {
try {
const response = await this.client.authen.accessToken.create({
data: {
@@ -63,7 +77,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;

View File

@@ -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(

View File

@@ -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<string, any>;
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<typeof u> => 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<string, any>;
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,
});
}
})();

View File

@@ -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);
}

View File

@@ -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");
@@ -59,7 +77,7 @@ export default function AdminView() {
}
function TopicsManagement() {
const [topics, setTopics] = useState<any[]>([]);
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState(true);
const fetchAllTopics = useCallback(async () => {
@@ -68,10 +86,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);
}
@@ -179,7 +208,7 @@ function TopicsManagement() {
}
function TopicRequestsList() {
const [requests, setRequests] = useState<any[]>([]);
const [requests, setRequests] = useState<Topic[]>([]);
const [loading, setLoading] = useState(true);
const fetchRequests = useCallback(async () => {
@@ -188,10 +217,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);
}

View File

@@ -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<Stats, "topics"> & {
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() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats.tasks.map((task: any) => (
{stats.tasks.map((task) => (
<tr
key={task.id}
className="hover:bg-gray-50 transition-colors"
@@ -348,6 +377,7 @@ function Gauge({ value }: { value: number }) {
return (
<div className="relative flex items-center justify-center">
<svg className="w-32 h-32 transform -rotate-90">
<title>Success Rate Gauge</title>
<circle
className="text-gray-100"
strokeWidth="8"

View File

@@ -35,12 +35,14 @@ interface Topic {
creator?: TopicUser;
approver?: TopicUser;
createdBy?: string;
status?: string;
createdAt?: string;
}
export default function TopicsView() {
const { user: currentUser } = useAuth();
const [topics, setTopics] = useState<Topic[]>([]);
const [myRequests, setMyRequests] = useState<any[]>([]);
const [myRequests, setMyRequests] = useState<Topic[]>([]);
const [users, setUsers] = useState<TopicUser[]>([]);
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.",
@@ -197,7 +222,15 @@ export default function TopicsView() {
...t.subscriptions,
{
userId,
user: users.find((u) => u.id === userId) || currentUser!,
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 };
@@ -214,7 +247,15 @@ export default function TopicsView() {
...selectedTopic.subscriptions,
{
userId,
user: users.find((u) => u.id === userId) || currentUser!,
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`;
};
@@ -385,12 +430,12 @@ export default function TopicsView() {
type="button"
onClick={() => handleSelfSubscribe(topic)}
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${
isSubscribed(topic)
isSubscribedToTopic(topic)
? "border-red-300 text-red-700 bg-red-50 hover:bg-red-100"
: "border-green-300 text-green-700 bg-green-50 hover:bg-green-100"
}`}
>
{isSubscribed(topic) ? (
{isSubscribedToTopic(topic) ? (
<>
<UserMinus className="w-3 h-3 mr-1" />
Unsubscribe
@@ -565,7 +610,9 @@ export default function TopicsView() {
)}
<p className="mt-1 text-xs text-gray-400">
Requested on:{" "}
{new Date(req.createdAt).toLocaleDateString()}
{req.createdAt
? new Date(req.createdAt).toLocaleDateString()
: "Unknown"}
{req.approver && (
<span className="ml-2">
| Approved by: {req.approver.name}
@@ -600,7 +647,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 +665,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 +681,7 @@ export default function TopicsView() {
</label>
<textarea
id="topic-description"
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.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })

View File

@@ -47,7 +47,11 @@ export default function UsersView() {
try {
const res = await client.api.users.$post(
{
json: formData as any,
json: formData as {
name: string;
feishuUserId?: string;
email?: string;
},
},
{
init: { credentials: "include" },
@@ -169,7 +173,7 @@ export default function UsersView() {
id="user-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 })
@@ -186,7 +190,7 @@ export default function UsersView() {
<input
id="user-feishu"
type="text"
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.feishuUserId}
onChange={(e) =>
setFormData({ ...formData, feishuUserId: e.target.value })
@@ -203,7 +207,7 @@ export default function UsersView() {
<input
id="user-email"
type="email"
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.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
@@ -214,7 +218,7 @@ export default function UsersView() {
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 text-gray-900"
>
Cancel
</button>

View File

@@ -1,4 +1,4 @@
# Project Context for GitHub Copilot (v1.2.2)
# Project Context for GitHub Copilot (v1.2.5)
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.
@@ -193,12 +193,17 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- **Imports**: Use relative imports.
- **Styling**: Use Tailwind utility classes directly in JSX.
- **Async/Await**: Prefer `async/await` over `.then()`.
- **Type Safety**: strict TypeScript usage. Backend and Frontend share types via Hono RPC or shared interfaces.
- **Type Safety**: strict TypeScript usage. Backend and Frontend share types via Hono RPC or shared interfaces. **Elimination of `any`** is a priority; use explicit interfaces (e.g., `WebhookBody`, `UserAccessTokenData`) for all externally sourced data.
- **Linter & Formatter**:
- Framework: [Biome](https://biomejs.dev/).
- **Rules**: Strict configuration for `a11y`, `suspicious`, `style`, and `correctness`.
- **Tailwind**: `noUnknownAtRules` is configured to ignore Tailwind directives (`@tailwind`, `@apply`, etc.).
- **Enforcement**: CI/CD runs `biome check` to ensure compliance. Avoid use of `as any` unless absolutely necessary (e.g., complex API payloads), in which case `// biome-ignore` should include a rationale.
- **Enforcement**: CI/CD runs `biome check` to ensure compliance. Avoid Use of `as any` is strictly prohibited except for specialized cases like `import.meta as any` (for Vite env) or very complex JSON spread operations. In those rare cases, use `// biome-ignore` with a clear explanation.
- **Vite Env Access**: When accessing Vite environment variables via `import.meta.env` (or casting `import.meta as any`), **always use optional chaining** (e.g., `meta.env?.VITE_...`). This prevents crashes if the environment is not initialized or if the code runs in a non-browser context during pre-rendering/testing.
- **Frontend Resilience**:
- Always check `res.ok` before attempting to parse or use API responses.
- Use `Array.isArray()` to verify that data expected to be a list actually is one, especially when mapping over it in JSX. This prevents "white page" crashes when the backend returns error objects instead of arrays.
- Provide fallback empty states (e.g., `setTopics([])`) in `catch` blocks or failed response branches.
- **Logging**:
- Framework: `pino`.
- **Structured Log**: Use JSON format for easy parsing and aggregation.
@@ -216,6 +221,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

View File

@@ -27,4 +27,6 @@
- [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).
- [x] **Frontend Resilience**: Hardened API calls to prevent crashes on empty data or env access errors.
- [x] **CI & Type Safety**: Resolved all TypeScript errors and Biome formatting issues to ensure a healthy CI pipeline.