feat: add lint

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-14 19:36:46 +08:00
parent f414264050
commit 451793f6ce
41 changed files with 3724 additions and 3017 deletions

View File

@@ -1,10 +1,12 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
schema: './src/db/schema.ts', schema: "./src/db/schema.ts",
out: './drizzle', out: "./drizzle",
dialect: 'postgresql', dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center', url:
}, process.env.DATABASE_URL ||
"postgres://postgres:password@localhost:5432/alert_message_center",
},
}); });

View File

@@ -1,27 +1,27 @@
{ {
"name": "@alertmessagecenter/server", "name": "@alertmessagecenter/server",
"version": "1.2.0", "version": "1.2.0",
"scripts": { "scripts": {
"dev": "bun run --env-file .env --watch src/index.ts", "dev": "bun run --env-file .env --watch src/index.ts",
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@hono/zod-validator": "^0.7.6", "@hono/zod-validator": "^0.7.6",
"@larksuiteoapi/node-sdk": "^1.56.1", "@larksuiteoapi/node-sdk": "^1.56.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"hono": "^4.11.3", "hono": "^4.11.3",
"pino": "^10.1.1", "pino": "^10.1.1",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"bun-types": "latest", "bun-types": "latest",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"pino-pretty": "^13.1.3" "pino-pretty": "^13.1.3"
} }
} }

View File

@@ -1,28 +1,30 @@
export { }; export {};
// Simulate admin checking requests // Simulate admin checking requests
async function run() { async function run() {
console.log('Fetching pending topics as admin...'); console.log("Fetching pending topics as admin...");
const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim(); const adminEmail = (process.env.ADMIN_EMAILS || "").split(",")[0].trim();
const res = await fetch('http://localhost:3000/api/topics/requests', { const res = await fetch("http://localhost:3000/api/topics/requests", {
method: 'GET', method: "GET",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Cookie': `session=${encodeURIComponent(JSON.stringify({ Cookie: `session=${encodeURIComponent(
id: 'admin_123', JSON.stringify({
name: 'Admin User', id: "admin_123",
email: adminEmail, name: "Admin User",
isAdmin: true email: adminEmail,
}))}` isAdmin: true,
} }),
}); )}`,
},
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log('Pending topics:', JSON.stringify(data, null, 2)); console.log("Pending topics:", JSON.stringify(data, null, 2));
} else { } else {
console.log('Error:', res.status, await res.text()); console.log("Error:", res.status, await res.text());
} }
} }
run(); run();

View File

@@ -1,28 +1,30 @@
export { }; export {};
async function run() { async function run() {
console.log('Fetching dashboard stats as admin...'); console.log("Fetching dashboard stats as admin...");
const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim(); const adminEmail = (process.env.ADMIN_EMAILS || "").split(",")[0].trim();
const res = await fetch('http://localhost:3000/api/dashboard/stats', { const res = await fetch("http://localhost:3000/api/dashboard/stats", {
method: 'GET', method: "GET",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
// Admin cookie // Admin cookie
'Cookie': `session=${encodeURIComponent(JSON.stringify({ Cookie: `session=${encodeURIComponent(
id: 'admin_123', JSON.stringify({
name: 'Admin User', id: "admin_123",
email: adminEmail, name: "Admin User",
isAdmin: true email: adminEmail,
}))}` isAdmin: true,
} }),
}); )}`,
},
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log('Dashboard Stats:', JSON.stringify(data, null, 2)); console.log("Dashboard Stats:", JSON.stringify(data, null, 2));
} else { } else {
console.log('Error:', res.status, await res.text()); console.log("Error:", res.status, await res.text());
} }
} }
run(); run();

View File

@@ -1,9 +1,10 @@
import { Database } from 'bun:sqlite'; import { Database } from "bun:sqlite";
const db = new Database('dev.db');
const db = new Database("dev.db");
try { try {
const query = db.query("SELECT * FROM topics"); const query = db.query("SELECT * FROM topics");
const topics = query.all(); const topics = query.all();
console.log('Topics:', JSON.stringify(topics, null, 2)); console.log("Topics:", JSON.stringify(topics, null, 2));
} catch (e) { } catch (e) {
console.error('Error querying topics:', e); console.error("Error querying topics:", e);
} }

View File

@@ -1,40 +1,41 @@
// Simulate topic creation // 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 // Let's use fetch directly against the server
async function run() { async function run() {
console.log('Creating pending topic...'); console.log("Creating pending topic...");
const res = await fetch('http://localhost:3000/api/topics', { const res = await fetch("http://localhost:3000/api/topics", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
// We need to bake a cookie. // We need to bake a cookie.
// But we can't easily bake a signed cookie without the secret. // But we can't easily bake a signed cookie without the secret.
// Wait, the cookies are not signed in the strict sense, just set. // Wait, the cookies are not signed in the strict sense, just set.
// But `middleware.ts` parses `JSON.parse(sessionCookie)`. // But `middleware.ts` parses `JSON.parse(sessionCookie)`.
// Let's fake a session cookie for a non-admin user. // Let's fake a session cookie for a non-admin user.
'Cookie': `session=${encodeURIComponent(JSON.stringify({ Cookie: `session=${encodeURIComponent(
id: 'user_123', JSON.stringify({
name: 'Test User', id: "user_123",
email: 'test@example.com', name: "Test User",
isAdmin: false email: "test@example.com",
}))}` isAdmin: false,
}, }),
body: JSON.stringify({ )}`,
name: 'Test Pending Topic', },
slug: 'test-pending', body: JSON.stringify({
description: 'This should be pending' name: "Test Pending Topic",
}) slug: "test-pending",
}); description: "This should be pending",
}),
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
console.log('Created topic:', data); console.log("Created topic:", data);
} else { } else {
console.log('Error:', res.status, await res.text()); console.log("Error:", res.status, await res.text());
} }
} }
run(); run();

View File

@@ -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() { async function run() {
try { try {
// 1. Get a topic // 1. Get a topic
const [topic] = await sql`SELECT * FROM topics LIMIT 1`; const [topic] = await sql`SELECT * FROM topics LIMIT 1`;
if (!topic) { if (!topic) {
console.log('No topics found. Create a topic first.'); console.log("No topics found. Create a topic first.");
return; return;
} }
console.log('Using topic:', topic.id, topic.slug); console.log("Using topic:", topic.id, topic.slug);
// 2. Define a fake user ID // 2. Define a fake user ID
const fakeUserId = 'user_fake_002'; const fakeUserId = "user_fake_002";
// Clean up first // Clean up first
await sql`DELETE FROM subscriptions WHERE user_id = ${fakeUserId}`; await sql`DELETE FROM subscriptions WHERE user_id = ${fakeUserId}`;
await sql`DELETE FROM users WHERE id = ${fakeUserId}`; await sql`DELETE FROM users WHERE id = ${fakeUserId}`;
// 3. Try to subscribe with non-existent user // 3. Try to subscribe with non-existent user
console.log('\n--- Attempt 1: 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}`, { const res1 = await fetch(
method: 'POST', `http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`,
headers: { {
'Cookie': `session=${encodeURIComponent(JSON.stringify({ method: "POST",
id: fakeUserId, headers: {
name: 'Fake User', Cookie: `session=${encodeURIComponent(
email: 'fake@example.com', JSON.stringify({
isAdmin: false 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 },
},
);
console.log("Status:", res1.status);
const text1 = await res1.text();
console.log("Response:", text1); // Expect 500 FK violation
// 4. Create the user // 4. Create the user
console.log('\n--- Creating user... ---'); console.log("\n--- Creating user... ---");
await sql`INSERT INTO users (id, name, feishu_user_id, email, is_admin) await sql`INSERT INTO users (id, name, feishu_user_id, email, is_admin)
VALUES (${fakeUserId}, 'Fake User', 'ou_fake', 'fake2@example.com', false) VALUES (${fakeUserId}, 'Fake User', 'ou_fake', 'fake2@example.com', false)
ON CONFLICT (id) DO NOTHING`; ON CONFLICT (id) DO NOTHING`;
// 5. Try to subscribe again // 5. Try to subscribe again
console.log('\n--- Attempt 2: Subscribe with existing user ---'); console.log("\n--- Attempt 2: Subscribe with existing user ---");
const res2 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, { const res2 = await fetch(
method: 'POST', `http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`,
headers: { {
'Cookie': `session=${encodeURIComponent(JSON.stringify({ method: "POST",
id: fakeUserId, headers: {
name: 'Fake User', Cookie: `session=${encodeURIComponent(
email: 'fake@example.com', JSON.stringify({
isAdmin: false 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); console.log("Status:", res2.status);
} finally { const text2 = await res2.text();
await sql.end(); console.log("Response:", text2); // Expect 200
} } catch (e) {
console.error(e);
} finally {
await sql.end();
}
} }
run(); run();

View File

@@ -1,174 +1,208 @@
import { Hono } from 'hono'; import { zValidator } from "@hono/zod-validator";
import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm'; import { and, count, desc, eq, gt, sql, sum } from "drizzle-orm";
import { z } from 'zod'; import { Hono } from "hono";
import { zValidator } from '@hono/zod-validator'; import { z } from "zod";
import { db } from './db'; import { db } from "./db";
import { topics, users, subscriptions, alertTasks, topicGroupChats, knownGroupChats } from './db/schema'; import {
import { requireAuth, requireAdmin, AuthSession } from './middleware'; alertTasks,
knownGroupChats,
subscriptions,
topicGroupChats,
topics,
users,
} from "./db/schema";
import { type AuthSession, requireAdmin, requireAuth } from "./middleware";
const api = new Hono<{ Variables: { session: AuthSession } }>(); const api = new Hono<{ Variables: { session: AuthSession } }>();
const topicSchema = z.object({ const topicSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
slug: z.string().min(1), slug: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
}); });
const groupBindingSchema = z.object({ const groupBindingSchema = z.object({
chatId: z.string().min(1), chatId: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
}); });
const userSchema = z.object({ const userSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
feishuUserId: z.string().min(1), feishuUserId: z.string().min(1),
email: z.string().email().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal("")),
}); });
// --- Topics --- // --- Topics ---
// --- Topics --- // --- Topics ---
api.get('/topics', requireAuth, async (c) => { api.get("/topics", requireAuth, async (c) => {
const session = c.get('session'); const session = c.get("session");
const isAdmin = session.isAdmin; const isAdmin = session.isAdmin;
const currentUserId = session.id; const currentUserId = session.id;
const allTopics = await db.query.topics.findMany({ const allTopics = await db.query.topics.findMany({
where: eq(topics.status, 'approved'), where: eq(topics.status, "approved"),
with: { with: {
creator: true, creator: true,
approver: true, approver: true,
subscriptions: { subscriptions: {
where: (subscriptions, { eq }) => where: (subscriptions, { eq }) =>
isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined), isAdmin
with: { ? undefined
user: true : currentUserId
} ? eq(subscriptions.userId, currentUserId)
} : undefined,
} with: {
}); user: true,
},
},
},
});
return c.json(allTopics); return c.json(allTopics);
}); });
api.get('/topics/requests', requireAdmin, async (c) => { api.get("/topics/requests", requireAdmin, async (c) => {
const requests = await db.query.topics.findMany({ const requests = await db.query.topics.findMany({
where: eq(topics.status, 'pending'), where: eq(topics.status, "pending"),
with: { with: {
creator: true creator: true,
} },
}); });
return c.json(requests); return c.json(requests);
}); });
api.get('/topics/all', requireAdmin, async (c) => { api.get("/topics/all", requireAdmin, async (c) => {
const allTopics = await db.query.topics.findMany({ const allTopics = await db.query.topics.findMany({
with: { with: {
creator: true, creator: true,
approver: true, approver: true,
subscriptions: true subscriptions: true,
}, },
orderBy: [desc(topics.createdAt)] orderBy: [desc(topics.createdAt)],
}); });
return c.json(allTopics); return c.json(allTopics);
}); });
api.get('/topics/my-requests', requireAuth, async (c) => { api.get("/topics/my-requests", requireAuth, async (c) => {
const session = c.get('session'); const session = c.get("session");
const requests = await db.query.topics.findMany({ const requests = await db.query.topics.findMany({
where: eq(topics.createdBy, session.id), where: eq(topics.createdBy, session.id),
orderBy: [desc(topics.createdAt)], orderBy: [desc(topics.createdAt)],
with: { with: {
approver: true, approver: true,
} },
}); });
return c.json(requests); return c.json(requests);
}); });
api.post('/topics/:id/approve', requireAdmin, async (c) => { api.post("/topics/:id/approve", requireAdmin, async (c) => {
const id = c.req.param('id'); const id = c.req.param("id");
const session = c.get('session'); const session = c.get("session");
const result = await db.update(topics) const result = await db
.set({ status: 'approved', approvedBy: session.id }) .update(topics)
.where(eq(topics.id, id)) .set({ status: "approved", approvedBy: session.id })
.returning(); .where(eq(topics.id, id))
return c.json(result[0]); .returning();
return c.json(result[0]);
}); });
api.post('/topics/:id/reject', requireAdmin, async (c) => { api.post("/topics/:id/reject", requireAdmin, async (c) => {
const id = c.req.param('id'); const id = c.req.param("id");
const result = await db.update(topics) const result = await db
.set({ status: 'rejected' }) .update(topics)
.where(eq(topics.id, id)) .set({ status: "rejected" })
.returning(); .where(eq(topics.id, id))
return c.json(result[0]); .returning();
return c.json(result[0]);
}); });
// Only admins can create topics // Only admins can create topics
// Authenticated users can create topics (requests) // Authenticated users can create topics (requests)
api.post('/topics', requireAuth, zValidator('json', topicSchema), async (c) => { api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
const body = c.req.valid('json'); const body = c.req.valid("json");
const session = c.get('session'); const session = c.get("session");
const status = session.isAdmin ? 'approved' : 'pending'; const status = session.isAdmin ? "approved" : "pending";
const result = await db.insert(topics).values({ const result = await db
...body, .insert(topics)
status, .values({
createdBy: session.id, ...body,
approvedBy: session.isAdmin ? session.id : null, status,
}).returning(); createdBy: session.id,
return c.json(result[0]); approvedBy: session.isAdmin ? session.id : null,
})
.returning();
return c.json(result[0]);
}); });
// Only admins can update topics // Only admins can update topics
api.put('/topics/:id', requireAdmin, zValidator('json', topicSchema.partial()), async (c) => { api.put(
const id = c.req.param('id'); "/topics/:id",
const body = c.req.valid('json'); requireAdmin,
const result = await db.update(topics).set(body).where(eq(topics.id, id)).returning(); zValidator("json", topicSchema.partial()),
return c.json(result[0]); 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 // Only admins can delete topics
api.delete('/topics/:id', requireAdmin, async (c) => { api.delete("/topics/:id", requireAdmin, async (c) => {
const id = c.req.param('id'); const id = c.req.param("id");
await db.delete(topics).where(eq(topics.id, id)); await db.delete(topics).where(eq(topics.id, id));
return c.json({ success: true }); return c.json({ success: true });
}); });
// --- Users --- // --- Users ---
api.get('/users', requireAdmin, async (c) => { api.get("/users", requireAdmin, async (c) => {
const allUsers = await db.query.users.findMany({ const allUsers = await db.query.users.findMany({
with: { with: {
subscriptions: { subscriptions: {
with: { with: {
topic: true topic: true,
} },
} },
} },
}); });
return c.json(allUsers); return c.json(allUsers);
}); });
api.post('/users', requireAdmin, zValidator('json', userSchema), async (c) => { api.post("/users", requireAdmin, zValidator("json", userSchema), async (c) => {
const body = c.req.valid('json'); const body = c.req.valid("json");
const result = await db.insert(users).values(body).returning(); const result = await db.insert(users).values(body).returning();
return c.json(result[0]); return c.json(result[0]);
}); });
api.put('/users/:id', requireAdmin, zValidator('json', userSchema.partial()), async (c) => { api.put(
const id = c.req.param('id'); "/users/:id",
const body = c.req.valid('json'); requireAdmin,
const result = await db.update(users).set(body).where(eq(users.id, id)).returning(); zValidator("json", userSchema.partial()),
return c.json(result[0]); 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) => { api.delete("/users/:id", requireAdmin, async (c) => {
const id = c.req.param('id'); const id = c.req.param("id");
await db.delete(users).where(eq(users.id, id)); await db.delete(users).where(eq(users.id, id));
return c.json({ success: true }); return c.json({ success: true });
}); });
// --- Subscriptions --- // --- Subscriptions ---
@@ -176,159 +210,184 @@ api.delete('/users/:id', requireAdmin, async (c) => {
// --- Subscriptions --- // --- Subscriptions ---
// Users can subscribe themselves or admins can subscribe anyone // Users can subscribe themselves or admins can subscribe anyone
api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => { api.post("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
const { topicId, userId } = c.req.param(); const { topicId, userId } = c.req.param();
const session = c.get('session'); const session = c.get("session");
// Check if user is subscribing themselves or is an admin // Check if user is subscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) { if (session.id !== userId && !session.isAdmin) {
return c.json({ error: 'You can only subscribe yourself' }, 403); return c.json({ error: "You can only subscribe yourself" }, 403);
} }
const result = await db.insert(subscriptions).values({ topicId, userId }).returning(); const result = await db
return c.json(result[0]); .insert(subscriptions)
.values({ topicId, userId })
.returning();
return c.json(result[0]);
}); });
// Users can unsubscribe themselves or admins can unsubscribe anyone // Users can unsubscribe themselves or admins can unsubscribe anyone
api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => { api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
const { topicId, userId } = c.req.param(); const { topicId, userId } = c.req.param();
const session = c.get('session'); const session = c.get("session");
// Check if user is unsubscribing themselves or is an admin // Check if user is unsubscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) { if (session.id !== userId && !session.isAdmin) {
return c.json({ error: 'You can only unsubscribe yourself' }, 403); return c.json({ error: "You can only unsubscribe yourself" }, 403);
} }
await db.delete(subscriptions) await db
.where(and( .delete(subscriptions)
eq(subscriptions.topicId, topicId), .where(
eq(subscriptions.userId, userId) and(eq(subscriptions.topicId, topicId), eq(subscriptions.userId, userId)),
)); );
return c.json({ success: true }); return c.json({ success: true });
}); });
// --- Group Bindings (App Bot) --- // --- Group Bindings (App Bot) ---
// Get list of known groups (for selection) // Get list of known groups (for selection)
api.get('/groups', requireAuth, async (c) => { api.get("/groups", requireAuth, async (c) => {
// Return recent active groups // Return recent active groups
const groups = await db.select().from(knownGroupChats) const groups = await db
.orderBy(desc(knownGroupChats.lastActiveAt)) .select()
.limit(50); .from(knownGroupChats)
return c.json(groups); .orderBy(desc(knownGroupChats.lastActiveAt))
.limit(50);
return c.json(groups);
}); });
// Get bindings for a topic // Get bindings for a topic
api.get('/topics/:id/groups', requireAuth, async (c) => { api.get("/topics/:id/groups", requireAuth, async (c) => {
const topicId = c.req.param('id'); const topicId = c.req.param("id");
const groups = await db.select().from(topicGroupChats) const groups = await db
.where(eq(topicGroupChats.topicId, topicId)) .select()
.orderBy(desc(topicGroupChats.createdAt)); .from(topicGroupChats)
return c.json(groups); .where(eq(topicGroupChats.topicId, topicId))
.orderBy(desc(topicGroupChats.createdAt));
return c.json(groups);
}); });
// Bind a group to a topic // Bind a group to a topic
api.post('/topics/:id/groups', requireAuth, zValidator('json', groupBindingSchema), async (c) => { api.post(
const topicId = c.req.param('id'); "/topics/:id/groups",
const body = c.req.valid('json'); requireAuth,
const session = c.get('session'); 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({ const result = await db
topicId, .insert(topicGroupChats)
chatId: body.chatId, .values({
name: body.name, topicId,
createdBy: session.id, chatId: body.chatId,
}).returning(); name: body.name,
createdBy: session.id,
})
.returning();
return c.json(result[0]); return c.json(result[0]);
}); },
);
// Unbind a group // Unbind a group
api.delete('/topics/:id/groups/:bindingId', requireAuth, async (c) => { api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
const { id: topicId, bindingId } = c.req.param(); const { id: topicId, bindingId } = c.req.param();
await db.delete(topicGroupChats) await db
.where(and( .delete(topicGroupChats)
eq(topicGroupChats.id, bindingId), .where(
eq(topicGroupChats.topicId, topicId) and(
)); eq(topicGroupChats.id, bindingId),
eq(topicGroupChats.topicId, topicId),
),
);
return c.json({ success: true }); return c.json({ success: true });
}); });
// --- Alert Tasks --- // --- Alert Tasks ---
api.get('/alerts/tasks', requireAdmin, async (c) => { api.get("/alerts/tasks", requireAdmin, async (c) => {
const limit = Math.min(Number(c.req.query('limit') || 50), 100); const limit = Math.min(Number(c.req.query("limit") || 50), 100);
const tasks = await db.query.alertTasks.findMany({ const tasks = await db.query.alertTasks.findMany({
orderBy: [desc(alertTasks.createdAt)], orderBy: [desc(alertTasks.createdAt)],
limit, limit,
with: { with: {
sender: true, sender: true,
logs: { logs: {
limit: 10, // Only show first 10 logs inline limit: 10, // Only show first 10 logs inline
} },
} },
}); });
return c.json(tasks); return c.json(tasks);
}); });
api.get('/alerts/tasks/:id', requireAuth, async (c) => { api.get("/alerts/tasks/:id", requireAuth, async (c) => {
const id = c.req.param('id'); const id = c.req.param("id");
const task = await db.query.alertTasks.findFirst({ const task = await db.query.alertTasks.findFirst({
where: eq(alertTasks.id, id), where: eq(alertTasks.id, id),
with: { with: {
sender: true, sender: true,
logs: true // Show all logs for detail view logs: true, // Show all logs for detail view
} },
}); });
if (!task) { if (!task) {
return c.json({ error: 'Task not found' }, 404); return c.json({ error: "Task not found" }, 404);
} }
return c.json(task); return c.json(task);
}); });
// --- Stats --- // --- Stats ---
api.get('/stats', requireAdmin, async (c) => { api.get("/stats", requireAdmin, async (c) => {
// 1. Message count per topic // 1. Message count per topic
const topicStats = await db.select({ const topicStats = await db
topicSlug: alertTasks.topicSlug, .select({
totalTasks: count(), topicSlug: alertTasks.topicSlug,
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`, totalTasks: count(),
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`, totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
}) totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
.from(alertTasks) })
.groupBy(alertTasks.topicSlug); .from(alertTasks)
.groupBy(alertTasks.topicSlug);
// 2. Recent metrics (last 24h) // 2. Recent metrics (last 24h)
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000); const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentStats = await db.select({ const recentStats = await db
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`, .select({
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`, totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
taskCount: count(), totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
}) taskCount: count(),
.from(alertTasks) })
.where(gt(alertTasks.createdAt, last24h)); .from(alertTasks)
.where(gt(alertTasks.createdAt, last24h));
const recent = recentStats[0] || { totalRecipients: 0, totalSuccess: 0, taskCount: 0 }; const recent = recentStats[0] || {
const totalRecipients = Number(recent.totalRecipients || 0); totalRecipients: 0,
const totalSuccess = Number(recent.totalSuccess || 0); totalSuccess: 0,
const failedCount = totalRecipients - totalSuccess; taskCount: 0,
const successRate = totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100; };
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({ return c.json({
topics: topicStats, topics: topicStats,
recent: { recent: {
alertsReceived: Number(recent.taskCount || 0), alertsReceived: Number(recent.taskCount || 0),
plannedMessages: totalRecipients, plannedMessages: totalRecipients,
successCount: totalSuccess, successCount: totalSuccess,
failedCount: failedCount, failedCount: failedCount,
successRate: successRate, successRate: successRate,
} },
}); });
}); });
export default api; export default api;

