commit b03707a794c6732728a80b95727a06553555cbab Author: d0zingcat Date: Fri Jan 9 23:44:05 2026 +0800 init Signed-off-by: d0zingcat diff --git a/.agent/workflows/dashboard.md b/.agent/workflows/dashboard.md new file mode 100644 index 0000000..c64028c --- /dev/null +++ b/.agent/workflows/dashboard.md @@ -0,0 +1,53 @@ +--- +description: System Dashboard and Monitoring Maintenance +--- + +# Dashboard and Monitoring Workflow + +This workflow describes how to maintain and extend the System Load dashboard and the underlying statistics API. + +## Architecture + +1. **Backend Statistics API**: + - File: `apps/server/src/api.ts` + - Endpoint: `GET /api/stats` (Requires Admin) + - Logic: Aggregates `alert_tasks` and `alert_logs` using Drizzle ORM. + - Key Metrics: + - `alertsReceived`: Total task count. + - `plannedMessages`: Sum of `recipientCount` across tasks. + - `successCount`: Sum of `successCount`. + - `failedCount`: calculated as `plannedMessages - successCount`. + +2. **Frontend Dashboard**: + - File: `apps/web/src/views/SystemLoadView.tsx` + - Component: `SystemLoadView` + - Features: + - 10s auto-refresh interval. + - SVG-based custom Gauges for success rate. + - Metric cards for immediate feedback. + - Live status indicator with breathing animation. + - **Audit Log**: A detailed table showing recent alert tasks, capturing the timestamp, topic, associated **Sender** (via personal token), and success counts. + +## Common Tasks + +### 1. Extending Metrics +To add a new metric (e.g., average delivery time): +1. Update `apps/server/src/api.ts` in the `/stats` handler. +2. Add the new fields to the query using Drizzle's `avg`, `min`, `max`, etc. +3. Update the `Stats` interface in `apps/web/src/views/SystemLoadView.tsx`. +4. Add a new `MetricCard` or a column to the table. + +### 2. Tuning Refresh Rates +If the 10s refresh is too aggressive: +- Change the interval in `useEffect` within `SystemLoadView.tsx`. +- Balance between "real-time feel" and server load. + +### 3. Debugging Type Erasure Issues +When working with Hono's RPC client (`hc`): +- The client in `apps/web/src/lib/client.ts` is currently cast as `any` to avoid complex type inference circularities. +- If adding new endpoints, ensure they are correctly routed in `apps/server/src/index.ts` so they appear in `AppType`. + +## Performance Considerations +- The `/stats` endpoint uses standard SQL aggregations. +- For extremely large datasets, consider adding an index on `alert_tasks(created_at, topic_slug)`. +- The dashboard is restricted to Admins to minimize the impact of frequent polling. diff --git a/.agent/workflows/topic-requests.md b/.agent/workflows/topic-requests.md new file mode 100644 index 0000000..444909b --- /dev/null +++ b/.agent/workflows/topic-requests.md @@ -0,0 +1,54 @@ +--- +description: Topic Request Lifecycle and User Feedback +--- + +# Topic Request Workflow + +This workflow describes the lifecycle of a Topic creation request, from submission by a normal user to approval/rejection by an admin, and how users track their requests. + +## Architecture + +1. **Backend API**: + - **Submission**: `POST /api/topics` (Authenticated users). + - If `isAdmin`, status is `approved`. + - If normal user, status is `pending`. + - **Global Admin View**: `GET /api/topics/requests` (Admin only). + - **User Personal View**: `GET /api/topics/my-requests` (Authenticated users). + - Retrieves all topics where `createdBy` matches the current session user ID. + - **Approval**: `POST /api/topics/:id/approve` (Admin only). + - **Rejection**: `POST /api/topics/:id/reject` (Admin only). + +2. **Frontend Implementation**: + - File: `apps/web/src/views/TopicsView.tsx` + - **Submission Modal**: + - Uses `submitStatus` state to provide immediate success/error feedback. + - Automatically closes and refreshes after 1.5s on success. + - **My Requests Table**: + - Rendered below the main topic list. + - Uses color-coded badges for status: + - `Approved`: Green. + - `Rejected`: Red. + - `Pending`: Yellow. + +## Common Maintenance Tasks + +### 1. Adding/Removing Request Fields +If a new field is needed (e.g., "Justification"): +1. Update `topics` table in `apps/server/src/db/schema.ts`. +2. Update `topicSchema` in `apps/server/src/api.ts`. +3. Update the form in `TopicsView.tsx`. +4. Ensure the field is displayed in both `AdminView` and `TopicsView` (personal requests). + +### 2. Customizing Feedback Messages +Feedback messages are managed in the `handleSubmit` function in `TopicsView.tsx`. Update the `message` property in `setSubmitStatus` to change the user-facing wording. + +### 3. Handling Rejection Comments (Future) +To add a reason for rejection: +1. Add a `rejectionReason` column to the `topics` table. +2. Modify the `AdminView.tsx` to prompt for a reason during rejection. +3. Update `POST /api/topics/:id/reject` to accept this reason. +4. Display the reason in the `My Requests` section of `TopicsView.tsx`. + +## Design Decisions +- **Separate Personal Requests**: We show personal requests in a dedicated section to avoid cluttering the primary Topic list, which only shows `approved` topics that can actually be subscribed to. +- **Optimistic UI vs. Simple Refresh**: Currently, we use a full refresh (`fetchTopics`, `fetchMyRequests`) for simplicity and data consistency. diff --git a/.agent/workflows/update-context.md b/.agent/workflows/update-context.md new file mode 100644 index 0000000..278f350 --- /dev/null +++ b/.agent/workflows/update-context.md @@ -0,0 +1,14 @@ +--- +description: Update project context documentation for AI/IDE awareness +--- + +// turbo-all +1. Check recent file changes to identify new features, API changes, or configuration updates. +2. Read `docs/copilot-context.md` to understand the current documented state. +3. Update `docs/copilot-context.md` with: + - New or modified entities in the data model. + - Added or changed API endpoints. + - New key workflows or architectural decisions. + - Updated development conventions or environment variables. +4. If `todo.md` exists, ensure it is also updated to reflect completed tasks. +5. Summarize the updates made to the context for the user. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a447259 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Testing +coverage + +# Production +build +dist +out + +# Misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor +.idea +.vscode +*.swp +*.swo + +# Database +*.sqlite +*.db +apps/server/drizzle/meta + +# Bun +.bun/ +bun.lockb diff --git a/README.md b/README.md new file mode 100644 index 0000000..5819136 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Alert Message Center + +这是一个基于 **Bun**, **Hono**, **Drizzle ORM (SQLite)** 和 **React (Vite + Tailwind)** 构建的企业级告警管理系统。 + +它采用 **Topic (主题)** 订阅模型,类似于 Sentry 的告警分发机制。告警发送到特定的 Topic,系统根据订阅关系,通过 **飞书机器人私聊 (Private Message)** 将告警精准推送给订阅该 Topic 的用户。 + +## 核心理念 + +- **Topic (主题)**: 业务逻辑上的告警分类,例如 `payment-service-error`, `frontend-performance`, `daily-report`。 +- **User (用户)**: 接收告警的实体,绑定飞书 User ID。 +- **Subscription (订阅)**: 用户订阅感兴趣的 Topic。 +- **Private Message (私聊)**: 告警不再发送到嘈杂的群组,而是直接私聊发送给相关负责人,确保触达。 + +## 功能特性 + +- **精准分发**: 告警只发给订阅的人,避免群消息轰炸。 +- **集中管理**: 统一管理所有告警入口和订阅关系,无需维护大量硬编码的 Webhook URL。 +- **飞书集成**: 使用飞书开放平台 API,支持发送富文本和卡片消息。 +- **全局监控**: 提供 Grafana 风格的系统负载看板,实时追踪告警接收数、应发消息数及发送成功率。 +- **现代化技术栈**: 全栈 TypeScript,高性能 Bun 运行时。 + +## 快速开始 + +### 1. 前置准备 + +你需要创建一个飞书企业自建应用 (Custom App): +1. 访问 [飞书开发者后台](https://open.feishu.cn/app)。 +2. 创建企业自建应用。 +3. **启用机器人能力**: 在 "添加应用能力" -> "机器人" 中启用。 +4. **申请权限**: 在 "权限管理" 中申请以下权限: + - `im:message` (获取与发送单聊、群组消息) + - `im:message:send_as_bot` (以应用身份发送消息) + - (可选) `contact:user.id:readonly` (通过手机号或邮箱获取用户 ID) +5. **发布版本**: 创建版本并发布,等待管理员审核通过。 +6. 获取 **App ID** and **App Secret**。 + +### 2. 安装依赖 + +确保你已经安装了 [Bun](https://bun.sh/)。 + +```bash +# 在根目录运行 +bun install +``` + +### 3. 配置环境变量 + +在 `apps/server` 目录下 (或者在启动命令中) 配置环境变量: + +```bash +export FEISHU_APP_ID="你的AppID" +export FEISHU_APP_SECRET="你的AppSecret" +``` + +### 4. 启动开发环境 + +这将同时启动后端 API (端口 3000) 和前端界面 (端口 5173)。 + +```bash +bun run dev +``` + +访问前端界面: [http://localhost:5173](http://localhost:5173) + +### 5. 使用指南 + +#### 第一步:配置 Topic +1. 进入 **Topics** 页面,点击 "Add Topic"。 +2. 填写 Name (显示名) 和 Slug (唯一标识,用于 URL)。 + * 例如: Name: "支付服务异常", Slug: `payment-error`。 + +#### 第二步:添加用户 +1. 进入 **Users** 页面,添加用户。 +2. 必须填入用户的 **飞书 User ID** (Open ID 或 User ID)。 + * *提示: 可以在飞书管理后台查看用户的 User ID,或者通过 API 获取。* + +#### 第三步:订阅告警 +1. 回到 **Topics** 页面。 +2. 点击 Topic 卡片上的 **订阅图标** (用户组图标)。 +3. 勾选需要接收该 Topic 告警的用户。 + +#### 第四步:发送告警 +使用你的程序或脚本向系统发送 POST 请求: + +**POST** `http://localhost:3000/api/webhook/:slug` + +示例 (curl): + +```bash +curl -X POST http://localhost:3000/api/webhook/payment-error \ + -H "Content-Type: application/json" \ + -d '{ + "msg_type": "text", + "content": { + "text": "支付接口响应超时 (500ms)" + } + }' +``` + +系统会查找订阅了 `payment-error` 的所有用户,并通过飞书机器人给他们分别发送私聊消息。 + +## API 参考 + +### 发送告警 + +`POST /api/webhook/:slug` + +**Parameters:** +- `slug`: Topic 的唯一标识符。 + +**Body:** +直接透传给飞书消息 API 的 `content` 和 `msg_type`。 + +1. **文本消息**: +```json +{ + "msg_type": "text", + "content": { + "text": "告警内容..." + } +} +``` + +2. **富文本/卡片消息**: +请参考 [飞书发送消息 API 文档](https://open.feishu.cn/document/server-docs/im-v1/message/create) 构建 `content`。 + +## 项目结构 + +* `apps/server`: 后端服务 (Hono + Drizzle) + * `src/index.ts`: 入口文件 + * `src/feishu.ts`: 飞书 API 客户端 + * `src/webhook.ts`: 告警处理与分发逻辑 + * `src/db`: 数据库 Schema (Topics, Users, Subscriptions) +* `apps/web`: 前端界面 (React + Vite) + * `src/views/SystemLoadView.tsx`: 实时监控仪表盘 + * `src/views/AdminView.tsx`: 后台管理与仪表盘集成 diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..9dd8aee --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgres://postgres:password@localhost:5432/alert_message_center +# Feishu Config +FEISHU_APP_ID= +FEISHU_APP_SECRET= +FEISHU_VERIFICATION_TOKEN= +FEISHU_ENCRYPT_KEY= +ADMIN_EMAILS= diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..a7ac621 --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center', + }, +}); diff --git a/apps/server/drizzle/0000_famous_lionheart.sql b/apps/server/drizzle/0000_famous_lionheart.sql new file mode 100644 index 0000000..68b54f4 --- /dev/null +++ b/apps/server/drizzle/0000_famous_lionheart.sql @@ -0,0 +1,52 @@ +CREATE TABLE "alert_logs" ( + "id" text PRIMARY KEY NOT NULL, + "task_id" text NOT NULL, + "user_id" text, + "status" text NOT NULL, + "error" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "alert_tasks" ( + "id" text PRIMARY KEY NOT NULL, + "topic_slug" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "recipient_count" integer DEFAULT 0, + "success_count" integer DEFAULT 0, + "payload" jsonb, + "error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "subscriptions" ( + "user_id" text NOT NULL, + "topic_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "subscriptions_user_id_topic_id_pk" PRIMARY KEY("user_id","topic_id") +); +--> statement-breakpoint +CREATE TABLE "topics" ( + "id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "status" text DEFAULT 'approved' NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "topics_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "feishu_user_id" text NOT NULL, + "email" text, + "is_admin" boolean DEFAULT false, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> 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 "subscriptions" ADD CONSTRAINT "subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "topics" ADD CONSTRAINT "topics_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..4cea1e7 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@alertmessagecenter/server", + "version": "1.0.0", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@hono/zod-validator": "^0.7.6", + "drizzle-orm": "^0.45.1", + "hono": "^4.11.3", + "postgres": "^3.4.8", + "zod": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "bun-types": "latest", + "drizzle-kit": "^0.31.8" + } +} \ No newline at end of file diff --git a/apps/server/scripts/check_admin_requests.ts b/apps/server/scripts/check_admin_requests.ts new file mode 100644 index 0000000..c77bf53 --- /dev/null +++ b/apps/server/scripts/check_admin_requests.ts @@ -0,0 +1,27 @@ + +// Simulate admin checking requests +async function run() { + console.log('Fetching pending topics as admin...'); + const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim(); + const res = await fetch('http://localhost:3000/api/topics/requests', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Cookie': `session=${encodeURIComponent(JSON.stringify({ + id: 'admin_123', + name: 'Admin User', + email: adminEmail, + isAdmin: true + }))}` + } + }); + + if (res.ok) { + const data = await res.json(); + console.log('Pending topics:', JSON.stringify(data, null, 2)); + } else { + console.log('Error:', res.status, await res.text()); + } +} + +run(); diff --git a/apps/server/scripts/check_dashboard.ts b/apps/server/scripts/check_dashboard.ts new file mode 100644 index 0000000..bb9c3c5 --- /dev/null +++ b/apps/server/scripts/check_dashboard.ts @@ -0,0 +1,27 @@ + +async function run() { + console.log('Fetching dashboard stats as admin...'); + const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim(); + const res = await fetch('http://localhost:3000/api/dashboard/stats', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + // Admin cookie + 'Cookie': `session=${encodeURIComponent(JSON.stringify({ + id: 'admin_123', + name: 'Admin User', + email: adminEmail, + isAdmin: true + }))}` + } + }); + + if (res.ok) { + const data = await res.json(); + console.log('Dashboard Stats:', JSON.stringify(data, null, 2)); + } else { + console.log('Error:', res.status, await res.text()); + } +} + +run(); diff --git a/apps/server/scripts/check_topics.ts b/apps/server/scripts/check_topics.ts new file mode 100644 index 0000000..2feda01 --- /dev/null +++ b/apps/server/scripts/check_topics.ts @@ -0,0 +1,9 @@ +import { Database } from 'bun:sqlite'; +const db = new Database('dev.db'); +try { + const query = db.query("SELECT * FROM topics"); + const topics = query.all(); + console.log('Topics:', JSON.stringify(topics, null, 2)); +} catch (e) { + console.error('Error querying topics:', e); +} diff --git a/apps/server/scripts/create_request.ts b/apps/server/scripts/create_request.ts new file mode 100644 index 0000000..ae63b42 --- /dev/null +++ b/apps/server/scripts/create_request.ts @@ -0,0 +1,40 @@ + +// Simulate topic creation +import { client } from './client'; // This won't work in node script easily due to frontend dependencies + +// Let's use fetch directly against the server +async function run() { + console.log('Creating pending topic...'); + const res = await fetch('http://localhost:3000/api/topics', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // We need to bake a cookie. + // But we can't easily bake a signed cookie without the secret. + // Wait, the cookies are not signed in the strict sense, just set. + // But `middleware.ts` parses `JSON.parse(sessionCookie)`. + + // Let's fake a session cookie for a non-admin user. + 'Cookie': `session=${encodeURIComponent(JSON.stringify({ + id: 'user_123', + name: 'Test User', + email: 'test@example.com', + isAdmin: false + }))}` + }, + body: JSON.stringify({ + name: 'Test Pending Topic', + slug: 'test-pending', + description: 'This should be pending' + }) + }); + + if (res.ok) { + const data = await res.json(); + console.log('Created topic:', data); + } else { + console.log('Error:', res.status, await res.text()); + } +} + +run(); diff --git a/apps/server/scripts/debug_subscription.ts b/apps/server/scripts/debug_subscription.ts new file mode 100644 index 0000000..8ba8280 --- /dev/null +++ b/apps/server/scripts/debug_subscription.ts @@ -0,0 +1,69 @@ +import postgres from 'postgres'; + +const sql = postgres('postgres://localhost:5432/alertmessagecenter'); + +async function run() { + try { + // 1. Get a topic + const [topic] = await sql`SELECT * FROM topics LIMIT 1`; + if (!topic) { + console.log('No topics found. Create a topic first.'); + return; + } + console.log('Using topic:', topic.id, topic.slug); + + // 2. Define a fake user ID + const fakeUserId = 'user_fake_002'; + + // Clean up first + await sql`DELETE FROM subscriptions WHERE user_id = ${fakeUserId}`; + await sql`DELETE FROM users WHERE id = ${fakeUserId}`; + + // 3. Try to subscribe with non-existent user + console.log('\n--- Attempt 1: Subscribe with non-existent user ---'); + const res1 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, { + method: 'POST', + headers: { + 'Cookie': `session=${encodeURIComponent(JSON.stringify({ + id: fakeUserId, + name: 'Fake User', + email: 'fake@example.com', + isAdmin: false + }))}` + } + }); + console.log('Status:', res1.status); + const text1 = await res1.text(); + console.log('Response:', text1); // Expect 500 FK violation + + // 4. Create the user + console.log('\n--- Creating user... ---'); + await sql`INSERT INTO users (id, name, feishu_user_id, email, is_admin) + VALUES (${fakeUserId}, 'Fake User', 'ou_fake', 'fake2@example.com', false) + ON CONFLICT (id) DO NOTHING`; + + // 5. Try to subscribe again + console.log('\n--- Attempt 2: Subscribe with existing user ---'); + const res2 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, { + method: 'POST', + headers: { + 'Cookie': `session=${encodeURIComponent(JSON.stringify({ + id: fakeUserId, + name: 'Fake User', + email: 'fake@example.com', + isAdmin: false + }))}` + } + }); + console.log('Status:', res2.status); + const text2 = await res2.text(); + console.log('Response:', text2); // Expect 200 + + } catch (e) { + console.error(e); + } finally { + await sql.end(); + } +} + +run(); diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts new file mode 100644 index 0000000..54dfa57 --- /dev/null +++ b/apps/server/src/api.ts @@ -0,0 +1,270 @@ +import { Hono } from 'hono'; +import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm'; +import { z } from 'zod'; +import { zValidator } from '@hono/zod-validator'; +import { db } from './db'; +import { topics, users, subscriptions, alertTasks } from './db/schema'; +import { requireAuth, requireAdmin, AuthSession } from './middleware'; + +const api = new Hono<{ Variables: { session: AuthSession } }>(); + +const topicSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1), + description: z.string().optional(), +}); + +const userSchema = z.object({ + name: z.string().min(1), + feishuUserId: z.string().min(1), + email: z.string().email().optional().or(z.literal('')), +}); + +// --- Topics --- + +// --- Topics --- + +api.get('/topics', requireAuth, async (c) => { + const session = c.get('session'); + const isAdmin = session.isAdmin; + const currentUserId = session.id; + + const allTopics = await db.query.topics.findMany({ + where: eq(topics.status, 'approved'), + with: { + subscriptions: { + where: (subscriptions, { eq }) => + isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined), + with: { + user: true + } + } + } + }); + + return c.json(allTopics); +}); + +api.get('/topics/requests', requireAdmin, async (c) => { + const requests = await db.query.topics.findMany({ + where: eq(topics.status, 'pending'), + with: { + creator: true + } + }); + return c.json(requests); +}); + +api.get('/topics/all', requireAdmin, async (c) => { + const allTopics = await db.query.topics.findMany({ + with: { + creator: true, + subscriptions: true + }, + orderBy: [desc(topics.createdAt)] + }); + return c.json(allTopics); +}); + +api.get('/topics/my-requests', requireAuth, async (c) => { + const session = c.get('session'); + const requests = await db.query.topics.findMany({ + where: eq(topics.createdBy, session.id), + orderBy: [desc(topics.createdAt)], + }); + return c.json(requests); +}); + +api.post('/topics/:id/approve', requireAdmin, async (c) => { + const id = c.req.param('id'); + const result = await db.update(topics) + .set({ status: 'approved' }) + .where(eq(topics.id, id)) + .returning(); + return c.json(result[0]); +}); + +api.post('/topics/:id/reject', requireAdmin, async (c) => { + const id = c.req.param('id'); + const result = await db.update(topics) + .set({ status: 'rejected' }) + .where(eq(topics.id, id)) + .returning(); + return c.json(result[0]); +}); + +// Only admins can create topics +// Authenticated users can create topics (requests) +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 result = await db.insert(topics).values({ + ...body, + status, + createdBy: session.id, + }).returning(); + return c.json(result[0]); +}); + +// Only admins can update topics +api.put('/topics/:id', requireAdmin, zValidator('json', topicSchema.partial()), async (c) => { + const id = c.req.param('id'); + const body = c.req.valid('json'); + const result = await db.update(topics).set(body).where(eq(topics.id, id)).returning(); + return c.json(result[0]); +}); + +// Only admins can delete topics +api.delete('/topics/:id', requireAdmin, async (c) => { + const id = c.req.param('id'); + await db.delete(topics).where(eq(topics.id, id)); + return c.json({ success: true }); +}); + +// --- Users --- + +api.get('/users', requireAdmin, async (c) => { + const allUsers = await db.query.users.findMany({ + with: { + subscriptions: { + with: { + topic: true + } + } + } + }); + return c.json(allUsers); +}); + +api.post('/users', requireAdmin, zValidator('json', userSchema), async (c) => { + const body = c.req.valid('json'); + const result = await db.insert(users).values(body).returning(); + return c.json(result[0]); +}); + +api.put('/users/:id', requireAdmin, zValidator('json', userSchema.partial()), async (c) => { + const id = c.req.param('id'); + const body = c.req.valid('json'); + const result = await db.update(users).set(body).where(eq(users.id, id)).returning(); + return c.json(result[0]); +}); + +api.delete('/users/:id', requireAdmin, async (c) => { + const id = c.req.param('id'); + await db.delete(users).where(eq(users.id, id)); + return c.json({ success: true }); +}); + +// --- Subscriptions --- + +// Users can subscribe themselves or admins can subscribe anyone +api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => { + const { topicId, userId } = c.req.param(); + const session = c.get('session'); + + // Check if user is subscribing themselves or is an admin + if (session.id !== userId && !session.isAdmin) { + return c.json({ error: 'You can only subscribe yourself' }, 403); + } + + const result = await db.insert(subscriptions).values({ topicId, userId }).returning(); + return c.json(result[0]); +}); + +// Users can unsubscribe themselves or admins can unsubscribe anyone +api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => { + const { topicId, userId } = c.req.param(); + const session = c.get('session'); + + // Check if user is unsubscribing themselves or is an admin + if (session.id !== userId && !session.isAdmin) { + return c.json({ error: 'You can only unsubscribe yourself' }, 403); + } + + await db.delete(subscriptions) + .where(and( + eq(subscriptions.topicId, topicId), + eq(subscriptions.userId, userId) + )); + return c.json({ success: true }); +}); + +// --- Alert Tasks --- + +api.get('/alerts/tasks', requireAdmin, async (c) => { + const limit = Math.min(Number(c.req.query('limit') || 50), 100); + const tasks = await db.query.alertTasks.findMany({ + orderBy: [desc(alertTasks.createdAt)], + limit, + with: { + sender: true, + logs: { + limit: 10, // Only show first 10 logs inline + } + } + }); + return c.json(tasks); +}); + +api.get('/alerts/tasks/:id', requireAuth, async (c) => { + const id = c.req.param('id'); + const task = await db.query.alertTasks.findFirst({ + where: eq(alertTasks.id, id), + with: { + sender: true, + logs: true // Show all logs for detail view + } + }); + + if (!task) { + return c.json({ error: 'Task not found' }, 404); + } + + return c.json(task); +}); + +// --- Stats --- + +api.get('/stats', requireAdmin, async (c) => { + // 1. Message count per topic + const topicStats = await db.select({ + topicSlug: alertTasks.topicSlug, + totalTasks: count(), + totalRecipients: sql`cast(${sum(alertTasks.recipientCount)} as int)`, + totalSuccess: sql`cast(${sum(alertTasks.successCount)} as int)`, + }) + .from(alertTasks) + .groupBy(alertTasks.topicSlug); + + // 2. Recent metrics (last 24h) + const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recentStats = await db.select({ + totalRecipients: sql`cast(${sum(alertTasks.recipientCount)} as int)`, + totalSuccess: sql`cast(${sum(alertTasks.successCount)} as int)`, + taskCount: count(), + }) + .from(alertTasks) + .where(gt(alertTasks.createdAt, last24h)); + + const recent = recentStats[0] || { totalRecipients: 0, totalSuccess: 0, taskCount: 0 }; + const totalRecipients = Number(recent.totalRecipients || 0); + const totalSuccess = Number(recent.totalSuccess || 0); + const failedCount = totalRecipients - totalSuccess; + const successRate = totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100; + + return c.json({ + topics: topicStats, + recent: { + alertsReceived: Number(recent.taskCount || 0), + plannedMessages: totalRecipients, + successCount: totalSuccess, + failedCount: failedCount, + successRate: successRate, + } + }); +}); + +export default api; diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..78f1f72 --- /dev/null +++ b/apps/server/src/auth.ts @@ -0,0 +1,141 @@ +import { Hono } from 'hono'; +import { setCookie, getCookie } from 'hono/cookie'; +import { db } from './db'; +import { users } from './db/schema'; +import { eq } from 'drizzle-orm'; +import { feishuClient } from './feishu'; + +const auth = new Hono(); + +const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map(email => email.trim()).filter(Boolean); + +// Get the login URL for frontend to redirect +auth.get('/login-url', (c) => { + const appId = process.env.FEISHU_APP_ID; + const redirectUri = encodeURIComponent(process.env.REDIRECT_URI || 'http://localhost:5173/auth/callback'); + const state = crypto.randomUUID(); + + // Store state in cookie for CSRF protection + setCookie(c, 'oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 600, // 10 minutes + sameSite: 'Lax', + }); + + const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`; + + return c.json({ loginUrl }); +}); + +// Handle OAuth callback +auth.get('/callback', async (c) => { + const code = c.req.query('code'); + const state = c.req.query('state'); + const storedState = getCookie(c, 'oauth_state'); + + // Verify state for CSRF protection + if (!state || state !== storedState) { + return c.json({ error: 'Invalid state parameter' }, 400); + } + + if (!code) { + return c.json({ error: 'No code provided' }, 400); + } + + try { + // Exchange code for user access token and user info + const userData = await feishuClient.getUserAccessToken(code); + + // Check if user exists, otherwise create + let user = await db.query.users.findFirst({ + where: eq(users.feishuUserId, userData.open_id), + }); + + const isAdmin = ADMIN_EMAILS.includes(userData.email || ''); + + if (!user) { + // Create new user + const result = await db.insert(users).values({ + name: userData.name, + feishuUserId: userData.open_id, + email: userData.email || null, + isAdmin, + }).returning(); + user = result[0]; + } else { + // Update user info (in case name or admin status changed) + const result = await db.update(users) + .set({ + name: userData.name, + email: userData.email || user.email, + isAdmin, + }) + .where(eq(users.id, user.id)) + .returning(); + user = result[0]; + } + + // Set session cookie + setCookie(c, 'session', JSON.stringify({ + id: user.id, + name: user.name, + email: user.email, + isAdmin: user.isAdmin, + personalToken: user.personalToken, + }), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7, // 7 days + sameSite: 'Lax', + }); + + return c.json({ + success: true, + user: { + id: user.id, + name: user.name, + email: user.email, + isAdmin: user.isAdmin, + }, + }); + } catch (error) { + console.error('OAuth callback error:', error); + return c.json({ error: 'Authentication failed' }, 500); + } +}); + +// Get current user from session +auth.get('/me', (c) => { + const sessionCookie = getCookie(c, 'session'); + + if (!sessionCookie) { + return c.json({ error: 'Not authenticated' }, 401); + } + + try { + const session = sessionCookie ? JSON.parse(sessionCookie) : null; + if (!session) { + return c.json({ error: 'Not authenticated' }, 401); + } + // Normalize user object to ensure id is present (handle legacy session with userId) + const user = { + ...session, + id: session.id || session.userId, + }; + return c.json({ user }); + } catch (error) { + console.error('[Auth] Failed to parse session cookie:', error); + return c.json({ error: 'Invalid session' }, 401); + } +}); + +// Logout +auth.post('/logout', (c) => { + setCookie(c, 'session', '', { + maxAge: 0, + }); + return c.json({ success: true }); +}); + +export default auth; diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts new file mode 100644 index 0000000..bc93dcc --- /dev/null +++ b/apps/server/src/db/index.ts @@ -0,0 +1,7 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +const connectionString = process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center'; +const client = postgres(connectionString); +export const db = drizzle(client, { schema }); diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..b5a06a3 --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,93 @@ +import { pgTable, text, integer, primaryKey, boolean, jsonb, timestamp } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag,例如 "payment-service", "prod-env" +export const topics = pgTable('topics', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + slug: text('slug').notNull().unique(), // 告警发送时使用的 key + name: text('name').notNull(), + description: text('description'), + status: text('status', { enum: ['pending', 'approved', 'rejected'] }).default('approved').notNull(), + createdBy: text('created_by').references(() => users.id), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const topicsRelations = relations(topics, ({ many, one }) => ({ + subscriptions: many(subscriptions), + creator: one(users, { + fields: [topics.createdBy], + references: [users.id], + }), +})); + +export const users = pgTable('users', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + name: text('name').notNull(), + feishuUserId: text('feishu_user_id').notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id) + email: text('email').unique(), + isAdmin: boolean('is_admin').default(false), + personalToken: text('personal_token').notNull().unique().$defaultFn(() => crypto.randomUUID().replace(/-/g, '')), +}); + +export const usersRelations = relations(users, ({ many }) => ({ + subscriptions: many(subscriptions), +})); + +// Subscriptions: 用户直接订阅 Topic +export const subscriptions = pgTable('subscriptions', { + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.userId, t.topicId] }), +})); + +export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ + user: one(users, { + fields: [subscriptions.userId], + references: [users.id], + }), + topic: one(topics, { + fields: [subscriptions.topicId], + references: [topics.id], + }), +})); + +// API Tasks: 记录 webhook 请求的处理状态 +export const alertTasks = pgTable('alert_tasks', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + topicSlug: text('topic_slug').notNull(), + senderId: text('sender_id').references(() => users.id), // 记录是谁发送的 (通过 personal_token) + status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).default('pending').notNull(), + recipientCount: integer('recipient_count').default(0), + successCount: integer('success_count').default(0), + payload: jsonb('payload'), // 存储 webhook body + error: text('error'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Logs for each recipient in a task (optional detail) +export const alertLogs = pgTable('alert_logs', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + taskId: text('task_id').notNull().references(() => alertTasks.id, { onDelete: 'cascade' }), + userId: text('user_id'), // Optional, in case user is deleted later + status: text('status', { enum: ['sent', 'failed'] }).notNull(), + error: text('error'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const alertTasksRelations = relations(alertTasks, ({ many, one }) => ({ + logs: many(alertLogs), + sender: one(users, { + fields: [alertTasks.senderId], + references: [users.id], + }), +})); + +export const alertLogsRelations = relations(alertLogs, ({ one }) => ({ + task: one(alertTasks, { + fields: [alertLogs.taskId], + references: [alertTasks.id], + }), +})); diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts new file mode 100644 index 0000000..b5e4b14 --- /dev/null +++ b/apps/server/src/feishu.ts @@ -0,0 +1,94 @@ +export class FeishuClient { + private appId: string; + private appSecret: string; + private token: string | null = null; + private tokenExpireAt: number = 0; + + constructor(appId: string, appSecret: string) { + this.appId = appId; + this.appSecret = appSecret; + } + + private async getTenantAccessToken(): Promise { + if (this.token && Date.now() < this.tokenExpireAt) { + return this.token; + } + + const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + app_id: this.appId, + app_secret: this.appSecret, + }), + }); + + const data = await res.json(); + if (data.code !== 0) { + throw new Error(`Failed to get tenant access token: ${data.msg}`); + } + + this.token = data.tenant_access_token; + // Expire 5 minutes early to be safe + this.tokenExpireAt = Date.now() + (data.expire * 1000) - 300000; + return this.token!; + } + + async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email', msgType: string, content: any) { + const token = await this.getTenantAccessToken(); + + // Content needs to be stringified for 'text' type, but might be object for 'interactive' + // Feishu API expects 'content' field to be a JSON string for most types + const contentStr = typeof content === 'string' ? content : JSON.stringify(content); + + const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ + receive_id: receiveId, + msg_type: msgType, + content: contentStr, + }), + }); + + const data = await res.json(); + if (data.code !== 0) { + console.error('Feishu send message error:', data); + throw new Error(`Failed to send message: ${data.msg}`); + } + return data; + } + + async getUserAccessToken(code: string): Promise { + const token = await this.getTenantAccessToken(); + + const res = await fetch('https://open.feishu.cn/open-apis/authen/v1/access_token', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + }), + }); + + const data = await res.json(); + if (data.code !== 0) { + console.error('Feishu get user access token error:', data); + throw new Error(`Failed to get user access token: ${data.msg}`); + } + + return data.data; + } +} + +// Singleton instance - replace with env vars in production +export const feishuClient = new FeishuClient( + process.env.FEISHU_APP_ID || 'cli_xxx', + process.env.FEISHU_APP_SECRET || 'xxx' +); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..004a8bb --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,36 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { db } from './db'; +import { topics } from './db/schema'; +import webhook from './webhook'; +import api from './api'; +import auth from './auth'; + +const app = new Hono(); + +// Enable CORS for frontend +app.use('/*', cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:5173', + credentials: true, +})); + +app.get('/', (c) => { + return c.text('Alert Message Center API is running!'); +}); + +const routes = app.route('/api/auth', auth) + .route('/api', api) + .route('/webhook', webhook); + +app.onError((err, c) => { + console.error(`[Global Error] ${c.req.method} ${c.req.url}:`, err); + return c.json({ error: err.message || 'Internal Server Error' }, 500); +}); + +app.get('/topics', async (c) => { + const allTopics = await db.select().from(topics); + return c.json(allTopics); +}); + +export type AppType = typeof routes; +export default app; diff --git a/apps/server/src/middleware.ts b/apps/server/src/middleware.ts new file mode 100644 index 0000000..0e7853d --- /dev/null +++ b/apps/server/src/middleware.ts @@ -0,0 +1,51 @@ +import { Context, Next } from 'hono'; +import { getCookie } from 'hono/cookie'; + +export interface AuthSession { + id: string; + name: string; + email: string | null; + isAdmin: boolean; +} + +export async function requireAuth(c: Context, next: Next) { + const sessionCookie = getCookie(c, 'session'); + + if (!sessionCookie) { + return c.json({ error: 'Authentication required' }, 401); + } + + try { + const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null; + if (!session) { + return c.json({ error: 'Authentication required' }, 401); + } + c.set('session', session); + await next(); + } catch (error) { + console.error('[Middleware] Failed to parse session cookie:', error); + return c.json({ error: 'Invalid session' }, 401); + } +} + +export async function requireAdmin(c: Context, next: Next) { + const sessionCookie = getCookie(c, 'session'); + + if (!sessionCookie) { + return c.json({ error: 'Authentication required' }, 401); + } + + try { + const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null; + + if (!session || !session.isAdmin) { + return c.json({ error: 'Admin access required' }, 403); + } + + c.set('session', session); + await next(); + } catch (error) { + console.error('[Middleware] Failed to parse session cookie in requireAdmin:', error); + return c.json({ error: 'Invalid session' }, 401); + } +} diff --git a/apps/server/src/verify_permissions.ts b/apps/server/src/verify_permissions.ts new file mode 100644 index 0000000..efe60ac --- /dev/null +++ b/apps/server/src/verify_permissions.ts @@ -0,0 +1,148 @@ + +import app from './index'; +import { db } from './db'; +import { users, topics, subscriptions } from './db/schema'; +import { eq } from 'drizzle-orm'; + +async function verify() { + console.log('Starting Verification...'); + let errors = 0; + + // 1. Setup Test Data + const timestamp = Date.now(); + + // Create Non-Admin User + const [userUser] = await db.insert(users).values({ + name: `TestUser_${timestamp}`, + feishuUserId: `test_user_${timestamp}`, + email: `test_user_${timestamp}@example.com`, + isAdmin: false + }).returning(); + + // Create Admin User + const [adminUser] = await db.insert(users).values({ + name: `TestAdmin_${timestamp}`, + feishuUserId: `test_admin_${timestamp}`, + email: `test_admin_${timestamp}@example.com`, + isAdmin: true + }).returning(); + + // Create Topic + const [topic] = await db.insert(topics).values({ + name: `TestTopic_${timestamp}`, + slug: `test-topic-${timestamp}`, + description: 'Test Description' + }).returning(); + + // Subscribe User to Topic + await db.insert(subscriptions).values({ + userId: userUser.id, + topicId: topic.id + }); + + console.log('Test Data Created:', { user: userUser.id, admin: adminUser.id, topic: topic.id }); + + try { + // 2. Test GET /users (Admin Only) + + // Test as Non-Admin + const sessionUser = { userId: userUser.id, name: userUser.name, email: userUser.email, isAdmin: userUser.isAdmin }; + const req1 = new Request('http://localhost/api/users', { + headers: { + 'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}` + } + }); + const res1 = await app.request(req1); + + if (res1.status === 403) { + console.log('✅ PASS: GET /users as Non-Admin returned 403'); + } else { + console.error(`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`); + errors++; + } + + // Test as Admin + const sessionAdmin = { userId: adminUser.id, name: adminUser.name, email: adminUser.email, isAdmin: adminUser.isAdmin }; + const req2 = new Request('http://localhost/api/users', { + headers: { + 'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}` + } + }); + const res2 = await app.request(req2); + + if (res2.status === 200) { + console.log('✅ PASS: GET /users as Admin returned 200'); + } else { + console.error(`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`); + errors++; + } + + // 3. Test GET /topics (Filtered) + + // Test as Non-Admin (Should see ONLY their subscription) + const req3 = new Request('http://localhost/api/topics', { + headers: { + 'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}` + } + }); + const res3 = await app.request(req3); + const data3 = await res3.json(); + + const targetTopic = (data3 as any).find((t: any) => t.id === topic.id); + if (targetTopic) { + if (targetTopic.subscriptions.length === 1 && targetTopic.subscriptions[0].userId === userUser.id) { + console.log('✅ PASS: GET /topics as Non-Admin shows correct personal subscription'); + } else { + console.error('❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:', targetTopic.subscriptions); + errors++; + } + } else { + console.error('❌ FAIL: Test topic not found in list'); + 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. + + 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 targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id); + // Should see the subscription for userUser + const hasUserSub = targetTopicAdmin.subscriptions.some((s: any) => s.userId === userUser.id); + if (hasUserSub) { + console.log('✅ PASS: GET /topics as Admin sees other users subscriptions'); + } else { + console.error('❌ FAIL: GET /topics as Admin did NOT see other users subscriptions'); + errors++; + } + + } catch (e) { + console.error('Test Exception:', e); + errors++; + } finally { + // 4. Cleanup + await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id)); + await db.delete(topics).where(eq(topics.id, topic.id)); + await db.delete(users).where(eq(users.id, userUser.id)); + await db.delete(users).where(eq(users.id, adminUser.id)); + console.log('Cleanup Completed'); + } + + if (errors === 0) { + console.log('🎉 ALL TESTS PASSED'); + process.exit(0); + } else { + console.error('💥 SOME TESTS FAILED'); + process.exit(1); + } +} + +verify(); diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts new file mode 100644 index 0000000..1220a23 --- /dev/null +++ b/apps/server/src/webhook.ts @@ -0,0 +1,174 @@ +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from './db'; +import { topics, alertTasks, alertLogs, users } from './db/schema'; +import { feishuClient } from './feishu'; + +const webhook = new Hono(); + +webhook.post('/:token/topic/:slug', async (c) => { + const token = c.req.param('token'); + const slug = c.req.param('slug'); + console.log(`[Webhook] Received request for token: ${token}, slug: ${slug}`); + + // 0. Find the User by Token + const user = await db.query.users.findFirst({ + where: eq(users.personalToken, token), + }); + + if (!user) { + console.warn(`[Webhook] Invalid personal token: ${token}`); + return c.json({ error: 'Invalid personal token' }, 401); + } + let body; + try { + const rawBody = await c.req.text(); + console.log(`[Webhook] Raw body length: ${rawBody.length}, content: "${rawBody}"`); + if (!rawBody || rawBody.trim() === '') { + return c.json({ error: 'Empty body' }, 400); + } + body = JSON.parse(rawBody); + } catch (e) { + console.error(`[Webhook] Failed to parse JSON body:`, e); + return c.json({ error: 'Invalid JSON body' }, 400); + } + + // 1. Find the Topic + const topic = await db.query.topics.findFirst({ + where: eq(topics.slug, slug), + with: { + subscriptions: { + with: { + user: true + } + } + } + }); + + if (!topic) { + console.warn(`[Webhook] Topic not found: ${slug}`); + return c.json({ error: 'Topic not found' }, 404); + } + + console.log(`[Webhook] Found topic: ${topic.name}, subscribers: ${topic.subscriptions.length}`); + + // 2. Collect subscribers + const subscribers = topic.subscriptions + .map(sub => sub.user) + .filter(u => !!u && !!u.feishuUserId); + + const [task] = await db.insert(alertTasks).values({ + topicSlug: topic.slug, + senderId: user.id, + status: 'processing', + recipientCount: subscribers.length, + successCount: 0, + payload: body, + }).returning(); + + if (subscribers.length === 0) { + await db.update(alertTasks) + .set({ status: 'completed', updatedAt: new Date() }) + .where(eq(alertTasks.id, task.id)); + + return c.json({ + message: 'No subscribers for this topic', + taskId: task.id, + status: 'completed' + }); + } + + // 4. Send Private Messages asynchronously + Promise.allSettled(subscribers.map(async (user) => { + try { + // Construct message content + let msgType = body.msg_type || 'text'; + let content = body.content; + + if (!content) { + msgType = 'text'; + content = { text: JSON.stringify(body, null, 2) }; + } + + // Add metadata + if (msgType === 'text' && content.text) { + content.text = `[Topic: ${topic.name}]\n${content.text}`; + } + if (msgType === 'interactive' && content.header) { + content.header.title.content = `[${topic.name}] ${content.header.title.content}`; + } + + const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id'; + await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content); + + return { userId: user.id, status: 'sent', error: null }; + } catch (error: any) { + console.error(`Failed to send to user ${user.name}:`, error); + return { userId: user.id, status: 'failed', error: error.message }; + } + })).then(async (results) => { + const successCount = results.filter(r => r.status === 'fulfilled' && (r.value as any).status === 'sent').length; + const failures = results + .filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && (r.value as any).status === 'failed')) + .length; + + // Determine final status + const finalStatus = failures === 0 ? 'completed' : (successCount > 0 ? 'completed' : 'failed'); + + // Update Task + await db.update(alertTasks).set({ + status: finalStatus, + successCount, + updatedAt: new Date(), + // If fully failed, maybe store the first error in the task record for quick view + error: failures > 0 ? `Failed to send to ${failures} recipients` : null, + }).where(eq(alertTasks.id, task.id)); + + // Insert Logs (Optional: insert only failures to save space, or all for audit) + // Let's insert all for now + const logs = results.map((r, index) => { + const user = subscribers[index]; + if (r.status === 'fulfilled') { + const val = r.value as any; + return { + taskId: task.id, + userId: user.id, + status: val.status, + error: val.error, + }; + } else { + return { + taskId: task.id, + userId: user.id, + status: 'failed', + error: r.reason ? String(r.reason) : 'Unknown error', + }; + } + }); + + if (logs.length > 0) { + await db.insert(alertLogs).values(logs as any); + } + + console.log(`[Webhook] Task ${task.id}: Sent ${successCount}/${subscribers.length} alerts for topic ${slug}`); + }); + + return c.json({ + message: 'Alert received and processing started', + taskId: task.id, + status: 'processing', + recipientCount: subscribers.length + }); +}); + +// Help message for non-POST requests or malformed URLs +webhook.all('/:token/topic/:slug', (c) => { + return c.json({ + error: 'Method not allowed', + message: 'Please use POST to send alerts to this webhook', + format: 'POST /webhook/:token/topic/:slug', + example: 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL' + }, 405); +}); + +export default webhook; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..d427eb3 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": [ + "bun-types" + ], + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..7a9b866 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Alert Message Center + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a0dd2de --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@alertmessagecenter/web", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "clsx": "^2.0.0", + "hono": "^4.11.3", + "lucide-react": "^0.300.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwind-merge": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.0.0", + "autoprefixer": "^10.0.0", + "postcss": "^8.0.0", + "tailwindcss": "^3.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "drizzle-orm": "^0.45.1", + "zod": "^3.0.0", + "@types/node": "^20.0.0", + "bun-types": "latest" + } +} \ No newline at end of file diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..4dae518 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react' +import { Hash, Users, Activity, LogIn, LogOut, ShieldCheck, Settings } from 'lucide-react' +import { useAuth } from './contexts/AuthContext' +import TopicsView from './views/TopicsView' +import UsersView from './views/UsersView' +import AdminView from './views/AdminView' + +function App() { + const { user, loading, login, logout } = useAuth() + const [activeTab, setActiveTab] = useState('topics') + const [hasSetDefault, setHasSetDefault] = useState(false) + + useEffect(() => { + if (!loading && user && !hasSetDefault) { + setActiveTab(user.isAdmin ? 'admin' : 'topics') + setHasSetDefault(true) + } + }, [user, loading, hasSetDefault]) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return ( +
+
+
+ +

