Merge pull request #15 from d0zingcat/perf/onboard

This commit is contained in:
2026-01-17 15:30:47 +08:00
committed by GitHub
14 changed files with 815 additions and 162 deletions

View File

@@ -5,7 +5,31 @@
本文件的格式基于 [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.3.0] - 2026-01-15
## [1.3.2] - 2026-01-17
### 新增
- **群聊搜索功能**: 在绑定群聊时新增了实时搜索功能,解决了群聊过多时难以查找的问题。
- **后端支持**: `GET /groups` 接口现在支持 `q` 查询参数进行模糊搜索,并提高了默认返回数量。
- **搜索前端**: 引入了带防抖逻辑的搜索输入框和自定义下拉列表,提升了用户体验。
### 变更
- **UI 优化**: 改进了 `GroupBindingsModal` 的视觉设计,使用了更现代的列表样式、状态图标和加载动画。
- **文档优化**: 将 `README.md` 拆分为英文版 (`README.md`) 和中文版 (`README.zh-CN.md`),以更好地支持国际化。
## [1.3.1] - 2026-01-16
### 新增
- **群聊绑定管理**: 增强了 Topic 与飞书群聊绑定的安全性与流程。
- **权限控制**: 仅 Topic 创建者或管理员允许执行群聊绑定/解绑操作。
- **审批流程**: 新增群聊绑定审批机制,非管理员/非信任用户的绑定请求需经过审批(`status` 追踪)。
- **管理员通知**: 引入 `admin-notifier.ts`,当有新的 Topic 或群聊绑定请求时,通过飞书卡片实时通知管理员。
- **信任用户系统**: 引入 `isTrusted` 标志。
- 信任用户创建 Topic 或绑定群聊时将自动通过审批。
- 管理员由于其特殊权限,默认享受自动审批。
### 变更
- **数据库架构**: `topic_group_chats` 表新增了 `status``created_by` 字段,以支持审批流和权限校验。
### 新增
- **视觉品牌**: 引入了自定义图标和 Favicon。

121
README.md
View File

