From 451793f6ce6437ffa4423ed1895f16cdd6793e28 Mon Sep 17 00:00:00 2001
From: d0zingcat
Date: Wed, 14 Jan 2026 19:36:46 +0800
Subject: [PATCH 1/4] feat: add lint
Signed-off-by: d0zingcat
---
apps/server/drizzle.config.ts | 16 +-
apps/server/package.json | 52 +-
apps/server/scripts/check_admin_requests.ts | 44 +-
apps/server/scripts/check_dashboard.ts | 46 +-
apps/server/scripts/check_topics.ts | 13 +-
apps/server/scripts/create_request.ts | 63 +-
apps/server/scripts/debug_subscription.ts | 121 +-
apps/server/src/api.ts | 533 ++++----
apps/server/src/api/feishu-event.ts | 63 +-
apps/server/src/auth.ts | 238 ++--
apps/server/src/db/index.ts | 10 +-
apps/server/src/db/schema.ts | 234 ++--
apps/server/src/event-handler.ts | 71 +-
apps/server/src/feishu.ts | 124 +-
apps/server/src/index.ts | 57 +-
apps/server/src/lib/logger.ts | 26 +-
apps/server/src/middleware.ts | 79 +-
apps/server/src/verify_permissions.ts | 289 ++--
apps/server/src/webhook.ts | 579 ++++----
apps/server/src/ws.ts | 36 +-
apps/server/tsconfig.json | 34 +-
apps/web/package.json | 62 +-
apps/web/postcss.config.js | 10 +-
apps/web/src/App.tsx | 247 ++--
.../web/src/components/GroupBindingsModal.tsx | 374 ++---
apps/web/src/components/Modal.tsx | 86 +-
apps/web/src/contexts/AuthContext.tsx | 139 +-
apps/web/src/index.css | 16 +-
apps/web/src/lib/client.ts | 6 +-
apps/web/src/main.tsx | 26 +-
apps/web/src/views/AdminView.tsx | 473 ++++---
apps/web/src/views/AuthCallback.tsx | 137 +-
apps/web/src/views/SystemLoadView.tsx | 625 +++++----
apps/web/src/views/TopicsView.tsx | 1212 ++++++++++-------
apps/web/src/views/UsersView.tsx | 380 +++---
apps/web/tailwind.config.js | 15 +-
apps/web/tsconfig.json | 52 +-
apps/web/tsconfig.node.json | 16 +-
apps/web/vite.config.ts | 44 +-
biome.json | 62 +
package.json | 31 +-
41 files changed, 3724 insertions(+), 3017 deletions(-)
create mode 100644 biome.json
diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts
index a7ac621..8f22b3f 100644
--- a/apps/server/drizzle.config.ts
+++ b/apps/server/drizzle.config.ts
@@ -1,10 +1,12 @@
-import { defineConfig } from 'drizzle-kit';
+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',
- },
+ 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/package.json b/apps/server/package.json
index 4e6b277..f9966c7 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,27 +1,27 @@
{
- "name": "@alertmessagecenter/server",
- "version": "1.2.0",
- "scripts": {
- "dev": "bun run --env-file .env --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",
- "@larksuiteoapi/node-sdk": "^1.56.1",
- "drizzle-orm": "^0.45.1",
- "hono": "^4.11.3",
- "pino": "^10.1.1",
- "postgres": "^3.4.8",
- "zod": "^3.0.0"
- },
- "devDependencies": {
- "@types/node": "^20.0.0",
- "bun-types": "latest",
- "drizzle-kit": "^0.31.8",
- "pino-pretty": "^13.1.3"
- }
-}
\ No newline at end of file
+ "name": "@alertmessagecenter/server",
+ "version": "1.2.0",
+ "scripts": {
+ "dev": "bun run --env-file .env --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",
+ "@larksuiteoapi/node-sdk": "^1.56.1",
+ "drizzle-orm": "^0.45.1",
+ "hono": "^4.11.3",
+ "pino": "^10.1.1",
+ "postgres": "^3.4.8",
+ "zod": "^3.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "bun-types": "latest",
+ "drizzle-kit": "^0.31.8",
+ "pino-pretty": "^13.1.3"
+ }
+}
diff --git a/apps/server/scripts/check_admin_requests.ts b/apps/server/scripts/check_admin_requests.ts
index 399c65b..3fc729d 100644
--- a/apps/server/scripts/check_admin_requests.ts
+++ b/apps/server/scripts/check_admin_requests.ts
@@ -1,28 +1,30 @@
-export { };
+export {};
// 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
- }))}`
- }
- });
+ 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());
- }
+ 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
index 22554ca..2bcaac2 100644
--- a/apps/server/scripts/check_dashboard.ts
+++ b/apps/server/scripts/check_dashboard.ts
@@ -1,28 +1,30 @@
-export { };
+export {};
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
- }))}`
- }
- });
+ 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());
- }
+ 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
index 2feda01..2687f29 100644
--- a/apps/server/scripts/check_topics.ts
+++ b/apps/server/scripts/check_topics.ts
@@ -1,9 +1,10 @@
-import { Database } from 'bun:sqlite';
-const db = new Database('dev.db');
+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));
+ 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);
+ console.error("Error querying topics:", e);
}
diff --git a/apps/server/scripts/create_request.ts b/apps/server/scripts/create_request.ts
index ae63b42..5b0ee6e 100644
--- a/apps/server/scripts/create_request.ts
+++ b/apps/server/scripts/create_request.ts
@@ -1,40 +1,41 @@
-
// Simulate topic creation
-import { client } from './client'; // This won't work in node script easily due to frontend dependencies
+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)`.
+ 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'
- })
- });
+ // 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());
- }
+ 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
index 8ba8280..e858f80 100644
--- a/apps/server/scripts/debug_subscription.ts
+++ b/apps/server/scripts/debug_subscription.ts
@@ -1,69 +1,78 @@
-import postgres from 'postgres';
+import postgres from "postgres";
-const sql = postgres('postgres://localhost:5432/alertmessagecenter');
+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);
+ 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';
+ // 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}`;
+ // 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
+ // 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)
+ // 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();
- }
+ // 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
index b8c2484..006f9d3 100644
--- a/apps/server/src/api.ts
+++ b/apps/server/src/api.ts
@@ -1,174 +1,208 @@
-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, topicGroupChats, knownGroupChats } from './db/schema';
-import { requireAuth, requireAdmin, AuthSession } from './middleware';
+import { zValidator } from "@hono/zod-validator";
+import { and, count, desc, eq, gt, sql, sum } from "drizzle-orm";
+import { Hono } from "hono";
+import { z } from "zod";
+import { db } from "./db";
+import {
+ alertTasks,
+ knownGroupChats,
+ subscriptions,
+ topicGroupChats,
+ topics,
+ users,
+} from "./db/schema";
+import { type AuthSession, requireAdmin, requireAuth } 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(),
+ name: z.string().min(1),
+ slug: z.string().min(1),
+ description: z.string().optional(),
});
const groupBindingSchema = z.object({
- chatId: z.string().min(1),
- name: z.string().min(1),
+ chatId: z.string().min(1),
+ name: z.string().min(1),
});
const userSchema = z.object({
- name: z.string().min(1),
- feishuUserId: z.string().min(1),
- email: z.string().email().optional().or(z.literal('')),
+ 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;
+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: {
- creator: true,
- approver: true,
- subscriptions: {
- where: (subscriptions, { eq }) =>
- isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined),
- with: {
- user: true
- }
- }
- }
- });
+ const allTopics = await db.query.topics.findMany({
+ where: eq(topics.status, "approved"),
+ with: {
+ creator: true,
+ approver: true,
+ subscriptions: {
+ where: (subscriptions, { eq }) =>
+ isAdmin
+ ? undefined
+ : currentUserId
+ ? eq(subscriptions.userId, currentUserId)
+ : undefined,
+ with: {
+ user: true,
+ },
+ },
+ },
+ });
- return c.json(allTopics);
+ 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/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,
- approver: true,
- subscriptions: true
- },
- orderBy: [desc(topics.createdAt)]
- });
- return c.json(allTopics);
+api.get("/topics/all", requireAdmin, async (c) => {
+ const allTopics = await db.query.topics.findMany({
+ with: {
+ creator: true,
+ approver: 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)],
- with: {
- approver: true,
- }
- });
- return c.json(requests);
+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)],
+ with: {
+ approver: true,
+ },
+ });
+ return c.json(requests);
});
-api.post('/topics/:id/approve', requireAdmin, async (c) => {
- const id = c.req.param('id');
- const session = c.get('session');
- const result = await db.update(topics)
- .set({ status: 'approved', approvedBy: session.id })
- .where(eq(topics.id, id))
- .returning();
- return c.json(result[0]);
+api.post("/topics/:id/approve", requireAdmin, async (c) => {
+ const id = c.req.param("id");
+ const session = c.get("session");
+ const result = await db
+ .update(topics)
+ .set({ status: "approved", approvedBy: session.id })
+ .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]);
+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');
+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 ? "approved" : "pending";
- const result = await db.insert(topics).values({
- ...body,
- status,
- createdBy: session.id,
- approvedBy: session.isAdmin ? session.id : null,
- }).returning();
- return c.json(result[0]);
+ const result = await db
+ .insert(topics)
+ .values({
+ ...body,
+ status,
+ createdBy: session.id,
+ approvedBy: session.isAdmin ? session.id : null,
+ })
+ .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]);
-});
+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 });
+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.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.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.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 });
+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 ---
@@ -176,159 +210,184 @@ api.delete('/users/:id', requireAdmin, async (c) => {
// --- 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');
+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);
- }
+ // 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]);
+ 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');
+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);
- }
+ // 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 });
+ await db
+ .delete(subscriptions)
+ .where(
+ and(eq(subscriptions.topicId, topicId), eq(subscriptions.userId, userId)),
+ );
+ return c.json({ success: true });
});
// --- Group Bindings (App Bot) ---
// Get list of known groups (for selection)
-api.get('/groups', requireAuth, async (c) => {
- // Return recent active groups
- const groups = await db.select().from(knownGroupChats)
- .orderBy(desc(knownGroupChats.lastActiveAt))
- .limit(50);
- return c.json(groups);
+api.get("/groups", requireAuth, async (c) => {
+ // Return recent active groups
+ const groups = await db
+ .select()
+ .from(knownGroupChats)
+ .orderBy(desc(knownGroupChats.lastActiveAt))
+ .limit(50);
+ return c.json(groups);
});
// Get bindings for a topic
-api.get('/topics/:id/groups', requireAuth, async (c) => {
- const topicId = c.req.param('id');
- const groups = await db.select().from(topicGroupChats)
- .where(eq(topicGroupChats.topicId, topicId))
- .orderBy(desc(topicGroupChats.createdAt));
- return c.json(groups);
+api.get("/topics/:id/groups", requireAuth, async (c) => {
+ const topicId = c.req.param("id");
+ const groups = await db
+ .select()
+ .from(topicGroupChats)
+ .where(eq(topicGroupChats.topicId, topicId))
+ .orderBy(desc(topicGroupChats.createdAt));
+ return c.json(groups);
});
// Bind a group to a topic
-api.post('/topics/:id/groups', requireAuth, zValidator('json', groupBindingSchema), async (c) => {
- const topicId = c.req.param('id');
- const body = c.req.valid('json');
- const session = c.get('session');
+api.post(
+ "/topics/:id/groups",
+ requireAuth,
+ zValidator("json", groupBindingSchema),
+ async (c) => {
+ const topicId = c.req.param("id");
+ const body = c.req.valid("json");
+ const session = c.get("session");
- const result = await db.insert(topicGroupChats).values({
- topicId,
- chatId: body.chatId,
- name: body.name,
- createdBy: session.id,
- }).returning();
+ const result = await db
+ .insert(topicGroupChats)
+ .values({
+ topicId,
+ chatId: body.chatId,
+ name: body.name,
+ createdBy: session.id,
+ })
+ .returning();
- return c.json(result[0]);
-});
+ return c.json(result[0]);
+ },
+);
// Unbind a group
-api.delete('/topics/:id/groups/:bindingId', requireAuth, async (c) => {
- const { id: topicId, bindingId } = c.req.param();
+api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
+ const { id: topicId, bindingId } = c.req.param();
- await db.delete(topicGroupChats)
- .where(and(
- eq(topicGroupChats.id, bindingId),
- eq(topicGroupChats.topicId, topicId)
- ));
+ await db
+ .delete(topicGroupChats)
+ .where(
+ and(
+ eq(topicGroupChats.id, bindingId),
+ eq(topicGroupChats.topicId, topicId),
+ ),
+ );
- return c.json({ success: true });
+ 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", 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
- }
- });
+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);
- }
+ if (!task) {
+ return c.json({ error: "Task not found" }, 404);
+ }
- return c.json(task);
+ 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);
+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));
+ // 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;
+ 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,
- }
- });
+ 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/api/feishu-event.ts b/apps/server/src/api/feishu-event.ts
index 83321e4..c2aa500 100644
--- a/apps/server/src/api/feishu-event.ts
+++ b/apps/server/src/api/feishu-event.ts
@@ -1,43 +1,46 @@
-import { Hono } from 'hono';
-import * as lark from '@larksuiteoapi/node-sdk';
-import { eventDispatcher } from '../event-handler';
+import * as lark from "@larksuiteoapi/node-sdk";
+import { Hono } from "hono";
+import { eventDispatcher } from "../event-handler";
const feishuEvent = new Hono();
// Helper to adapt Hono request to Lark SDK request
-feishuEvent.post('/', async (c) => {
- try {
- const headers = c.req.raw.headers;
- const headerRecord: Record = {};
- headers.forEach((value, key) => {
- headerRecord[key] = value;
- });
+feishuEvent.post("/", async (c) => {
+ try {
+ const headers = c.req.raw.headers;
+ const headerRecord: Record = {};
+ headers.forEach((value, key) => {
+ headerRecord[key] = value;
+ });
- const body = await c.req.json();
+ const body = await c.req.json();
- // Use the official SDK functions directly for Hono compatibility
- // 1. Handle URL verification (Challenge)
- const { isChallenge, challenge } = lark.generateChallenge(body, {
- encryptKey: process.env.FEISHU_ENCRYPT_KEY || ''
- });
+ // Use the official SDK functions directly for Hono compatibility
+ // 1. Handle URL verification (Challenge)
+ const { isChallenge, challenge } = lark.generateChallenge(body, {
+ encryptKey: process.env.FEISHU_ENCRYPT_KEY || "",
+ });
- if (isChallenge) {
- return c.json(challenge);
- }
+ if (isChallenge) {
+ return c.json(challenge);
+ }
- // 2. Dispatch event
- // The dispatcher expects an object containing headers and body.
- // We use Object.create to put headers on the prototype so they are accessible
- // but not included in JSON.stringify, which preserves signature verification.
- const payload = Object.assign(Object.create({ headers: headerRecord }), body);
- const result = await eventDispatcher.invoke(payload);
+ // 2. Dispatch event
+ // The dispatcher expects an object containing headers and body.
+ // We use Object.create to put headers on the prototype so they are accessible
+ // but not included in JSON.stringify, which preserves signature verification.
+ const payload = Object.assign(
+ Object.create({ headers: headerRecord }),
+ body,
+ );
+ const result = await eventDispatcher.invoke(payload);
- return c.json(result || {});
- } catch (e) {
- console.error('[Feishu Event] Error:', e);
- return c.json({ error: 'Internal Server Error' }, 500);
- }
+ return c.json(result || {});
+ } catch (e) {
+ console.error("[Feishu Event] Error:", e);
+ return c.json({ error: "Internal Server Error" }, 500);
+ }
});
export default feishuEvent;
diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts
index a436770..4610f9b 100644
--- a/apps/server/src/auth.ts
+++ b/apps/server/src/auth.ts
@@ -1,142 +1,156 @@
-import { Hono } from 'hono';
-import { logger } from './lib/logger';
-import { setCookie, getCookie } from 'hono/cookie';
-import { db } from './db';
-import { users } from './db/schema';
-import { eq } from 'drizzle-orm';
-import { feishuClient } from './feishu';
+import { eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { getCookie, setCookie } from "hono/cookie";
+import { db } from "./db";
+import { users } from "./db/schema";
+import { feishuClient } from "./feishu";
+import { logger } from "./lib/logger";
const auth = new Hono();
-const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map(email => email.trim()).filter(Boolean);
+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();
+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',
- });
+ // 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}`;
+ const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`;
- return c.json({ loginUrl });
+ 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');
+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);
- }
+ // 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);
- }
+ 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);
+ 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),
- });
+ // 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 || '');
+ 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];
- }
+ 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',
- });
+ // 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) {
- logger.error({ err: error }, 'OAuth callback error');
- return c.json({ error: 'Authentication failed' }, 500);
- }
+ return c.json({
+ success: true,
+ user: {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ isAdmin: user.isAdmin,
+ },
+ });
+ } catch (error) {
+ logger.error({ err: error }, "OAuth callback error");
+ return c.json({ error: "Authentication failed" }, 500);
+ }
});
// Get current user from session
-auth.get('/me', (c) => {
- const sessionCookie = getCookie(c, 'session');
+auth.get("/me", (c) => {
+ const sessionCookie = getCookie(c, "session");
- if (!sessionCookie) {
- return c.json({ error: 'Not authenticated' }, 401);
- }
+ 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) {
- logger.error({ err: error }, '[Auth] Failed to parse session cookie');
- return c.json({ error: 'Invalid session' }, 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) {
+ logger.error({ err: error }, "[Auth] Failed to parse session cookie");
+ return c.json({ error: "Invalid session" }, 401);
+ }
});
// Logout
-auth.post('/logout', (c) => {
- setCookie(c, 'session', '', {
- maxAge: 0,
- });
- return c.json({ success: true });
+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
index bc93dcc..332329e 100644
--- a/apps/server/src/db/index.ts
+++ b/apps/server/src/db/index.ts
@@ -1,7 +1,9 @@
-import { drizzle } from 'drizzle-orm/postgres-js';
-import postgres from 'postgres';
-import * as schema from './schema';
+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 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
index f054317..36202a3 100644
--- a/apps/server/src/db/schema.ts
+++ b/apps/server/src/db/schema.ts
@@ -1,131 +1,173 @@
-import { pgTable, text, integer, primaryKey, boolean, jsonb, timestamp } from 'drizzle-orm/pg-core';
-import { relations } from 'drizzle-orm';
+import { relations } from "drizzle-orm";
+import {
+ boolean,
+ integer,
+ jsonb,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+} from "drizzle-orm/pg-core";
// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag,例如 "payment-service",
-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),
- approvedBy: text('approved_by').references(() => users.id),
- createdAt: timestamp('created_at').defaultNow().notNull(),
+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),
+ approvedBy: text("approved_by").references(() => users.id),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Group Chats: App Bot 所在的群绑定
-export const topicGroupChats = pgTable('topic_group_chats', {
- id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
- topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
- chatId: text('chat_id').notNull(), // 飞书群 chat_id
- name: text('name').notNull(), // 群名称快照
- createdAt: timestamp('created_at').defaultNow().notNull(),
- createdBy: text('created_by').references(() => users.id),
+export const topicGroupChats = pgTable("topic_group_chats", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ topicId: text("topic_id")
+ .notNull()
+ .references(() => topics.id, { onDelete: "cascade" }),
+ chatId: text("chat_id").notNull(), // 飞书群 chat_id
+ name: text("name").notNull(), // 群名称快照
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ createdBy: text("created_by").references(() => users.id),
});
-export const topicGroupChatsRelations = relations(topicGroupChats, ({ one }) => ({
- topic: one(topics, {
- fields: [topicGroupChats.topicId],
- references: [topics.id],
- }),
- creator: one(users, {
- fields: [topicGroupChats.createdBy],
- references: [users.id],
- }),
-}));
+export const topicGroupChatsRelations = relations(
+ topicGroupChats,
+ ({ one }) => ({
+ topic: one(topics, {
+ fields: [topicGroupChats.topicId],
+ references: [topics.id],
+ }),
+ creator: one(users, {
+ fields: [topicGroupChats.createdBy],
+ references: [users.id],
+ }),
+ }),
+);
// Known Group Chats: 机器人已知的群 (通过事件发现)
-export const knownGroupChats = pgTable('known_group_chats', {
- chatId: text('chat_id').primaryKey(), // 飞书 chat_id
- name: text('name').notNull(),
- lastActiveAt: timestamp('last_active_at').defaultNow(),
+export const knownGroupChats = pgTable("known_group_chats", {
+ chatId: text("chat_id").primaryKey(), // 飞书 chat_id
+ name: text("name").notNull(),
+ lastActiveAt: timestamp("last_active_at").defaultNow(),
});
export const topicsRelations = relations(topics, ({ many, one }) => ({
- subscriptions: many(subscriptions),
- groupChats: many(topicGroupChats),
- creator: one(users, {
- fields: [topics.createdBy],
- references: [users.id],
- relationName: 'creator',
- }),
- approver: one(users, {
- fields: [topics.approvedBy],
- references: [users.id],
- relationName: 'approver',
- }),
+ subscriptions: many(subscriptions),
+ groupChats: many(topicGroupChats),
+ creator: one(users, {
+ fields: [topics.createdBy],
+ references: [users.id],
+ relationName: "creator",
+ }),
+ approver: one(users, {
+ fields: [topics.approvedBy],
+ references: [users.id],
+ relationName: "approver",
+ }),
}));
-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 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),
- createdTopics: many(topics, { relationName: 'creator' }),
- approvedTopics: many(topics, { relationName: 'approver' }),
+ subscriptions: many(subscriptions),
+ createdTopics: many(topics, { relationName: "creator" }),
+ approvedTopics: many(topics, { relationName: "approver" }),
}));
// 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 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],
- }),
+ 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'),
- 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(),
+export const alertTasks = pgTable("alert_tasks", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ topicSlug: text("topic_slug"),
+ 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 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],
- }),
+ 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],
- }),
+ task: one(alertTasks, {
+ fields: [alertLogs.taskId],
+ references: [alertTasks.id],
+ }),
}));
diff --git a/apps/server/src/event-handler.ts b/apps/server/src/event-handler.ts
index dbfe924..ef221c4 100644
--- a/apps/server/src/event-handler.ts
+++ b/apps/server/src/event-handler.ts
@@ -1,38 +1,45 @@
-import { db } from './db';
-import { knownGroupChats, topicGroupChats } from './db/schema';
-import { eq } from 'drizzle-orm';
-import * as lark from '@larksuiteoapi/node-sdk';
-import { logger } from './lib/logger';
+import * as lark from "@larksuiteoapi/node-sdk";
+import { eq } from "drizzle-orm";
+import { db } from "./db";
+import { knownGroupChats, topicGroupChats } from "./db/schema";
+import { logger } from "./lib/logger";
export const eventDispatcher = new lark.EventDispatcher({
- encryptKey: process.env.FEISHU_ENCRYPT_KEY,
- verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
+ encryptKey: process.env.FEISHU_ENCRYPT_KEY,
+ verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
}).register({
- 'im.chat.member.bot.added_v1': async (data) => {
- const { chat_id, name } = data as any;
- logger.info({ chat_id, name }, '[Feishu Event] Bot added to group');
+ "im.chat.member.bot.added_v1": async (data) => {
+ const { chat_id, name } = data as any;
+ logger.info({ chat_id, name }, "[Feishu Event] Bot added to group");
- if (chat_id) {
- await db.insert(knownGroupChats).values({
- chatId: chat_id,
- name: name || 'Unknown Group',
- lastActiveAt: new Date(),
- }).onConflictDoUpdate({
- target: knownGroupChats.chatId,
- set: {
- name: name || 'Unknown Group',
- lastActiveAt: new Date(),
- }
- });
- }
- },
- 'im.chat.member.bot.deleted_v1': async (data) => {
- const { chat_id } = data as any;
- logger.info({ chat_id }, '[Feishu Event] Bot removed from group');
+ if (chat_id) {
+ await db
+ .insert(knownGroupChats)
+ .values({
+ chatId: chat_id,
+ name: name || "Unknown Group",
+ lastActiveAt: new Date(),
+ })
+ .onConflictDoUpdate({
+ target: knownGroupChats.chatId,
+ set: {
+ name: name || "Unknown Group",
+ lastActiveAt: new Date(),
+ },
+ });
+ }
+ },
+ "im.chat.member.bot.deleted_v1": async (data) => {
+ const { chat_id } = data as any;
+ logger.info({ chat_id }, "[Feishu Event] Bot removed from group");
- if (chat_id) {
- await db.delete(knownGroupChats).where(eq(knownGroupChats.chatId, chat_id));
- await db.delete(topicGroupChats).where(eq(topicGroupChats.chatId, chat_id));
- }
- },
+ if (chat_id) {
+ await db
+ .delete(knownGroupChats)
+ .where(eq(knownGroupChats.chatId, chat_id));
+ await db
+ .delete(topicGroupChats)
+ .where(eq(topicGroupChats.chatId, chat_id));
+ }
+ },
});
diff --git a/apps/server/src/feishu.ts b/apps/server/src/feishu.ts
index e79626a..b69ee11 100644
--- a/apps/server/src/feishu.ts
+++ b/apps/server/src/feishu.ts
@@ -1,72 +1,78 @@
-import * as lark from '@larksuiteoapi/node-sdk';
-import { logger } from './lib/logger';
+import * as lark from "@larksuiteoapi/node-sdk";
+import { logger } from "./lib/logger";
export class FeishuClient {
- public client: lark.Client;
- public appId: string;
- public appSecret: string;
+ public client: lark.Client;
+ public appId: string;
+ public appSecret: string;
- constructor(appId: string, appSecret: string) {
- this.appId = appId;
- this.appSecret = appSecret;
- this.client = new lark.Client({
- appId: appId,
- appSecret: appSecret,
- disableTokenCache: false,
- });
- }
+ constructor(appId: string, appSecret: string) {
+ this.appId = appId;
+ this.appSecret = appSecret;
+ this.client = new lark.Client({
+ appId: appId,
+ appSecret: appSecret,
+ disableTokenCache: false,
+ });
+ }
- async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email' | 'chat_id', msgType: string, content: any) {
- // Content needs to be stringified for 'text' type in API, but SDK might handle it differently?
- // Actually SDK expects 'content' as string JSON for 'im.v1.messages.create'
- const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
+ async sendMessage(
+ receiveId: string,
+ receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
+ msgType: string,
+ content: any,
+ ) {
+ // Content needs to be stringified for 'text' type in API, but SDK might handle it differently?
+ // Actually SDK expects 'content' as string JSON for 'im.v1.messages.create'
+ const contentStr =
+ typeof content === "string" ? content : JSON.stringify(content);
- try {
- const response = await this.client.im.message.create({
- params: {
- receive_id_type: receiveIdType,
- },
- data: {
- receive_id: receiveId,
- msg_type: msgType,
- content: contentStr,
- },
- });
+ try {
+ const response = await this.client.im.message.create({
+ params: {
+ receive_id_type: receiveIdType,
+ },
+ data: {
+ receive_id: receiveId,
+ msg_type: msgType,
+ content: contentStr,
+ },
+ });
- if (response.code !== 0) {
- logger.error({ response }, 'Feishu send message error');
- throw new Error(`Failed to send message: ${response.msg}`);
- }
- return response.data;
- } catch (e) {
- console.error('Feishu SDK error:', e);
- throw e;
- }
- }
+ if (response.code !== 0) {
+ logger.error({ response }, "Feishu send message error");
+ throw new Error(`Failed to send message: ${response.msg}`);
+ }
+ return response.data;
+ } catch (e) {
+ console.error("Feishu SDK error:", e);
+ throw e;
+ }
+ }
- async getUserAccessToken(code: string): Promise {
- try {
- const response = await this.client.authen.accessToken.create({
- data: {
- grant_type: 'authorization_code',
- code,
- },
- });
+ async getUserAccessToken(code: string): Promise {
+ try {
+ const response = await this.client.authen.accessToken.create({
+ data: {
+ grant_type: "authorization_code",
+ code,
+ },
+ });
- if (response.code !== 0) {
- logger.error({ response }, 'Feishu get user access token error');
- throw new Error(`Failed to get user access token: ${response.msg}`);
- }
- return response.data;
- } catch (e) {
- console.error('Feishu SDK error:', e);
- throw e;
- }
- }
+ if (response.code !== 0) {
+ logger.error({ response }, "Feishu get user access token error");
+ throw new Error(`Failed to get user access token: ${response.msg}`);
+ }
+ return response.data;
+ } catch (e) {
+ console.error("Feishu SDK error:", e);
+ throw e;
+ }
+ }
}
// Singleton instance
export const feishuClient = new FeishuClient(
- process.env.FEISHU_APP_ID || '',
- process.env.FEISHU_APP_SECRET || ''
+ process.env.FEISHU_APP_ID || "",
+ process.env.FEISHU_APP_SECRET || "",
);
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index b10daa7..ca642a3 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -1,47 +1,52 @@
-import { Hono } from 'hono';
-import { logger } from './lib/logger';
-import { cors } from 'hono/cors';
-import { serveStatic } from 'hono/bun';
-import { db } from './db';
-import { topics } from './db/schema';
-import webhook from './webhook';
-import api from './api';
-import auth from './auth';
+import { Hono } from "hono";
+import { serveStatic } from "hono/bun";
+import { cors } from "hono/cors";
+import api from "./api";
+import auth from "./auth";
+import { db } from "./db";
+import { topics } from "./db/schema";
+import { logger } from "./lib/logger";
+import webhook from "./webhook";
const app = new Hono();
// Enable CORS for frontend
-app.use('/*', cors({
- origin: process.env.FRONTEND_URL || 'http://localhost:5173',
- credentials: true,
-}));
+app.use(
+ "/*",
+ cors({
+ origin: process.env.FRONTEND_URL || "http://localhost:5173",
+ credentials: true,
+ }),
+);
-import feishuEvent from './api/feishu-event';
+import feishuEvent from "./api/feishu-event";
// ...
// API Routes
-const routes = app.route('/api/auth', auth)
- .route('/api', api)
- .route('/api/feishu/event', feishuEvent)
- .route('/webhook', webhook);
+const routes = app
+ .route("/api/auth", auth)
+ .route("/api", api)
+ .route("/api/feishu/event", feishuEvent)
+ .route("/webhook", webhook);
// Serve static files (Frontend)
-app.use('/*', serveStatic({ root: './public' }));
-app.get('*', serveStatic({ path: './public/index.html' }));
+app.use("/*", serveStatic({ root: "./public" }));
+app.get("*", serveStatic({ path: "./public/index.html" }));
app.onError((err, c) => {
- logger.error({ err, method: c.req.method, url: c.req.url }, 'Global Error');
- return c.json({ error: err.message || 'Internal Server Error' }, 500);
+ logger.error({ err, method: c.req.method, url: c.req.url }, "Global Error");
+ 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);
+app.get("/topics", async (c) => {
+ const allTopics = await db.select().from(topics);
+ return c.json(allTopics);
});
// Start WebSocket if enabled
-import { startWebSocket } from './ws';
+import { startWebSocket } from "./ws";
+
startWebSocket();
export type AppType = typeof routes;
diff --git a/apps/server/src/lib/logger.ts b/apps/server/src/lib/logger.ts
index 0d60549..c79218a 100644
--- a/apps/server/src/lib/logger.ts
+++ b/apps/server/src/lib/logger.ts
@@ -1,19 +1,19 @@
-import pino from 'pino';
+import pino from "pino";
-const isDevelopment = process.env.NODE_ENV !== 'production';
+const isDevelopment = process.env.NODE_ENV !== "production";
export const logger = pino({
- level: process.env.LOG_LEVEL || 'info',
- transport: isDevelopment
- ? {
- target: 'pino-pretty',
- options: {
- colorize: true,
- translateTime: 'HH:MM:ss Z',
- ignore: 'pid,hostname',
- },
- }
- : undefined,
+ level: process.env.LOG_LEVEL || "info",
+ transport: isDevelopment
+ ? {
+ target: "pino-pretty",
+ options: {
+ colorize: true,
+ translateTime: "HH:MM:ss Z",
+ ignore: "pid,hostname",
+ },
+ }
+ : undefined,
});
export default logger;
diff --git a/apps/server/src/middleware.ts b/apps/server/src/middleware.ts
index 0e7853d..6741dd7 100644
--- a/apps/server/src/middleware.ts
+++ b/apps/server/src/middleware.ts
@@ -1,51 +1,58 @@
-import { Context, Next } from 'hono';
-import { getCookie } from 'hono/cookie';
+import type { Context, Next } from "hono";
+import { getCookie } from "hono/cookie";
export interface AuthSession {
- id: string;
- name: string;
- email: string | null;
- isAdmin: boolean;
+ id: string;
+ name: string;
+ email: string | null;
+ isAdmin: boolean;
}
export async function requireAuth(c: Context, next: Next) {
- const sessionCookie = getCookie(c, 'session');
+ const sessionCookie = getCookie(c, "session");
- if (!sessionCookie) {
- return c.json({ error: 'Authentication required' }, 401);
- }
+ 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);
- }
+ 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');
+ const sessionCookie = getCookie(c, "session");
- if (!sessionCookie) {
- return c.json({ error: 'Authentication required' }, 401);
- }
+ if (!sessionCookie) {
+ return c.json({ error: "Authentication required" }, 401);
+ }
- try {
- const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
+ try {
+ const session: AuthSession = sessionCookie
+ ? JSON.parse(sessionCookie)
+ : null;
- if (!session || !session.isAdmin) {
- return c.json({ error: 'Admin access required' }, 403);
- }
+ 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);
- }
+ 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
index efe60ac..6632813 100644
--- a/apps/server/src/verify_permissions.ts
+++ b/apps/server/src/verify_permissions.ts
@@ -1,148 +1,187 @@
-
-import app from './index';
-import { db } from './db';
-import { users, topics, subscriptions } from './db/schema';
-import { eq } from 'drizzle-orm';
+import { eq } from "drizzle-orm";
+import { db } from "./db";
+import { subscriptions, topics, users } from "./db/schema";
+import app from "./index";
async function verify() {
- console.log('Starting Verification...');
- let errors = 0;
+ console.log("Starting Verification...");
+ let errors = 0;
- // 1. Setup Test Data
- const timestamp = Date.now();
+ // 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 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 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();
+ // 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
- });
+ // 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 });
+ console.log("Test Data Created:", {
+ user: userUser.id,
+ admin: adminUser.id,
+ topic: topic.id,
+ });
- try {
- // 2. Test GET /users (Admin Only)
+ 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);
+ // 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++;
- }
+ 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);
+ // 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++;
- }
+ 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)
+ // 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();
+ // 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++;
- }
+ 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.
+ // 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 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++;
- }
+ 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");
+ }
- } 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);
- }
+ 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
index 6598d93..026490b 100644
--- a/apps/server/src/webhook.ts
+++ b/apps/server/src/webhook.ts
@@ -1,311 +1,362 @@
-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';
-import { logger } from './lib/logger';
+import { eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { db } from "./db";
+import { alertLogs, alertTasks, topics, users } from "./db/schema";
+import { feishuClient } from "./feishu";
+import { logger } from "./lib/logger";
const webhook = new Hono();
-webhook.post('/:token/topic/:slug', async (c) => {
- const token = c.req.param('token');
- const slug = c.req.param('slug');
- logger.info({ token, slug }, '[Webhook] Received request');
+webhook.post("/:token/topic/:slug", async (c) => {
+ const token = c.req.param("token");
+ const slug = c.req.param("slug");
+ logger.info({ token, slug }, "[Webhook] Received request");
- // 0. Find the User by Token
- const user = await db.query.users.findFirst({
- where: eq(users.personalToken, token),
- });
+ // 0. Find the User by Token
+ const user = await db.query.users.findFirst({
+ where: eq(users.personalToken, token),
+ });
- if (!user) {
- logger.warn({ token }, '[Webhook] Invalid personal token');
- return c.json({ error: 'Invalid personal token' }, 401);
- }
- let body;
- try {
- const rawBody = await c.req.text();
- logger.debug({ bodyLength: rawBody.length }, '[Webhook] Received raw body');
- if (!rawBody || rawBody.trim() === '') {
- return c.json({ error: 'Empty body' }, 400);
- }
- body = JSON.parse(rawBody);
- } catch (e) {
- logger.error({ err: e }, '[Webhook] Failed to parse JSON body');
- return c.json({ error: 'Invalid JSON body' }, 400);
- }
+ if (!user) {
+ logger.warn({ token }, "[Webhook] Invalid personal token");
+ return c.json({ error: "Invalid personal token" }, 401);
+ }
+ let body;
+ try {
+ const rawBody = await c.req.text();
+ logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body");
+ if (!rawBody || rawBody.trim() === "") {
+ return c.json({ error: "Empty body" }, 400);
+ }
+ body = JSON.parse(rawBody);
+ } catch (e) {
+ logger.error({ err: e }, "[Webhook] Failed to parse JSON body");
+ 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
- }
- },
- groupChats: true
- }
- });
+ // 1. Find the Topic
+ const topic = await db.query.topics.findFirst({
+ where: eq(topics.slug, slug),
+ with: {
+ subscriptions: {
+ with: {
+ user: true,
+ },
+ },
+ groupChats: true,
+ },
+ });
- if (!topic) {
- logger.warn({ slug }, '[Webhook] Topic not found');
- return c.json({ error: 'Topic not found' }, 404);
- }
+ if (!topic) {
+ logger.warn({ slug }, "[Webhook] Topic not found");
+ return c.json({ error: "Topic not found" }, 404);
+ }
- logger.info({ topicName: topic.name }, '[Webhook] Found topic');
+ logger.info({ topicName: topic.name }, "[Webhook] Found topic");
- // 2. Collect recipients
- const userRecipients = topic.subscriptions
- .map(sub => sub.user)
- .filter(u => !!u && !!u.feishuUserId)
- .map(u => ({
- type: 'user',
- id: u.id,
- name: u.name,
- feishuId: u.feishuUserId,
- idType: u.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id'
- }));
+ // 2. Collect recipients
+ const userRecipients = topic.subscriptions
+ .map((sub) => sub.user)
+ .filter((u) => !!u && !!u.feishuUserId)
+ .map((u) => ({
+ type: "user",
+ id: u.id,
+ name: u.name,
+ feishuId: u.feishuUserId,
+ idType: u.feishuUserId.startsWith("ou_") ? "open_id" : "user_id",
+ }));
- const groupRecipients = topic.groupChats.map(g => ({
- type: 'group',
- id: g.id, // Binding ID
- name: g.name,
- feishuId: g.chatId,
- idType: 'chat_id'
- }));
+ const groupRecipients = topic.groupChats.map((g) => ({
+ type: "group",
+ id: g.id, // Binding ID
+ name: g.name,
+ feishuId: g.chatId,
+ idType: "chat_id",
+ }));
- const allRecipients = [...userRecipients, ...groupRecipients];
+ const allRecipients = [...userRecipients, ...groupRecipients];
- const [task] = await db.insert(alertTasks).values({
- topicSlug: topic.slug,
- senderId: user.id,
- status: 'processing',
- recipientCount: allRecipients.length,
- successCount: 0,
- payload: body,
- }).returning();
+ const [task] = await db
+ .insert(alertTasks)
+ .values({
+ topicSlug: topic.slug,
+ senderId: user.id,
+ status: "processing",
+ recipientCount: allRecipients.length,
+ successCount: 0,
+ payload: body,
+ })
+ .returning();
- if (allRecipients.length === 0) {
- await db.update(alertTasks)
- .set({ status: 'completed', updatedAt: new Date() })
- .where(eq(alertTasks.id, task.id));
+ if (allRecipients.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'
- });
- }
+ return c.json({
+ message: "No subscribers for this topic",
+ taskId: task.id,
+ status: "completed",
+ });
+ }
- logger.info({
- taskId: task.id,
- userCount: userRecipients.length,
- groupCount: groupRecipients.length
- }, '[Webhook] Dispatching alerts');
+ logger.info(
+ {
+ taskId: task.id,
+ userCount: userRecipients.length,
+ groupCount: groupRecipients.length,
+ },
+ "[Webhook] Dispatching alerts",
+ );
- // 4. Send Private Messages asynchronously
- Promise.allSettled(allRecipients.map(async (recipient) => {
- try {
- // Construct message content
- let msgType = body.msg_type || 'text';
- let content = body.content;
+ // 4. Send Private Messages asynchronously
+ Promise.allSettled(
+ allRecipients.map(async (recipient) => {
+ 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) };
- // Deep copy needed? usually content is new obj if we parsed body
- } else {
- // Deep clone content to avoid mutating shared object for parallel requests if we modify it
- content = JSON.parse(JSON.stringify(content));
- }
+ if (!content) {
+ msgType = "text";
+ content = { text: JSON.stringify(body, null, 2) };
+ // Deep copy needed? usually content is new obj if we parsed body
+ } else {
+ // Deep clone content to avoid mutating shared object for parallel requests if we modify it
+ content = JSON.parse(JSON.stringify(content));
+ }
- // 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}`;
- }
+ // 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}`;
+ }
- await feishuClient.sendMessage(recipient.feishuId, recipient.idType as any, msgType, content);
+ await feishuClient.sendMessage(
+ recipient.feishuId,
+ recipient.idType as any,
+ msgType,
+ content,
+ );
- return { recipientId: recipient.id, status: 'sent', error: null };
- } catch (error: any) {
- logger.error({
- err: error,
- recipientType: recipient.type,
- recipientName: recipient.name
- }, 'Failed to send alert');
- return { recipientId: recipient.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;
+ return { recipientId: recipient.id, status: "sent", error: null };
+ } catch (error: any) {
+ logger.error(
+ {
+ err: error,
+ recipientType: recipient.type,
+ recipientName: recipient.name,
+ },
+ "Failed to send alert",
+ );
+ return {
+ recipientId: recipient.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');
+ // 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));
+ // 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
- const logs = results.map((r, index) => {
- const recipient = allRecipients[index];
- if (r.status === 'fulfilled') {
- const val = r.value as any;
- return {
- taskId: task.id,
- userId: recipient.type === 'user' ? recipient.id : null, // Only link users
- // We could add connection to group binding if we altered schema, but for now log it
- status: val.status,
- error: val.error,
- };
- } else {
- return {
- taskId: task.id,
- userId: recipient.type === 'user' ? recipient.id : null,
- status: 'failed',
- error: r.reason ? String(r.reason) : 'Unknown error',
- };
- }
- });
+ // Insert Logs
+ const logs = results.map((r, index) => {
+ const recipient = allRecipients[index];
+ if (r.status === "fulfilled") {
+ const val = r.value as any;
+ return {
+ taskId: task.id,
+ userId: recipient.type === "user" ? recipient.id : null, // Only link users
+ // We could add connection to group binding if we altered schema, but for now log it
+ status: val.status,
+ error: val.error,
+ };
+ } else {
+ return {
+ taskId: task.id,
+ userId: recipient.type === "user" ? recipient.id : null,
+ status: "failed",
+ error: r.reason ? String(r.reason) : "Unknown error",
+ };
+ }
+ });
- if (logs.length > 0) {
- await db.insert(alertLogs).values(logs as any);
- }
+ if (logs.length > 0) {
+ await db.insert(alertLogs).values(logs as any);
+ }
- logger.info({
- taskId: task.id,
- successCount,
- totalCount: allRecipients.length,
- slug
- }, '[Webhook] Task processed');
- });
+ logger.info(
+ {
+ taskId: task.id,
+ successCount,
+ totalCount: allRecipients.length,
+ slug,
+ },
+ "[Webhook] Task processed",
+ );
+ });
- return c.json({
- message: 'Alert received and processing started',
- taskId: task.id,
- status: 'processing',
- recipientCount: allRecipients.length
- });
+ return c.json({
+ message: "Alert received and processing started",
+ taskId: task.id,
+ status: "processing",
+ recipientCount: allRecipients.length,
+ });
});
-webhook.post('/:token/dm', async (c) => {
- const token = c.req.param('token');
- logger.info({ token }, '[Webhook] Received DM request');
+webhook.post("/:token/dm", async (c) => {
+ const token = c.req.param("token");
+ logger.info({ token }, "[Webhook] Received DM request");
- // 0. Find the User by Token
- const user = await db.query.users.findFirst({
- where: eq(users.personalToken, token),
- });
+ // 0. Find the User by Token
+ const user = await db.query.users.findFirst({
+ where: eq(users.personalToken, token),
+ });
- if (!user) {
- logger.warn({ token }, '[Webhook] Invalid personal token');
- return c.json({ error: 'Invalid personal token' }, 401);
- }
+ if (!user) {
+ logger.warn({ token }, "[Webhook] Invalid personal token");
+ return c.json({ error: "Invalid personal token" }, 401);
+ }
- if (!user.feishuUserId) {
- return c.json({ error: 'User has no Feishu ID linked' }, 400);
- }
+ if (!user.feishuUserId) {
+ return c.json({ error: "User has no Feishu ID linked" }, 400);
+ }
- let body;
- try {
- const rawBody = await c.req.text();
- if (!rawBody || rawBody.trim() === '') {
- return c.json({ error: 'Empty body' }, 400);
- }
- body = JSON.parse(rawBody);
- } catch (e) {
- return c.json({ error: 'Invalid JSON body' }, 400);
- }
+ let body;
+ try {
+ const rawBody = await c.req.text();
+ if (!rawBody || rawBody.trim() === "") {
+ return c.json({ error: "Empty body" }, 400);
+ }
+ body = JSON.parse(rawBody);
+ } catch (e) {
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
- // 1. Create Task (topicSlug is null for DM)
- const [task] = await db.insert(alertTasks).values({
- topicSlug: null,
- senderId: user.id,
- status: 'processing',
- recipientCount: 1,
- successCount: 0,
- payload: body,
- }).returning();
+ // 1. Create Task (topicSlug is null for DM)
+ const [task] = await db
+ .insert(alertTasks)
+ .values({
+ topicSlug: null,
+ senderId: user.id,
+ status: "processing",
+ recipientCount: 1,
+ successCount: 0,
+ payload: body,
+ })
+ .returning();
- // 2. Send Message
- (async () => {
- try {
- let msgType = body.msg_type || 'text';
- let content = body.content;
+ // 2. Send Message
+ (async () => {
+ try {
+ let msgType = body.msg_type || "text";
+ let content = body.content;
- if (!content) {
- msgType = 'text';
- content = { text: JSON.stringify(body, null, 2) };
- }
+ if (!content) {
+ msgType = "text";
+ content = { text: JSON.stringify(body, null, 2) };
+ }
- // Add metadata
- if (msgType === 'text' && content.text) {
- content.text = `[Direct Message]\n${content.text}`;
- }
- if (msgType === 'interactive' && content.header) {
- content.header.title.content = `[DM] ${content.header.title.content}`;
- }
+ // Add metadata
+ if (msgType === "text" && content.text) {
+ content.text = `[Direct Message]\n${content.text}`;
+ }
+ if (msgType === "interactive" && content.header) {
+ content.header.title.content = `[DM] ${content.header.title.content}`;
+ }
- const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id';
- await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content);
+ const idType = user.feishuUserId.startsWith("ou_")
+ ? "open_id"
+ : "user_id";
+ await feishuClient.sendMessage(
+ user.feishuUserId,
+ idType,
+ msgType,
+ content,
+ );
- // Update Task
- await db.update(alertTasks).set({
- status: 'completed',
- successCount: 1,
- updatedAt: new Date(),
- }).where(eq(alertTasks.id, task.id));
+ // Update Task
+ await db
+ .update(alertTasks)
+ .set({
+ status: "completed",
+ successCount: 1,
+ updatedAt: new Date(),
+ })
+ .where(eq(alertTasks.id, task.id));
- // Insert Log
- await db.insert(alertLogs).values({
- taskId: task.id,
- userId: user.id,
- status: 'sent',
- });
+ // Insert Log
+ await db.insert(alertLogs).values({
+ taskId: task.id,
+ userId: user.id,
+ status: "sent",
+ });
+ } catch (error: any) {
+ logger.error({ err: error, userName: user.name }, "Failed to send DM");
+ await db
+ .update(alertTasks)
+ .set({
+ status: "failed",
+ updatedAt: new Date(),
+ error: error.message,
+ })
+ .where(eq(alertTasks.id, task.id));
- } catch (error: any) {
- logger.error({ err: error, userName: user.name }, 'Failed to send DM');
- await db.update(alertTasks).set({
- status: 'failed',
- updatedAt: new Date(),
- error: error.message,
- }).where(eq(alertTasks.id, task.id));
+ await db.insert(alertLogs).values({
+ taskId: task.id,
+ userId: user.id,
+ status: "failed",
+ error: error.message,
+ });
+ }
+ })();
- await db.insert(alertLogs).values({
- taskId: task.id,
- userId: user.id,
- status: 'failed',
- error: error.message,
- });
- }
- })();
-
- return c.json({
- message: 'DM received and processing started',
- taskId: task.id,
- status: 'processing',
- recipientCount: 1
- });
+ return c.json({
+ message: "DM received and processing started",
+ taskId: task.id,
+ status: "processing",
+ recipientCount: 1,
+ });
});
// 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);
+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/src/ws.ts b/apps/server/src/ws.ts
index fb68aed..e12d0ce 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -1,22 +1,22 @@
-import * as lark from '@larksuiteoapi/node-sdk';
-import { feishuClient } from './feishu';
-import { eventDispatcher } from './event-handler';
-import { logger } from './lib/logger';
+import * as lark from "@larksuiteoapi/node-sdk";
+import { eventDispatcher } from "./event-handler";
+import { feishuClient } from "./feishu";
+import { logger } from "./lib/logger";
export const startWebSocket = async () => {
- if (process.env.FEISHU_USE_WS !== 'true') {
- return;
- }
+ if (process.env.FEISHU_USE_WS !== "true") {
+ return;
+ }
- logger.info('[Feishu WS] Starting WebSocket connection...');
- try {
- const wsClient = new lark.WSClient({
- appId: feishuClient.appId,
- appSecret: feishuClient.appSecret,
- });
- await wsClient.start({ eventDispatcher });
- logger.info('[Feishu WS] Connected successfully');
- } catch (e) {
- logger.error({ err: e }, '[Feishu WS] Connection failed');
- }
+ logger.info("[Feishu WS] Starting WebSocket connection...");
+ try {
+ const wsClient = new lark.WSClient({
+ appId: feishuClient.appId,
+ appSecret: feishuClient.appSecret,
+ });
+ await wsClient.start({ eventDispatcher });
+ logger.info("[Feishu WS] Connected successfully");
+ } catch (e) {
+ logger.error({ err: e }, "[Feishu WS] Connection failed");
+ }
};
diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json
index d427eb3..0a75c5f 100644
--- a/apps/server/tsconfig.json
+++ b/apps/server/tsconfig.json
@@ -1,21 +1,15 @@
{
- "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
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "types": ["bun-types"],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 949cef5..fab5c95 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,32 +1,32 @@
{
- "name": "@alertmessagecenter/web",
- "version": "1.2.0",
- "type": "module",
- "scripts": {
- "dev": "bun run --env-file .env 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
+ "name": "@alertmessagecenter/web",
+ "version": "1.2.0",
+ "type": "module",
+ "scripts": {
+ "dev": "bun run --env-file .env 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"
+ }
+}
diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js
index 2e7af2b..7b75c83 100644
--- a/apps/web/postcss.config.js
+++ b/apps/web/postcss.config.js
@@ -1,6 +1,6 @@
export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-}
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4dae518..73c0b2b 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,125 +1,142 @@
-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'
+import {
+ Activity,
+ Hash,
+ LogIn,
+ LogOut,
+ Settings,
+ ShieldCheck,
+ Users,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { useAuth } from "./contexts/AuthContext";
+import AdminView from "./views/AdminView";
+import TopicsView from "./views/TopicsView";
+import UsersView from "./views/UsersView";
function App() {
- const { user, loading, login, logout } = useAuth()
- const [activeTab, setActiveTab] = useState('topics')
- const [hasSetDefault, setHasSetDefault] = useState(false)
+ 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])
+ useEffect(() => {
+ if (!loading && user && !hasSetDefault) {
+ setActiveTab(user.isAdmin ? "admin" : "topics");
+ setHasSetDefault(true);
+ }
+ }, [user, loading, hasSetDefault]);
- if (loading) {
- return (
-
- )
- }
+ if (loading) {
+ return (
+
+ );
+ }
- if (!user) {
- return (
-
-
-
-
-
Alert Message Center
-
Please sign in with Feishu to continue
-
-
- Sign in with Feishu
-
-
-
-
- )
- }
+ if (!user) {
+ return (
+
+
+
+
+
+ Alert Message Center
+
+
+ Please sign in with Feishu to continue
+
+
+
+ Sign in with Feishu
+
+
+
+
+ );
+ }
- return (
-
-
-
-
-
-
-
-
Alert Message Center
-
-
- {user.isAdmin && (
- setActiveTab('admin')}
- className={`${activeTab === 'admin'
- ? 'border-indigo-500 text-gray-900'
- : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
- } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
- >
-
- Admin
-
- )}
- setActiveTab('topics')}
- className={`${activeTab === 'topics'
- ? 'border-indigo-500 text-gray-900'
- : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
- } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
- >
-
- Topics
-
- {user.isAdmin && (
- setActiveTab('users')}
- className={`${activeTab === 'users'
- ? 'border-indigo-500 text-gray-900'
- : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
- } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
- >
-
- Users
-
- )}
-
-
+ return (
+
+
+
+
+
+
+
+
+ Alert Message Center
+
+
+
+ {user.isAdmin && (
+ setActiveTab("admin")}
+ className={`${
+ activeTab === "admin"
+ ? "border-indigo-500 text-gray-900"
+ : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
+ } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
+ >
+
+ Admin
+
+ )}
+ setActiveTab("topics")}
+ className={`${
+ activeTab === "topics"
+ ? "border-indigo-500 text-gray-900"
+ : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
+ } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
+ >
+
+ Topics
+
+ {user.isAdmin && (
+ setActiveTab("users")}
+ className={`${
+ activeTab === "users"
+ ? "border-indigo-500 text-gray-900"
+ : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
+ } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
+ >
+
+ Users
+
+ )}
+
+
-
-
- {user.name}
- {user.isAdmin && (
-
- )}
-
-
-
- Logout
-
-
-
-
-
+
+
+ {user.name}
+ {user.isAdmin && (
+
+ )}
+
+
+
+ Logout
+
+
+
+
+
-
- {activeTab === 'topics' && }
- {activeTab === 'users' && user.isAdmin && }
- {activeTab === 'admin' && user.isAdmin && }
-
-
- )
+
+ {activeTab === "topics" && }
+ {activeTab === "users" && user.isAdmin && }
+ {activeTab === "admin" && user.isAdmin && }
+
+
+ );
}
-export default App
+export default App;
diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx
index f61421e..084c19a 100644
--- a/apps/web/src/components/GroupBindingsModal.tsx
+++ b/apps/web/src/components/GroupBindingsModal.tsx
@@ -1,196 +1,228 @@
-import { useState, useEffect } from 'react';
-import { Trash2, Plus, MessageCircle } from 'lucide-react';
-import Modal from './Modal';
-import { client } from '../lib/client';
+import { MessageCircle, Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import { client } from "../lib/client";
+import Modal from "./Modal";
interface GroupBinding {
- id: string;
- chatId: string;
- name: string;
+ id: string;
+ chatId: string;
+ name: string;
}
interface KnownGroup {
- chatId: string;
- name: string;
- lastActiveAt: string;
+ chatId: string;
+ name: string;
+ lastActiveAt: string;
}
interface GroupBindingsModalProps {
- isOpen: boolean;
- onClose: () => void;
- topicId: string;
- topicName: string;
+ isOpen: boolean;
+ onClose: () => void;
+ topicId: string;
+ topicName: string;
}
-export default function GroupBindingsModal({ isOpen, onClose, topicId, topicName }: GroupBindingsModalProps) {
- // const { user } = useAuth(); // Unused
- const [bindings, setBindings] = useState([]);
- const [knownGroups, setKnownGroups] = useState([]);
- const [selectedChatId, setSelectedChatId] = useState('');
- const [loading, setLoading] = useState(false);
- const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
+export default function GroupBindingsModal({
+ isOpen,
+ onClose,
+ topicId,
+ topicName,
+}: GroupBindingsModalProps) {
+ // const { user } = useAuth(); // Unused
+ const [bindings, setBindings] = useState([]);
+ const [knownGroups, setKnownGroups] = useState([]);
+ const [selectedChatId, setSelectedChatId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [status, setStatus] = useState<{
+ type: "success" | "error";
+ message: string;
+ } | null>(null);
- useEffect(() => {
- if (isOpen && topicId) {
- fetchBindings();
- fetchKnownGroups();
- setStatus(null);
- setSelectedChatId('');
- }
- }, [isOpen, topicId]);
+ useEffect(() => {
+ if (isOpen && topicId) {
+ fetchBindings();
+ fetchKnownGroups();
+ setStatus(null);
+ setSelectedChatId("");
+ }
+ }, [isOpen, topicId]);
- const fetchBindings = async () => {
- try {
- const res = await client.api.topics[':id'].groups.$get({
- param: { id: topicId }
- }, {
- init: { credentials: 'include' }
- });
- const data = await res.json();
- setBindings(data as any);
- } catch (err) {
- console.error(err);
- }
- };
+ const fetchBindings = async () => {
+ try {
+ const res = await client.api.topics[":id"].groups.$get(
+ {
+ param: { id: topicId },
+ },
+ {
+ init: { credentials: "include" },
+ },
+ );
+ const data = await res.json();
+ setBindings(data as any);
+ } catch (err) {
+ console.error(err);
+ }
+ };
- const fetchKnownGroups = async () => {
- try {
- const res = await client.api.groups.$get(undefined, {
- init: { credentials: 'include' }
- });
- const data = await res.json();
- // Only verify uniqueness if needed, but here we just list what server returns
- setKnownGroups(data as any);
- } catch (err) {
- console.error(err);
- }
- };
+ const fetchKnownGroups = async () => {
+ try {
+ const res = await client.api.groups.$get(undefined, {
+ init: { credentials: "include" },
+ });
+ const data = await res.json();
+ // Only verify uniqueness if needed, but here we just list what server returns
+ setKnownGroups(data as any);
+ } catch (err) {
+ console.error(err);
+ }
+ };
- const handleBind = async () => {
- if (!selectedChatId) return;
- setLoading(true);
- setStatus(null);
+ const handleBind = async () => {
+ if (!selectedChatId) return;
+ setLoading(true);
+ setStatus(null);
- const group = knownGroups.find(g => g.chatId === selectedChatId);
- if (!group) return;
+ const group = knownGroups.find((g) => g.chatId === selectedChatId);
+ if (!group) return;
- try {
- const res = await client.api.topics[':id'].groups.$post({
- param: { id: topicId },
- json: {
- chatId: group.chatId,
- name: group.name,
- }
- }, {
- init: { credentials: 'include' }
- });
+ try {
+ const res = await client.api.topics[":id"].groups.$post(
+ {
+ param: { id: topicId },
+ json: {
+ chatId: group.chatId,
+ name: group.name,
+ },
+ },
+ {
+ init: { credentials: "include" },
+ },
+ );
- if (res.ok) {
- setStatus({ type: 'success', message: 'Group bound successfully!' });
- fetchBindings();
- setSelectedChatId('');
- } else {
- await res.json(); // Consume body
- setStatus({ type: 'error', message: 'Failed to bind group' });
- }
- } catch (_) { // Ignore error
- setStatus({ type: 'error', message: 'An error occurred' });
- } finally {
- setLoading(false);
- }
- };
+ if (res.ok) {
+ setStatus({ type: "success", message: "Group bound successfully!" });
+ fetchBindings();
+ setSelectedChatId("");
+ } else {
+ await res.json(); // Consume body
+ setStatus({ type: "error", message: "Failed to bind group" });
+ }
+ } catch (_) {
+ // Ignore error
+ setStatus({ type: "error", message: "An error occurred" });
+ } finally {
+ setLoading(false);
+ }
+ };
- const handleUnbind = async (bindingId: string) => {
- if (!confirm('Are you sure you want to remove this group binding?')) return;
+ const handleUnbind = async (bindingId: string) => {
+ if (!confirm("Are you sure you want to remove this group binding?")) return;
- try {
- const res = await client.api.topics[':id'].groups[':bindingId'].$delete({
- param: { id: topicId, bindingId }
- }, {
- init: { credentials: 'include' }
- });
+ try {
+ const res = await client.api.topics[":id"].groups[":bindingId"].$delete(
+ {
+ param: { id: topicId, bindingId },
+ },
+ {
+ init: { credentials: "include" },
+ },
+ );
- if (res.ok) {
- setBindings(prev => prev.filter(b => b.id !== bindingId));
- }
- } catch (err) {
- console.error(err);
- }
- };
+ if (res.ok) {
+ setBindings((prev) => prev.filter((b) => b.id !== bindingId));
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
- // Filter out groups that are already bound
- const availableGroups = knownGroups.filter(
- kg => !bindings.some(b => b.chatId === kg.chatId)
- );
+ // Filter out groups that are already bound
+ const availableGroups = knownGroups.filter(
+ (kg) => !bindings.some((b) => b.chatId === kg.chatId),
+ );
- return (
-
-
-
-
Bound Groups
- {bindings.length === 0 ? (
-
No groups bound to this topic yet.
- ) : (
-
- {bindings.map(binding => (
-
-
-
- {binding.name}
-
- handleUnbind(binding.id)}
- className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
- title="Remove binding"
- >
-
-
-
- ))}
-
- )}
-
+ return (
+
+
+
+
+ Bound Groups
+
+ {bindings.length === 0 ? (
+
+ No groups bound to this topic yet.
+
+ ) : (
+
+ {bindings.map((binding) => (
+
+
+
+
+ {binding.name}
+
+
+ handleUnbind(binding.id)}
+ className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
+ title="Remove binding"
+ >
+
+
+
+ ))}
+
+ )}
+
-
-
Add Group Binding
-
- 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.
-
+
+
+ Add Group Binding
+
+
+ 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.
+
-
-
setSelectedChatId(e.target.value)}
- disabled={loading}
- >
- Select a group...
- {availableGroups.map(group => (
-
- {group.name}
-
- ))}
-
-
-
- Add
-
-
- {status && (
-
- {status.message}
-
- )}
-
-
-
- );
+
+
setSelectedChatId(e.target.value)}
+ disabled={loading}
+ >
+ Select a group...
+ {availableGroups.map((group) => (
+
+ {group.name}
+
+ ))}
+
+
+
+ Add
+
+
+ {status && (
+
+ {status.message}
+
+ )}
+
+
+
+ );
}
diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx
index b58e338..b9c0205 100644
--- a/apps/web/src/components/Modal.tsx
+++ b/apps/web/src/components/Modal.tsx
@@ -1,44 +1,58 @@
-import React from 'react';
-import { X } from 'lucide-react';
+import { X } from "lucide-react";
+import type React from "react";
interface ModalProps {
- isOpen: boolean;
- onClose: () => void;
- title: string;
- children: React.ReactNode;
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
}
-export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
- if (!isOpen) return null;
+export default function Modal({
+ isOpen,
+ onClose,
+ title,
+ children,
+}: ModalProps) {
+ if (!isOpen) return null;
- return (
-
-
-
+ return (
+
+
+
-
+
+
+
-
-
-
-
- {title}
-
-
-
-
-
-
- {children}
-
-
-
-
-
- );
+
+
+
+
+ {title}
+
+
+
+
+
+
{children}
+
+
+
+
+ );
}
diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx
index 2f74b8b..db948ee 100644
--- a/apps/web/src/contexts/AuthContext.tsx
+++ b/apps/web/src/contexts/AuthContext.tsx
@@ -1,85 +1,92 @@
-import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
-import { client } from '../lib/client';
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { client } from "../lib/client";
interface User {
- id: string;
- name: string;
- email: string | null;
- isAdmin: boolean;
- personalToken: string;
+ id: string;
+ name: string;
+ email: string | null;
+ isAdmin: boolean;
+ personalToken: string;
}
interface AuthContextType {
- user: User | null;
- loading: boolean;
- login: () => void;
- logout: () => void;
- checkAuth: () => Promise;
+ 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 [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);
- }
- }, []);
+ 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]);
+ 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 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);
- }
- }, []);
+ 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}
-
- );
+ return (
+
+ {children}
+
+ );
}
export function useAuth() {
- const context = useContext(AuthContext);
- if (context === undefined) {
- throw new Error('useAuth must be used within an AuthProvider');
- }
- return context;
+ 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
index 483323e..07d07f9 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -3,12 +3,18 @@
@tailwind utilities;
@layer utilities {
- .animate-fade-in {
- animation: fadeIn 0.5s ease-out forwards;
- }
+ .animate-fade-in {
+ animation: fadeIn 0.5s ease-out forwards;
+ }
}
@keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
+ 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
index 1946f61..51f388a 100644
--- a/apps/web/src/lib/client.ts
+++ b/apps/web/src/lib/client.ts
@@ -1,4 +1,4 @@
-import { hc } from 'hono/client';
-import type { AppType } from '../../../server/src/index';
+import { hc } from "hono/client";
+import type { AppType } from "../../../server/src/index";
-export const client = hc('/') as any;
+export const client = hc("/") as any;
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index f47f434..d51ba04 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,17 +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'
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App.tsx";
+import { AuthProvider } from "./contexts/AuthContext.tsx";
+import AuthCallback from "./views/AuthCallback.tsx";
+import "./index.css";
// Simple routing based on pathname
const pathname = window.location.pathname;
-ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
- {pathname === '/auth/callback' ? : }
-
- ,
-)
+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
index de82f2b..d0616b0 100644
--- a/apps/web/src/views/AdminView.tsx
+++ b/apps/web/src/views/AdminView.tsx
@@ -1,236 +1,285 @@
-import { useState, useEffect } from 'react';
-import { client } from '../lib/client';
-import SystemLoadView from './SystemLoadView';
+import { useEffect, useState } from "react";
+import { client } from "../lib/client";
+import SystemLoadView from "./SystemLoadView";
export default function AdminView() {
- const [activeTab, setActiveTab] = useState('load');
+ const [activeTab, setActiveTab] = useState("load");
- return (
-
-
-
Admin Dashboard
-
+ return (
+
+
+
Admin Dashboard
+
-
-
-
- setActiveTab('load')}
- className={`${activeTab === 'load'
- ? '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`}
- >
- System Load
-
- setActiveTab('requests')}
- className={`${activeTab === '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`}
- >
- Topic Requests
-
- setActiveTab('topics')}
- className={`${activeTab === 'topics'
- ? '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`}
- >
- All Topics
-
-
-
+
+
+
+ setActiveTab("load")}
+ className={`${
+ activeTab === "load"
+ ? "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`}
+ >
+ System Load
+
+ setActiveTab("requests")}
+ className={`${
+ activeTab === "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`}
+ >
+ Topic Requests
+
+ setActiveTab("topics")}
+ className={`${
+ activeTab === "topics"
+ ? "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`}
+ >
+ All Topics
+
+
+
- {activeTab === 'load' &&
}
- {activeTab === 'requests' &&
}
- {activeTab === 'topics' &&
}
-
-
- );
+ {activeTab === "load" &&
}
+ {activeTab === "requests" &&
}
+ {activeTab === "topics" &&
}
+
+
+ );
}
function TopicsManagement() {
- const [topics, setTopics] = useState([]);
- const [loading, setLoading] = useState(true);
+ 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);
- }
- };
+ 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();
- }, []);
+ 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;
- }
+ 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);
- }
- };
+ try {
+ await client.api.topics[":id"].$delete(
+ { param: { id } },
+ { init: { credentials: "include" } },
+ );
+ fetchAllTopics();
+ } catch (error) {
+ console.error(error);
+ }
+ };
- if (loading) return Loading topics...
;
+ if (loading) return Loading topics...
;
- return (
-
-
-
-
- Topic
- Status
- Subscribers
- Created By
- Approved By
- Actions
-
-
-
- {topics.map((topic) => (
-
-
- {topic.name}
- {topic.slug}
-
-
-
- {topic.status}
-
-
-
- {topic.subscriptions?.length || 0}
-
-
- {topic.creator?.name || 'Unknown'}
-
-
- {topic.approver?.name || '-'}
-
-
- handleDelete(topic.id, topic.name)}
- className="text-red-600 hover:text-red-900"
- >
- Delete
-
-
-
- ))}
-
-
-
- );
+ return (
+
+
+
+
+
+ Topic
+
+
+ Status
+
+
+ Subscribers
+
+
+ Created By
+
+
+ Approved By
+
+
+ Actions
+
+
+
+
+ {topics.map((topic) => (
+
+
+
+ {topic.name}
+
+
+ {topic.slug}
+
+
+
+
+ {topic.status}
+
+
+
+ {topic.subscriptions?.length || 0}
+
+
+ {topic.creator?.name || "Unknown"}
+
+
+ {topic.approver?.name || "-"}
+
+
+ handleDelete(topic.id, topic.name)}
+ className="text-red-600 hover:text-red-900"
+ >
+ Delete
+
+
+
+ ))}
+
+
+
+ );
}
function TopicRequestsList() {
- const [requests, setRequests] = useState([]);
- const [loading, setLoading] = useState(true);
+ 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);
- }
- };
+ 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();
- }, []);
+ 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);
- }
- };
+ 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 (loading) return Loading requests...
;
- if (requests.length === 0) {
- return (
-
- No pending topic 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}"
- )}
-
-
- handleAction(req.id, '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
-
- handleAction(req.id, 'reject')}
- className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
- >
- Reject
-
- handleAction(req.id, 'delete', req.name)}
- className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
- >
- Delete
-
-
-
- ))}
-
-
- );
+ 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}"
+
+ )}
+
+
+ handleAction(req.id, "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
+
+ handleAction(req.id, "reject")}
+ className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
+ >
+ Reject
+
+ handleAction(req.id, "delete", req.name)}
+ className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
+ >
+ Delete
+
+
+
+ ))}
+
+
+ );
}
diff --git a/apps/web/src/views/AuthCallback.tsx b/apps/web/src/views/AuthCallback.tsx
index 41bf7da..1a8c777 100644
--- a/apps/web/src/views/AuthCallback.tsx
+++ b/apps/web/src/views/AuthCallback.tsx
@@ -1,76 +1,83 @@
-import { useEffect, useState, useRef } from 'react';
-import { useAuth } from '../contexts/AuthContext';
-import { client } from '../lib/client';
+import { useEffect, useRef, useState } 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);
+ const [error, setError] = useState(null);
+ const { checkAuth } = useAuth();
+ const processed = useRef(false);
- useEffect(() => {
- const handleCallback = async () => {
- if (processed.current) return;
- processed.current = true;
+ 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');
+ 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;
- }
+ 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' }
- });
+ 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);
- }
- };
+ 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]);
+ handleCallback();
+ }, [checkAuth]);
- if (error) {
- return (
-
-
-
Authentication Error
-
{error}
-
window.location.href = '/'}
- className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
- >
- Return to Home
-
-
-
- );
- }
+ if (error) {
+ return (
+
+
+
+ Authentication Error
+
+
{error}
+
(window.location.href = "/")}
+ className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
+ >
+ Return to Home
+
+
+
+ );
+ }
- return (
-
- );
+ return (
+
+
+
+ Authenticating...
+
+
+
+
+ );
}
diff --git a/apps/web/src/views/SystemLoadView.tsx b/apps/web/src/views/SystemLoadView.tsx
index a8fd657..e210c58 100644
--- a/apps/web/src/views/SystemLoadView.tsx
+++ b/apps/web/src/views/SystemLoadView.tsx
@@ -1,291 +1,380 @@
-import { useState, useEffect } from 'react';
-import { client } from '../lib/client';
-import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react';
+import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react";
+import { useEffect, useState } from "react";
+import { client } from "../lib/client";
interface Stats {
- topics: {
- topicSlug: string;
- totalTasks: number;
- totalRecipients: number;
- totalSuccess: number;
- }[];
- recent: {
- alertsReceived: number;
- plannedMessages: number;
- successCount: number;
- failedCount: number;
- successRate: number;
- };
- tasks: any[];
+ 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 [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();
+ 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();
+ // 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);
- }
- };
+ 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);
- }, []);
+ useEffect(() => {
+ fetchStats();
+ const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
+ return () => clearInterval(interval);
+ }, []);
- if (loading) return (
-
- );
+ if (loading)
+ return (
+
+ );
- if (!stats) return Failed to load statistics.
;
+ if (!stats)
+ return (
+
+ Failed to load statistics.
+
+ );
- return (
-
-
-
-
-
-
-
- Live Feedback
-
-
Last updated: {lastUpdated.toLocaleTimeString()}
-
+ 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
-
-
-
+ {/* 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
-
-
-
-
-
-
- Topic
- Alerts (Tasks)
- Planned (Recipients)
- Distributed (Success)
- Health Rate
- Status
-
-
-
- {stats.topics.map((topic) => {
- const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100;
- return (
-
-
-
- {topic.topicSlug || '[Private DM]'}
-
-
- {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'}
-
-
-
- );
- })}
-
-
-
-
+ {/* Middle Row: Topic Message Counts */}
+
+
+
+
+
+ Historical Topic Metrics
+
+
+
+
+
+
+
+ Topic
+
+ Alerts (Tasks)
+
+
+ Planned (Recipients)
+
+
+ Distributed (Success)
+
+
+ Health Rate
+
+ Status
+
+
+
+ {stats.topics.map((topic) => {
+ const rate =
+ topic.totalRecipients > 0
+ ? (topic.totalSuccess / topic.totalRecipients) * 100
+ : 100;
+ return (
+
+
+
+ {topic.topicSlug || "[Private DM]"}
+
+
+
+ {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)
-
-
-
-
-
-
- Time
- Topic
- Sender
- Recipients
- Status
-
-
-
- {stats.tasks.map((task: any) => (
-
-
- {new Date(task.createdAt).toLocaleString()}
-
-
-
- {task.topicSlug || '[Private DM]'}
-
-
-
-
- {task.sender?.name || 'Unknown'}
- {task.sender?.email || 'N/A'}
-
-
-
- {task.successCount} / {task.recipientCount}
-
-
-
- {task.status}
-
-
-
- ))}
- {stats.tasks.length === 0 && (
-
-
- No alerts sent yet.
-
-
- )}
-
-
-
-
-
- );
+ {/* Bottom Row: Recent Alerts with Sender Info */}
+
+
+
+
+
+ Recent Alerts (Audit Log)
+
+
+
+
+
+
+
+ Time
+ Topic
+ Sender
+
+ Recipients
+
+ Status
+
+
+
+ {stats.tasks.map((task: any) => (
+
+
+ {new Date(task.createdAt).toLocaleString()}
+
+
+
+ {task.topicSlug || "[Private DM]"}
+
+
+
+
+
+ {task.sender?.name || "Unknown"}
+
+
+ {task.sender?.email || "N/A"}
+
+
+
+
+ {task.successCount} / {task.recipientCount}
+
+
+
+ {task.status}
+
+
+
+ ))}
+ {stats.tasks.length === 0 && (
+
+
+ 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 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;
+ 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
- };
+ // 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)}%
-
-
- );
+ return (
+
+
+
+
+
+
+
+ {value.toFixed(1)}%
+
+
+
+ );
}
diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx
index 19b3439..367d94b 100644
--- a/apps/web/src/views/TopicsView.tsx
+++ b/apps/web/src/views/TopicsView.tsx
@@ -1,570 +1,722 @@
-import { useState, useEffect } from 'react';
-import { Plus, Settings, UserPlus, UserMinus, Copy, Check, User, ShieldCheck, Users } from 'lucide-react';
-import Modal from '../components/Modal';
-import GroupBindingsModal from '../components/GroupBindingsModal';
-import { useAuth } from '../contexts/AuthContext';
-import { client } from '../lib/client';
+import {
+ Check,
+ Copy,
+ Plus,
+ Settings,
+ ShieldCheck,
+ User,
+ UserMinus,
+ UserPlus,
+ Users,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import GroupBindingsModal from "../components/GroupBindingsModal";
+import Modal from "../components/Modal";
+import { useAuth } from "../contexts/AuthContext";
+import { client } from "../lib/client";
interface User {
- id: string;
- name: string;
- email?: string | null;
+ id: string;
+ name: string;
+ email?: string | null;
}
interface Subscription {
- userId: string;
- user: User;
+ userId: string;
+ user: User;
}
interface Topic {
- id: string;
- name: string;
- slug: string;
- description?: string;
- subscriptions: Subscription[];
- creator?: User;
- approver?: User;
- createdBy?: string;
+ id: string;
+ name: string;
+ slug: string;
+ description?: string;
+ subscriptions: Subscription[];
+ creator?: User;
+ approver?: User;
+ createdBy?: string;
}
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 [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
- const [copiedId, setCopiedId] = useState(null);
+ 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 [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
+ const [copiedId, setCopiedId] = useState(null);
- const [formData, setFormData] = useState>({
- name: '',
- slug: '',
- description: '',
- });
- const [submitStatus, setSubmitStatus] = useState<{ type: 'success' | 'error', message: string } | null>(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 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 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);
- }
- };
+ 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]);
+ 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' }
- });
+ 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.' });
- }
- };
+ 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 handleGroupClick = (topic: Topic) => {
+ setSelectedTopic(topic);
+ setIsGroupModalOpen(true);
+ };
- 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 });
- const handleGroupClick = (topic: Topic) => {
- setSelectedTopic(topic);
- setIsGroupModalOpen(true);
- };
+ 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" },
+ },
+ );
+ }
- const toggleSubscription = async (topicId: string, userId: string, isSubscribed: boolean) => {
- try {
- console.log('Toggling subscription:', { topicId, userId, isSubscribed });
+ // 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;
+ }),
+ );
- 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' }
- });
- }
+ // 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 });
+ }
- // 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;
- })
- );
+ fetchTopics(); // Re-fetch to ensure consistency
+ } catch (error) {
+ console.error("Error toggling subscription:", error);
+ }
+ };
- // 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 });
- }
+ const isSubscribed = (topic: Topic) => {
+ return topic.subscriptions.some((sub) => sub.userId === currentUser?.id);
+ };
- fetchTopics(); // Re-fetch to ensure consistency
- } catch (error) {
- console.error('Error toggling subscription:', error);
- }
- };
+ const handleSelfSubscribe = async (topic: Topic) => {
+ if (!currentUser) return;
+ const subscribed = isSubscribed(topic);
+ await toggleSubscription(topic.id, currentUser.id, subscribed);
+ };
- const isSubscribed = (topic: Topic) => {
- return topic.subscriptions.some(sub => sub.userId === currentUser?.id);
- };
+ const copyToClipboard = (text: string, topicId: string) => {
+ navigator.clipboard.writeText(text);
+ setCopiedId(topicId);
+ setTimeout(() => setCopiedId(null), 2000);
+ };
- const handleSelfSubscribe = async (topic: Topic) => {
- if (!currentUser) return;
- const subscribed = isSubscribed(topic);
- await toggleSubscription(topic.id, currentUser.id, subscribed);
- };
+ 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}`;
+ };
- const copyToClipboard = (text: string, topicId: string) => {
- navigator.clipboard.writeText(text);
- setCopiedId(topicId);
- setTimeout(() => setCopiedId(null), 2000);
- };
+ const getDmWebhookUrl = () => {
+ if (!currentUser?.personalToken) return "";
+ const baseUrl = (
+ (import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin
+ ).replace(/\/$/, "");
+ return `${baseUrl}/webhook/${currentUser.personalToken}/dm`;
+ };
- 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...
;
- const getDmWebhookUrl = () => {
- if (!currentUser?.personalToken) return '';
- const baseUrl = ((import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin).replace(/\/$/, '');
- return `${baseUrl}/webhook/${currentUser.personalToken}/dm`;
- };
+ return (
+
+
+
+
+
How it works?
+
+
+
+ Subscribe: Click{" "}
+
+ Subscribe
+ {" "}
+ on any topic to start receiving alerts via Feishu private
+ message.
+
+
+ Personal Webhook: Use topic-specific URLs to
+ notify all subscribers, or use your{" "}
+
+ Personal Inbox
+ {" "}
+ to notify only yourself.
+
+
+ Need more? If you can't find a suitable
+ topic, click{" "}
+ Request Topic to ask
+ admins for a new one.
+
+
+
+
+
+
- if (loading) return
Loading...
;
+
+
+
+
Personal Inbox
+
+
+
+
+
+ Your private alert endpoint. No topic required.
+
+
+
+
+ Inbox Webhook URL
+
+
+ copyToClipboard(getDmWebhookUrl(), "personal-dm")
+ }
+ className="flex items-center text-xs hover:text-indigo-200 transition-colors"
+ >
+ {copiedId === "personal-dm" ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy URL
+ >
+ )}
+
+
+
+ {getDmWebhookUrl()}
+
+
+
+
+
+
+
+
+
Direct Push
+
+ Always delivered to you
+
+
+
+
+
+
- return (
-
-
-
-
-
How it works?
-
-
- Subscribe: Click Subscribe on any topic to start receiving alerts via Feishu private message.
- Personal Webhook: Use topic-specific URLs to notify all subscribers, or use your Personal Inbox to notify only yourself.
- Need more? If you can't find a suitable topic, click Request Topic to ask admins for a new one.
-
-
-
-
-
+
+
Topics
+
+ {currentUser && (
+
setIsModalOpen(true)}
+ className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
+ >
+
+ {currentUser.isAdmin ? "Add Topic" : "Request Topic"}
+
+ )}
+
+
-
-
-
-
Personal Inbox
-
-
-
-
-
Your private alert endpoint. No topic required.
-
-
- Inbox Webhook URL
- copyToClipboard(getDmWebhookUrl(), 'personal-dm')}
- className="flex items-center text-xs hover:text-indigo-200 transition-colors"
- >
- {copiedId === 'personal-dm' ? (
- <>
-
- Copied!
- >
- ) : (
- <>
-
- Copy URL
- >
- )}
-
-
-
- {getDmWebhookUrl()}
-
-
-
-
-
-
-
-
-
Direct Push
-
Always delivered to you
-
-
-
-
-
+
+
+ {topics.map((topic) => (
+
+
+
+
+
+
+ {topic.name}
+
+
+ handleSelfSubscribe(topic)}
+ className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${
+ isSubscribed(topic)
+ ? "border-red-300 text-red-700 bg-red-50 hover:bg-red-100"
+ : "border-green-300 text-green-700 bg-green-50 hover:bg-green-100"
+ }`}
+ >
+ {isSubscribed(topic) ? (
+ <>
+
+ Unsubscribe
+ >
+ ) : (
+ <>
+
+ Subscribe
+ >
+ )}
+
+ {currentUser &&
+ (currentUser.isAdmin ||
+ currentUser.id === topic.createdBy) && (
+ <>
+ {currentUser.isAdmin && (
+ handleSubscriptionClick(topic)}
+ className="text-gray-400 hover:text-gray-500"
+ title="Manage Subscriptions"
+ >
+
+
+ )}
+ handleGroupClick(topic)}
+ className="text-gray-400 hover:text-gray-500"
+ title="Manage Group Chats"
+ >
+
+
+ >
+ )}
+
+
+
+
+
+ Slug:{" "}
+
+ {topic.slug}
+
+
+
+ {topic.description}
+
+
+ {topic.creator && (
+
+
+
+ Created by:{" "}
+
+ {topic.creator.name}
+
+
+
+ )}
+ {topic.approver && (
+
+
+
+ Approved by:{" "}
+
+ {topic.approver.name}
+
+
+
+ )}
+
+ {currentUser && (
+
+
+
+ Your Personal Webhook
+
+
+ copyToClipboard(
+ getWebhookUrl(topic.slug),
+ topic.id,
+ )
+ }
+ className="text-indigo-600 hover:text-indigo-800 flex items-center text-xs font-medium"
+ >
+ {copiedId === topic.id ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy URL
+ >
+ )}
+
+
+
+ {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."}
+
+
+
+ )}
+
+
-
-
Topics
-
- {currentUser && (
-
setIsModalOpen(true)}
- className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
- >
-
- {currentUser.isAdmin ? 'Add Topic' : 'Request Topic'}
-
- )}
-
-
+ {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()}
+ {req.approver && (
+
+ | Approved by: {req.approver.name}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
setIsModalOpen(false)}
+ title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"}
+ >
+
+
+
setIsSubModalOpen(false)}
+ title={`Manage Subscribers for ${selectedTopic?.name}`}
+ >
+
+
+ Select users who should receive alerts for this topic.
+
+
+ {users.map((user) => {
+ const isSubscribed = selectedTopic?.subscriptions.some(
+ (s) => s.userId === user.id,
+ );
+ return (
+
+
+ selectedTopic &&
+ toggleSubscription(
+ selectedTopic.id,
+ user.id,
+ isSubscribed || false,
+ )
+ }
+ />
+
+ {user.name}{" "}
+
+ ({user.email})
+
+
+
+ );
+ })}
+ {users.length === 0 && (
+
No users available.
+ )}
+
+
+ setIsSubModalOpen(false)}
+ className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
+ >
+ Close
+
+
+
+
-
-
- {topics.map((topic) => (
-
-
-
-
-
-
{topic.name}
-
- handleSelfSubscribe(topic)}
- className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${isSubscribed(topic)
- ? 'border-red-300 text-red-700 bg-red-50 hover:bg-red-100'
- : 'border-green-300 text-green-700 bg-green-50 hover:bg-green-100'
- }`}
- >
- {isSubscribed(topic) ? (
- <>
-
- Unsubscribe
- >
- ) : (
- <>
-
- Subscribe
- >
- )}
-
- {currentUser && (currentUser.isAdmin || currentUser.id === topic.createdBy) && (
- <>
- {currentUser.isAdmin && (
- handleSubscriptionClick(topic)}
- className="text-gray-400 hover:text-gray-500"
- title="Manage Subscriptions"
- >
-
-
- )}
- handleGroupClick(topic)}
- className="text-gray-400 hover:text-gray-500"
- title="Manage Group Chats"
- >
-
-
- >
- )}
-
-
-
-
-
- Slug: {topic.slug}
-
-
- {topic.description}
-
-
- {topic.creator && (
-
-
- Created by: {topic.creator.name}
-
- )}
- {topic.approver && (
-
-
- Approved by: {topic.approver.name}
-
- )}
-
- {currentUser && (
-
-
- Your Personal Webhook
- copyToClipboard(getWebhookUrl(topic.slug), topic.id)}
- className="text-indigo-600 hover:text-indigo-800 flex items-center text-xs font-medium"
- >
- {copiedId === topic.id ? (
- <>
-
- Copied!
- >
- ) : (
- <>
-
- Copy URL
- >
- )}
-
-
-
- {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()}
- {req.approver && | Approved by: {req.approver.name} }
-
-
-
-
-
-
- ))}
-
-
-
- )}
-
-
setIsModalOpen(false)}
- title={currentUser?.isAdmin ? "Add New Topic" : "Request New Topic"}
- >
-
-
- Name
- setFormData({ ...formData, name: e.target.value })}
- />
-
-
- Slug (Unique ID)
- setFormData({ ...formData, slug: e.target.value })}
- />
-
-
- Description
- setFormData({ ...formData, description: e.target.value })}
- />
-
- {submitStatus && (
-
- {submitStatus.message}
-
- )}
-
- setIsModalOpen(false)}
- className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
- >
- Cancel
-
-
- Create Topic
-
-
-
-
-
-
setIsSubModalOpen(false)}
- title={`Manage Subscribers for ${selectedTopic?.name}`}
- >
-
-
Select users who should receive alerts for this topic.
-
- {users.map(user => {
- const isSubscribed = selectedTopic?.subscriptions.some(s => s.userId === user.id);
- return (
-
- selectedTopic && toggleSubscription(selectedTopic.id, user.id, isSubscribed || false)}
- />
-
- {user.name} ({user.email})
-
-
- );
- })}
- {users.length === 0 &&
No users available.
}
-
-
- setIsSubModalOpen(false)}
- className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
- >
- Close
-
-
-
-
-
- {selectedTopic && (
-
setIsGroupModalOpen(false)}
- topicId={selectedTopic.id}
- topicName={selectedTopic.name}
- />
- )}
-
- );
+ {selectedTopic && (
+
setIsGroupModalOpen(false)}
+ topicId={selectedTopic.id}
+ topicName={selectedTopic.name}
+ />
+ )}
+
+ );
}
-
-
diff --git a/apps/web/src/views/UsersView.tsx b/apps/web/src/views/UsersView.tsx
index 3f79f7a..168bc10 100644
--- a/apps/web/src/views/UsersView.tsx
+++ b/apps/web/src/views/UsersView.tsx
@@ -1,192 +1,218 @@
-import { useState, useEffect } from 'react';
-import { Trash2, Plus } from 'lucide-react';
-import Modal from '../components/Modal';
-import { useAuth } from '../contexts/AuthContext';
-import { client } from '../lib/client';
+import { Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import Modal from "../components/Modal";
+import { useAuth } from "../contexts/AuthContext";
+import { client } from "../lib/client";
interface User {
- id: string;
- name: string;
- feishuUserId?: string;
- email?: string;
- personalToken?: string;
+ id: string;
+ name: string;
+ feishuUserId?: string;
+ email?: string;
+ personalToken?: string;
}
export default function UsersView() {
- const { user: currentUser } = useAuth();
- const [users, setUsers] = useState([]);
- const [loading, setLoading] = useState(true);
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [formData, setFormData] = useState>({
- name: '',
- feishuUserId: '',
- email: '',
- });
+ const { user: currentUser } = useAuth();
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [formData, setFormData] = useState>({
+ name: "",
+ feishuUserId: "",
+ email: "",
+ });
- const fetchUsers = async () => {
- setLoading(true);
- try {
- const res = await client.api.users.$get(undefined, {
- init: { credentials: 'include' }
- });
- const data = await res.json();
- setUsers(data as unknown as User[]);
- setLoading(false);
- } catch (err) {
- console.error(err);
- setLoading(false);
- }
- };
+ const fetchUsers = async () => {
+ setLoading(true);
+ try {
+ const res = await client.api.users.$get(undefined, {
+ init: { credentials: "include" },
+ });
+ const data = await res.json();
+ setUsers(data as unknown as User[]);
+ setLoading(false);
+ } catch (err) {
+ console.error(err);
+ setLoading(false);
+ }
+ };
- useEffect(() => {
- fetchUsers();
- }, []);
+ useEffect(() => {
+ fetchUsers();
+ }, []);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- try {
- const res = await client.api.users.$post({
- json: formData as any
- }, {
- init: { credentials: 'include' }
- });
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ const res = await client.api.users.$post(
+ {
+ json: formData as any,
+ },
+ {
+ init: { credentials: "include" },
+ },
+ );
- if (res.ok) {
- setIsModalOpen(false);
- setFormData({ name: '', feishuUserId: '', email: '' });
- fetchUsers();
- }
- } catch (error) {
- console.error('Error creating user:', error);
- }
- };
+ if (res.ok) {
+ setIsModalOpen(false);
+ setFormData({ name: "", feishuUserId: "", email: "" });
+ fetchUsers();
+ }
+ } catch (error) {
+ console.error("Error creating user:", error);
+ }
+ };
- const handleDelete = async (id: string) => {
- if (!confirm('Are you sure you want to delete this user?')) return;
- try {
- await client.api.users[':id'].$delete({
- param: { id }
- }, {
- init: { credentials: 'include' }
- });
- fetchUsers();
- } catch (error) {
- console.error('Error deleting user:', error);
- }
- };
+ const handleDelete = async (id: string) => {
+ if (!confirm("Are you sure you want to delete this user?")) return;
+ try {
+ await client.api.users[":id"].$delete(
+ {
+ param: { id },
+ },
+ {
+ init: { credentials: "include" },
+ },
+ );
+ fetchUsers();
+ } catch (error) {
+ console.error("Error deleting user:", error);
+ }
+ };
- if (loading) return Loading...
;
+ if (loading) return Loading...
;
- return (
-
-
-
Users
- {currentUser?.isAdmin && (
-
setIsModalOpen(true)}
- className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
- >
-
- Add User
-
- )}
-
+ return (
+
+
+
Users
+ {currentUser?.isAdmin && (
+
setIsModalOpen(true)}
+ className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
+ >
+
+ Add User
+
+ )}
+
-
-
- {users.map((user) => (
-
-
-
-
-
{user.name}
-
-
-
- Feishu ID: {user.feishuUserId || 'N/A'}
-
-
- Email: {user.email || 'N/A'}
-
-
- Personal Token: {user.personalToken || 'N/A'}
-
-
-
-
- {currentUser?.isAdmin && (
-
- handleDelete(user.id)}
- className="text-red-600 hover:text-red-900 p-2"
- >
-
-
-
- )}
-
-
-
- ))}
- {users.length === 0 && (
-
- No users found. Create one to get started.
-
- )}
-
-
+
+
+ {users.map((user) => (
+
+
+
+
+
+ {user.name}
+
+
+
+
+ Feishu ID:{" "}
+
+ {user.feishuUserId || "N/A"}
+
+
+
+ Email: {user.email || "N/A"}
+
+
+ Personal Token:{" "}
+
+ {user.personalToken || "N/A"}
+
+
+
+
+
+ {currentUser?.isAdmin && (
+
+ handleDelete(user.id)}
+ className="text-red-600 hover:text-red-900 p-2"
+ >
+
+
+
+ )}
+
+
+
+ ))}
+ {users.length === 0 && (
+
+ No users found. Create one to get started.
+
+ )}
+
+
-
setIsModalOpen(false)}
- title="Add New User"
- >
-
-
- Name
- setFormData({ ...formData, name: e.target.value })}
- />
-
-
- Feishu User ID
- setFormData({ ...formData, feishuUserId: e.target.value })}
- />
-
-
- Email
- setFormData({ ...formData, email: e.target.value })}
- />
-
-
- setIsModalOpen(false)}
- className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
- >
- Cancel
-
-
- Create User
-
-
-
-
-
- );
+
setIsModalOpen(false)}
+ title="Add New User"
+ >
+
+
+
+ Name
+
+
+ setFormData({ ...formData, name: e.target.value })
+ }
+ />
+
+
+
+ Feishu User ID
+
+
+ setFormData({ ...formData, feishuUserId: e.target.value })
+ }
+ />
+
+
+
+ Email
+
+
+ setFormData({ ...formData, email: e.target.value })
+ }
+ />
+
+
+ setIsModalOpen(false)}
+ className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
+ >
+ Cancel
+
+
+ Create User
+
+
+
+
+
+ );
}
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
index dca8ba0..f7de5e9 100644
--- a/apps/web/tailwind.config.js
+++ b/apps/web/tailwind.config.js
@@ -1,11 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
- content: [
- "./index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
- theme: {
- extend: {},
- },
- plugins: [],
-}
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 5d7c3f5..300e11c 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,30 +1,30 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
- /* Linting */
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true,
-
- "baseUrl": ".",
- "paths": {
- "@/*": ["./src/*"]
- }
- },
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
}
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
index 42872c5..eca6668 100644
--- a/apps/web/tsconfig.node.json
+++ b/apps/web/tsconfig.node.json
@@ -1,10 +1,10 @@
{
- "compilerOptions": {
- "composite": true,
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "bundler",
- "allowSyntheticDefaultImports": true
- },
- "include": ["vite.config.ts"]
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 14e4692..80411de 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,25 +1,25 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-import path from 'path'
+import react from "@vitejs/plugin-react";
+import path from "path";
+import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
- resolve: {
- alias: {
- "@": path.resolve(__dirname, "./src"),
- },
- },
- server: {
- proxy: {
- '/api': {
- target: process.env.VITE_API_URL || 'http://localhost:3000',
- changeOrigin: true,
- },
- '/webhook': {
- target: process.env.VITE_API_URL || 'http://localhost:3000',
- changeOrigin: true,
- }
- }
- }
-})
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ proxy: {
+ "/api": {
+ target: process.env.VITE_API_URL || "http://localhost:3000",
+ changeOrigin: true,
+ },
+ "/webhook": {
+ target: process.env.VITE_API_URL || "http://localhost:3000",
+ changeOrigin: true,
+ },
+ },
+ },
+});
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..06e7db6
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,62 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": false
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "tab"
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "a11y": {
+ "useButtonType": "off",
+ "noStaticElementInteractions": "off",
+ "useKeyWithClickEvents": "off",
+ "noSvgWithoutTitle": "off",
+ "noLabelWithoutControl": "off"
+ },
+ "suspicious": {
+ "noAssignInExpressions": "off",
+ "noExplicitAny": "off",
+ "noImplicitAnyLet": "off",
+ "noRedeclare": "off",
+ "noUnknownAtRules": "off"
+ },
+ "style": {
+ "noNonNullAssertion": "off",
+ "useNodejsImportProtocol": "off"
+ },
+ "correctness": {
+ "useExhaustiveDependencies": "off",
+ "noUnusedVariables": "off",
+ "noUnusedImports": "off"
+ }
+ }
+ },
+ "css": {
+ "linter": {
+ "enabled": true
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "assist": {
+ "enabled": true,
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 2fee0cb..adb9b83 100644
--- a/package.json
+++ b/package.json
@@ -1,15 +1,18 @@
{
- "name": "alertmessagecenter",
- "version": "1.1.1",
- "workspaces": [
- "apps/*"
- ],
- "scripts": {
- "dev": "bun run --filter '*' dev",
- "build": "bun run --filter '*' build",
- "start": "bun run --filter '@alertmessagecenter/server' start"
- },
- "devDependencies": {
- "bun-types": "latest"
- }
-}
\ No newline at end of file
+ "name": "alertmessagecenter",
+ "version": "1.1.1",
+ "workspaces": [
+ "apps/*"
+ ],
+ "scripts": {
+ "dev": "bun run --filter '*' dev",
+ "build": "bun run --filter '*' build",
+ "start": "bun run --filter '@alertmessagecenter/server' start",
+ "lint": "bunx @biomejs/biome lint .",
+ "format": "bunx @biomejs/biome format . --write",
+ "check": "bunx @biomejs/biome check --write ."
+ },
+ "devDependencies": {
+ "bun-types": "latest"
+ }
+}
From d38b75fb667742a759aaa09e23f6f1892f2b83af Mon Sep 17 00:00:00 2001
From: d0zingcat
Date: Wed, 14 Jan 2026 20:21:22 +0800
Subject: [PATCH 2/4] feat: add lint
Signed-off-by: d0zingcat
---
apps/server/scripts/create_request.ts | 3 --
apps/server/src/webhook.ts | 6 +--
apps/web/src/App.tsx | 5 ++
.../web/src/components/GroupBindingsModal.tsx | 31 +++++------
apps/web/src/components/Modal.tsx | 10 ++--
apps/web/src/lib/client.ts | 1 +
apps/web/src/main.tsx | 17 +++---
apps/web/src/views/AdminView.tsx | 21 +++++---
apps/web/src/views/AuthCallback.tsx | 5 +-
apps/web/src/views/SystemLoadView.tsx | 8 +--
apps/web/src/views/TopicsView.tsx | 53 +++++++++++++------
apps/web/src/views/UsersView.tsx | 28 +++++++---
apps/web/vite.config.ts | 2 +-
biome.json | 45 ++++++++++------
14 files changed, 151 insertions(+), 84 deletions(-)
diff --git a/apps/server/scripts/create_request.ts b/apps/server/scripts/create_request.ts
index 5b0ee6e..d0e3c5d 100644
--- a/apps/server/scripts/create_request.ts
+++ b/apps/server/scripts/create_request.ts
@@ -1,6 +1,3 @@
-// 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...");
diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts
index 026490b..375cd89 100644
--- a/apps/server/src/webhook.ts
+++ b/apps/server/src/webhook.ts
@@ -21,7 +21,7 @@ webhook.post("/:token/topic/:slug", async (c) => {
logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: "Invalid personal token" }, 401);
}
- let body;
+ let body: any;
try {
const rawBody = await c.req.text();
logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body");
@@ -248,14 +248,14 @@ webhook.post("/:token/dm", async (c) => {
return c.json({ error: "User has no Feishu ID linked" }, 400);
}
- let body;
+ let body: any;
try {
const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
- } catch (e) {
+ } catch (_e) {
return c.json({ error: "Invalid JSON body" }, 400);
}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 73c0b2b..7d93f92 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -46,6 +46,7 @@ function App() {
Please sign in with Feishu to continue
@@ -73,6 +74,7 @@ function App() {
{user.isAdmin && (
setActiveTab("admin")}
className={`${
activeTab === "admin"
@@ -85,6 +87,7 @@ function App() {
)}
setActiveTab("topics")}
className={`${
activeTab === "topics"
@@ -97,6 +100,7 @@ function App() {
{user.isAdmin && (
setActiveTab("users")}
className={`${
activeTab === "users"
@@ -119,6 +123,7 @@ function App() {
)}
diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx
index 084c19a..bb138aa 100644
--- a/apps/web/src/components/GroupBindingsModal.tsx
+++ b/apps/web/src/components/GroupBindingsModal.tsx
@@ -1,5 +1,5 @@
import { MessageCircle, Plus, Trash2 } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { client } from "../lib/client";
import Modal from "./Modal";
@@ -38,16 +38,7 @@ export default function GroupBindingsModal({
message: string;
} | null>(null);
- useEffect(() => {
- if (isOpen && topicId) {
- fetchBindings();
- fetchKnownGroups();
- setStatus(null);
- setSelectedChatId("");
- }
- }, [isOpen, topicId]);
-
- const fetchBindings = async () => {
+ const fetchBindings = useCallback(async () => {
try {
const res = await client.api.topics[":id"].groups.$get(
{
@@ -62,20 +53,28 @@ export default function GroupBindingsModal({
} catch (err) {
console.error(err);
}
- };
+ }, [topicId]);
- const fetchKnownGroups = async () => {
+ const fetchKnownGroups = useCallback(async () => {
try {
const res = await client.api.groups.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
- // Only verify uniqueness if needed, but here we just list what server returns
setKnownGroups(data as any);
} catch (err) {
console.error(err);
}
- };
+ }, []);
+
+ useEffect(() => {
+ if (isOpen && topicId) {
+ fetchBindings();
+ fetchKnownGroups();
+ setStatus(null);
+ setSelectedChatId("");
+ }
+ }, [isOpen, topicId, fetchBindings, fetchKnownGroups]);
const handleBind = async () => {
if (!selectedChatId) return;
@@ -170,6 +169,7 @@ export default function GroupBindingsModal({
handleUnbind(binding.id)}
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Remove binding"
@@ -206,6 +206,7 @@ export default function GroupBindingsModal({
))}
-
+ onKeyDown={(e) => e.key === "Escape" && onClose()}
+ aria-label="Close modal"
+ />
diff --git a/apps/web/src/lib/client.ts b/apps/web/src/lib/client.ts
index 51f388a..38bc26c 100644
--- a/apps/web/src/lib/client.ts
+++ b/apps/web/src/lib/client.ts
@@ -1,4 +1,5 @@
import { hc } from "hono/client";
import type { AppType } from "../../../server/src/index";
+// biome-ignore lint/suspicious/noExplicitAny: Hono client types can be complex
export const client = hc("/") as any;
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index d51ba04..b49c687 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -8,10 +8,13 @@ import "./index.css";
// Simple routing based on pathname
const pathname = window.location.pathname;
-ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
- {pathname === "/auth/callback" ? : }
-
- ,
-);
+const rootElement = document.getElementById("root");
+if (rootElement) {
+ ReactDOM.createRoot(rootElement).render(
+
+
+ {pathname === "/auth/callback" ? : }
+
+ ,
+ );
+}
diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx
index d0616b0..83b39b9 100644
--- a/apps/web/src/views/AdminView.tsx
+++ b/apps/web/src/views/AdminView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { client } from "../lib/client";
import SystemLoadView from "./SystemLoadView";
@@ -15,6 +15,7 @@ export default function AdminView() {
setActiveTab("load")}
className={`${
activeTab === "load"
@@ -25,6 +26,7 @@ export default function AdminView() {
System Load
setActiveTab("requests")}
className={`${
activeTab === "requests"
@@ -35,6 +37,7 @@ export default function AdminView() {
Topic Requests
setActiveTab("topics")}
className={`${
activeTab === "topics"
@@ -59,7 +62,7 @@ function TopicsManagement() {
const [topics, setTopics] = useState([]);
const [loading, setLoading] = useState(true);
- const fetchAllTopics = async () => {
+ const fetchAllTopics = useCallback(async () => {
setLoading(true);
try {
const res = await client.api.topics.all.$get(undefined, {
@@ -72,11 +75,11 @@ function TopicsManagement() {
} finally {
setLoading(false);
}
- };
+ }, []);
useEffect(() => {
fetchAllTopics();
- }, []);
+ }, [fetchAllTopics]);
const handleDelete = async (id: string, name: string) => {
if (
@@ -160,6 +163,7 @@ function TopicsManagement() {
handleDelete(topic.id, topic.name)}
className="text-red-600 hover:text-red-900"
>
@@ -178,7 +182,7 @@ function TopicRequestsList() {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
- const fetchRequests = async () => {
+ const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const res = await client.api.topics.requests.$get(undefined, {
@@ -191,11 +195,11 @@ function TopicRequestsList() {
} finally {
setLoading(false);
}
- };
+ }, []);
useEffect(() => {
fetchRequests();
- }, []);
+ }, [fetchRequests]);
const handleAction = async (
id: string,
@@ -259,18 +263,21 @@ function TopicRequestsList() {
handleAction(req.id, "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
handleAction(req.id, "reject")}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
>
Reject
handleAction(req.id, "delete", req.name)}
className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
>
diff --git a/apps/web/src/views/AuthCallback.tsx b/apps/web/src/views/AuthCallback.tsx
index 1a8c777..f6871e4 100644
--- a/apps/web/src/views/AuthCallback.tsx
+++ b/apps/web/src/views/AuthCallback.tsx
@@ -60,7 +60,10 @@ export default function AuthCallback() {
{error}
(window.location.href = "/")}
+ type="button"
+ onClick={() => {
+ window.location.href = "/";
+ }}
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
>
Return to Home
diff --git a/apps/web/src/views/SystemLoadView.tsx b/apps/web/src/views/SystemLoadView.tsx
index e210c58..d96f660 100644
--- a/apps/web/src/views/SystemLoadView.tsx
+++ b/apps/web/src/views/SystemLoadView.tsx
@@ -1,5 +1,5 @@
import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { client } from "../lib/client";
interface Stats {
@@ -24,7 +24,7 @@ export default function SystemLoadView() {
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState(new Date());
- const fetchStats = async () => {
+ const fetchStats = useCallback(async () => {
try {
const res = await client.api.stats.$get(undefined, {
init: { credentials: "include" },
@@ -47,13 +47,13 @@ export default function SystemLoadView() {
} finally {
setLoading(false);
}
- };
+ }, []);
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
return () => clearInterval(interval);
- }, []);
+ }, [fetchStats]);
if (loading)
return (
diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx
index 367d94b..8534d9d 100644
--- a/apps/web/src/views/TopicsView.tsx
+++ b/apps/web/src/views/TopicsView.tsx
@@ -9,13 +9,13 @@ import {
UserPlus,
Users,
} from "lucide-react";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import GroupBindingsModal from "../components/GroupBindingsModal";
import Modal from "../components/Modal";
import { useAuth } from "../contexts/AuthContext";
import { client } from "../lib/client";
-interface User {
+interface TopicUser {
id: string;
name: string;
email?: string | null;
@@ -23,7 +23,7 @@ interface User {
interface Subscription {
userId: string;
- user: User;
+ user: TopicUser;
}
interface Topic {
@@ -32,8 +32,8 @@ interface Topic {
slug: string;
description?: string;
subscriptions: Subscription[];
- creator?: User;
- approver?: User;
+ creator?: TopicUser;
+ approver?: TopicUser;
createdBy?: string;
}
@@ -41,7 +41,7 @@ export default function TopicsView() {
const { user: currentUser } = useAuth();
const [topics, setTopics] = useState([]);
const [myRequests, setMyRequests] = useState([]);
- const [users, setUsers] = useState([]);
+ const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
@@ -59,7 +59,7 @@ export default function TopicsView() {
message: string;
} | null>(null);
- const fetchTopics = async () => {
+ const fetchTopics = useCallback(async () => {
setLoading(true);
try {
const res = await client.api.topics.$get(undefined, {
@@ -72,9 +72,9 @@ export default function TopicsView() {
} finally {
setLoading(false);
}
- };
+ }, []);
- const fetchMyRequests = async () => {
+ const fetchMyRequests = useCallback(async () => {
try {
const res = await client.api.topics["my-requests"].$get(undefined, {
init: { credentials: "include" },
@@ -84,19 +84,19 @@ export default function TopicsView() {
} catch (err) {
console.error(err);
}
- };
+ }, []);
- const fetchUsers = async () => {
+ const fetchUsers = useCallback(async () => {
try {
const res = await client.api.users.$get(undefined, {
init: { credentials: "include" },
});
const data = await res.json();
- setUsers(data as unknown as User[]);
+ setUsers(data as unknown as TopicUser[]);
} catch (err) {
console.error(err);
}
- };
+ }, []);
useEffect(() => {
fetchTopics();
@@ -104,7 +104,7 @@ export default function TopicsView() {
if (currentUser?.isAdmin) {
fetchUsers();
}
- }, [currentUser]);
+ }, [currentUser, fetchMyRequests, fetchTopics, fetchUsers]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -314,6 +314,7 @@ export default function TopicsView() {
Inbox Webhook URL
copyToClipboard(getDmWebhookUrl(), "personal-dm")
}
@@ -357,6 +358,7 @@ export default function TopicsView() {
{currentUser && (
setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
>
@@ -380,6 +382,7 @@ export default function TopicsView() {
handleSelfSubscribe(topic)}
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${
isSubscribed(topic)
@@ -405,6 +408,7 @@ export default function TopicsView() {
<>
{currentUser.isAdmin && (
handleSubscriptionClick(topic)}
className="text-gray-400 hover:text-gray-500"
title="Manage Subscriptions"
@@ -413,6 +417,7 @@ export default function TopicsView() {
)}
handleGroupClick(topic)}
className="text-gray-400 hover:text-gray-500"
title="Manage Group Chats"
@@ -465,6 +470,7 @@ export default function TopicsView() {
Your Personal Webhook
copyToClipboard(
getWebhookUrl(topic.slug),
@@ -584,10 +590,14 @@ export default function TopicsView() {
>
-
+
Name
-
+
Slug (Unique ID)
-
+
Description
@@ -700,6 +718,7 @@ export default function TopicsView() {
setIsSubModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
diff --git a/apps/web/src/views/UsersView.tsx b/apps/web/src/views/UsersView.tsx
index 168bc10..5c3bfc1 100644
--- a/apps/web/src/views/UsersView.tsx
+++ b/apps/web/src/views/UsersView.tsx
@@ -1,5 +1,5 @@
import { Plus, Trash2 } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import Modal from "../components/Modal";
import { useAuth } from "../contexts/AuthContext";
import { client } from "../lib/client";
@@ -23,7 +23,7 @@ export default function UsersView() {
email: "",
});
- const fetchUsers = async () => {
+ const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await client.api.users.$get(undefined, {
@@ -36,11 +36,11 @@ export default function UsersView() {
console.error(err);
setLoading(false);
}
- };
+ }, []);
useEffect(() => {
fetchUsers();
- }, []);
+ }, [fetchUsers]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -89,6 +89,7 @@ export default function UsersView() {
Users
{currentUser?.isAdmin && (
setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
>
@@ -131,6 +132,7 @@ export default function UsersView() {
{currentUser?.isAdmin && (
handleDelete(user.id)}
className="text-red-600 hover:text-red-900 p-2"
>
@@ -157,10 +159,14 @@ export default function UsersView() {
>
-
+
Name
-
+
Feishu User ID
-
+
Email
Date: Wed, 14 Jan 2026 20:22:54 +0800
Subject: [PATCH 3/4] feat: add lint
Signed-off-by: d0zingcat
---
CHANGELOG.md | 12 ++++++++++++
docs/copilot-context.md | 7 ++++++-
todo.md | 1 +
3 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5db873..609c364 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@
本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。
+
+## [1.2.2] - 2026-01-14
+
+### 变更
+- **Linting**: 强化了 Biome 配置,启用了更严格的 `a11y` (可访问性), `suspicious` (可疑代码), `style` (代码规范) 和 `correctness` (正确性) 检查规则。
+- **配置**: 配置 `noUnknownAtRules` 规则以忽略 Tailwind CSS 特有的 At-rules。
+
+### 修复
+- **Web 可访问性**: 为所有按钮添加了显式的 `type="button"` 以符合规范。
+- **语义化/ARAI**: 修正了 `Modal` 背景的交互逻辑,将非语义化的 `div` 替换为 `` 并添加了必要的键盘事件与 ARIA 属性。
+- **Hook 依赖**: 在多个视图中使用了 `useCallback` 来确保 `useEffect` 依赖链的稳定性,解决了 `exhaustive-deps` 警告。
+- **代码健壮性**: 修复了 `main.tsx` 中的 Non-null Assertion 并解决了 `TopicsView` 中的类型重声明冲突。
## [1.2.1] - 2026-01-14
diff --git a/docs/copilot-context.md b/docs/copilot-context.md
index 26320aa..25b0882 100644
--- a/docs/copilot-context.md
+++ b/docs/copilot-context.md
@@ -1,4 +1,4 @@
-# Project Context for GitHub Copilot (v1.2.1)
+# Project Context for GitHub Copilot (v1.2.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.
@@ -194,6 +194,11 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
- **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.
+- **Linter & Formatter**:
+ - Framework: [Biome](https://biomejs.dev/).
+ - **Rules**: Strict configuration for `a11y`, `suspicious`, `style`, and `correctness`.
+ - **Tailwind**: `noUnknownAtRules` is configured to ignore Tailwind directives (`@tailwind`, `@apply`, etc.).
+ - **Enforcement**: CI/CD runs `biome check` to ensure compliance. Avoid use of `as any` unless absolutely necessary (e.g., complex API payloads), in which case `// biome-ignore` should include a rationale.
- **Logging**:
- Framework: `pino`.
- **Structured Log**: Use JSON format for easy parsing and aggregation.
diff --git a/todo.md b/todo.md
index b0ef349..7d251e1 100644
--- a/todo.md
+++ b/todo.md
@@ -26,4 +26,5 @@
- [x] **Auto-Cleanup**: Unbind subscriptions when bot is removed from group.
- [x] **Long Connection**: WebSocket support for intranet deployments.
- [x] **Structured Logging**: Integrated `pino` for better observability.
+- [x] **Linting**: Tightened Biome rules and resolved all a11y/correctness issues.
From 99fa772000ad3710e5129b9ecf5768c15b1eca89 Mon Sep 17 00:00:00 2001
From: d0zingcat
Date: Wed, 14 Jan 2026 20:25:08 +0800
Subject: [PATCH 4/4] feat: add lint during ci
Signed-off-by: d0zingcat
---
.github/workflows/ci.yml | 27 ++++++++++++++++++++++++---
CHANGELOG.md | 1 +
biome.json | 2 +-
3 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d59eddf..22de4c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: Build and Push Docker Images
+name: CI
on:
push:
@@ -11,7 +11,27 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
- build-and-push:
+ lint:
+ name: Lint Check
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Run Biome Check
+ run: bun x @biomejs/biome ci .
+
+ build:
+ name: Build & Push Docker
+ needs: lint
runs-on: ubuntu-latest
permissions:
contents: read
@@ -22,6 +42,7 @@ jobs:
uses: actions/checkout@v4
- name: Log in to the Container registry
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -33,7 +54,7 @@ jobs:
with:
context: .
file: Dockerfile
- push: true
+ push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 609c364..57c690d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
### 变更
- **Linting**: 强化了 Biome 配置,启用了更严格的 `a11y` (可访问性), `suspicious` (可疑代码), `style` (代码规范) 和 `correctness` (正确性) 检查规则。
- **配置**: 配置 `noUnknownAtRules` 规则以忽略 Tailwind CSS 特有的 At-rules。
+- **CI/CD**: 集成 Biome 检查到 GitHub Actions 工作流,确保在所有 Pull Request 中强制执行代码规范检查。
### 修复
- **Web 可访问性**: 为所有按钮添加了显式的 `type="button"` 以符合规范。
diff --git a/biome.json b/biome.json
index 3609c30..88b1ed8 100644
--- a/biome.json
+++ b/biome.json
@@ -72,4 +72,4 @@
}
}
}
-}
\ No newline at end of file
+}