mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
Merge pull request #9 from d0zingcat/feature/db_init
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -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
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
# 复制并填写环境变量
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
26
apps/server/src/db/migrate.ts
Normal file
26
apps/server/src/db/migrate.ts
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
todo.md
4
todo.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user