@@ -1,50 +1,48 @@
# Alert Message Center
**README** | [简体中文](./README.zh-CN.md)
[![Tech Stack](https://img.shields.io/badge/Stack-Bun%20%7C%20Hono%20%7C%20React-blue)](https://bun.sh)
[![Database](https://img.shields.io/badge/Database-PostgreSQL-blue)](https://www.postgresql.org/)
**Alert Message Center** 是一个现代化、企业级的告警路由与分发中心。它旨在将纷繁复杂的告警源Prometheus, Grafana, 自建脚本等)与最终接收人解耦,通过 **飞书机器人私聊** 实现告警的精准触达。
**Alert Message Center** is a modern, enterprise-grade alert routing and dispatching hub. It decouples complex alert sources (Prometheus, Grafana, custom scripts, etc.) from end recipients, ensuring precise alert delivery via **Feishu (Lark) private messages and group chats.**
---
## 📸 界面预览
## 📸 Preview
### 1. 话题管理与个人信箱
支持通过 **Topic (主题)** 订阅模式分发告警,同时也提供 **Personal Inbox (个人信箱)** 功能,无需创建话题即可快速给自己推送消息。
### 1. Topic Management & Personal Inbox
Supports alert distribution through the **Topic** subscription model, and also provides a **Personal Inbox** feature for quick self-notifications without creating a topic.
![Topics View](docs/images/topics_view.png)
除了个人订阅外,您可以将 Topic 绑定至多个**飞书群聊**。
> [!TIP]
> **群聊发现**:请先将机器人邀请进入目标群聊。机器人入群后会触发自动感应,此时刷新管理页面即可在下拉菜单中看到并绑定该群组。
### 2. 群聊告警分发
支持将机器人加入飞书群聊,并将话题绑定到群聊中,实现告警的群组广播。
### 2. Group Chat Alert Dispatch
Supports adding the bot to Feishu group chats and binding topics to these groups for group-wide broadcasting.
![Group Binding](docs/images/group_binding.png)
![Group Alert](docs/images/group_alert.png)
### 3. 管理员看板 (Live Stats)
实时追踪全系统的告警负载、分发成功率以及各话题的热度。
### 3. Admin Dashboard (Live Stats)
Real-time tracking of system alert load, dispatch success rates, and topic popularity.
![Admin Dashboard](docs/images/admin_dashboard.png)
---
## 🔥 核心特性
## 🔥 Key Features
- **🚀 极简推送 (Personal Inbox)**: 每个用户拥有专属的 Webhook Token直接向 `/dm` 接口发送即可在飞书收到私聊,零配置成本。
- **📢 主题订阅 (Topic Model)**: 灵活的“发布-订阅”机制。告警发送至 Topic系统自动分发给所有订阅成员。
- **👥 群聊分发 (Group Support)**: 告警可同步分发至绑定的飞书群聊,支持机器人自动发现与解绑。
- **🛡️ 权限与审计**:
- 话题创建需经过管理员审批。
- 记录完整的 `Alert Task` 日志,实现发送链路可追溯。
- **📊 实时看板**: Grafana 风格的监控界面,直观展示系统运行健壮性。
- **🔌 长连接模式 (WebSocket)**: 支持飞书开放平台长连接,无需公网 IP 或域名即可在内网环境接收事件回调。
- **⚡ 高性能架构**: 基于 Bun + Hono 的全异步架构,毫秒级分发延迟。
- **🚀 Zero-Config Personal Inbox**: Every user has a unique Webhook Token. Send directly to the `/dm` interface to receive messages on Feishu with zero configuration.
- **📢 Topic Subscription Model**: Flexible "Publish-Subscribe" mechanism. Alerts sent to a Topic are automatically distributed to all subscribers.
- **👥 Group Chat Distribution**: Alerts can be simultaneously dispatched to bound Feishu group chats, supporting automatic discovery and unbinding.
- **🛡️ Permissions & Auditing**:
- Topic creation requires admin approval.
- Full `Alert Task` logs for end-to-end traceability.
- **📊 Real-time Dashboard**: Grafana-style monitoring interface for system health visualization.
- **🔌 WebSocket Mode**: Supports Feishu Open Platform WebSocket for intranet deployments without public IP or domain.
- **⚡ High Performance**: Built on Bun + Hono for millisecond-level dispatch latency.
---
## 🛠️ 技术栈
## 🛠️ Tech Stack
- **Runtime**: [Bun](https://bun.sh/) (高性能 TS 运行时)
- **Runtime**: [Bun](https://bun.sh/) (High-performance TS runtime)
- **Backend**: [Hono](https://hono.dev/) (Web Standards Based)
- **Frontend**: [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [Tailwind CSS](https://tailwindcss.com/)
- **Database**: [PostgreSQL](https://www.postgresql.org/) + [Drizzle ORM](https://orm.drizzle.team/)
@@ -52,84 +50,53 @@
---
## 🚀 快速开始
## 🚀 Quick Start
### 1. 飞书应用配置
1. 登录 [飞书开放平台](https://open.feishu.cn/) 创建一个 **企业自建应用**
2. 在“应用能力”中开启 **机器人**
3. 在“权限管理”中申请 `im:message:send_as_bot` (以应用身份发送消息)。
4. 获取 `App ID` `App Secret`
### 1. Feishu App Configuration
1. Login to the [Feishu Open Platform](https://open.feishu.cn/) and create an **Enterprise Custom App**.
2. Enable **Bot** capability in "App Capabilities".
3. Apply for `im:message:send_as_bot` permission in "Permission Management".
4. Get your `App ID` and `App Secret`.
### 2. 部署运行
### 2. Deployment
```bash
# 安装依赖
# Install dependencies
bun install
# 配置环境变量 (apps/server/.env)
# Configure environment variables (apps/server/.env)
DATABASE_URL="postgresql://user:pass@localhost:5432/db"
FEISHU_APP_ID="cli_xxx"
FEISHU_APP_SECRET="xxx"
ADMIN_EMAILS="user1@example.com,user2@example.com" # 管理员列表
ADMIN_EMAILS="user1@example.com,user2@example.com"
# 数据库推送/迁移
# Database migration
cd apps/server && bun run db:migrate:deploy
# 启动开发环境
# Start development
bun run dev
```
### 3. Docker 部署
项目支持使用 Docker Compose 快速部署,且**数据库会自动进行初始化与迁移**
### 3. Docker Deployment
```bash
# 复制并填写环境变量
cp apps/server/.env.example .env
# 启动所有服务 (Postgres + Server + Web)
docker-compose up -d
```
---
## 🏗️ CI/CD
Automatically builds and pushes Docker images to GitHub Container Registry (GHCR) on every push to `main`.
项目通过 GitHub Actions 实现了自动化流水线:
## 📜 Changelog
See [CHANGELOG.md](CHANGELOG.md) for version history.
- **自动化构建**: 每次推送至 `main` 分支或提交 Pull Request 时,会自动触发 Docker 镜像构建。
- **镜像仓库**: 构建生成的镜像会同步推送到 GitHub Container Registry (GHCR)。
- **镜像路径**: `ghcr.io/${USER}/alert-message-center` (包含前后端的统一镜像)
## 📡 Webhook Usage
- **Personal Inbox**: `POST /api/webhook/:your_token/dm`
- **Topic**: `POST /api/webhook/:your_token/topic/:topic_slug`
---
## 📜 更新日志
所有版本的详细变更记录请查看 [CHANGELOG.md](CHANGELOG.md)。
---
## 📡 Webhook 使用指南
### 1. 发送给个人 (Personal Inbox)
**URL**: `POST /api/webhook/:your_token/dm`
**Body**:
```json
{
"msg_type": "text",
"content": { "text": "这是一条私有告警" }
}
```
### 2. 发送到主题 (Topic)
**URL**: `POST /api/webhook/:your_token/topic/:topic_slug`
**Body**: 同上。系统会自动根据该 Topic 的订阅列表进行广播。
---
## 📂 项目结构
- `apps/server`: 核心 API 服务,处理 OAuth、Webhook 解析与飞书分发。
- `apps/web`: 响应式管理后台。
- `docs/copilot-context.md`: 为 AI 辅助编程提供的深层架构背景。
## 📂 Project Structure
- `apps/server`: Core API service
- `apps/web`: Responsive management dashboard
---
*Created with ❤️ by the Alert Message Center Team.*

119
README.zh-CN.md Normal file
View File

@@ -0,0 +1,119 @@
# Alert Message Center
[English](./README.md) | **简体中文**
[![Tech Stack](https://img.shields.io/badge/Stack-Bun%20%7C%20Hono%20%7C%20React-blue)](https://bun.sh)
[![Database](https://img.shields.io/badge/Database-PostgreSQL-blue)](https://www.postgresql.org/)
**Alert Message Center** 是一个现代化、企业级的告警路由与分发中心。它旨在将纷繁复杂的告警源Prometheus, Grafana, 自建脚本等)与最终接收人解耦,通过 **飞书机器人私聊** 实现告警的精准触达。
---
## 📸 界面预览
### 1. 话题管理与个人信箱
支持通过 **Topic (主题)** 订阅模式分发告警,同时也提供 **Personal Inbox (个人信箱)** 功能,无需创建话题即可快速给自己推送消息。
![Topics View](docs/images/topics_view.png)
### 2. 群聊告警分发
支持将机器人加入飞书群聊,并将话题绑定到群聊中,实现告警的群组广播。
![Group Binding](docs/images/group_binding.png)
![Group Alert](docs/images/group_alert.png)
### 3. 管理员看板 (Live Stats)
实时追踪全系统的告警负载、分发成功率以及各话题的热度。
![Admin Dashboard](docs/images/admin_dashboard.png)
---
## 🔥 核心特性
- **🚀 极简推送 (Personal Inbox)**: 每个用户拥有专属的 Webhook Token直接向 `/dm` 接口发送即可在飞书收到私聊,零配置成本。
- **📢 主题订阅 (Topic Model)**: 灵活的“发布-订阅”机制。告警发送至 Topic系统自动分发给所有订阅成员。
- **👥 群聊分发 (Group Support)**: 告警可同步分发至绑定的飞书群聊,支持机器人自动发现与解绑。
- **🛡️ 权限与审计**:
- 话题创建需经过管理员审批。
- 记录完整的 `Alert Task` 日志,实现发送链路可追溯。
- **📊 实时看板**: Grafana 风格的监控界面,直观展示系统运行健壮性。
- **🔌 长连接模式 (WebSocket)**: 支持飞书开放平台长连接,无需公网 IP 或域名即可在内网环境接收事件回调。
- **⚡ 高性能架构**: 基于 Bun + Hono 的全异步架构,毫秒级分发延迟。
---
## 🛠️ 技术栈
- **Runtime**: [Bun](https://bun.sh/) (高性能 TS 运行时)
- **Backend**: [Hono](https://hono.dev/) (Web Standards Based)
- **Frontend**: [React](https://react.dev/) + [Vite](https://vitejs.dev/) + [Tailwind CSS](https://tailwindcss.com/)
- **Database**: [PostgreSQL](https://www.postgresql.org/) + [Drizzle ORM](https://orm.drizzle.team/)
- **Messaging**: [Feishu (Lark) Open Platform](https://open.feishu.cn/)
---
## 🚀 快速开始
### 1. 飞书应用配置
1. 登录 [飞书开放平台](https://open.feishu.cn/) 创建一个 **企业自建应用**
2. 在“应用能力”中开启 **机器人**
3. 在“权限管理”中申请 `im:message:send_as_bot` (以应用身份发送消息)。
4. 获取 `App ID``App Secret`
### 2. 部署运行
```bash
# 安装依赖
bun install
# 配置环境变量 (apps/server/.env)
DATABASE_URL="postgresql://user:pass@localhost:5432/db"
FEISHU_APP_ID="cli_xxx"
FEISHU_APP_SECRET="xxx"
ADMIN_EMAILS="user1@example.com,user2@example.com" # 管理员列表
# 数据库推送/迁移
cd apps/server && bun run db:migrate:deploy
# 启动开发环境
bun run dev
```
### 3. Docker 部署
项目支持使用 Docker Compose 快速部署,且**数据库会自动进行初始化与迁移**
```bash
# 复制并填写环境变量
cp apps/server/.env.example .env
# 启动所有服务 (Postgres + Server + Web)
docker-compose up -d
```
---
## 🏗️ CI/CD
项目通过 GitHub Actions 实现了自动化流水线。
## 📜 更新日志
所有版本的详细变更记录请查看 [CHANGELOG.md](CHANGELOG.md)。
---
## 📡 Webhook 使用指南
### 1. 发送给个人 (Personal Inbox)
**URL**: `POST /api/webhook/:your_token/dm`
### 2. 发送到主题 (Topic)
**URL**: `POST /api/webhook/:your_token/topic/:topic_slug`
---
## 📂 项目结构
- `apps/server`: 核心 API 服务,处理 OAuth、Webhook 解析与飞书分发。
- `apps/web`: 响应式管理后台。
- `docs/copilot-context.md`: 为 AI 辅助编程提供的深层架构背景。
---
*Created with ❤️ by the Alert Message Center Team.*

View File

@@ -11,6 +11,7 @@ import {
topics,
users,
} from "./db/schema";
import { notifyAdminsOfNewTopic } from "./lib/admin-notifier";
import { type AuthSession, requireAdmin, requireAuth } from "./middleware";
const api = new Hono<{ Variables: { session: AuthSession } }>();
@@ -30,6 +31,7 @@ const userSchema = z.object({
name: z.string().min(1),
feishuUserId: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
isTrusted: z.boolean().optional(),
});
// --- Topics ---
@@ -73,6 +75,17 @@ api.get("/topics/requests", requireAdmin, async (c) => {
return c.json(requests);
});
api.get("/topics/groups/requests", requireAdmin, async (c) => {
const requests = await db.query.topicGroupChats.findMany({
where: eq(topicGroupChats.status, "pending"),
with: {
topic: true,
creator: true,
},
});
return c.json(requests);
});
api.get("/topics/all", requireAdmin, async (c) => {
const allTopics = await db.query.topics.findMany({
with: {
@@ -124,7 +137,7 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
const body = c.req.valid("json");
const session = c.get("session");
const status = session.isAdmin ? "approved" : "pending";
const status = session.isAdmin || session.isTrusted ? "approved" : "pending";
const result = await db
.insert(topics)
@@ -132,9 +145,18 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
...body,
status,
createdBy: session.id,
approvedBy: session.isAdmin ? session.id : null,
approvedBy: session.isAdmin || session.isTrusted ? session.id : null,
})
.returning();
if (status === "pending") {
await notifyAdminsOfNewTopic({
id: result[0].id,
name: result[0].name,
slug: result[0].slug,
createdBy: session.id,
});
}
return c.json(result[0]);
});
@@ -248,12 +270,20 @@ api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
// Get list of known groups (for selection)
api.get("/groups", requireAuth, async (c) => {
const query = c.req.query("q")?.trim();
const limit = Math.min(Number(c.req.query("limit") || 100), 200);
const whereClause = query
? sql`${knownGroupChats.name} ilike ${`%${query}%`}`
: undefined;
// Return recent active groups
const groups = await db
.select()
.from(knownGroupChats)
.where(whereClause)
.orderBy(desc(knownGroupChats.lastActiveAt))
.limit(50);
.limit(limit);
return c.json(groups);
});
@@ -278,6 +308,25 @@ api.post(
const body = c.req.valid("json");
const session = c.get("session");
// Check topic ownership or admin
const topic = await db.query.topics.findFirst({
where: eq(topics.id, topicId),
});
if (!topic) {
return c.json({ error: "Topic not found" }, 404);
}
if (topic.createdBy !== session.id && !session.isAdmin) {
return c.json(
{ error: "Only topic owner or admin can bind groups" },
403,
);
}
const status =
session.isAdmin || session.isTrusted ? "approved" : "pending";
const result = await db
.insert(topicGroupChats)
.values({
@@ -285,9 +334,23 @@ api.post(
chatId: body.chatId,
name: body.name,
createdBy: session.id,
status,
})
.returning();
if (status === "pending") {
// Notify admins about the new group binding request
await notifyAdminsOfNewTopic({
id: topic.id,
name: topic.name,
slug: topic.slug,
createdBy: session.id,
// Metadata passed to notifier for better context
isGroupBinding: true,
groupName: body.name,
});
}
return c.json(result[0]);
},
);
@@ -295,6 +358,23 @@ api.post(
// Unbind a group
api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
const { id: topicId, bindingId } = c.req.param();
const session = c.get("session");
// Check topic ownership or admin
const topic = await db.query.topics.findFirst({
where: eq(topics.id, topicId),
});
if (!topic) {
return c.json({ error: "Topic not found" }, 404);
}
if (topic.createdBy !== session.id && !session.isAdmin) {
return c.json(
{ error: "Only topic owner or admin can unbind groups" },
403,
);
}
await db
.delete(topicGroupChats)
@@ -308,6 +388,38 @@ api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
return c.json({ success: true });
});
// Approve a group binding
api.post("/topics/:id/groups/:bindingId/approve", requireAdmin, async (c) => {
const { id: topicId, bindingId } = c.req.param();
const result = await db
.update(topicGroupChats)
.set({ status: "approved" })
.where(
and(
eq(topicGroupChats.id, bindingId),
eq(topicGroupChats.topicId, topicId),
),
)
.returning();
return c.json(result[0]);
});
// Reject a group binding
api.post("/topics/:id/groups/:bindingId/reject", requireAdmin, async (c) => {
const { id: topicId, bindingId } = c.req.param();
const result = await db
.update(topicGroupChats)
.set({ status: "rejected" })
.where(
and(
eq(topicGroupChats.id, bindingId),
eq(topicGroupChats.topicId, topicId),
),
)
.returning();
return c.json(result[0]);
});
// --- Alert Tasks ---
api.get("/alerts/tasks", requireAdmin, async (c) => {

View File

@@ -78,13 +78,13 @@ auth.get("/callback", async (c) => {
.returning();
user = result[0];
} else {
// Update user info (in case name or admin status changed)
// Update user info (don't overwrite admin/trusted status from feishu logic unless it's a new admin)
const result = await db
.update(users)
.set({
name: userData.name,
email: userData.email || user.email,
isAdmin,
isAdmin: user.isAdmin || isAdmin, // Keep admin if already admin or in ADMIN_EMAILS
})
.where(eq(users.id, user.id))
.returning();
@@ -100,6 +100,7 @@ auth.get("/callback", async (c) => {
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
isTrusted: user.isTrusted,
personalToken: user.personalToken,
}),
{
@@ -117,6 +118,7 @@ auth.get("/callback", async (c) => {
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
isTrusted: user.isTrusted,
},
});
} catch (error) {

View File

@@ -35,6 +35,9 @@ export const topicGroupChats = pgTable("topic_group_chats", {
.references(() => topics.id, { onDelete: "cascade" }),
chatId: text("chat_id").notNull(), // 飞书群 chat_id
name: text("name").notNull(), // 群名称快照
status: text("status", { enum: ["pending", "approved", "rejected"] })
.default("approved")
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
createdBy: text("created_by").references(() => users.id),
});
@@ -83,6 +86,7 @@ export const users = pgTable("users", {
feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
email: text("email").unique(),
isAdmin: boolean("is_admin").default(false),
isTrusted: boolean("is_trusted").default(false),
personalToken: text("personal_token")
.notNull()
.unique()

View File

@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import { users } from "../db/schema";
import { feishuClient } from "../feishu";
import { logger } from "./logger";
export async function notifyAdminsOfNewTopic(topic: {
id: string;
name: string;
slug: string;
createdBy: string | null;
isGroupBinding?: boolean;
groupName?: string;
}) {
try {
// 1. Get all admins
const admins = await db.query.users.findMany({
where: eq(users.isAdmin, true),
});
if (admins.length === 0) {
logger.warn("No admins found to notify");
return;
}
// 2. Get creator name
let creatorName = "Unknown";
if (topic.createdBy) {
const creator = await db.query.users.findFirst({
where: eq(users.id, topic.createdBy),
});
if (creator) creatorName = creator.name;
}
// 3. Prepare message content
const title = topic.isGroupBinding
? "🔗 新的群聊绑定申请"
: "🆕 新的 Topic 申请";
const detailContent = topic.isGroupBinding
? `**Topic:** ${topic.name}\n**群聊:** ${topic.groupName}\n**创建者:** ${creatorName}`
: `**名称:** ${topic.name}\n**Slug:** ${topic.slug}\n**创建者:** ${creatorName}`;
const content = {
config: { wide_screen_mode: true },
header: {
template: topic.isGroupBinding ? "blue" : "orange",
title: { content: title, tag: "plain_text" },
},
elements: [
{
tag: "div",
text: {
content: detailContent,
tag: "lark_md",
},
},
{
tag: "action",
actions: [
{
tag: "button",
text: { content: "前往审批", tag: "plain_text" },
type: "primary",
url: `${process.env.FRONTEND_URL || "http://localhost:5173"}/admin/topics`,
},
],
},
],
};
// 4. Send notification to each admin
for (const admin of admins) {
if (admin.feishuUserId) {
await feishuClient.sendMessage(
admin.feishuUserId,
"open_id",
"interactive",
content,
);
}
}
} catch (error) {
logger.error({ err: error, topicId: topic.id }, "Failed to notify admins");
}
}

View File

@@ -6,6 +6,7 @@ export interface AuthSession {
name: string;
email: string | null;
isAdmin: boolean;
isTrusted: boolean;
}
export async function requireAuth(c: Context, next: Next) {

View File

@@ -82,13 +82,15 @@ webhook.post("/:token/topic/:slug", async (c) => {
})
.filter((u): u is NonNullable<typeof u> => u !== null);
const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({
type: "group",
id: g.id, // Binding ID
name: g.name,
feishuId: g.chatId,
idType: "chat_id" as FeishuReceiveIdType,
}));
const groupRecipients: Recipient[] = topic.groupChats
.filter((g) => g.status === "approved")
.map((g) => ({
type: "group",
id: g.id, // Binding ID
name: g.name,
feishuId: g.chatId,
idType: "chat_id" as FeishuReceiveIdType,
}));
const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients];

View File

@@ -1,5 +1,5 @@
import { MessageCircle, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { MessageCircle, Plus, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { client } from "../lib/client";
import Modal from "./Modal";
@@ -7,6 +7,7 @@ interface GroupBinding {
id: string;
chatId: string;
name: string;
status: "pending" | "approved" | "rejected";
}
interface KnownGroup {
@@ -28,16 +29,21 @@ export default function GroupBindingsModal({
topicId,
topicName,
}: GroupBindingsModalProps) {
// const { user } = useAuth(); // Unused
const [bindings, setBindings] = useState<GroupBinding[]>([]);
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedChatId, setSelectedChatId] = useState("");
const [loading, setLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [status, setStatus] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchBindings = useCallback(async () => {
try {
const res = await client.api.topics[":id"].groups.$get(
@@ -57,17 +63,25 @@ export default function GroupBindingsModal({
}
}, [topicId]);
const fetchKnownGroups = useCallback(async () => {
const fetchKnownGroups = useCallback(async (q?: string) => {
setIsSearching(true);
try {
const res = await client.api.groups.$get(undefined, {
init: { credentials: "include" },
});
const res = await client.api.groups.$get(
{
query: q ? { q } : undefined,
},
{
init: { credentials: "include" },
},
);
const data = (await res.json()) as KnownGroup[];
if (Array.isArray(data)) {
setKnownGroups(data);
}
} catch (err) {
console.error(err);
} finally {
setIsSearching(false);
}
}, []);
@@ -77,24 +91,60 @@ export default function GroupBindingsModal({
fetchKnownGroups();
setStatus(null);
setSelectedChatId("");
setSearchQuery("");
setShowDropdown(false);
}
}, [isOpen, topicId, fetchBindings, fetchKnownGroups]);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
setSelectedChatId("");
setShowDropdown(true);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
fetchKnownGroups(value);
}, 300);
};
const handleSelectGroup = (group: KnownGroup) => {
setSelectedChatId(group.chatId);
setSearchQuery(group.name);
setShowDropdown(false);
};
const handleBind = async () => {
if (!selectedChatId) return;
setLoading(true);
setStatus(null);
const group = knownGroups.find((g) => g.chatId === selectedChatId);
if (!group) return;
const groupName = searchQuery;
try {
const res = await client.api.topics[":id"].groups.$post(
{
param: { id: topicId },
json: {
chatId: group.chatId,
name: group.name,
chatId: selectedChatId,
name: groupName,
},
},
{
@@ -103,15 +153,22 @@ export default function GroupBindingsModal({
);
if (res.ok) {
setStatus({ type: "success", message: "Group bound successfully!" });
const data = (await res.json()) as GroupBinding;
setStatus({
type: "success",
message:
data.status === "approved"
? "Group bound successfully!"
: "Request submitted! Waiting for approval.",
});
fetchBindings();
setSelectedChatId("");
setSearchQuery("");
} else {
await res.json(); // Consume body
await res.json();
setStatus({ type: "error", message: "Failed to bind group" });
}
} catch (_) {
// Ignore error
setStatus({ type: "error", message: "An error occurred" });
} finally {
setLoading(false);
@@ -139,7 +196,6 @@ export default function GroupBindingsModal({
}
};
// Filter out groups that are already bound
const availableGroups = knownGroups.filter(
(kg) => !bindings.some((b) => b.chatId === kg.chatId),
);
@@ -152,33 +208,54 @@ export default function GroupBindingsModal({
>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center">
<MessageCircle className="w-4 h-4 mr-2 text-indigo-500" />
Bound Groups
</h4>
{bindings.length === 0 ? (
<p className="text-sm text-gray-500 italic">
No groups bound to this topic yet.
</p>
<div className="text-center py-6 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
<p className="text-sm text-gray-400">
No groups bound to this topic yet.
</p>
</div>
) : (
<ul className="divide-y divide-gray-200 border rounded-md">
<ul className="divide-y divide-gray-100 border rounded-lg overflow-hidden bg-white shadow-sm">
{bindings.map((binding) => (
<li
key={binding.id}
className="flex justify-between items-center p-3"
className="flex justify-between items-center p-3 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center">
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-700">
{binding.name}
<div className="flex items-center flex-1 min-w-0">
<div className="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center mr-3 flex-shrink-0">
<MessageCircle className="w-4 h-4 text-indigo-600" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-gray-900 truncate">
{binding.name}
</span>
<span className="text-[10px] text-gray-400 font-mono truncate">
{binding.chatId}
</span>
</div>
<span
className={`ml-3 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase ${
binding.status === "approved"
? "bg-green-100 text-green-700"
: binding.status === "rejected"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}
>
{binding.status}
</span>
</div>
<button
type="button"
onClick={() => handleUnbind(binding.id)}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
className="ml-2 text-gray-400 hover:text-red-500 p-1.5 rounded-md hover:bg-red-50 transition-colors"
title="Remove binding"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-4.5 h-4.5" />
</button>
</li>
))}
@@ -186,45 +263,118 @@ export default function GroupBindingsModal({
)}
</div>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<h4 className="text-sm font-medium text-gray-900 mb-3">
<div className="bg-indigo-50/50 p-5 rounded-xl border border-indigo-100 shadow-sm">
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center">
<Plus className="w-4 h-4 mr-2 text-indigo-500" />
Add Group Binding
</h4>
<p className="text-xs text-gray-500 mb-3">
Select a group where the Feishu Bot has been added. If your group is
not listed, try removing and re-adding the bot to the group.
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
Search and select a group where the <strong>Alert Messenger</strong>{" "}
bot has been added.
</p>
<div className="flex gap-2">
<select
className="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={selectedChatId}
onChange={(e) => setSelectedChatId(e.target.value)}
disabled={loading}
>
<option value="">Select a group...</option>
{availableGroups.map((group) => (
<option key={group.chatId} value={group.chatId}>
{group.name}
</option>
))}
</select>
<div className="flex flex-col gap-3">
<div className="relative" ref={dropdownRef}>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
{isSearching ? (
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
) : (
<Search className="h-4 w-4 text-gray-400" />
)}
</div>
<input
type="text"
className="block w-full pl-10 pr-10 py-2.5 bg-white border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 placeholder-gray-400 transition-all"
placeholder="Search for a group name..."
value={searchQuery}
onChange={handleSearchChange}
onFocus={() =>
knownGroups.length > 0 && setShowDropdown(true)
}
disabled={loading}
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
setSelectedChatId("");
fetchKnownGroups();
}}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{showDropdown && (
<div className="absolute z-10 mt-1 w-full bg-white shadow-xl max-h-60 rounded-lg py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm border border-gray-100">
{availableGroups.length > 0 ? (
availableGroups.map((group) => (
<button
key={group.chatId}
type="button"
className="w-full text-left px-4 py-2.5 hover:bg-indigo-50 cursor-pointer flex items-center group transition-colors"
onClick={() => handleSelectGroup(group)}
>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate group-hover:text-indigo-700">
{group.name}
</div>
<div className="text-xs text-gray-400 font-mono truncate">
{group.chatId}
</div>
</div>
{selectedChatId === group.chatId && (
<Plus className="w-4 h-4 text-indigo-600 ml-2" />
)}
</button>
))
) : (
<div className="px-4 py-6 text-center text-gray-500">
<p className="text-sm font-medium">No results found</p>
<p className="text-xs mt-1">
Try a different search term or check if the bot is in
the group.
</p>
</div>
)}
</div>
)}
</div>
<button
type="button"
onClick={handleBind}
disabled={!selectedChatId || loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-semibold rounded-lg shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-[0.98]"
>
<Plus className="w-4 h-4 mr-1" />
Add
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
Processing...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Bind This Group
</>
)}
</button>
</div>
{status && (
<p
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
<div
className={`mt-4 p-3 rounded-lg flex items-start gap-2 ${
status.type === "success"
? "bg-green-50 text-green-700 border border-green-100"
: "bg-red-50 text-red-700 border border-red-100"
}`}
>
{status.message}
</p>
<div className="text-sm font-medium">{status.message}</div>
</div>
)}
</div>
</div>

View File

@@ -30,7 +30,7 @@ export default function AdminView() {
</div>
<div className="bg-white shadow rounded-lg p-6">
<div className="border-b border-gray-200 mb-6">
<div className="border-b border-gray-200 mb-6 overflow-x-auto">
<nav className="-mb-px flex space-x-8">
<button
type="button"
@@ -54,6 +54,17 @@ export default function AdminView() {
>
Topic Requests
</button>
<button
type="button"
onClick={() => setActiveTab("group-requests")}
className={`${
activeTab === "group-requests"
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
>
Group Bindings
</button>
<button
type="button"
onClick={() => setActiveTab("topics")}
@@ -70,12 +81,122 @@ export default function AdminView() {
{activeTab === "load" && <SystemLoadView />}
{activeTab === "requests" && <TopicRequestsList />}
{activeTab === "group-requests" && <GroupRequestsList />}
{activeTab === "topics" && <TopicsManagement />}
</div>
</div>
);
}
interface GroupRequest {
id: string;
topicId: string;
chatId: string;
name: string;
status: string;
createdAt: string;
topic?: Topic;
creator?: TopicUser;
}
function GroupRequestsList() {
const [requests, setRequests] = useState<GroupRequest[]>([]);
const [loading, setLoading] = useState(true);
const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const res = await client.api.topics.groups.requests.$get(undefined, {
init: { credentials: "include" },
});
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) {
setRequests(data as unknown as GroupRequest[]);
} else {
setRequests([]);
}
} else {
setRequests([]);
}
} catch (error) {
console.error(error);
setRequests([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
const handleAction = async (
req: GroupRequest,
action: "approve" | "reject",
) => {
try {
await client.api.topics[":id"].groups[":bindingId"][action].$post(
{ param: { id: req.topicId, bindingId: req.id } },
{ init: { credentials: "include" } },
);
fetchRequests();
} catch (error) {
console.error(error);
}
};
if (loading) return <div>Loading group requests...</div>;
if (requests.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No pending group binding requests.
</div>
);
}
return (
<div className="overflow-hidden">
<ul className="divide-y divide-gray-200">
{requests.map((req) => (
<li key={req.id} className="py-4 flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">
Group: <span className="text-indigo-600">{req.name}</span>
</p>
<p className="text-sm text-gray-500">
Topic: <span className="font-semibold">{req.topic?.name}</span>{" "}
({req.topic?.slug})
</p>
<p className="text-sm text-gray-500">
Requested by: {req.creator?.name || "Unknown"}
</p>
<p className="text-xs text-gray-400 mt-1">ID: {req.chatId}</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAction(req, "approve")}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors"
>
Approve
</button>
<button
type="button"
onClick={() => handleAction(req, "reject")}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
>
Reject
</button>
</div>
</li>
))}
</ul>
</div>
);
}
function TopicsManagement() {
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState(true);

View File

@@ -10,6 +10,8 @@ interface User {
feishuUserId?: string;
email?: string;
personalToken?: string;
isTrusted?: boolean;
isAdmin?: boolean;
}
export default function UsersView() {
@@ -85,6 +87,28 @@ export default function UsersView() {
}
};
const handleToggleTrusted = async (user: User) => {
try {
await client.api.users[":id"].$put(
{
param: { id: user.id },
json: {
name: user.name,
feishuUserId: user.feishuUserId,
email: user.email,
isTrusted: !user.isTrusted,
},
},
{
init: { credentials: "include" },
},
);
fetchUsers();
} catch (error) {
console.error("Error toggling trusted status:", error);
}
};
if (loading) return <div className="p-4">Loading...</div>;
return (
@@ -110,9 +134,16 @@ export default function UsersView() {
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-indigo-600 truncate">
{user.name}
</p>
<div className="flex items-center space-x-2">
<p className="text-sm font-medium text-indigo-600 truncate">
{user.name}
</p>
{user.isAdmin && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
Admin
</span>
)}
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex flex-col">
<p className="flex items-center text-sm text-gray-500">
@@ -134,11 +165,25 @@ export default function UsersView() {
</div>
</div>
{currentUser?.isAdmin && (
<div className="ml-4 flex items-center space-x-2">
<div className="ml-4 flex items-center space-x-4">
<div className="flex items-center">
<label className="inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={user.isTrusted || false}
onChange={() => handleToggleTrusted(user)}
/>
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600" />
<span className="ms-3 text-sm font-medium text-gray-500">
Trusted
</span>
</label>
</div>
<button
type="button"
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900 p-2"
className="text-red-400 hover:text-red-600 p-2 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>

View File

@@ -1,4 +1,4 @@
# Project Context for GitHub Copilot (v1.2.7)
# Project Context for GitHub Copilot (v1.3.2)
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.
@@ -51,6 +51,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- `feishuUserId`: The Feishu `open_id`. **Critical** for sending messages.
- `email`: Contact info.
- `isAdmin`: Boolean flag for administrative privileges (create topics, view all users).
- `isTrusted`: Boolean flag for trusted users (topics are auto-approved).
3. **Subscription** (`subscriptions`)
- `topicId`: Foreign Key -> `topics.id`.
@@ -62,6 +63,8 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- `topicId`: Foreign Key -> `topics.id`.
- `chatId`: The Feishu `chat_id`.
- `name`: Group name (snapshot).
- `status`: `pending`, `approved`, or `rejected`.
- `createdBy`: Foreign Key -> `users.id`.
- **Relationship**: Many-to-Many between Topics and Feishu Groups.
5. **Known Group Chat** (`known_group_chats`)
@@ -130,7 +133,12 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- The system listens for `im.chat.member.bot.deleted_v1` events.
- When the bot is removed, the cached group is deleted from `known_group_chats`.
- **Auto-Unbind**: All bindings in `topic_group_chats` for that `chat_id` are automatically deleted to ensure data consistency.
- **Binding**: Admins bind a Topic to a known Feishu Group in the UI.
- **Binding**: Users/Admins bind a Topic to a known Feishu Group in the UI.
- **Search**: The binding UI supports real-time, server-side debounced search by group name.
- **Security**: Only the Topic Creator or an Admin can bind/unbind groups to a Topic.
- **Approval**:
- Normal users: Binding status is `pending` upon creation. Admins receive notification.
- Admins/Trusted Users: Binding status is `approved` immediately.
- **Dispatch**: Alerts for the topic are sent to all bound `chat_id`s in addition to individual subscribers.
### Long Connection (WebSocket)
@@ -142,6 +150,12 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- Admins can manage subscriptions for other users globally in `AdminView`.
- **Topic Deletion**: Centralized in the **Admin Dashboard (All Topics Tab)** to avoid accidental deletion from the main topic list.
- Button logic on frontend toggles between "Subscribe" and "Unsubscribe".
- **Topic Approval**:
- Normal users: Topic status is `pending` upon creation. Admins receive an interactive Feishu notification.
- Admins/Trusted Users: Topic status is `approved` immediately.
- Admin notification logic is located in `apps/server/src/lib/admin-notifier.ts`.
- **Trusted User System**:
- Users with `isTrusted=true` (set by Admin) or `isAdmin=true` have their requests (Topics/Bindings) automatically approved.
## 5. API Endpoints
@@ -167,7 +181,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- `GET /api/users`: List users (Admin only).
### Feishu Group Management
- `GET /api/groups`: List known groups (cached from bot events).
- `GET /api/groups`: List known groups (cached from bot events). Supports `q` for search and `limit` parameters.
- `GET /api/topics/:id/groups`: List group bindings for a topic.
- `POST /api/topics/:id/groups`: Bind a group to a topic.
- `DELETE /api/topics/:id/groups/:bindingId`: Unbind a group.
@@ -193,13 +207,15 @@ 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. **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. **AI assistants MUST run `bun x biome check --write .` (or equivalent) in the respective app directory after every code modification to verify and fix lint/formatting issues before finalizing.** 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.
- **Strict Type Safety & `any` Prohibition**:
> [!IMPORTANT]
> **The usage of `any` is strictly prohibited.** This has been a recurring issue and must be avoided at all costs.
- **Explicit Interfaces**: Always define clear interfaces or types for API responses, webhook payloads, and complex objects.
- **Type Inference**: Leverage TypeScript's type inference. If a variable is initialized later, provide an explicit type during declaration (e.g., `let whereClause: SQL | undefined;`) instead of leaving it implicit.
- **Hono RPC**: Utilize the type-safe client (`client.api...`) to ensure end-to-end type safety between backend and frontend.
- **No Type Casting**: Avoid `as any` or `<any>` casts. Use type guards (`if`, `switch`, `instanceof`) or Zod schema validation to narrow types safely.
- **AI Responsibility**: AI assistants MUST ensure every new or modified piece of code passes strict TypeScript and Biome checks. If a type is unknown, research the schema rather than defaulting to `any`.
- **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.
@@ -227,6 +243,7 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
## 8. Core Documents
- **[README.md](file:///Users/lilithgames/Workspace/play/alert-message-center/README.md)**: Main project documentation, including quick start, tech stack overview, and Webhook usage guide.
- **[CHANGELOG.md](file:///Users/lilithgames/Workspace/play/alert-message-center/CHANGELOG.md)**: Record of version changes, following the Keep a Changelog specification.
- **[todo.md](file:///Users/lilithgames/Workspace/play/alert-message-center/todo.md)**: Task tracking and upcoming features.
- **[README.md](file:///Users/lilithgames/Workspace/play/alert-message-center/README.md)**: Main project documentation (English version).
- **[README.zh-CN.md](file:///Users/lilithgames/Workspace/play/alert-message-center/README.zh-CN.md)**: Simplified Chinese version of the documentation.
- **[CHANGELOG.md](file:///Users/lilithgames/Workspace/play/alert-message-center/CHANGELOG.md)**: Record of version changes.
- **[todo.md](file:///Users/lilithgames/Workspace/play/alert-message-center/todo.md)**: Task tracking.

View File

@@ -33,4 +33,8 @@
- [x] **User Token Shortening**: Shortened `personalToken` to 8 characters and integrated automated migration into the deployment script.
- [x] **Visual Identity**: Added custom logo, favicon and integrated them into the UI (login/navbar).
- [x] **Migration Robustness**: Fixed migration failures in Docker by un-ignoring the drizzle meta directory.
- [x] **Scalability & Security**: Implemented Trusted User system, ownership-based group binding, and Admin notification for topic requests.
- [x] **User Management UI**: Added "Admin" badges and a "Trusted" toggle in the User Management view.
- [x] **Searchable Group Binding**: Implemented server-side search and searchable dropdown for smoother group chat management.
- [x] **Bilingual Documentation**: Split README into English and Chinese versions for international outreach.