View File

@@ -1,43 +1,46 @@
import { Hono } from 'hono'; import * as lark from "@larksuiteoapi/node-sdk";
import * as lark from '@larksuiteoapi/node-sdk'; import { Hono } from "hono";
import { eventDispatcher } from '../event-handler'; import { eventDispatcher } from "../event-handler";
const feishuEvent = new Hono(); const feishuEvent = new Hono();
// Helper to adapt Hono request to Lark SDK request // Helper to adapt Hono request to Lark SDK request
feishuEvent.post('/', async (c) => { feishuEvent.post("/", async (c) => {
try { try {
const headers = c.req.raw.headers; const headers = c.req.raw.headers;
const headerRecord: Record<string, string> = {}; const headerRecord: Record<string, string> = {};
headers.forEach((value, key) => { headers.forEach((value, key) => {
headerRecord[key] = value; headerRecord[key] = value;
}); });
const body = await c.req.json(); const body = await c.req.json();
// Use the official SDK functions directly for Hono compatibility // Use the official SDK functions directly for Hono compatibility
// 1. Handle URL verification (Challenge) // 1. Handle URL verification (Challenge)
const { isChallenge, challenge } = lark.generateChallenge(body, { const { isChallenge, challenge } = lark.generateChallenge(body, {
encryptKey: process.env.FEISHU_ENCRYPT_KEY || '' encryptKey: process.env.FEISHU_ENCRYPT_KEY || "",
}); });
if (isChallenge) { if (isChallenge) {
return c.json(challenge); return c.json(challenge);
} }
// 2. Dispatch event // 2. Dispatch event
// The dispatcher expects an object containing headers and body. // The dispatcher expects an object containing headers and body.
// We use Object.create to put headers on the prototype so they are accessible // We use Object.create to put headers on the prototype so they are accessible
// but not included in JSON.stringify, which preserves signature verification. // but not included in JSON.stringify, which preserves signature verification.
const payload = Object.assign(Object.create({ headers: headerRecord }), body); const payload = Object.assign(
const result = await eventDispatcher.invoke(payload); Object.create({ headers: headerRecord }),
body,
);
const result = await eventDispatcher.invoke(payload);
return c.json(result || {}); return c.json(result || {});
} catch (e) { } catch (e) {
console.error('[Feishu Event] Error:', e); console.error("[Feishu Event] Error:", e);
return c.json({ error: 'Internal Server Error' }, 500); return c.json({ error: "Internal Server Error" }, 500);
} }
}); });
export default feishuEvent; export default feishuEvent;

View File

