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

- -
-
-
- ) - } + if (!user) { + return ( +
+
+
+ +

+ Alert Message Center +

+

+ Please sign in with Feishu to continue +

+ +
+
+
+ ); + } - return ( -
- -
- {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} -
    - -
  • - ))} -
- )} -
+ return ( + +
+
+

+ Bound Groups +

+ {bindings.length === 0 ? ( +

+ No groups bound to this topic yet. +

+ ) : ( +
    + {bindings.map((binding) => ( +
  • +
    + + + {binding.name} + +
    + +
  • + ))} +
+ )} +
-
-

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. +

-
- - -
- {status && ( -

- {status.message} -

- )} -
-
- - ); +
+ + +
+ {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 ( +
+
+ - + -
-
-
- - -
-
- {children} -
-
-
-
-
- ); +
+
+
+ + +
+
{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

+
-
-
- -
+
+
+ +
- {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 ( -
- - - - - - - - - - - - - {topics.map((topic) => ( - - - - - - - - - ))} - -
TopicStatusSubscribersCreated ByApproved ByActions
-
{topic.name}
-
{topic.slug}
-
- - {topic.status} - - - {topic.subscriptions?.length || 0} - - {topic.creator?.name || 'Unknown'} - - {topic.approver?.name || '-'} - - -
-
- ); + return ( +
+ + + + + + + + + + + + + {topics.map((topic) => ( + + + + + + + + + ))} + +
+ Topic + + Status + + Subscribers + + Created By + + Approved By + + Actions +
+
+ {topic.name} +
+
+ {topic.slug} +
+
+ + {topic.status} + + + {topic.subscriptions?.length || 0} + + {topic.creator?.name || "Unknown"} + + {topic.approver?.name || "-"} + + +
+
+ ); } 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}"

    - )} -
    -
    - - - -
    -
  • - ))} -
-
- ); + return ( +
+
    + {requests.map((req) => ( +
  • +
    +

    {req.name}

    +

    + Slug: {req.slug} +

    +

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

    + {req.description && ( +

    + "{req.description}" +

    + )} +
    +
    + + + +
    +
  • + ))} +
+
+ ); } diff --git a/apps/web/src/views/AuthCallback.tsx b/apps/web/src/views/AuthCallback.tsx 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}

- -
-
- ); - } + if (error) { + return ( +
+
+

+ Authentication Error +

+

{error}

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

Authenticating...

-
-
-
- ); + 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

-
-
-
- - - - - - - - - - - - - {stats.topics.map((topic) => { - const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100; - return ( - - - - - - - - - ); - })} - -
TopicAlerts (Tasks)Planned (Recipients)Distributed (Success)Health RateStatus
- - {topic.topicSlug || '[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 +

+
+
+
+ + + + + + + + + + + + + {stats.topics.map((topic) => { + const rate = + topic.totalRecipients > 0 + ? (topic.totalSuccess / topic.totalRecipients) * 100 + : 100; + return ( + + + + + + + + + ); + })} + +
Topic + Alerts (Tasks) + + Planned (Recipients) + + Distributed (Success) + + Health Rate + Status
+ + {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)

-
-
-
- - - - - - - - - - - - {stats.tasks.map((task: any) => ( - - - - - - - - ))} - {stats.tasks.length === 0 && ( - - - - )} - -
TimeTopicSenderRecipientsStatus
- {new Date(task.createdAt).toLocaleString()} - - - {task.topicSlug || '[Private DM]'} - - -
- {task.sender?.name || 'Unknown'} - {task.sender?.email || 'N/A'} -
-
- {task.successCount} / {task.recipientCount} - - - {task.status} - -
- No alerts sent yet. -
-
-
-
- ); + {/* Bottom Row: Recent Alerts with Sender Info */} +
+
+
+ +

+ Recent Alerts (Audit Log) +

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

{title}

-

{value.toLocaleString()}

-
-
- {description &&

/ {description}

} -
- ); +function 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 + + +
+
+ {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 && ( + + )} +
+
-
-
- -

Personal Inbox

-
-
-
-
-

Your private alert endpoint. No topic required.

-
-
- Inbox Webhook URL - -
-
- {getDmWebhookUrl()} -
-
-
-
-
- -
-
-
Direct Push
-
Always delivered to you
-
-
-
-
-
+
+
    + {topics.map((topic) => ( +
  • +
    +
    +
    +
    +

    + {topic.name} +

    +
    + + {currentUser && + (currentUser.isAdmin || + currentUser.id === topic.createdBy) && ( + <> + {currentUser.isAdmin && ( + + )} + + + )} +
    +
    +
    +
    +

    + Slug:{" "} + + {topic.slug} + +

    +

    + {topic.description} +

    +
    + {topic.creator && ( +
    + + + Created by:{" "} + + {topic.creator.name} + + +
    + )} + {topic.approver && ( +
    + + + Approved by:{" "} + + {topic.approver.name} + + +
    + )} +
    + {currentUser && ( +
    +
    + + Your Personal Webhook + + +
    +
    + {getWebhookUrl(topic.slug)} +
    +
    + )} +
    +
    +
    +
    +
    +
  • + ))} + {topics.length === 0 && ( +
  • +
    +
    + +
    +

    + No topics available yet. +

    +

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

    +
    +
  • + )} +
+
-
-

Topics

-
- {currentUser && ( - - )} -
-
+ {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"} + > +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + /> +
+
+ + + setFormData({ ...formData, slug: e.target.value }) + } + /> +
+
+ +