Alert Message Center

+

Please sign in with Feishu to continue

+ +
+
+
+ ) + } + + return ( +
+ + +
+ {activeTab === 'topics' && } + {activeTab === 'users' && user.isAdmin && } + {activeTab === 'admin' && user.isAdmin && } +
+
+ ) +} + +export default App diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx new file mode 100644 index 0000000..b58e338 --- /dev/null +++ b/apps/web/src/components/Modal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +export default function Modal({ isOpen, onClose, title, children }: ModalProps) { + if (!isOpen) return null; + + return ( +
+
+ + + + +
+
+
+ + +
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..2f74b8b --- /dev/null +++ b/apps/web/src/contexts/AuthContext.tsx @@ -0,0 +1,85 @@ +import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; +import { client } from '../lib/client'; + +interface User { + id: string; + name: string; + email: string | null; + isAdmin: boolean; + personalToken: string; +} + +interface AuthContextType { + user: User | null; + loading: boolean; + login: () => void; + logout: () => void; + checkAuth: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const checkAuth = useCallback(async () => { + try { + const res = await client.api.auth.me.$get(undefined, { + init: { credentials: 'include' } + }); + if (res.ok) { + const data = await res.json(); + setUser(data.user); + } else { + setUser(null); + } + } catch (error) { + console.error('Auth check failed:', error); + setUser(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + const login = useCallback(async () => { + try { + const res = await client.api.auth['login-url'].$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + window.location.href = data.loginUrl; + } catch (error) { + console.error('Login failed:', error); + } + }, []); + + const logout = useCallback(async () => { + try { + await client.api.auth.logout.$post(undefined, { + init: { credentials: 'include' } + }); + setUser(null); + } catch (error) { + console.error('Logout failed:', error); + } + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..483323e --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,14 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .animate-fade-in { + animation: fadeIn 0.5s ease-out forwards; + } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/apps/web/src/lib/client.ts b/apps/web/src/lib/client.ts new file mode 100644 index 0000000..1946f61 --- /dev/null +++ b/apps/web/src/lib/client.ts @@ -0,0 +1,4 @@ +import { hc } from 'hono/client'; +import type { AppType } from '../../../server/src/index'; + +export const client = hc('/') as any; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..f47f434 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import AuthCallback from './views/AuthCallback.tsx' +import { AuthProvider } from './contexts/AuthContext.tsx' +import './index.css' + +// Simple routing based on pathname +const pathname = window.location.pathname; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + {pathname === '/auth/callback' ? : } + + , +) diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx new file mode 100644 index 0000000..714380c --- /dev/null +++ b/apps/web/src/views/AdminView.tsx @@ -0,0 +1,232 @@ +import { useState, useEffect } from 'react'; +import { client } from '../lib/client'; +import SystemLoadView from './SystemLoadView'; + +export default function AdminView() { + const [activeTab, setActiveTab] = useState('load'); + + return ( +
+
+

Admin Dashboard

+
+ +
+
+ +
+ + {activeTab === 'load' && } + {activeTab === 'requests' && } + {activeTab === 'topics' && } +
+
+ ); +} + +function TopicsManagement() { + const [topics, setTopics] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchAllTopics = async () => { + setLoading(true); + try { + const res = await client.api.topics.all.$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + setTopics(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAllTopics(); + }, []); + + const handleDelete = async (id: string, name: string) => { + if (!confirm(`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`)) { + return; + } + + try { + await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } }); + fetchAllTopics(); + } catch (error) { + console.error(error); + } + }; + + if (loading) return
Loading topics...
; + + return ( +
+ + + + + + + + + + + + {topics.map((topic) => ( + + + + + + + + ))} + +
TopicStatusSubscribersCreated ByActions
+
{topic.name}
+
{topic.slug}
+
+ + {topic.status} + + + {topic.subscriptions?.length || 0} + + {topic.creator?.name || 'Unknown'} + + +
+
+ ); +} + +function TopicRequestsList() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchRequests = async () => { + setLoading(true); + try { + const res = await client.api.topics.requests.$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + setRequests(data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchRequests(); + }, []); + + const handleAction = async (id: string, action: 'approve' | 'reject' | 'delete', name?: string) => { + try { + if (action === 'approve') { + await client.api.topics[':id'].approve.$post({ param: { id } }, { init: { credentials: 'include' } }); + } else if (action === 'reject') { + await client.api.topics[':id'].reject.$post({ param: { id } }, { init: { credentials: 'include' } }); + } else if (action === 'delete') { + if (!confirm(`Are you sure you want to delete request "${name}"?`)) return; + await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } }); + } + fetchRequests(); + } catch (error) { + console.error(error); + } + }; + + if (loading) return
Loading requests...
; + + if (requests.length === 0) { + return ( +
+ No pending topic requests. +
+ ); + } + + return ( +
+
    + {requests.map(req => ( +
  • +
    +

    {req.name}

    +

    Slug: {req.slug}

    +

    + Requested by: {req.creator?.name || 'Unknown'} + {req.creator?.email ? ` (${req.creator.email})` : ''} +

    + {req.description && ( +

    "{req.description}"

    + )} +
    +
    + + + +
    +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/views/AuthCallback.tsx b/apps/web/src/views/AuthCallback.tsx new file mode 100644 index 0000000..41bf7da --- /dev/null +++ b/apps/web/src/views/AuthCallback.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState, useRef } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { client } from '../lib/client'; + +export default function AuthCallback() { + const [error, setError] = useState(null); + const { checkAuth } = useAuth(); + const processed = useRef(false); + + useEffect(() => { + const handleCallback = async () => { + if (processed.current) return; + processed.current = true; + + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + + if (!code) { + setError('No authorization code received'); + return; + } + + try { + const res = await client.api.auth.callback.$get({ + query: { + code, + state: state || undefined + } + }, { + init: { credentials: 'include' } + }); + + if (res.ok) { + await checkAuth(); + // Redirect to home + window.location.href = '/'; + } else { + const data = await res.json(); + setError(data.error || 'Authentication failed'); + } + } catch (err) { + setError('Authentication failed'); + console.error(err); + } + }; + + handleCallback(); + }, [checkAuth]); + + if (error) { + return ( +
+
+

Authentication Error

+

{error}

+ +
+
+ ); + } + + return ( +
+
+

Authenticating...

+
+
+
+ ); +} diff --git a/apps/web/src/views/SystemLoadView.tsx b/apps/web/src/views/SystemLoadView.tsx new file mode 100644 index 0000000..3c2ec94 --- /dev/null +++ b/apps/web/src/views/SystemLoadView.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect } from 'react'; +import { client } from '../lib/client'; +import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react'; + +interface Stats { + topics: { + topicSlug: string; + totalTasks: number; + totalRecipients: number; + totalSuccess: number; + }[]; + recent: { + alertsReceived: number; + plannedMessages: number; + successCount: number; + failedCount: number; + successRate: number; + }; + tasks: any[]; +} + +export default function SystemLoadView() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(new Date()); + + const fetchStats = async () => { + try { + const res = await client.api.stats.$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + + // Fetch recent tasks as well + const tasksRes = await client.api.alerts.tasks.$get({ query: { limit: '10' } }, { + init: { credentials: 'include' } + }); + const tasks = await tasksRes.json(); + + setStats({ ...data, tasks } as Stats); + setLastUpdated(new Date()); + } catch (error) { + console.error('Failed to fetch stats:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStats(); + const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel + return () => clearInterval(interval); + }, []); + + if (loading) return ( +
+
+
+ ); + + if (!stats) return
Failed to load statistics.
; + + return ( +
+
+
+ + + + + Live Feedback +
+ Last updated: {lastUpdated.toLocaleTimeString()} +
+ + {/* Top Row: General Metrics */} +
+ } + color="purple" + description="Total webhook hits" + /> + } + color="blue" + description="Total subscribers" + /> + } + color="green" + description="Successfully sent" + /> + } + color="red" + description="API errors/failures" + /> +
+ Success Rate + +
+
+ + {/* Middle Row: Topic Message Counts */} +
+
+
+ +

Historical Topic Metrics

+
+
+
+ + + + + + + + + + + + + {stats.topics.map((topic) => { + const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100; + return ( + + + + + + + + + ); + })} + +
TopicAlerts (Tasks)Planned (Recipients)Distributed (Success)Health RateStatus
+ + {topic.topicSlug} + + {topic.totalTasks}{topic.totalRecipients}{topic.totalSuccess} +
+
+
90 ? 'bg-green-500' : rate > 70 ? 'bg-yellow-500' : 'bg-red-500'}`} + style={{ width: `${rate}%` }} + >
+
+ {rate.toFixed(1)}% +
+
+ + {rate === 100 ? 'Healthy' : 'Errors'} + +
+
+
+ + {/* Bottom Row: Recent Alerts with Sender Info */} +
+
+
+ +

Recent Alerts (Audit Log)

+
+
+
+ + + + + + + + + + + + {stats.tasks.map((task: any) => ( + + + + + + + + ))} + {stats.tasks.length === 0 && ( + + + + )} + +
TimeTopicSenderRecipientsStatus
+ {new Date(task.createdAt).toLocaleString()} + + + {task.topicSlug} + + +
+ {task.sender?.name || 'Unknown'} + {task.sender?.email || 'N/A'} +
+
+ {task.successCount} / {task.recipientCount} + + + {task.status} + +
+ No alerts sent yet. +
+
+
+
+ ); +} + +function MetricCard({ title, value, icon, color, description }: { title: string, value: number, icon: React.ReactNode, color: string, description?: string }) { + return ( +
+
+
+ {icon} +
+
+

{title}

+

{value.toLocaleString()}

+
+
+ {description &&

/ {description}

} +
+ ); +} + +function Gauge({ value }: { value: number }) { + const radius = 40; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (value / 100) * circumference; + + // Determine color based on value + const getColor = (v: number) => { + if (v >= 95) return '#10b981'; // green-500 + if (v >= 80) return '#f59e0b'; // yellow-500 + return '#ef4444'; // red-500 + }; + + return ( +
+ + + + +
+ {value.toFixed(1)}% +
+
+ ); +} diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx new file mode 100644 index 0000000..fbf4942 --- /dev/null +++ b/apps/web/src/views/TopicsView.tsx @@ -0,0 +1,472 @@ +import { useState, useEffect } from 'react'; +import { Plus, Settings, UserPlus, UserMinus, Copy, Check } from 'lucide-react'; +import Modal from '../components/Modal'; +import { useAuth } from '../contexts/AuthContext'; +import { client } from '../lib/client'; + +interface User { + id: string; + name: string; + email?: string | null; +} + +interface Subscription { + userId: string; + user: User; +} + +interface Topic { + id: string; + name: string; + slug: string; + description?: string; + subscriptions: Subscription[]; +} + +export default function TopicsView() { + const { user: currentUser } = useAuth(); + const [topics, setTopics] = useState([]); + const [myRequests, setMyRequests] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isSubModalOpen, setIsSubModalOpen] = useState(false); + const [selectedTopic, setSelectedTopic] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + const [formData, setFormData] = useState>({ + name: '', + slug: '', + description: '', + }); + const [submitStatus, setSubmitStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); + + const fetchTopics = async () => { + setLoading(true); + try { + const res = await client.api.topics.$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + setTopics(data as unknown as Topic[]); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const fetchMyRequests = async () => { + try { + const res = await client.api.topics['my-requests'].$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + setMyRequests(data); + } catch (err) { + console.error(err); + } + }; + + const fetchUsers = async () => { + try { + const res = await client.api.users.$get(undefined, { + init: { credentials: 'include' } + }); + const data = await res.json(); + setUsers(data as unknown as User[]); + } catch (err) { + console.error(err); + } + }; + + useEffect(() => { + fetchTopics(); + fetchMyRequests(); + if (currentUser?.isAdmin) { + fetchUsers(); + } + }, [currentUser]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitStatus(null); + try { + const res = await client.api.topics.$post({ + json: formData as any + }, { + init: { credentials: 'include' } + }); + + if (res.ok) { + setSubmitStatus({ + type: 'success', + message: currentUser?.isAdmin ? 'Topic created successfully!' : 'Request submitted! Waiting for approval.' + }); + setFormData({ name: '', slug: '', description: '' }); + fetchTopics(); + fetchMyRequests(); + setTimeout(() => { + setIsModalOpen(false); + setSubmitStatus(null); + }, 1500); + } else { + const error = await res.json(); + setSubmitStatus({ type: 'error', message: error.message || 'Failed to submit request.' }); + } + } catch (error) { + console.error('Error creating topic:', error); + setSubmitStatus({ type: 'error', message: 'An unexpected error occurred.' }); + } + }; + + + + const handleSubscriptionClick = (topic: Topic) => { + setSelectedTopic(topic); + setIsSubModalOpen(true); + }; + + const toggleSubscription = async (topicId: string, userId: string, isSubscribed: boolean) => { + try { + console.log('Toggling subscription:', { topicId, userId, isSubscribed }); + + if (isSubscribed) { + await client.api.topics[':topicId'].subscribe[':userId'].$delete({ + param: { topicId, userId } + }, { + init: { credentials: 'include' } + }); + } else { + await client.api.topics[':topicId'].subscribe[':userId'].$post({ + param: { topicId, userId } + }, { + init: { credentials: 'include' } + }); + } + + // Optimistic update for the main list + setTopics(prevTopics => + prevTopics.map(t => { + if (t.id === topicId) { + const updatedSubs = isSubscribed + ? t.subscriptions.filter(s => s.userId !== userId) + : [...t.subscriptions, { userId, user: users.find(u => u.id === userId) || currentUser! }]; + return { ...t, subscriptions: updatedSubs }; + } + return t; + }) + ); + + // Also update selectedTopic if it's open + if (selectedTopic && selectedTopic.id === topicId) { + const updatedSubs = isSubscribed + ? selectedTopic.subscriptions.filter(s => s.userId !== userId) + : [...selectedTopic.subscriptions, { userId, user: users.find(u => u.id === userId) || currentUser! }]; + setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs }); + } + + fetchTopics(); // Re-fetch to ensure consistency + } catch (error) { + console.error('Error toggling subscription:', error); + } + }; + + const isSubscribed = (topic: Topic) => { + return topic.subscriptions.some(sub => sub.userId === currentUser?.id); + }; + + const handleSelfSubscribe = async (topic: Topic) => { + if (!currentUser) return; + const subscribed = isSubscribed(topic); + await toggleSubscription(topic.id, currentUser.id, subscribed); + }; + + const copyToClipboard = (text: string, topicId: string) => { + navigator.clipboard.writeText(text); + setCopiedId(topicId); + setTimeout(() => setCopiedId(null), 2000); + }; + + const getWebhookUrl = (topicSlug: string) => { + if (!currentUser?.personalToken) return ''; + // Use an environment variable if available, otherwise fallback to current origin + const baseUrl = ((import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin).replace(/\/$/, ''); + return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; + }; + + if (loading) return
Loading...
; + + return ( +
+
+
+
+

How it works?

+
+
    +
  • Subscribe: Click Subscribe on any topic to start receiving alerts via Feishu private message.
  • +
  • Personal Webhook: Each topic provides a unique URL just for you. Send JSON alerts to this URL to notify yourself.
  • +
  • Need more? If you can't find a suitable topic, click Request Topic to ask admins for a new one.
  • +
+
+
+
+
+ +
+

Topics

+
+ {currentUser && ( + + )} +
+
+ + + +
+
    + {topics.map((topic) => ( +
  • +
    +
    +
    +
    +

    {topic.name}

    +
    + + {currentUser?.isAdmin && ( + <> + + + )} +
    +
    +
    +
    +

    + Slug: {topic.slug} +

    +

    + {topic.description} +

    + {currentUser && ( +
    +
    + Your Personal Webhook + +
    +
    + {getWebhookUrl(topic.slug)} +
    +
    + )} +
    +
    +
    +
    +
    +
  • + ))} + {topics.length === 0 && ( +
  • +
    +
    + +
    +

    No topics available yet.

    +

    + {currentUser?.isAdmin + ? "Click 'Add Topic' above to create the first alert topic for your team." + : "There are no approved topics yet. You can request one by clicking 'Request Topic' above."} +

    +
    +
  • + )} +
+
+ + {myRequests.length > 0 && ( +
+

My Requests

+
+
    + {myRequests.map((req) => ( +
  • +
    +
    +
    +
    +

    {req.name}

    +
    + + {req.status === 'approved' ? 'Approved' : + req.status === 'rejected' ? 'Rejected' : + 'Pending'} + +
    +
    +
    +

    Slug: {req.slug}

    + {req.description &&

    {req.description}

    } +

    Requested on: {new Date(req.createdAt).toLocaleDateString()}

    +
    +
    +
    +
    +
  • + ))} +
+
+
+ )} + + setIsModalOpen(false)} + title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"} + > +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + setFormData({ ...formData, slug: e.target.value })} + /> +
+
+ +