@@ -1,142 +1,156 @@
import { Hono } from 'hono'; import { eq } from "drizzle-orm";
import { logger } from './lib/logger'; import { Hono } from "hono";
import { setCookie, getCookie } from 'hono/cookie'; import { getCookie, setCookie } from "hono/cookie";
import { db } from './db'; import { db } from "./db";
import { users } from './db/schema'; import { users } from "./db/schema";
import { eq } from 'drizzle-orm'; import { feishuClient } from "./feishu";
import { feishuClient } from './feishu'; import { logger } from "./lib/logger";
const auth = new Hono(); 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 // Get the login URL for frontend to redirect
auth.get('/login-url', (c) => { auth.get("/login-url", (c) => {
const appId = process.env.FEISHU_APP_ID; const appId = process.env.FEISHU_APP_ID;
const redirectUri = encodeURIComponent(process.env.REDIRECT_URI || 'http://localhost:5173/auth/callback'); const redirectUri = encodeURIComponent(
const state = crypto.randomUUID(); process.env.REDIRECT_URI || "http://localhost:5173/auth/callback",
);
const state = crypto.randomUUID();
// Store state in cookie for CSRF protection // Store state in cookie for CSRF protection
setCookie(c, 'oauth_state', state, { setCookie(c, "oauth_state", state, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === "production",
maxAge: 600, // 10 minutes maxAge: 600, // 10 minutes
sameSite: 'Lax', 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 // Handle OAuth callback
auth.get('/callback', async (c) => { auth.get("/callback", async (c) => {
const code = c.req.query('code'); const code = c.req.query("code");
const state = c.req.query('state'); const state = c.req.query("state");
const storedState = getCookie(c, 'oauth_state'); const storedState = getCookie(c, "oauth_state");
// Verify state for CSRF protection // Verify state for CSRF protection
if (!state || state !== storedState) { if (!state || state !== storedState) {
return c.json({ error: 'Invalid state parameter' }, 400); return c.json({ error: "Invalid state parameter" }, 400);
} }
if (!code) { if (!code) {
return c.json({ error: 'No code provided' }, 400); return c.json({ error: "No code provided" }, 400);
} }
try { try {
// Exchange code for user access token and user info // Exchange code for user access token and user info
const userData = await feishuClient.getUserAccessToken(code); const userData = await feishuClient.getUserAccessToken(code);
// Check if user exists, otherwise create // Check if user exists, otherwise create
let user = await db.query.users.findFirst({ let user = await db.query.users.findFirst({
where: eq(users.feishuUserId, userData.open_id), where: eq(users.feishuUserId, userData.open_id),
}); });
const isAdmin = ADMIN_EMAILS.includes(userData.email || ''); const isAdmin = ADMIN_EMAILS.includes(userData.email || "");
if (!user) { if (!user) {
// Create new user // Create new user
const result = await db.insert(users).values({ const result = await db
name: userData.name, .insert(users)
feishuUserId: userData.open_id, .values({
email: userData.email || null, name: userData.name,
isAdmin, feishuUserId: userData.open_id,
}).returning(); email: userData.email || null,
user = result[0]; isAdmin,
} else { })
// Update user info (in case name or admin status changed) .returning();
const result = await db.update(users) user = result[0];
.set({ } else {
name: userData.name, // Update user info (in case name or admin status changed)
email: userData.email || user.email, const result = await db
isAdmin, .update(users)
}) .set({
.where(eq(users.id, user.id)) name: userData.name,
.returning(); email: userData.email || user.email,
user = result[0]; isAdmin,
} })
.where(eq(users.id, user.id))
.returning();
user = result[0];
}
// Set session cookie // Set session cookie
setCookie(c, 'session', JSON.stringify({ setCookie(
id: user.id, c,
name: user.name, "session",
email: user.email, JSON.stringify({
isAdmin: user.isAdmin, id: user.id,
personalToken: user.personalToken, name: user.name,
}), { email: user.email,
httpOnly: true, isAdmin: user.isAdmin,
secure: process.env.NODE_ENV === 'production', personalToken: user.personalToken,
maxAge: 60 * 60 * 24 * 7, // 7 days }),
sameSite: 'Lax', {
}); httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: "Lax",
},
);
return c.json({ return c.json({
success: true, success: true,
user: { user: {
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
}, },
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, 'OAuth callback error'); logger.error({ err: error }, "OAuth callback error");
return c.json({ error: 'Authentication failed' }, 500); return c.json({ error: "Authentication failed" }, 500);
} }
}); });
// Get current user from session // Get current user from session
auth.get('/me', (c) => { auth.get("/me", (c) => {
const sessionCookie = getCookie(c, 'session'); const sessionCookie = getCookie(c, "session");
if (!sessionCookie) { if (!sessionCookie) {
return c.json({ error: 'Not authenticated' }, 401); return c.json({ error: "Not authenticated" }, 401);
} }
try { try {
const session = sessionCookie ? JSON.parse(sessionCookie) : null; const session = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session) { if (!session) {
return c.json({ error: 'Not authenticated' }, 401); return c.json({ error: "Not authenticated" }, 401);
} }
// Normalize user object to ensure id is present (handle legacy session with userId) // Normalize user object to ensure id is present (handle legacy session with userId)
const user = { const user = {
...session, ...session,
id: session.id || session.userId, id: session.id || session.userId,
}; };
return c.json({ user }); return c.json({ user });
} catch (error) { } catch (error) {
logger.error({ err: error }, '[Auth] Failed to parse session cookie'); logger.error({ err: error }, "[Auth] Failed to parse session cookie");
return c.json({ error: 'Invalid session' }, 401); return c.json({ error: "Invalid session" }, 401);
} }
}); });
// Logout // Logout
auth.post('/logout', (c) => { auth.post("/logout", (c) => {
setCookie(c, 'session', '', { setCookie(c, "session", "", {
maxAge: 0, maxAge: 0,
}); });
return c.json({ success: true }); return c.json({ success: true });
}); });
export default auth; export default auth;

View File

@@ -1,7 +1,9 @@
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from 'postgres'; import postgres from "postgres";
import * as schema from './schema'; 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); const client = postgres(connectionString);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });

View File

@@ -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", // Topics: 类似于 Kafka 的 Topic 或 告警的 Tag例如 "payment-service",
export const topics = pgTable('topics', { export const topics = pgTable("topics", {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
slug: text('slug').notNull().unique(), // 告警发送时使用的 key .primaryKey()
name: text('name').notNull(), .$defaultFn(() => crypto.randomUUID()),
description: text('description'), slug: text("slug").notNull().unique(), // 告警发送时使用的 key
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).default('approved').notNull(), name: text("name").notNull(),
createdBy: text('created_by').references(() => users.id), description: text("description"),
approvedBy: text('approved_by').references(() => users.id), status: text("status", { enum: ["pending", "approved", "rejected"] })
createdAt: timestamp('created_at').defaultNow().notNull(), .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 所在的群绑定 // Group Chats: App Bot 所在的群绑定
export const topicGroupChats = pgTable('topic_group_chats', { export const topicGroupChats = pgTable("topic_group_chats", {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }), .primaryKey()
chatId: text('chat_id').notNull(), // 飞书群 chat_id .$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(), // 群名称快照 topicId: text("topic_id")
createdAt: timestamp('created_at').defaultNow().notNull(), .notNull()
createdBy: text('created_by').references(() => users.id), .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 }) => ({ export const topicGroupChatsRelations = relations(
topic: one(topics, { topicGroupChats,
fields: [topicGroupChats.topicId], ({ one }) => ({
references: [topics.id], topic: one(topics, {
}), fields: [topicGroupChats.topicId],
creator: one(users, { references: [topics.id],
fields: [topicGroupChats.createdBy], }),
references: [users.id], creator: one(users, {
}), fields: [topicGroupChats.createdBy],
})); references: [users.id],
}),
}),
);
// Known Group Chats: 机器人已知的群 (通过事件发现) // Known Group Chats: 机器人已知的群 (通过事件发现)
export const knownGroupChats = pgTable('known_group_chats', { export const knownGroupChats = pgTable("known_group_chats", {
chatId: text('chat_id').primaryKey(), // 飞书 chat_id chatId: text("chat_id").primaryKey(), // 飞书 chat_id
name: text('name').notNull(), name: text("name").notNull(),
lastActiveAt: timestamp('last_active_at').defaultNow(), lastActiveAt: timestamp("last_active_at").defaultNow(),
}); });
export const topicsRelations = relations(topics, ({ many, one }) => ({ export const topicsRelations = relations(topics, ({ many, one }) => ({
subscriptions: many(subscriptions), subscriptions: many(subscriptions),
groupChats: many(topicGroupChats), groupChats: many(topicGroupChats),
creator: one(users, { creator: one(users, {
fields: [topics.createdBy], fields: [topics.createdBy],
references: [users.id], references: [users.id],
relationName: 'creator', relationName: "creator",
}), }),
approver: one(users, { approver: one(users, {
fields: [topics.approvedBy], fields: [topics.approvedBy],
references: [users.id], references: [users.id],
relationName: 'approver', relationName: "approver",
}), }),
})); }));
export const users = pgTable('users', { export const users = pgTable("users", {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
name: text('name').notNull(), .primaryKey()
feishuUserId: text('feishu_user_id').notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id) .$defaultFn(() => crypto.randomUUID()),
email: text('email').unique(), name: text("name").notNull(),
isAdmin: boolean('is_admin').default(false), feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
personalToken: text('personal_token').notNull().unique().$defaultFn(() => crypto.randomUUID().replace(/-/g, '')), 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 }) => ({ export const usersRelations = relations(users, ({ many }) => ({
subscriptions: many(subscriptions), subscriptions: many(subscriptions),
createdTopics: many(topics, { relationName: 'creator' }), createdTopics: many(topics, { relationName: "creator" }),
approvedTopics: many(topics, { relationName: 'approver' }), approvedTopics: many(topics, { relationName: "approver" }),
})); }));
// Subscriptions: 用户直接订阅 Topic // Subscriptions: 用户直接订阅 Topic
export const subscriptions = pgTable('subscriptions', { export const subscriptions = pgTable(
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), "subscriptions",
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }), {
createdAt: timestamp('created_at').defaultNow().notNull(), userId: text("user_id")
}, (t) => ({ .notNull()
pk: primaryKey({ columns: [t.userId, t.topicId] }), .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 }) => ({ export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
user: one(users, { user: one(users, {
fields: [subscriptions.userId], fields: [subscriptions.userId],
references: [users.id], references: [users.id],
}), }),
topic: one(topics, { topic: one(topics, {
fields: [subscriptions.topicId], fields: [subscriptions.topicId],
references: [topics.id], references: [topics.id],
}), }),
})); }));
// API Tasks: 记录 webhook 请求的处理状态 // API Tasks: 记录 webhook 请求的处理状态
export const alertTasks = pgTable('alert_tasks', { export const alertTasks = pgTable("alert_tasks", {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
topicSlug: text('topic_slug'), .primaryKey()
senderId: text('sender_id').references(() => users.id), // 记录是谁发送的 (通过 personal_token) .$defaultFn(() => crypto.randomUUID()),
status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).default('pending').notNull(), topicSlug: text("topic_slug"),
recipientCount: integer('recipient_count').default(0), senderId: text("sender_id").references(() => users.id), // 记录是谁发送的 (通过 personal_token)
successCount: integer('success_count').default(0), status: text("status", {
payload: jsonb('payload'), // 存储 webhook body enum: ["pending", "processing", "completed", "failed"],
error: text('error'), })
createdAt: timestamp('created_at').defaultNow().notNull(), .default("pending")
updatedAt: timestamp('updated_at').defaultNow().notNull(), .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) // Logs for each recipient in a task (optional detail)
export const alertLogs = pgTable('alert_logs', { export const alertLogs = pgTable("alert_logs", {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), id: text("id")
taskId: text('task_id').notNull().references(() => alertTasks.id, { onDelete: 'cascade' }), .primaryKey()
userId: text('user_id'), // Optional, in case user is deleted later .$defaultFn(() => crypto.randomUUID()),
status: text('status', { enum: ['sent', 'failed'] }).notNull(), taskId: text("task_id")
error: text('error'), .notNull()
createdAt: timestamp('created_at').defaultNow().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 }) => ({ export const alertTasksRelations = relations(alertTasks, ({ many, one }) => ({
logs: many(alertLogs), logs: many(alertLogs),
sender: one(users, { sender: one(users, {
fields: [alertTasks.senderId], fields: [alertTasks.senderId],
references: [users.id], references: [users.id],
}), }),
})); }));
export const alertLogsRelations = relations(alertLogs, ({ one }) => ({ export const alertLogsRelations = relations(alertLogs, ({ one }) => ({
task: one(alertTasks, { task: one(alertTasks, {
fields: [alertLogs.taskId], fields: [alertLogs.taskId],
references: [alertTasks.id], references: [alertTasks.id],
}), }),
})); }));

View File

@@ -1,38 +1,45 @@
import { db } from './db'; import * as lark from "@larksuiteoapi/node-sdk";
import { knownGroupChats, topicGroupChats } from './db/schema'; import { eq } from "drizzle-orm";
import { eq } from 'drizzle-orm'; import { db } from "./db";
import * as lark from '@larksuiteoapi/node-sdk'; import { knownGroupChats, topicGroupChats } from "./db/schema";
import { logger } from './lib/logger'; import { logger } from "./lib/logger";
export const eventDispatcher = new lark.EventDispatcher({ export const eventDispatcher = new lark.EventDispatcher({
encryptKey: process.env.FEISHU_ENCRYPT_KEY, encryptKey: process.env.FEISHU_ENCRYPT_KEY,
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN, verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
}).register({ }).register({
'im.chat.member.bot.added_v1': async (data) => { "im.chat.member.bot.added_v1": async (data) => {
const { chat_id, name } = data as any; const { chat_id, name } = data as any;
logger.info({ chat_id, name }, '[Feishu Event] Bot added to group'); logger.info({ chat_id, name }, "[Feishu Event] Bot added to group");
if (chat_id) { if (chat_id) {
await db.insert(knownGroupChats).values({ await db
chatId: chat_id, .insert(knownGroupChats)
name: name || 'Unknown Group', .values({
lastActiveAt: new Date(), chatId: chat_id,
}).onConflictDoUpdate({ name: name || "Unknown Group",
target: knownGroupChats.chatId, lastActiveAt: new Date(),
set: { })
name: name || 'Unknown Group', .onConflictDoUpdate({
lastActiveAt: new Date(), 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'); },
"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) { if (chat_id) {
await db.delete(knownGroupChats).where(eq(knownGroupChats.chatId, chat_id)); await db
await db.delete(topicGroupChats).where(eq(topicGroupChats.chatId, chat_id)); .delete(knownGroupChats)
} .where(eq(knownGroupChats.chatId, chat_id));
}, await db
.delete(topicGroupChats)
.where(eq(topicGroupChats.chatId, chat_id));
}
},
}); });

View File

@@ -1,72 +1,78 @@
import * as lark from '@larksuiteoapi/node-sdk'; import * as lark from "@larksuiteoapi/node-sdk";
import { logger } from './lib/logger'; import { logger } from "./lib/logger";
export class FeishuClient { export class FeishuClient {
public client: lark.Client; public client: lark.Client;
public appId: string; public appId: string;
public appSecret: string; public appSecret: string;
constructor(appId: string, appSecret: string) { constructor(appId: string, appSecret: string) {
this.appId = appId; this.appId = appId;
this.appSecret = appSecret; this.appSecret = appSecret;
this.client = new lark.Client({ this.client = new lark.Client({
appId: appId, appId: appId,
appSecret: appSecret, appSecret: appSecret,
disableTokenCache: false, disableTokenCache: false,
}); });
} }
async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email' | 'chat_id', msgType: string, content: any) { async sendMessage(
// Content needs to be stringified for 'text' type in API, but SDK might handle it differently? receiveId: string,
// Actually SDK expects 'content' as string JSON for 'im.v1.messages.create' receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
const contentStr = typeof content === 'string' ? content : JSON.stringify(content); 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 { try {
const response = await this.client.im.message.create({ const response = await this.client.im.message.create({
params: { params: {
receive_id_type: receiveIdType, receive_id_type: receiveIdType,
}, },
data: { data: {
receive_id: receiveId, receive_id: receiveId,
msg_type: msgType, msg_type: msgType,
content: contentStr, content: contentStr,
}, },
}); });
if (response.code !== 0) { if (response.code !== 0) {
logger.error({ response }, 'Feishu send message error'); logger.error({ response }, "Feishu send message error");
throw new Error(`Failed to send message: ${response.msg}`); throw new Error(`Failed to send message: ${response.msg}`);
} }
return response.data; return response.data;
} catch (e) { } catch (e) {
console.error('Feishu SDK error:', e); console.error("Feishu SDK error:", e);
throw e; throw e;
} }
} }
async getUserAccessToken(code: string): Promise<any> { async getUserAccessToken(code: string): Promise<any> {
try { try {
const response = await this.client.authen.accessToken.create({ const response = await this.client.authen.accessToken.create({
data: { data: {
grant_type: 'authorization_code', grant_type: "authorization_code",
code, code,
}, },
}); });
if (response.code !== 0) { if (response.code !== 0) {
logger.error({ response }, 'Feishu get user access token error'); logger.error({ response }, "Feishu get user access token error");
throw new Error(`Failed to get user access token: ${response.msg}`); throw new Error(`Failed to get user access token: ${response.msg}`);
} }
return response.data; return response.data;
} catch (e) { } catch (e) {
console.error('Feishu SDK error:', e); console.error("Feishu SDK error:", e);
throw e; throw e;
} }
} }
} }
// Singleton instance // Singleton instance
export const feishuClient = new FeishuClient( export const feishuClient = new FeishuClient(
process.env.FEISHU_APP_ID || '', process.env.FEISHU_APP_ID || "",
process.env.FEISHU_APP_SECRET || '' process.env.FEISHU_APP_SECRET || "",
); );

View File

@@ -1,47 +1,52 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { logger } from './lib/logger'; import { serveStatic } from "hono/bun";
import { cors } from 'hono/cors'; import { cors } from "hono/cors";
import { serveStatic } from 'hono/bun'; import api from "./api";
import { db } from './db'; import auth from "./auth";
import { topics } from './db/schema'; import { db } from "./db";
import webhook from './webhook'; import { topics } from "./db/schema";
import api from './api'; import { logger } from "./lib/logger";
import auth from './auth'; import webhook from "./webhook";
const app = new Hono(); const app = new Hono();
// Enable CORS for frontend // Enable CORS for frontend
app.use('/*', cors({ app.use(
origin: process.env.FRONTEND_URL || 'http://localhost:5173', "/*",
credentials: true, 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 // API Routes
const routes = app.route('/api/auth', auth) const routes = app
.route('/api', api) .route("/api/auth", auth)
.route('/api/feishu/event', feishuEvent) .route("/api", api)
.route('/webhook', webhook); .route("/api/feishu/event", feishuEvent)
.route("/webhook", webhook);
// Serve static files (Frontend) // Serve static files (Frontend)
app.use('/*', serveStatic({ root: './public' })); app.use("/*", serveStatic({ root: "./public" }));
app.get('*', serveStatic({ path: './public/index.html' })); app.get("*", serveStatic({ path: "./public/index.html" }));
app.onError((err, c) => { app.onError((err, c) => {
logger.error({ err, method: c.req.method, url: c.req.url }, 'Global Error'); logger.error({ err, method: c.req.method, url: c.req.url }, "Global Error");
return c.json({ error: err.message || 'Internal Server Error' }, 500); return c.json({ error: err.message || "Internal Server Error" }, 500);
}); });
app.get('/topics', async (c) => { app.get("/topics", async (c) => {
const allTopics = await db.select().from(topics); const allTopics = await db.select().from(topics);
return c.json(allTopics); return c.json(allTopics);
}); });
// Start WebSocket if enabled // Start WebSocket if enabled
import { startWebSocket } from './ws'; import { startWebSocket } from "./ws";
startWebSocket(); startWebSocket();
export type AppType = typeof routes; export type AppType = typeof routes;

View File

@@ -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({ export const logger = pino({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || "info",
transport: isDevelopment transport: isDevelopment
? { ? {
target: 'pino-pretty', target: "pino-pretty",
options: { options: {
colorize: true, colorize: true,
translateTime: 'HH:MM:ss Z', translateTime: "HH:MM:ss Z",
ignore: 'pid,hostname', ignore: "pid,hostname",
}, },
} }
: undefined, : undefined,
}); });
export default logger; export default logger;

View File

@@ -1,51 +1,58 @@
import { Context, Next } from 'hono'; import type { Context, Next } from "hono";
import { getCookie } from 'hono/cookie'; import { getCookie } from "hono/cookie";
export interface AuthSession { export interface AuthSession {
id: string; id: string;
name: string; name: string;
email: string | null; email: string | null;
isAdmin: boolean; isAdmin: boolean;
} }
export async function requireAuth(c: Context, next: Next) { export async function requireAuth(c: Context, next: Next) {
const sessionCookie = getCookie(c, 'session'); const sessionCookie = getCookie(c, "session");
if (!sessionCookie) { if (!sessionCookie) {
return c.json({ error: 'Authentication required' }, 401); return c.json({ error: "Authentication required" }, 401);
} }
try { try {
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null; const session: AuthSession = sessionCookie
if (!session) { ? JSON.parse(sessionCookie)
return c.json({ error: 'Authentication required' }, 401); : null;
} if (!session) {
c.set('session', session); return c.json({ error: "Authentication required" }, 401);
await next(); }
} catch (error) { c.set("session", session);
console.error('[Middleware] Failed to parse session cookie:', error); await next();
return c.json({ error: 'Invalid session' }, 401); } 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) { export async function requireAdmin(c: Context, next: Next) {
const sessionCookie = getCookie(c, 'session'); const sessionCookie = getCookie(c, "session");
if (!sessionCookie) { if (!sessionCookie) {
return c.json({ error: 'Authentication required' }, 401); return c.json({ error: "Authentication required" }, 401);
} }
try { try {
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null; const session: AuthSession = sessionCookie
? JSON.parse(sessionCookie)
: null;
if (!session || !session.isAdmin) { if (!session || !session.isAdmin) {
return c.json({ error: 'Admin access required' }, 403); return c.json({ error: "Admin access required" }, 403);
} }
c.set('session', session); c.set("session", session);
await next(); await next();
} catch (error) { } catch (error) {
console.error('[Middleware] Failed to parse session cookie in requireAdmin:', error); console.error(
return c.json({ error: 'Invalid session' }, 401); "[Middleware] Failed to parse session cookie in requireAdmin:",
} error,
);
return c.json({ error: "Invalid session" }, 401);
}
} }

View File

@@ -1,148 +1,187 @@
import { eq } from "drizzle-orm";
import app from './index'; import { db } from "./db";
import { db } from './db'; import { subscriptions, topics, users } from "./db/schema";
import { users, topics, subscriptions } from './db/schema'; import app from "./index";
import { eq } from 'drizzle-orm';
async function verify() { async function verify() {
console.log('Starting Verification...'); console.log("Starting Verification...");
let errors = 0; let errors = 0;
// 1. Setup Test Data // 1. Setup Test Data
const timestamp = Date.now(); const timestamp = Date.now();
// Create Non-Admin User // Create Non-Admin User
const [userUser] = await db.insert(users).values({ const [userUser] = await db
name: `TestUser_${timestamp}`, .insert(users)
feishuUserId: `test_user_${timestamp}`, .values({
email: `test_user_${timestamp}@example.com`, name: `TestUser_${timestamp}`,
isAdmin: false feishuUserId: `test_user_${timestamp}`,
}).returning(); email: `test_user_${timestamp}@example.com`,
isAdmin: false,
})
.returning();
// Create Admin User // Create Admin User
const [adminUser] = await db.insert(users).values({ const [adminUser] = await db
name: `TestAdmin_${timestamp}`, .insert(users)
feishuUserId: `test_admin_${timestamp}`, .values({
email: `test_admin_${timestamp}@example.com`, name: `TestAdmin_${timestamp}`,
isAdmin: true feishuUserId: `test_admin_${timestamp}`,
}).returning(); email: `test_admin_${timestamp}@example.com`,
isAdmin: true,
})
.returning();
// Create Topic // Create Topic
const [topic] = await db.insert(topics).values({ const [topic] = await db
name: `TestTopic_${timestamp}`, .insert(topics)
slug: `test-topic-${timestamp}`, .values({
description: 'Test Description' name: `TestTopic_${timestamp}`,
}).returning(); slug: `test-topic-${timestamp}`,
description: "Test Description",
})
.returning();
// Subscribe User to Topic // Subscribe User to Topic
await db.insert(subscriptions).values({ await db.insert(subscriptions).values({
userId: userUser.id, userId: userUser.id,
topicId: topic.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 { try {
// 2. Test GET /users (Admin Only) // 2. Test GET /users (Admin Only)
// Test as Non-Admin // Test as Non-Admin
const sessionUser = { userId: userUser.id, name: userUser.name, email: userUser.email, isAdmin: userUser.isAdmin }; const sessionUser = {
const req1 = new Request('http://localhost/api/users', { userId: userUser.id,
headers: { name: userUser.name,
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}` email: userUser.email,
} isAdmin: userUser.isAdmin,
}); };
const res1 = await app.request(req1); 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) { if (res1.status === 403) {
console.log('✅ PASS: GET /users as Non-Admin returned 403'); console.log("✅ PASS: GET /users as Non-Admin returned 403");
} else { } else {
console.error(`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`); console.error(
errors++; `❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`,
} );
errors++;
}
// Test as Admin // Test as Admin
const sessionAdmin = { userId: adminUser.id, name: adminUser.name, email: adminUser.email, isAdmin: adminUser.isAdmin }; const sessionAdmin = {
const req2 = new Request('http://localhost/api/users', { userId: adminUser.id,
headers: { name: adminUser.name,
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}` email: adminUser.email,
} isAdmin: adminUser.isAdmin,
}); };
const res2 = await app.request(req2); 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) { if (res2.status === 200) {
console.log('✅ PASS: GET /users as Admin returned 200'); console.log("✅ PASS: GET /users as Admin returned 200");
} else { } else {
console.error(`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`); console.error(
errors++; `❌ 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) // Test as Non-Admin (Should see ONLY their subscription)
const req3 = new Request('http://localhost/api/topics', { const req3 = new Request("http://localhost/api/topics", {
headers: { headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}` Cookie: `session=${encodeURIComponent(JSON.stringify(sessionUser))}`,
} },
}); });
const res3 = await app.request(req3); const res3 = await app.request(req3);
const data3 = await res3.json(); const data3 = await res3.json();
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id); const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
if (targetTopic) { if (targetTopic) {
if (targetTopic.subscriptions.length === 1 && targetTopic.subscriptions[0].userId === userUser.id) { if (
console.log('✅ PASS: GET /topics as Non-Admin shows correct personal subscription'); targetTopic.subscriptions.length === 1 &&
} else { targetTopic.subscriptions[0].userId === userUser.id
console.error('❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:', targetTopic.subscriptions); ) {
errors++; console.log(
} "✅ PASS: GET /topics as Non-Admin shows correct personal subscription",
} else { );
console.error('❌ FAIL: Test topic not found in list'); } else {
errors++; 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) // 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. // Actually, let's just check that Admin sees the User's subscription.
// In my logic: isAdmin ? undefined (all) : ... // In my logic: isAdmin ? undefined (all) : ...
// So Admin should see User's subscription. // So Admin should see User's subscription.
const req4 = new Request('http://localhost/api/topics', { const req4 = new Request("http://localhost/api/topics", {
headers: { headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}` Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`,
} },
}); });
const res4 = await app.request(req4); const res4 = await app.request(req4);
const data4 = await res4.json(); const data4 = await res4.json();
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id); const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
// Should see the subscription for userUser // Should see the subscription for userUser
const hasUserSub = targetTopicAdmin.subscriptions.some((s: any) => s.userId === userUser.id); const hasUserSub = targetTopicAdmin.subscriptions.some(
if (hasUserSub) { (s: any) => s.userId === userUser.id,
console.log('✅ PASS: GET /topics as Admin sees other users subscriptions'); );
} else { if (hasUserSub) {
console.error('❌ FAIL: GET /topics as Admin did NOT see other users subscriptions'); console.log(
errors++; "✅ 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) { if (errors === 0) {
console.error('Test Exception:', e); console.log("🎉 ALL TESTS PASSED");
errors++; process.exit(0);
} finally { } else {
// 4. Cleanup console.error("💥 SOME TESTS FAILED");
await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id)); process.exit(1);
await db.delete(topics).where(eq(topics.id, topic.id)); }
await db.delete(users).where(eq(users.id, userUser.id));
await db.delete(users).where(eq(users.id, adminUser.id));
console.log('Cleanup Completed');
}
if (errors === 0) {
console.log('🎉 ALL TESTS PASSED');
process.exit(0);
} else {
console.error('💥 SOME TESTS FAILED');
process.exit(1);
}
} }
verify(); verify();

View File

@@ -1,311 +1,362 @@
import { Hono } from 'hono'; import { eq } from "drizzle-orm";
import { eq } from 'drizzle-orm'; import { Hono } from "hono";
import { db } from './db'; import { db } from "./db";
import { topics, alertTasks, alertLogs, users } from './db/schema'; import { alertLogs, alertTasks, topics, users } from "./db/schema";
import { feishuClient } from './feishu'; import { feishuClient } from "./feishu";
import { logger } from './lib/logger'; import { logger } from "./lib/logger";
const webhook = new Hono(); const webhook = new Hono();
webhook.post('/:token/topic/:slug', async (c) => { webhook.post("/:token/topic/:slug", async (c) => {
const token = c.req.param('token'); const token = c.req.param("token");
const slug = c.req.param('slug'); const slug = c.req.param("slug");
logger.info({ token, slug }, '[Webhook] Received request'); logger.info({ token, slug }, "[Webhook] Received request");
// 0. Find the User by Token // 0. Find the User by Token
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.personalToken, token), where: eq(users.personalToken, token),
}); });
if (!user) { if (!user) {
logger.warn({ token }, '[Webhook] Invalid personal token'); logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: 'Invalid personal token' }, 401); return c.json({ error: "Invalid personal token" }, 401);
} }
let body; let body;
try { try {
const rawBody = await c.req.text(); const rawBody = await c.req.text();
logger.debug({ bodyLength: rawBody.length }, '[Webhook] Received raw body'); logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body");
if (!rawBody || rawBody.trim() === '') { if (!rawBody || rawBody.trim() === "") {
return c.json({ error: 'Empty body' }, 400); return c.json({ error: "Empty body" }, 400);
} }
body = JSON.parse(rawBody); body = JSON.parse(rawBody);
} catch (e) { } catch (e) {
logger.error({ err: e }, '[Webhook] Failed to parse JSON body'); logger.error({ err: e }, "[Webhook] Failed to parse JSON body");
return c.json({ error: 'Invalid JSON body' }, 400); return c.json({ error: "Invalid JSON body" }, 400);
} }
// 1. Find the Topic // 1. Find the Topic
const topic = await db.query.topics.findFirst({ const topic = await db.query.topics.findFirst({
where: eq(topics.slug, slug), where: eq(topics.slug, slug),
with: { with: {
subscriptions: { subscriptions: {
with: { with: {
user: true user: true,
} },
}, },
groupChats: true groupChats: true,
} },
}); });
if (!topic) { if (!topic) {
logger.warn({ slug }, '[Webhook] Topic not found'); logger.warn({ slug }, "[Webhook] Topic not found");
return c.json({ error: 'Topic not found' }, 404); 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 // 2. Collect recipients
const userRecipients = topic.subscriptions const userRecipients = topic.subscriptions
.map(sub => sub.user) .map((sub) => sub.user)
.filter(u => !!u && !!u.feishuUserId) .filter((u) => !!u && !!u.feishuUserId)
.map(u => ({ .map((u) => ({
type: 'user', type: "user",
id: u.id, id: u.id,
name: u.name, name: u.name,
feishuId: u.feishuUserId, feishuId: u.feishuUserId,
idType: u.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id' idType: u.feishuUserId.startsWith("ou_") ? "open_id" : "user_id",
})); }));
const groupRecipients = topic.groupChats.map(g => ({ const groupRecipients = topic.groupChats.map((g) => ({
type: 'group', type: "group",
id: g.id, // Binding ID id: g.id, // Binding ID
name: g.name, name: g.name,
feishuId: g.chatId, feishuId: g.chatId,
idType: 'chat_id' idType: "chat_id",
})); }));
const allRecipients = [...userRecipients, ...groupRecipients]; const allRecipients = [...userRecipients, ...groupRecipients];
const [task] = await db.insert(alertTasks).values({ const [task] = await db
topicSlug: topic.slug, .insert(alertTasks)
senderId: user.id, .values({
status: 'processing', topicSlug: topic.slug,
recipientCount: allRecipients.length, senderId: user.id,
successCount: 0, status: "processing",
payload: body, recipientCount: allRecipients.length,
}).returning(); successCount: 0,
payload: body,
})
.returning();
if (allRecipients.length === 0) { if (allRecipients.length === 0) {
await db.update(alertTasks) await db
.set({ status: 'completed', updatedAt: new Date() }) .update(alertTasks)
.where(eq(alertTasks.id, task.id)); .set({ status: "completed", updatedAt: new Date() })
.where(eq(alertTasks.id, task.id));
return c.json({ return c.json({
message: 'No subscribers for this topic', message: "No subscribers for this topic",
taskId: task.id, taskId: task.id,
status: 'completed' status: "completed",
}); });
} }
logger.info({ logger.info(
taskId: task.id, {
userCount: userRecipients.length, taskId: task.id,
groupCount: groupRecipients.length userCount: userRecipients.length,
}, '[Webhook] Dispatching alerts'); groupCount: groupRecipients.length,
},
"[Webhook] Dispatching alerts",
);
// 4. Send Private Messages asynchronously // 4. Send Private Messages asynchronously
Promise.allSettled(allRecipients.map(async (recipient) => { Promise.allSettled(
try { allRecipients.map(async (recipient) => {
// Construct message content try {
let msgType = body.msg_type || 'text'; // Construct message content
let content = body.content; let msgType = body.msg_type || "text";
let content = body.content;
if (!content) { if (!content) {
msgType = 'text'; msgType = "text";
content = { text: JSON.stringify(body, null, 2) }; content = { text: JSON.stringify(body, null, 2) };
// Deep copy needed? usually content is new obj if we parsed body // Deep copy needed? usually content is new obj if we parsed body
} else { } else {
// Deep clone content to avoid mutating shared object for parallel requests if we modify it // Deep clone content to avoid mutating shared object for parallel requests if we modify it
content = JSON.parse(JSON.stringify(content)); content = JSON.parse(JSON.stringify(content));
} }
// Add metadata // Add metadata
if (msgType === 'text' && content.text) { if (msgType === "text" && content.text) {
content.text = `[Topic: ${topic.name}]\n${content.text}`; content.text = `[Topic: ${topic.name}]\n${content.text}`;
} }
if (msgType === 'interactive' && content.header) { if (msgType === "interactive" && content.header) {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`; 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 }; return { recipientId: recipient.id, status: "sent", error: null };
} catch (error: any) { } catch (error: any) {
logger.error({ logger.error(
err: error, {
recipientType: recipient.type, err: error,
recipientName: recipient.name recipientType: recipient.type,
}, 'Failed to send alert'); recipientName: recipient.name,
return { recipientId: recipient.id, status: 'failed', error: error.message }; },
} "Failed to send alert",
})).then(async (results) => { );
const successCount = results.filter(r => r.status === 'fulfilled' && (r.value as any).status === 'sent').length; return {
const failures = results recipientId: recipient.id,
.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && (r.value as any).status === 'failed')) status: "failed",
.length; 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 // Determine final status
const finalStatus = failures === 0 ? 'completed' : (successCount > 0 ? 'completed' : 'failed'); const finalStatus =
failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed";
// Update Task // Update Task
await db.update(alertTasks).set({ await db
status: finalStatus, .update(alertTasks)
successCount, .set({
updatedAt: new Date(), status: finalStatus,
// If fully failed, maybe store the first error in the task record for quick view successCount,
error: failures > 0 ? `Failed to send to ${failures} recipients` : null, updatedAt: new Date(),
}).where(eq(alertTasks.id, task.id)); // 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 // Insert Logs
const logs = results.map((r, index) => { const logs = results.map((r, index) => {
const recipient = allRecipients[index]; const recipient = allRecipients[index];
if (r.status === 'fulfilled') { if (r.status === "fulfilled") {
const val = r.value as any; const val = r.value as any;
return { return {
taskId: task.id, taskId: task.id,
userId: recipient.type === 'user' ? recipient.id : null, // Only link users 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 // We could add connection to group binding if we altered schema, but for now log it
status: val.status, status: val.status,
error: val.error, error: val.error,
}; };
} else { } else {
return { return {
taskId: task.id, taskId: task.id,
userId: recipient.type === 'user' ? recipient.id : null, userId: recipient.type === "user" ? recipient.id : null,
status: 'failed', status: "failed",
error: r.reason ? String(r.reason) : 'Unknown error', error: r.reason ? String(r.reason) : "Unknown error",
}; };
} }
}); });
if (logs.length > 0) { if (logs.length > 0) {
await db.insert(alertLogs).values(logs as any); await db.insert(alertLogs).values(logs as any);
} }
logger.info({ logger.info(
taskId: task.id, {
successCount, taskId: task.id,
totalCount: allRecipients.length, successCount,
slug totalCount: allRecipients.length,
}, '[Webhook] Task processed'); slug,
}); },
"[Webhook] Task processed",
);
});
return c.json({ return c.json({
message: 'Alert received and processing started', message: "Alert received and processing started",
taskId: task.id, taskId: task.id,
status: 'processing', status: "processing",
recipientCount: allRecipients.length recipientCount: allRecipients.length,
}); });
}); });
webhook.post('/:token/dm', async (c) => { webhook.post("/:token/dm", async (c) => {
const token = c.req.param('token'); const token = c.req.param("token");
logger.info({ token }, '[Webhook] Received DM request'); logger.info({ token }, "[Webhook] Received DM request");
// 0. Find the User by Token // 0. Find the User by Token
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.personalToken, token), where: eq(users.personalToken, token),
}); });
if (!user) { if (!user) {
logger.warn({ token }, '[Webhook] Invalid personal token'); logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: 'Invalid personal token' }, 401); return c.json({ error: "Invalid personal token" }, 401);
} }
if (!user.feishuUserId) { if (!user.feishuUserId) {
return c.json({ error: 'User has no Feishu ID linked' }, 400); return c.json({ error: "User has no Feishu ID linked" }, 400);
} }
let body; let body;
try { try {
const rawBody = await c.req.text(); const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === '') { if (!rawBody || rawBody.trim() === "") {
return c.json({ error: 'Empty body' }, 400); return c.json({ error: "Empty body" }, 400);
} }
body = JSON.parse(rawBody); body = JSON.parse(rawBody);
} catch (e) { } catch (e) {
return c.json({ error: 'Invalid JSON body' }, 400); return c.json({ error: "Invalid JSON body" }, 400);
} }
// 1. Create Task (topicSlug is null for DM) // 1. Create Task (topicSlug is null for DM)
const [task] = await db.insert(alertTasks).values({ const [task] = await db
topicSlug: null, .insert(alertTasks)
senderId: user.id, .values({
status: 'processing', topicSlug: null,
recipientCount: 1, senderId: user.id,
successCount: 0, status: "processing",
payload: body, recipientCount: 1,
}).returning(); successCount: 0,
payload: body,
})
.returning();
// 2. Send Message // 2. Send Message
(async () => { (async () => {
try { try {
let msgType = body.msg_type || 'text'; let msgType = body.msg_type || "text";
let content = body.content; let content = body.content;
if (!content) { if (!content) {
msgType = 'text'; msgType = "text";
content = { text: JSON.stringify(body, null, 2) }; content = { text: JSON.stringify(body, null, 2) };
} }
// Add metadata // Add metadata
if (msgType === 'text' && content.text) { if (msgType === "text" && content.text) {
content.text = `[Direct Message]\n${content.text}`; content.text = `[Direct Message]\n${content.text}`;
} }
if (msgType === 'interactive' && content.header) { if (msgType === "interactive" && content.header) {
content.header.title.content = `[DM] ${content.header.title.content}`; content.header.title.content = `[DM] ${content.header.title.content}`;
} }
const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id'; const idType = user.feishuUserId.startsWith("ou_")
await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content); ? "open_id"
: "user_id";
await feishuClient.sendMessage(
user.feishuUserId,
idType,
msgType,
content,
);
// Update Task // Update Task
await db.update(alertTasks).set({ await db
status: 'completed', .update(alertTasks)
successCount: 1, .set({
updatedAt: new Date(), status: "completed",
}).where(eq(alertTasks.id, task.id)); successCount: 1,
updatedAt: new Date(),
})
.where(eq(alertTasks.id, task.id));
// Insert Log // Insert Log
await db.insert(alertLogs).values({ await db.insert(alertLogs).values({
taskId: task.id, taskId: task.id,
userId: user.id, userId: user.id,
status: 'sent', 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) { await db.insert(alertLogs).values({
logger.error({ err: error, userName: user.name }, 'Failed to send DM'); taskId: task.id,
await db.update(alertTasks).set({ userId: user.id,
status: 'failed', status: "failed",
updatedAt: new Date(), error: error.message,
error: error.message, });
}).where(eq(alertTasks.id, task.id)); }
})();
await db.insert(alertLogs).values({ return c.json({
taskId: task.id, message: "DM received and processing started",
userId: user.id, taskId: task.id,
status: 'failed', status: "processing",
error: error.message, 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 // Help message for non-POST requests or malformed URLs
webhook.all('/:token/topic/:slug', (c) => { webhook.all("/:token/topic/:slug", (c) => {
return c.json({ return c.json(
error: 'Method not allowed', {
message: 'Please use POST to send alerts to this webhook', error: "Method not allowed",
format: 'POST /webhook/:token/topic/:slug', message: "Please use POST to send alerts to this webhook",
example: 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL' format: "POST /webhook/:token/topic/:slug",
}, 405); example:
'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL',
},
405,
);
}); });
export default webhook; export default webhook;

View File

@@ -1,22 +1,22 @@
import * as lark from '@larksuiteoapi/node-sdk'; import * as lark from "@larksuiteoapi/node-sdk";
import { feishuClient } from './feishu'; import { eventDispatcher } from "./event-handler";
import { eventDispatcher } from './event-handler'; import { feishuClient } from "./feishu";
import { logger } from './lib/logger'; import { logger } from "./lib/logger";
export const startWebSocket = async () => { export const startWebSocket = async () => {
if (process.env.FEISHU_USE_WS !== 'true') { if (process.env.FEISHU_USE_WS !== "true") {
return; return;
} }
logger.info('[Feishu WS] Starting WebSocket connection...'); logger.info("[Feishu WS] Starting WebSocket connection...");
try { try {
const wsClient = new lark.WSClient({ const wsClient = new lark.WSClient({
appId: feishuClient.appId, appId: feishuClient.appId,
appSecret: feishuClient.appSecret, appSecret: feishuClient.appSecret,
}); });
await wsClient.start({ eventDispatcher }); await wsClient.start({ eventDispatcher });
logger.info('[Feishu WS] Connected successfully'); logger.info("[Feishu WS] Connected successfully");
} catch (e) { } catch (e) {
logger.error({ err: e }, '[Feishu WS] Connection failed'); logger.error({ err: e }, "[Feishu WS] Connection failed");
} }
}; };

View File

@@ -1,21 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"types": [ "types": ["bun-types"],
"bun-types" "baseUrl": ".",
], "paths": {
"baseUrl": ".", "@/*": ["./src/*"]
"paths": { }
"@/*": [ },
"./src/*" "include": ["src"]
] }
}
},
"include": [
"src"
]
}

View File

@@ -1,32 +1,32 @@
{ {
"name": "@alertmessagecenter/web", "name": "@alertmessagecenter/web",
"version": "1.2.0", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run --env-file .env vite", "dev": "bun run --env-file .env vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.0.0", "clsx": "^2.0.0",
"hono": "^4.11.3", "hono": "^4.11.3",
"lucide-react": "^0.300.0", "lucide-react": "^0.300.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tailwind-merge": "^2.0.0" "tailwind-merge": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.0.0", "autoprefixer": "^10.0.0",
"postcss": "^8.0.0", "postcss": "^8.0.0",
"tailwindcss": "^3.0.0", "tailwindcss": "^3.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"zod": "^3.0.0", "zod": "^3.0.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"bun-types": "latest" "bun-types": "latest"
} }
} }

View File

@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

View File

@@ -1,125 +1,142 @@
import { useState, useEffect } from 'react' import {
import { Hash, Users, Activity, LogIn, LogOut, ShieldCheck, Settings } from 'lucide-react' Activity,
import { useAuth } from './contexts/AuthContext' Hash,
import TopicsView from './views/TopicsView' LogIn,
import UsersView from './views/UsersView' LogOut,
import AdminView from './views/AdminView' 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() { function App() {
const { user, loading, login, logout } = useAuth() const { user, loading, login, logout } = useAuth();
const [activeTab, setActiveTab] = useState('topics') const [activeTab, setActiveTab] = useState("topics");
const [hasSetDefault, setHasSetDefault] = useState(false) const [hasSetDefault, setHasSetDefault] = useState(false);
useEffect(() => { useEffect(() => {
if (!loading && user && !hasSetDefault) { if (!loading && user && !hasSetDefault) {
setActiveTab(user.isAdmin ? 'admin' : 'topics') setActiveTab(user.isAdmin ? "admin" : "topics");
setHasSetDefault(true) setHasSetDefault(true);
} }
}, [user, loading, hasSetDefault]) }, [user, loading, hasSetDefault]);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div> </div>
) );
} }
if (!user) { if (!user) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8"> <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center"> <div className="text-center">
<Activity className="h-16 w-16 text-indigo-600 mx-auto mb-4" /> <Activity className="h-16 w-16 text-indigo-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">Alert Message Center</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">
<p className="text-gray-600 mb-6">Please sign in with Feishu to continue</p> Alert Message Center
<button </h1>
onClick={login} <p className="text-gray-600 mb-6">
className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition" Please sign in with Feishu to continue
> </p>
<LogIn className="mr-2 h-5 w-5" /> <button
Sign in with Feishu onClick={login}
</button> className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition"
</div> >
</div> <LogIn className="mr-2 h-5 w-5" />
</div> Sign in with Feishu
) </button>
} </div>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b"> <nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex justify-between h-16">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">
<Activity className="h-8 w-8 text-indigo-600" /> <Activity className="h-8 w-8 text-indigo-600" />
<span className="ml-2 text-xl font-bold text-gray-900">Alert Message Center</span> <span className="ml-2 text-xl font-bold text-gray-900">
</div> Alert Message Center
<div className="hidden sm:ml-6 sm:flex sm:space-x-8"> </span>
{user.isAdmin && ( </div>
<button <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
onClick={() => setActiveTab('admin')} {user.isAdmin && (
className={`${activeTab === 'admin' <button
? 'border-indigo-500 text-gray-900' onClick={() => setActiveTab("admin")}
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' className={`${
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`} activeTab === "admin"
> ? "border-indigo-500 text-gray-900"
<Settings className="mr-2 h-4 w-4" /> : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
Admin } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
</button> >
)} <Settings className="mr-2 h-4 w-4" />
<button Admin
onClick={() => setActiveTab('topics')} </button>
className={`${activeTab === 'topics' )}
? 'border-indigo-500 text-gray-900' <button
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' onClick={() => setActiveTab("topics")}
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`} className={`${
> activeTab === "topics"
<Hash className="mr-2 h-4 w-4" /> ? "border-indigo-500 text-gray-900"
Topics : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
</button> } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
{user.isAdmin && ( >
<button <Hash className="mr-2 h-4 w-4" />
onClick={() => setActiveTab('users')} Topics
className={`${activeTab === 'users' </button>
? 'border-indigo-500 text-gray-900' {user.isAdmin && (
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' <button
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`} onClick={() => setActiveTab("users")}
> className={`${
<Users className="mr-2 h-4 w-4" /> activeTab === "users"
Users ? "border-indigo-500 text-gray-900"
</button> : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
)} } inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
</div> >
</div> <Users className="mr-2 h-4 w-4" />
Users
</button>
)}
</div>
</div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-sm text-gray-700">{user.name}</span> <span className="text-sm text-gray-700">{user.name}</span>
{user.isAdmin && ( {user.isAdmin && (
<ShieldCheck className="h-5 w-5 text-indigo-600" /> <ShieldCheck className="h-5 w-5 text-indigo-600" />
)} )}
</div> </div>
<button <button
onClick={logout} onClick={logout}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100" className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100"
> >
<LogOut className="mr-1 h-4 w-4" /> <LogOut className="mr-1 h-4 w-4" />
Logout Logout
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{activeTab === 'topics' && <TopicsView />} {activeTab === "topics" && <TopicsView />}
{activeTab === 'users' && user.isAdmin && <UsersView />} {activeTab === "users" && user.isAdmin && <UsersView />}
{activeTab === 'admin' && user.isAdmin && <AdminView />} {activeTab === "admin" && user.isAdmin && <AdminView />}
</main> </main>
</div> </div>
) );
} }
export default App export default App;

View File

@@ -1,196 +1,228 @@
import { useState, useEffect } from 'react'; import { MessageCircle, Plus, Trash2 } from "lucide-react";
import { Trash2, Plus, MessageCircle } from 'lucide-react'; import { useEffect, useState } from "react";
import Modal from './Modal'; import { client } from "../lib/client";
import { client } from '../lib/client'; import Modal from "./Modal";
interface GroupBinding { interface GroupBinding {
id: string; id: string;
chatId: string; chatId: string;
name: string; name: string;
} }
interface KnownGroup { interface KnownGroup {
chatId: string; chatId: string;
name: string; name: string;
lastActiveAt: string; lastActiveAt: string;
} }
interface GroupBindingsModalProps { interface GroupBindingsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
topicId: string; topicId: string;
topicName: string; topicName: string;
} }
export default function GroupBindingsModal({ isOpen, onClose, topicId, topicName }: GroupBindingsModalProps) { export default function GroupBindingsModal({
// const { user } = useAuth(); // Unused isOpen,
const [bindings, setBindings] = useState<GroupBinding[]>([]); onClose,
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]); topicId,
const [selectedChatId, setSelectedChatId] = useState(''); topicName,
const [loading, setLoading] = useState(false); }: GroupBindingsModalProps) {
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); // const { user } = useAuth(); // Unused
const [bindings, setBindings] = useState<GroupBinding[]>([]);
const [knownGroups, setKnownGroups] = useState<KnownGroup[]>([]);
const [selectedChatId, setSelectedChatId] = useState("");
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
useEffect(() => { useEffect(() => {
if (isOpen && topicId) { if (isOpen && topicId) {
fetchBindings(); fetchBindings();
fetchKnownGroups(); fetchKnownGroups();
setStatus(null); setStatus(null);
setSelectedChatId(''); setSelectedChatId("");
} }
}, [isOpen, topicId]); }, [isOpen, topicId]);
const fetchBindings = async () => { const fetchBindings = async () => {
try { try {
const res = await client.api.topics[':id'].groups.$get({ const res = await client.api.topics[":id"].groups.$get(
param: { id: topicId } {
}, { param: { id: topicId },
init: { credentials: 'include' } },
}); {
const data = await res.json(); init: { credentials: "include" },
setBindings(data as any); },
} catch (err) { );
console.error(err); const data = await res.json();
} setBindings(data as any);
}; } catch (err) {
console.error(err);
}
};
const fetchKnownGroups = async () => { const fetchKnownGroups = async () => {
try { try {
const res = await client.api.groups.$get(undefined, { const res = await client.api.groups.$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
const data = await res.json(); const data = await res.json();
// Only verify uniqueness if needed, but here we just list what server returns // Only verify uniqueness if needed, but here we just list what server returns
setKnownGroups(data as any); setKnownGroups(data as any);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}; };
const handleBind = async () => { const handleBind = async () => {
if (!selectedChatId) return; if (!selectedChatId) return;
setLoading(true); setLoading(true);
setStatus(null); setStatus(null);
const group = knownGroups.find(g => g.chatId === selectedChatId); const group = knownGroups.find((g) => g.chatId === selectedChatId);
if (!group) return; if (!group) return;
try { try {
const res = await client.api.topics[':id'].groups.$post({ const res = await client.api.topics[":id"].groups.$post(
param: { id: topicId }, {
json: { param: { id: topicId },
chatId: group.chatId, json: {
name: group.name, chatId: group.chatId,
} name: group.name,
}, { },
init: { credentials: 'include' } },
}); {
init: { credentials: "include" },
},
);
if (res.ok) { if (res.ok) {
setStatus({ type: 'success', message: 'Group bound successfully!' }); setStatus({ type: "success", message: "Group bound successfully!" });
fetchBindings(); fetchBindings();
setSelectedChatId(''); setSelectedChatId("");
} else { } else {
await res.json(); // Consume body await res.json(); // Consume body
setStatus({ type: 'error', message: 'Failed to bind group' }); setStatus({ type: "error", message: "Failed to bind group" });
} }
} catch (_) { // Ignore error } catch (_) {
setStatus({ type: 'error', message: 'An error occurred' }); // Ignore error
} finally { setStatus({ type: "error", message: "An error occurred" });
setLoading(false); } finally {
} setLoading(false);
}; }
};
const handleUnbind = async (bindingId: string) => { const handleUnbind = async (bindingId: string) => {
if (!confirm('Are you sure you want to remove this group binding?')) return; if (!confirm("Are you sure you want to remove this group binding?")) return;
try { try {
const res = await client.api.topics[':id'].groups[':bindingId'].$delete({ const res = await client.api.topics[":id"].groups[":bindingId"].$delete(
param: { id: topicId, bindingId } {
}, { param: { id: topicId, bindingId },
init: { credentials: 'include' } },
}); {
init: { credentials: "include" },
},
);
if (res.ok) { if (res.ok) {
setBindings(prev => prev.filter(b => b.id !== bindingId)); setBindings((prev) => prev.filter((b) => b.id !== bindingId));
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}; };
// Filter out groups that are already bound // Filter out groups that are already bound
const availableGroups = knownGroups.filter( const availableGroups = knownGroups.filter(
kg => !bindings.some(b => b.chatId === kg.chatId) (kg) => !bindings.some((b) => b.chatId === kg.chatId),
); );
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={`Manage Group Chats for ${topicName}`} title={`Manage Group Chats for ${topicName}`}
> >
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="text-sm font-medium text-gray-900 mb-2">Bound Groups</h4> <h4 className="text-sm font-medium text-gray-900 mb-2">
{bindings.length === 0 ? ( Bound Groups
<p className="text-sm text-gray-500 italic">No groups bound to this topic yet.</p> </h4>
) : ( {bindings.length === 0 ? (
<ul className="divide-y divide-gray-200 border rounded-md"> <p className="text-sm text-gray-500 italic">
{bindings.map(binding => ( No groups bound to this topic yet.
<li key={binding.id} className="flex justify-between items-center p-3"> </p>
<div className="flex items-center"> ) : (
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" /> <ul className="divide-y divide-gray-200 border rounded-md">
<span className="text-sm text-gray-700">{binding.name}</span> {bindings.map((binding) => (
</div> <li
<button key={binding.id}
onClick={() => handleUnbind(binding.id)} className="flex justify-between items-center p-3"
className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50" >
title="Remove binding" <div className="flex items-center">
> <MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
<Trash2 className="w-4 h-4" /> <span className="text-sm text-gray-700">
</button> {binding.name}
</li> </span>
))} </div>
</ul> <button
)} onClick={() => handleUnbind(binding.id)}
</div> className="text-red-500 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Remove binding"
>
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
<div className="bg-gray-50 p-4 rounded-md border border-gray-200"> <div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<h4 className="text-sm font-medium text-gray-900 mb-3">Add Group Binding</h4> <h4 className="text-sm font-medium text-gray-900 mb-3">
<p className="text-xs text-gray-500 mb-3"> 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. </h4>
</p> <p className="text-xs text-gray-500 mb-3">
Select a group where the Feishu Bot has been added. If your group is
not listed, try removing and re-adding the bot to the group.
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900" className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
value={selectedChatId} value={selectedChatId}
onChange={(e) => setSelectedChatId(e.target.value)} onChange={(e) => setSelectedChatId(e.target.value)}
disabled={loading} disabled={loading}
> >
<option value="">Select a group...</option> <option value="">Select a group...</option>
{availableGroups.map(group => ( {availableGroups.map((group) => (
<option key={group.chatId} value={group.chatId}> <option key={group.chatId} value={group.chatId}>
{group.name} {group.name}
</option> </option>
))} ))}
</select> </select>
<button <button
onClick={handleBind} onClick={handleBind}
disabled={!selectedChatId || loading} disabled={!selectedChatId || loading}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
Add Add
</button> </button>
</div> </div>
{status && ( {status && (
<p className={`mt-2 text-xs ${status.type === 'success' ? 'text-green-600' : 'text-red-600'}`}> <p
{status.message} className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
</p> >
)} {status.message}
</div> </p>
</div> )}
</Modal> </div>
); </div>
</Modal>
);
} }

View File

@@ -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 { interface ModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
} }
export default function Modal({ isOpen, onClose, title, children }: ModalProps) { export default function Modal({
if (!isOpen) return null; isOpen,
onClose,
title,
children,
}: ModalProps) {
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true"> <div className="fixed inset-0 transition-opacity" aria-hidden="true">
<div className="absolute inset-0 bg-gray-500 opacity-75" onClick={onClose}></div> <div
</div> className="absolute inset-0 bg-gray-500 opacity-75"
onClick={onClose}
></div>
</div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title"> <h3
{title} className="text-lg leading-6 font-medium text-gray-900"
</h3> id="modal-title"
<button >
onClick={onClose} {title}
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none" </h3>
> <button
<X className="h-6 w-6" /> onClick={onClose}
</button> className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
</div> >
<div className="mt-2"> <X className="h-6 w-6" />
{children} </button>
</div> </div>
</div> <div className="mt-2">{children}</div>
</div> </div>
</div> </div>
</div> </div>
); </div>
);
} }

View File

@@ -1,85 +1,92 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import {
import { client } from '../lib/client'; createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { client } from "../lib/client";
interface User { interface User {
id: string; id: string;
name: string; name: string;
email: string | null; email: string | null;
isAdmin: boolean; isAdmin: boolean;
personalToken: string; personalToken: string;
} }
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
login: () => void; login: () => void;
logout: () => void; logout: () => void;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const checkAuth = useCallback(async () => { const checkAuth = useCallback(async () => {
try { try {
const res = await client.api.auth.me.$get(undefined, { const res = await client.api.auth.me.$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setUser(data.user); setUser(data.user);
} else { } else {
setUser(null); setUser(null);
} }
} catch (error) { } catch (error) {
console.error('Auth check failed:', error); console.error("Auth check failed:", error);
setUser(null); setUser(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
checkAuth(); checkAuth();
}, [checkAuth]); }, [checkAuth]);
const login = useCallback(async () => { const login = useCallback(async () => {
try { try {
const res = await client.api.auth['login-url'].$get(undefined, { const res = await client.api.auth["login-url"].$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
const data = await res.json(); const data = await res.json();
window.location.href = data.loginUrl; window.location.href = data.loginUrl;
} catch (error) { } catch (error) {
console.error('Login failed:', error); console.error("Login failed:", error);
} }
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
await client.api.auth.logout.$post(undefined, { await client.api.auth.logout.$post(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
setUser(null); setUser(null);
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error("Logout failed:", error);
} }
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}> <AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error("useAuth must be used within an AuthProvider");
} }
return context; return context;
} }

View File

@@ -3,12 +3,18 @@
@tailwind utilities; @tailwind utilities;
@layer utilities { @layer utilities {
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.5s ease-out forwards; animation: fadeIn 0.5s ease-out forwards;
} }
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }

View File

@@ -1,4 +1,4 @@
import { hc } from 'hono/client'; import { hc } from "hono/client";
import type { AppType } from '../../../server/src/index'; import type { AppType } from "../../../server/src/index";
export const client = hc<AppType>('/') as any; export const client = hc<AppType>("/") as any;

View File

@@ -1,17 +1,17 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import App from './App.tsx' import App from "./App.tsx";
import AuthCallback from './views/AuthCallback.tsx' import { AuthProvider } from "./contexts/AuthContext.tsx";
import { AuthProvider } from './contexts/AuthContext.tsx' import AuthCallback from "./views/AuthCallback.tsx";
import './index.css' import "./index.css";
// Simple routing based on pathname // Simple routing based on pathname
const pathname = window.location.pathname; const pathname = window.location.pathname;
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider> <AuthProvider>
{pathname === '/auth/callback' ? <AuthCallback /> : <App />} {pathname === "/auth/callback" ? <AuthCallback /> : <App />}
</AuthProvider> </AuthProvider>
</React.StrictMode>, </React.StrictMode>,
) );

View File

@@ -1,236 +1,285 @@
import { useState, useEffect } from 'react'; import { useEffect, useState } from "react";
import { client } from '../lib/client'; import { client } from "../lib/client";
import SystemLoadView from './SystemLoadView'; import SystemLoadView from "./SystemLoadView";
export default function AdminView() { export default function AdminView() {
const [activeTab, setActiveTab] = useState('load'); const [activeTab, setActiveTab] = useState("load");
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2> <h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2>
</div> </div>
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-white shadow rounded-lg p-6">
<div className="border-b border-gray-200 mb-6"> <div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex space-x-8">
<button <button
onClick={() => setActiveTab('load')} onClick={() => setActiveTab("load")}
className={`${activeTab === 'load' className={`${
? 'border-indigo-500 text-indigo-600' activeTab === "load"
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' ? "border-indigo-500 text-indigo-600"
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`} : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
> } whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
System Load >
</button> System Load
<button </button>
onClick={() => setActiveTab('requests')} <button
className={`${activeTab === 'requests' onClick={() => setActiveTab("requests")}
? 'border-indigo-500 text-indigo-600' className={`${
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' activeTab === "requests"
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`} ? "border-indigo-500 text-indigo-600"
> : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
Topic Requests } whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
</button> >
<button Topic Requests
onClick={() => setActiveTab('topics')} </button>
className={`${activeTab === 'topics' <button
? 'border-indigo-500 text-indigo-600' onClick={() => setActiveTab("topics")}
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' className={`${
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`} activeTab === "topics"
> ? "border-indigo-500 text-indigo-600"
All Topics : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
</button> } whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
</nav> >
</div> All Topics
</button>
</nav>
</div>
{activeTab === 'load' && <SystemLoadView />} {activeTab === "load" && <SystemLoadView />}
{activeTab === 'requests' && <TopicRequestsList />} {activeTab === "requests" && <TopicRequestsList />}
{activeTab === 'topics' && <TopicsManagement />} {activeTab === "topics" && <TopicsManagement />}
</div> </div>
</div> </div>
); );
} }
function TopicsManagement() { function TopicsManagement() {
const [topics, setTopics] = useState<any[]>([]); const [topics, setTopics] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchAllTopics = async () => { const fetchAllTopics = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await client.api.topics.all.$get(undefined, { const res = await client.api.topics.all.$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
const data = await res.json(); const data = await res.json();
setTopics(data); setTopics(data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchAllTopics(); fetchAllTopics();
}, []); }, []);
const handleDelete = async (id: string, name: string) => { const handleDelete = async (id: string, name: string) => {
if (!confirm(`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`)) { if (
return; !confirm(
} `Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`,
)
) {
return;
}
try { try {
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } }); await client.api.topics[":id"].$delete(
fetchAllTopics(); { param: { id } },
} catch (error) { { init: { credentials: "include" } },
console.error(error); );
} fetchAllTopics();
}; } catch (error) {
console.error(error);
}
};
if (loading) return <div>Loading topics...</div>; if (loading) return <div>Loading topics...</div>;
return ( return (
<div className="overflow-hidden"> <div className="overflow-hidden">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Topic</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> Topic
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Subscribers</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created By</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved By</th> Status
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> </th>
</tr> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</thead> Subscribers
<tbody className="bg-white divide-y divide-gray-200"> </th>
{topics.map((topic) => ( <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<tr key={topic.id}> Created By
<td className="px-6 py-4 whitespace-nowrap"> </th>
<div className="text-sm font-medium text-gray-900">{topic.name}</div> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="text-sm text-gray-500 font-mono">{topic.slug}</div> Approved By
</td> </th>
<td className="px-6 py-4 whitespace-nowrap"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${topic.status === 'approved' ? 'bg-green-100 text-green-800' : Actions
topic.status === 'rejected' ? 'bg-red-100 text-red-800' : </th>
'bg-yellow-100 text-yellow-800' </tr>
}`}> </thead>
{topic.status} <tbody className="bg-white divide-y divide-gray-200">
</span> {topics.map((topic) => (
</td> <tr key={topic.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap">
{topic.subscriptions?.length || 0} <div className="text-sm font-medium text-gray-900">
</td> {topic.name}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> </div>
{topic.creator?.name || 'Unknown'} <div className="text-sm text-gray-500 font-mono">
</td> {topic.slug}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> </div>
{topic.approver?.name || '-'} </td>
</td> <td className="px-6 py-4 whitespace-nowrap">
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <span
<button className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
onClick={() => handleDelete(topic.id, topic.name)} topic.status === "approved"
className="text-red-600 hover:text-red-900" ? "bg-green-100 text-green-800"
> : topic.status === "rejected"
Delete ? "bg-red-100 text-red-800"
</button> : "bg-yellow-100 text-yellow-800"
</td> }`}
</tr> >
))} {topic.status}
</tbody> </span>
</table> </td>
</div> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
); {topic.subscriptions?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.creator?.name || "Unknown"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{topic.approver?.name || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDelete(topic.id, topic.name)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
} }
function TopicRequestsList() { function TopicRequestsList() {
const [requests, setRequests] = useState<any[]>([]); const [requests, setRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchRequests = async () => { const fetchRequests = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await client.api.topics.requests.$get(undefined, { const res = await client.api.topics.requests.$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
const data = await res.json(); const data = await res.json();
setRequests(data); setRequests(data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchRequests(); fetchRequests();
}, []); }, []);
const handleAction = async (id: string, action: 'approve' | 'reject' | 'delete', name?: string) => { const handleAction = async (
try { id: string,
if (action === 'approve') { action: "approve" | "reject" | "delete",
await client.api.topics[':id'].approve.$post({ param: { id } }, { init: { credentials: 'include' } }); name?: string,
} else if (action === 'reject') { ) => {
await client.api.topics[':id'].reject.$post({ param: { id } }, { init: { credentials: 'include' } }); try {
} else if (action === 'delete') { if (action === "approve") {
if (!confirm(`Are you sure you want to delete request "${name}"?`)) return; await client.api.topics[":id"].approve.$post(
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } }); { param: { id } },
} { init: { credentials: "include" } },
fetchRequests(); );
} catch (error) { } else if (action === "reject") {
console.error(error); 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 <div>Loading requests...</div>; if (loading) return <div>Loading requests...</div>;
if (requests.length === 0) { if (requests.length === 0) {
return ( return (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
No pending topic requests. No pending topic requests.
</div> </div>
); );
} }
return ( return (
<div className="overflow-hidden"> <div className="overflow-hidden">
<ul className="divide-y divide-gray-200"> <ul className="divide-y divide-gray-200">
{requests.map(req => ( {requests.map((req) => (
<li key={req.id} className="py-4 flex justify-between items-center"> <li key={req.id} className="py-4 flex justify-between items-center">
<div> <div>
<p className="font-medium text-gray-900">{req.name}</p> <p className="font-medium text-gray-900">{req.name}</p>
<p className="text-sm text-gray-500">Slug: <span className="font-mono">{req.slug}</span></p> <p className="text-sm text-gray-500">
<p className="text-sm text-gray-500"> Slug: <span className="font-mono">{req.slug}</span>
Requested by: {req.creator?.name || 'Unknown'} </p>
{req.creator?.email ? ` (${req.creator.email})` : ''} <p className="text-sm text-gray-500">
</p> Requested by: {req.creator?.name || "Unknown"}
{req.description && ( {req.creator?.email ? ` (${req.creator.email})` : ""}
<p className="text-sm text-gray-500 mt-1 italic">"{req.description}"</p> </p>
)} {req.description && (
</div> <p className="text-sm text-gray-500 mt-1 italic">
<div className="flex gap-2"> "{req.description}"
<button </p>
onClick={() => handleAction(req.id, 'approve')} )}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors" </div>
> <div className="flex gap-2">
Approve <button
</button> onClick={() => handleAction(req.id, "approve")}
<button className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium shadow-sm transition-colors"
onClick={() => handleAction(req.id, 'reject')} >
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors" Approve
> </button>
Reject <button
</button> onClick={() => handleAction(req.id, "reject")}
<button className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium transition-colors"
onClick={() => handleAction(req.id, 'delete', req.name)} >
className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors" Reject
> </button>
Delete <button
</button> onClick={() => handleAction(req.id, "delete", req.name)}
</div> className="px-4 py-2 border border-gray-300 text-gray-600 rounded hover:bg-gray-50 text-sm font-medium transition-colors"
</li> >
))} Delete
</ul> </button>
</div> </div>
); </li>
))}
</ul>
</div>
);
} }

View File

@@ -1,76 +1,83 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useRef, useState } from "react";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
import { client } from '../lib/client'; import { client } from "../lib/client";
export default function AuthCallback() { export default function AuthCallback() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { checkAuth } = useAuth(); const { checkAuth } = useAuth();
const processed = useRef(false); const processed = useRef(false);
useEffect(() => { useEffect(() => {
const handleCallback = async () => { const handleCallback = async () => {
if (processed.current) return; if (processed.current) return;
processed.current = true; processed.current = true;
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const code = params.get('code'); const code = params.get("code");
const state = params.get('state'); const state = params.get("state");
if (!code) { if (!code) {
setError('No authorization code received'); setError("No authorization code received");
return; return;
} }
try { try {
const res = await client.api.auth.callback.$get({ const res = await client.api.auth.callback.$get(
query: { {
code, query: {
state: state || undefined code,
} state: state || undefined,
}, { },
init: { credentials: 'include' } },
}); {
init: { credentials: "include" },
},
);
if (res.ok) { if (res.ok) {
await checkAuth(); await checkAuth();
// Redirect to home // Redirect to home
window.location.href = '/'; window.location.href = "/";
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || 'Authentication failed'); setError(data.error || "Authentication failed");
} }
} catch (err) { } catch (err) {
setError('Authentication failed'); setError("Authentication failed");
console.error(err); console.error(err);
} }
}; };
handleCallback(); handleCallback();
}, [checkAuth]); }, [checkAuth]);
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6"> <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-red-600 mb-4">Authentication Error</h2> <h2 className="text-2xl font-bold text-red-600 mb-4">
<p className="text-gray-700">{error}</p> Authentication Error
<button </h2>
onClick={() => window.location.href = '/'} <p className="text-gray-700">{error}</p>
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700" <button
> onClick={() => (window.location.href = "/")}
Return to Home className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
</button> >
</div> Return to Home
</div> </button>
); </div>
} </div>
);
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6"> <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Authenticating...</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div> Authenticating...
</div> </h2>
</div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
); </div>
</div>
);
} }

View File

@@ -1,291 +1,380 @@
import { useState, useEffect } from 'react'; import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react";
import { client } from '../lib/client'; import { useEffect, useState } from "react";
import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react'; import { client } from "../lib/client";
interface Stats { interface Stats {
topics: { topics: {
topicSlug: string; topicSlug: string;
totalTasks: number; totalTasks: number;
totalRecipients: number; totalRecipients: number;
totalSuccess: number; totalSuccess: number;
}[]; }[];
recent: { recent: {
alertsReceived: number; alertsReceived: number;
plannedMessages: number; plannedMessages: number;
successCount: number; successCount: number;
failedCount: number; failedCount: number;
successRate: number; successRate: number;
}; };
tasks: any[]; tasks: any[];
} }
export default function SystemLoadView() { export default function SystemLoadView() {
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date()); const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const fetchStats = async () => { const fetchStats = async () => {
try { try {
const res = await client.api.stats.$get(undefined, { const res = await client.api.stats.$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
const data = await res.json(); const data = await res.json();
// Fetch recent tasks as well // Fetch recent tasks as well
const tasksRes = await client.api.alerts.tasks.$get({ query: { limit: '10' } }, { const tasksRes = await client.api.alerts.tasks.$get(
init: { credentials: 'include' } { query: { limit: "10" } },
}); {
const tasks = await tasksRes.json(); init: { credentials: "include" },
},
);
const tasks = await tasksRes.json();
setStats({ ...data, tasks } as Stats); setStats({ ...data, tasks } as Stats);
setLastUpdated(new Date()); setLastUpdated(new Date());
} catch (error) { } catch (error) {
console.error('Failed to fetch stats:', error); console.error("Failed to fetch stats:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchStats(); fetchStats();
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
if (loading) return ( if (loading)
<div className="flex justify-center items-center h-64"> return (
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div> <div className="flex justify-center items-center h-64">
</div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
); </div>
);
if (!stats) return <div className="text-center py-12 text-gray-500">Failed to load statistics.</div>; if (!stats)
return (
<div className="text-center py-12 text-gray-500">
Failed to load statistics.
</div>
);
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="relative flex h-3 w-3"> <span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span> <span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span> </span>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">Live Feedback</span> <span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
</div> Live Feedback
<span className="text-xs text-gray-400">Last updated: {lastUpdated.toLocaleTimeString()}</span> </span>
</div> </div>
<span className="text-xs text-gray-400">
Last updated: {lastUpdated.toLocaleTimeString()}
</span>
</div>
{/* Top Row: General Metrics */} {/* Top Row: General Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<MetricCard <MetricCard
title="Alerts Received" title="Alerts Received"
value={stats.recent.alertsReceived} value={stats.recent.alertsReceived}
icon={<Activity className="w-5 h-5 text-purple-500" />} icon={<Activity className="w-5 h-5 text-purple-500" />}
color="purple" color="purple"
description="Total webhook hits" description="Total webhook hits"
/> />
<MetricCard <MetricCard
title="Planned Deliveries" title="Planned Deliveries"
value={stats.recent.plannedMessages} value={stats.recent.plannedMessages}
icon={<Clock className="w-5 h-5 text-blue-500" />} icon={<Clock className="w-5 h-5 text-blue-500" />}
color="blue" color="blue"
description="Total subscribers" description="Total subscribers"
/> />
<MetricCard <MetricCard
title="Success" title="Success"
value={stats.recent.successCount} value={stats.recent.successCount}
icon={<CheckCircle className="w-5 h-5 text-green-500" />} icon={<CheckCircle className="w-5 h-5 text-green-500" />}
color="green" color="green"
description="Successfully sent" description="Successfully sent"
/> />
<MetricCard <MetricCard
title="Failed" title="Failed"
value={stats.recent.failedCount} value={stats.recent.failedCount}
icon={<XCircle className="w-5 h-5 text-red-500" />} icon={<XCircle className="w-5 h-5 text-red-500" />}
color="red" color="red"
description="API errors/failures" description="API errors/failures"
/> />
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center"> <div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center justify-center">
<span className="text-xs font-medium text-gray-500 mb-2">Success Rate</span> <span className="text-xs font-medium text-gray-500 mb-2">
<Gauge value={stats.recent.successRate} /> Success Rate
</div> </span>
</div> <Gauge value={stats.recent.successRate} />
</div>
</div>
{/* Middle Row: Topic Message Counts */} {/* Middle Row: Topic Message Counts */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"> <div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-indigo-500" /> <BarChart3 className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">Historical Topic Metrics</h3> <h3 className="font-semibold text-gray-800">
</div> Historical Topic Metrics
</div> </h3>
<div className="overflow-x-auto"> </div>
<table className="min-w-full divide-y divide-gray-200"> </div>
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500"> <div className="overflow-x-auto">
<tr> <table className="min-w-full divide-y divide-gray-200">
<th className="px-6 py-4 text-left tracking-wider">Topic</th> <thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<th className="px-6 py-4 text-left tracking-wider">Alerts (Tasks)</th> <tr>
<th className="px-6 py-4 text-left tracking-wider">Planned (Recipients)</th> <th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Distributed (Success)</th> <th className="px-6 py-4 text-left tracking-wider">
<th className="px-6 py-4 text-left tracking-wider">Health Rate</th> Alerts (Tasks)
<th className="px-6 py-4 text-left tracking-wider">Status</th> </th>
</tr> <th className="px-6 py-4 text-left tracking-wider">
</thead> Planned (Recipients)
<tbody className="bg-white divide-y divide-gray-200"> </th>
{stats.topics.map((topic) => { <th className="px-6 py-4 text-left tracking-wider">
const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100; Distributed (Success)
return ( </th>
<tr key={topic.topicSlug} className="hover:bg-gray-50 transition-colors"> <th className="px-6 py-4 text-left tracking-wider">
<td className="px-6 py-4 whitespace-nowrap"> Health Rate
<span className={`font-mono text-xs font-bold ${topic.topicSlug ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 bg-gray-100'} px-2 py-1 rounded-md`}> </th>
{topic.topicSlug || '[Private DM]'} <th className="px-6 py-4 text-left tracking-wider">Status</th>
</span> </tr>
</td> </thead>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">{topic.totalTasks}</td> <tbody className="bg-white divide-y divide-gray-200">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalRecipients}</td> {stats.topics.map((topic) => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalSuccess}</td> const rate =
<td className="px-6 py-4 whitespace-nowrap"> topic.totalRecipients > 0
<div className="flex items-center gap-3"> ? (topic.totalSuccess / topic.totalRecipients) * 100
<div className="w-20 bg-gray-100 rounded-full h-1.5"> : 100;
<div return (
className={`h-1.5 rounded-full transition-all duration-1000 ${rate > 90 ? 'bg-green-500' : rate > 70 ? 'bg-yellow-500' : 'bg-red-500'}`} <tr
style={{ width: `${rate}%` }} key={topic.topicSlug}
></div> className="hover:bg-gray-50 transition-colors"
</div> >
<span className="text-[11px] font-bold text-gray-700">{rate.toFixed(1)}%</span> <td className="px-6 py-4 whitespace-nowrap">
</div> <span
</td> className={`font-mono text-xs font-bold ${topic.topicSlug ? "text-indigo-600 bg-indigo-50" : "text-gray-600 bg-gray-100"} px-2 py-1 rounded-md`}
<td className="px-6 py-4 whitespace-nowrap"> >
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${rate === 100 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700' {topic.topicSlug || "[Private DM]"}
}`}> </span>
{rate === 100 ? 'Healthy' : 'Errors'} </td>
</span> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">
</td> {topic.totalTasks}
</tr> </td>
); <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
})} {topic.totalRecipients}
</tbody> </td>
</table> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
</div> {topic.totalSuccess}
</div> </td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div className="w-20 bg-gray-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all duration-1000 ${rate > 90 ? "bg-green-500" : rate > 70 ? "bg-yellow-500" : "bg-red-500"}`}
style={{ width: `${rate}%` }}
></div>
</div>
<span className="text-[11px] font-bold text-gray-700">
{rate.toFixed(1)}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
rate === 100
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{rate === 100 ? "Healthy" : "Errors"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Bottom Row: Recent Alerts with Sender Info */} {/* Bottom Row: Recent Alerts with Sender Info */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"> <div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-indigo-500" /> <Clock className="w-5 h-5 text-indigo-500" />
<h3 className="font-semibold text-gray-800">Recent Alerts (Audit Log)</h3> <h3 className="font-semibold text-gray-800">
</div> Recent Alerts (Audit Log)
</div> </h3>
<div className="overflow-x-auto"> </div>
<table className="min-w-full divide-y divide-gray-200"> </div>
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500"> <div className="overflow-x-auto">
<tr> <table className="min-w-full divide-y divide-gray-200">
<th className="px-6 py-4 text-left tracking-wider">Time</th> <thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
<th className="px-6 py-4 text-left tracking-wider">Topic</th> <tr>
<th className="px-6 py-4 text-left tracking-wider">Sender</th> <th className="px-6 py-4 text-left tracking-wider">Time</th>
<th className="px-6 py-4 text-left tracking-wider">Recipients</th> <th className="px-6 py-4 text-left tracking-wider">Topic</th>
<th className="px-6 py-4 text-left tracking-wider">Status</th> <th className="px-6 py-4 text-left tracking-wider">Sender</th>
</tr> <th className="px-6 py-4 text-left tracking-wider">
</thead> Recipients
<tbody className="bg-white divide-y divide-gray-200"> </th>
{stats.tasks.map((task: any) => ( <th className="px-6 py-4 text-left tracking-wider">Status</th>
<tr key={task.id} className="hover:bg-gray-50 transition-colors"> </tr>
<td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium"> </thead>
{new Date(task.createdAt).toLocaleString()} <tbody className="bg-white divide-y divide-gray-200">
</td> {stats.tasks.map((task: any) => (
<td className="px-6 py-4 whitespace-nowrap"> <tr
<span className={`font-mono text-xs font-bold ${task.topicSlug ? 'text-indigo-600 bg-indigo-50' : 'text-gray-600 bg-gray-100'} px-2 py-1 rounded-md`}> key={task.id}
{task.topicSlug || '[Private DM]'} className="hover:bg-gray-50 transition-colors"
</span> >
</td> <td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium">
<td className="px-6 py-4 whitespace-nowrap"> {new Date(task.createdAt).toLocaleString()}
<div className="flex flex-col"> </td>
<span className="text-sm font-medium text-gray-900">{task.sender?.name || 'Unknown'}</span> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-[10px] text-gray-400">{task.sender?.email || 'N/A'}</span> <span
</div> className={`font-mono text-xs font-bold ${task.topicSlug ? "text-indigo-600 bg-indigo-50" : "text-gray-600 bg-gray-100"} px-2 py-1 rounded-md`}
</td> >
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600"> {task.topicSlug || "[Private DM]"}
{task.successCount} / {task.recipientCount} </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${task.status === 'completed' ? 'bg-green-100 text-green-700' : task.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700' <div className="flex flex-col">
}`}> <span className="text-sm font-medium text-gray-900">
{task.status} {task.sender?.name || "Unknown"}
</span> </span>
</td> <span className="text-[10px] text-gray-400">
</tr> {task.sender?.email || "N/A"}
))} </span>
{stats.tasks.length === 0 && ( </div>
<tr> </td>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500 italic text-sm"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
No alerts sent yet. {task.successCount} / {task.recipientCount}
</td> </td>
</tr> <td className="px-6 py-4 whitespace-nowrap">
)} <span
</tbody> className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
</table> task.status === "completed"
</div> ? "bg-green-100 text-green-700"
</div> : task.status === "failed"
</div> ? "bg-red-100 text-red-700"
); : "bg-blue-100 text-blue-700"
}`}
>
{task.status}
</span>
</td>
</tr>
))}
{stats.tasks.length === 0 && (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-gray-500 italic text-sm"
>
No alerts sent yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
} }
function MetricCard({ title, value, icon, color, description }: { title: string, value: number, icon: React.ReactNode, color: string, description?: string }) { function MetricCard({
return ( title,
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex flex-col transition-all hover:shadow-md hover:-translate-y-1"> value,
<div className="flex items-start justify-between mb-4"> icon,
<div className={`p-2.5 rounded-xl bg-${color}-50`}> color,
{icon} description,
</div> }: {
<div className="text-right"> title: string;
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">{title}</p> value: number;
<h3 className="text-2xl font-black text-gray-900 leading-none mt-1">{value.toLocaleString()}</h3> icon: React.ReactNode;
</div> color: string;
</div> description?: string;
{description && <p className="text-[10px] text-gray-500 font-medium italic">/ {description}</p>} }) {
</div> return (
); <div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex flex-col transition-all hover:shadow-md hover:-translate-y-1">
<div className="flex items-start justify-between mb-4">
<div className={`p-2.5 rounded-xl bg-${color}-50`}>{icon}</div>
<div className="text-right">
<p className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">
{title}
</p>
<h3 className="text-2xl font-black text-gray-900 leading-none mt-1">
{value.toLocaleString()}
</h3>
</div>
</div>
{description && (
<p className="text-[10px] text-gray-500 font-medium italic">
/ {description}
</p>
)}
</div>
);
} }
function Gauge({ value }: { value: number }) { function Gauge({ value }: { value: number }) {
const radius = 40; const radius = 40;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference; const offset = circumference - (value / 100) * circumference;
// Determine color based on value // Determine color based on value
const getColor = (v: number) => { const getColor = (v: number) => {
if (v >= 95) return '#10b981'; // green-500 if (v >= 95) return "#10b981"; // green-500
if (v >= 80) return '#f59e0b'; // yellow-500 if (v >= 80) return "#f59e0b"; // yellow-500
return '#ef4444'; // red-500 return "#ef4444"; // red-500
}; };
return ( return (
<div className="relative flex items-center justify-center"> <div className="relative flex items-center justify-center">
<svg className="w-32 h-32 transform -rotate-90"> <svg className="w-32 h-32 transform -rotate-90">
<circle <circle
className="text-gray-100" className="text-gray-100"
strokeWidth="8" strokeWidth="8"
stroke="currentColor" stroke="currentColor"
fill="transparent" fill="transparent"
r={radius} r={radius}
cx="64" cx="64"
cy="64" cy="64"
/> />
<circle <circle
className="transition-all duration-1000 ease-out" className="transition-all duration-1000 ease-out"
strokeWidth="8" strokeWidth="8"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={offset} strokeDashoffset={offset}
strokeLinecap="round" strokeLinecap="round"
stroke={getColor(value)} stroke={getColor(value)}
fill="transparent" fill="transparent"
r={radius} r={radius}
cx="64" cx="64"
cy="64" cy="64"
/> />
</svg> </svg>
<div className="absolute inset-0 flex flex-col items-center justify-center -mt-1"> <div className="absolute inset-0 flex flex-col items-center justify-center -mt-1">
<span className="text-2xl font-bold text-gray-900">{value.toFixed(1)}%</span> <span className="text-2xl font-bold text-gray-900">
</div> {value.toFixed(1)}%
</div> </span>
); </div>
</div>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +1,218 @@
import { useState, useEffect } from 'react'; import { Plus, Trash2 } from "lucide-react";
import { Trash2, Plus } from 'lucide-react'; import { useEffect, useState } from "react";
import Modal from '../components/Modal'; import Modal from "../components/Modal";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
import { client } from '../lib/client'; import { client } from "../lib/client";
interface User { interface User {
id: string; id: string;
name: string; name: string;
feishuUserId?: string; feishuUserId?: string;
email?: string; email?: string;
personalToken?: string; personalToken?: string;
} }
export default function UsersView() { export default function UsersView() {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Partial<User>>({ const [formData, setFormData] = useState<Partial<User>>({
name: '', name: "",
feishuUserId: '', feishuUserId: "",
email: '', email: "",
}); });
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await client.api.users.$get(undefined, { const res = await client.api.users.$get(undefined, {
init: { credentials: 'include' } init: { credentials: "include" },
}); });
const data = await res.json(); const data = await res.json();
setUsers(data as unknown as User[]); setUsers(data as unknown as User[]);
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
const res = await client.api.users.$post({ const res = await client.api.users.$post(
json: formData as any {
}, { json: formData as any,
init: { credentials: 'include' } },
}); {
init: { credentials: "include" },
},
);
if (res.ok) { if (res.ok) {
setIsModalOpen(false); setIsModalOpen(false);
setFormData({ name: '', feishuUserId: '', email: '' }); setFormData({ name: "", feishuUserId: "", email: "" });
fetchUsers(); fetchUsers();
} }
} catch (error) { } catch (error) {
console.error('Error creating user:', error); console.error("Error creating user:", error);
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this user?')) return; if (!confirm("Are you sure you want to delete this user?")) return;
try { try {
await client.api.users[':id'].$delete({ await client.api.users[":id"].$delete(
param: { id } {
}, { param: { id },
init: { credentials: 'include' } },
}); {
fetchUsers(); init: { credentials: "include" },
} catch (error) { },
console.error('Error deleting user:', error); );
} fetchUsers();
}; } catch (error) {
console.error("Error deleting user:", error);
}
};
if (loading) return <div className="p-4">Loading...</div>; if (loading) return <div className="p-4">Loading...</div>;
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Users</h2> <h2 className="text-2xl font-bold text-gray-900">Users</h2>
{currentUser?.isAdmin && ( {currentUser?.isAdmin && (
<button <button
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center" className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 flex items-center"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Add User Add User
</button> </button>
)} )}
</div> </div>
<div className="bg-white shadow overflow-hidden sm:rounded-md"> <div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200"> <ul className="divide-y divide-gray-200">
{users.map((user) => ( {users.map((user) => (
<li key={user.id}> <li key={user.id}>
<div className="px-4 py-4 sm:px-6"> <div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-indigo-600 truncate">{user.name}</p> <p className="text-sm font-medium text-indigo-600 truncate">
<div className="mt-2 sm:flex sm:justify-between"> {user.name}
<div className="sm:flex flex-col"> </p>
<p className="flex items-center text-sm text-gray-500"> <div className="mt-2 sm:flex sm:justify-between">
Feishu ID: <span className="font-mono ml-1 bg-gray-100 px-1 rounded">{user.feishuUserId || 'N/A'}</span> <div className="sm:flex flex-col">
</p> <p className="flex items-center text-sm text-gray-500">
<p className="flex items-center text-sm text-gray-500 mt-1"> Feishu ID:{" "}
Email: {user.email || 'N/A'} <span className="font-mono ml-1 bg-gray-100 px-1 rounded">
</p> {user.feishuUserId || "N/A"}
<p className="flex items-center text-sm text-gray-500 mt-1"> </span>
Personal Token: <span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">{user.personalToken || 'N/A'}</span> </p>
</p> <p className="flex items-center text-sm text-gray-500 mt-1">
</div> Email: {user.email || "N/A"}
</div> </p>
</div> <p className="flex items-center text-sm text-gray-500 mt-1">
{currentUser?.isAdmin && ( Personal Token:{" "}
<div className="ml-4 flex items-center space-x-2"> <span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">
<button {user.personalToken || "N/A"}
onClick={() => handleDelete(user.id)} </span>
className="text-red-600 hover:text-red-900 p-2" </p>
> </div>
<Trash2 className="w-5 h-5" /> </div>
</button> </div>
</div> {currentUser?.isAdmin && (
)} <div className="ml-4 flex items-center space-x-2">
</div> <button
</div> onClick={() => handleDelete(user.id)}
</li> className="text-red-600 hover:text-red-900 p-2"
))} >
{users.length === 0 && ( <Trash2 className="w-5 h-5" />
<li className="px-4 py-8 text-center text-gray-500"> </button>
No users found. Create one to get started. </div>
</li> )}
)} </div>
</ul> </div>
</div> </li>
))}
{users.length === 0 && (
<li className="px-4 py-8 text-center text-gray-500">
No users found. Create one to get started.
</li>
)}
</ul>
</div>
<Modal <Modal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)} onClose={() => setIsModalOpen(false)}
title="Add New User" title="Add New User"
> >
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Name</label> <label className="block text-sm font-medium text-gray-700">
<input Name
type="text" </label>
required <input
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" type="text"
value={formData.name} required
onChange={e => setFormData({ ...formData, name: e.target.value })} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
/> value={formData.name}
</div> onChange={(e) =>
<div> setFormData({ ...formData, name: e.target.value })
<label className="block text-sm font-medium text-gray-700">Feishu User ID</label> }
<input />
type="text" </div>
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" <div>
value={formData.feishuUserId} <label className="block text-sm font-medium text-gray-700">
onChange={e => setFormData({ ...formData, feishuUserId: e.target.value })} Feishu User ID
/> </label>
</div> <input
<div> type="text"
<label className="block text-sm font-medium text-gray-700">Email</label> className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
<input value={formData.feishuUserId}
type="email" onChange={(e) =>
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2" setFormData({ ...formData, feishuUserId: e.target.value })
value={formData.email} }
onChange={e => setFormData({ ...formData, email: e.target.value })} />
/> </div>
</div> <div>
<div className="flex justify-end pt-4"> <label className="block text-sm font-medium text-gray-700">
<button Email
type="button" </label>
onClick={() => setIsModalOpen(false)} <input
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50" type="email"
> className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
Cancel value={formData.email}
</button> onChange={(e) =>
<button setFormData({ ...formData, email: e.target.value })
type="submit" }
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700" />
> </div>
Create User <div className="flex justify-end pt-4">
</button> <button
</div> type="button"
</form> onClick={() => setIsModalOpen(false)}
</Modal> className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
</div> >
); Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700"
>
Create User
</button>
</div>
</form>
</Modal>
</div>
);
} }

View File

@@ -1,11 +1,8 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
"./index.html", theme: {
"./src/**/*.{js,ts,jsx,tsx}", extend: {},
], },
theme: { plugins: [],
extend: {}, };
},
plugins: [],
}

View File

@@ -1,30 +1,30 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,25 +1,25 @@
import { defineConfig } from 'vite' import react from "@vitejs/plugin-react";
import react from '@vitejs/plugin-react' import path from "path";
import path from 'path' import { defineConfig } from "vite";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: { server: {
proxy: { proxy: {
'/api': { "/api": {
target: process.env.VITE_API_URL || 'http://localhost:3000', target: process.env.VITE_API_URL || "http://localhost:3000",
changeOrigin: true, changeOrigin: true,
}, },
'/webhook': { "/webhook": {
target: process.env.VITE_API_URL || 'http://localhost:3000', target: process.env.VITE_API_URL || "http://localhost:3000",
changeOrigin: true, changeOrigin: true,
} },
} },
} },
}) });

62
biome.json Normal file
View File

@@ -0,0 +1,62 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useButtonType": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off"
},
"suspicious": {
"noAssignInExpressions": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noRedeclare": "off",
"noUnknownAtRules": "off"
},
"style": {
"noNonNullAssertion": "off",
"useNodejsImportProtocol": "off"
},
"correctness": {
"useExhaustiveDependencies": "off",
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
},
"css": {
"linter": {
"enabled": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View File

@@ -1,15 +1,18 @@
{ {
"name": "alertmessagecenter", "name": "alertmessagecenter",
"version": "1.1.1", "version": "1.1.1",
"workspaces": [ "workspaces": [
"apps/*" "apps/*"
], ],
"scripts": { "scripts": {
"dev": "bun run --filter '*' dev", "dev": "bun run --filter '*' dev",
"build": "bun run --filter '*' build", "build": "bun run --filter '*' build",
"start": "bun run --filter '@alertmessagecenter/server' start" "start": "bun run --filter '@alertmessagecenter/server' start",
}, "lint": "bunx @biomejs/biome lint .",
"devDependencies": { "format": "bunx @biomejs/biome format . --write",
"bun-types": "latest" "check": "bunx @biomejs/biome check --write ."
} },
} "devDependencies": {
"bun-types": "latest"
}
}