mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 15:09:19 +00:00
@@ -1,10 +1,12 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center',
|
||||
},
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://postgres:password@localhost:5432/alert_message_center",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"name": "@alertmessagecenter/server",
|
||||
"version": "1.2.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --env-file .env --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@larksuiteoapi/node-sdk": "^1.56.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.11.3",
|
||||
"pino": "^10.1.1",
|
||||
"postgres": "^3.4.8",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "latest",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"pino-pretty": "^13.1.3"
|
||||
}
|
||||
}
|
||||
"name": "@alertmessagecenter/server",
|
||||
"version": "1.2.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --env-file .env --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@larksuiteoapi/node-sdk": "^1.56.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4.11.3",
|
||||
"pino": "^10.1.1",
|
||||
"postgres": "^3.4.8",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "latest",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"pino-pretty": "^13.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
export { };
|
||||
export {};
|
||||
|
||||
// Simulate admin checking requests
|
||||
async function run() {
|
||||
console.log('Fetching pending topics as admin...');
|
||||
const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim();
|
||||
const res = await fetch('http://localhost:3000/api/topics/requests', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify({
|
||||
id: 'admin_123',
|
||||
name: 'Admin User',
|
||||
email: adminEmail,
|
||||
isAdmin: true
|
||||
}))}`
|
||||
}
|
||||
});
|
||||
console.log("Fetching pending topics as admin...");
|
||||
const adminEmail = (process.env.ADMIN_EMAILS || "").split(",")[0].trim();
|
||||
const res = await fetch("http://localhost:3000/api/topics/requests", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `session=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
id: "admin_123",
|
||||
name: "Admin User",
|
||||
email: adminEmail,
|
||||
isAdmin: true,
|
||||
}),
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('Pending topics:', JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log('Error:', res.status, await res.text());
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log("Pending topics:", JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log("Error:", res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
export { };
|
||||
export {};
|
||||
|
||||
async function run() {
|
||||
console.log('Fetching dashboard stats as admin...');
|
||||
const adminEmail = (process.env.ADMIN_EMAILS || '').split(',')[0].trim();
|
||||
const res = await fetch('http://localhost:3000/api/dashboard/stats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Admin cookie
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify({
|
||||
id: 'admin_123',
|
||||
name: 'Admin User',
|
||||
email: adminEmail,
|
||||
isAdmin: true
|
||||
}))}`
|
||||
}
|
||||
});
|
||||
console.log("Fetching dashboard stats as admin...");
|
||||
const adminEmail = (process.env.ADMIN_EMAILS || "").split(",")[0].trim();
|
||||
const res = await fetch("http://localhost:3000/api/dashboard/stats", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Admin cookie
|
||||
Cookie: `session=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
id: "admin_123",
|
||||
name: "Admin User",
|
||||
email: adminEmail,
|
||||
isAdmin: true,
|
||||
}),
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('Dashboard Stats:', JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log('Error:', res.status, await res.text());
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log("Dashboard Stats:", JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log("Error:", res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
const db = new Database('dev.db');
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
const db = new Database("dev.db");
|
||||
try {
|
||||
const query = db.query("SELECT * FROM topics");
|
||||
const topics = query.all();
|
||||
console.log('Topics:', JSON.stringify(topics, null, 2));
|
||||
const query = db.query("SELECT * FROM topics");
|
||||
const topics = query.all();
|
||||
console.log("Topics:", JSON.stringify(topics, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error querying topics:', e);
|
||||
console.error("Error querying topics:", e);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
|
||||
// Simulate topic creation
|
||||
import { client } from './client'; // This won't work in node script easily due to frontend dependencies
|
||||
import { client } from "./client"; // This won't work in node script easily due to frontend dependencies
|
||||
|
||||
// Let's use fetch directly against the server
|
||||
async function run() {
|
||||
console.log('Creating pending topic...');
|
||||
const res = await fetch('http://localhost:3000/api/topics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// We need to bake a cookie.
|
||||
// But we can't easily bake a signed cookie without the secret.
|
||||
// Wait, the cookies are not signed in the strict sense, just set.
|
||||
// But `middleware.ts` parses `JSON.parse(sessionCookie)`.
|
||||
console.log("Creating pending topic...");
|
||||
const res = await fetch("http://localhost:3000/api/topics", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// We need to bake a cookie.
|
||||
// But we can't easily bake a signed cookie without the secret.
|
||||
// Wait, the cookies are not signed in the strict sense, just set.
|
||||
// But `middleware.ts` parses `JSON.parse(sessionCookie)`.
|
||||
|
||||
// Let's fake a session cookie for a non-admin user.
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify({
|
||||
id: 'user_123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
isAdmin: false
|
||||
}))}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'Test Pending Topic',
|
||||
slug: 'test-pending',
|
||||
description: 'This should be pending'
|
||||
})
|
||||
});
|
||||
// Let's fake a session cookie for a non-admin user.
|
||||
Cookie: `session=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
id: "user_123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
isAdmin: false,
|
||||
}),
|
||||
)}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Test Pending Topic",
|
||||
slug: "test-pending",
|
||||
description: "This should be pending",
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('Created topic:', data);
|
||||
} else {
|
||||
console.log('Error:', res.status, await res.text());
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log("Created topic:", data);
|
||||
} else {
|
||||
console.log("Error:", res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@@ -1,69 +1,78 @@
|
||||
import postgres from 'postgres';
|
||||
import postgres from "postgres";
|
||||
|
||||
const sql = postgres('postgres://localhost:5432/alertmessagecenter');
|
||||
const sql = postgres("postgres://localhost:5432/alertmessagecenter");
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// 1. Get a topic
|
||||
const [topic] = await sql`SELECT * FROM topics LIMIT 1`;
|
||||
if (!topic) {
|
||||
console.log('No topics found. Create a topic first.');
|
||||
return;
|
||||
}
|
||||
console.log('Using topic:', topic.id, topic.slug);
|
||||
try {
|
||||
// 1. Get a topic
|
||||
const [topic] = await sql`SELECT * FROM topics LIMIT 1`;
|
||||
if (!topic) {
|
||||
console.log("No topics found. Create a topic first.");
|
||||
return;
|
||||
}
|
||||
console.log("Using topic:", topic.id, topic.slug);
|
||||
|
||||
// 2. Define a fake user ID
|
||||
const fakeUserId = 'user_fake_002';
|
||||
// 2. Define a fake user ID
|
||||
const fakeUserId = "user_fake_002";
|
||||
|
||||
// Clean up first
|
||||
await sql`DELETE FROM subscriptions WHERE user_id = ${fakeUserId}`;
|
||||
await sql`DELETE FROM users WHERE id = ${fakeUserId}`;
|
||||
// Clean up first
|
||||
await sql`DELETE FROM subscriptions WHERE user_id = ${fakeUserId}`;
|
||||
await sql`DELETE FROM users WHERE id = ${fakeUserId}`;
|
||||
|
||||
// 3. Try to subscribe with non-existent user
|
||||
console.log('\n--- Attempt 1: Subscribe with non-existent user ---');
|
||||
const res1 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify({
|
||||
id: fakeUserId,
|
||||
name: 'Fake User',
|
||||
email: 'fake@example.com',
|
||||
isAdmin: false
|
||||
}))}`
|
||||
}
|
||||
});
|
||||
console.log('Status:', res1.status);
|
||||
const text1 = await res1.text();
|
||||
console.log('Response:', text1); // Expect 500 FK violation
|
||||
// 3. Try to subscribe with non-existent user
|
||||
console.log("\n--- Attempt 1: Subscribe with non-existent user ---");
|
||||
const res1 = await fetch(
|
||||
`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `session=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
id: fakeUserId,
|
||||
name: "Fake User",
|
||||
email: "fake@example.com",
|
||||
isAdmin: false,
|
||||
}),
|
||||
)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log("Status:", res1.status);
|
||||
const text1 = await res1.text();
|
||||
console.log("Response:", text1); // Expect 500 FK violation
|
||||
|
||||
// 4. Create the user
|
||||
console.log('\n--- Creating user... ---');
|
||||
await sql`INSERT INTO users (id, name, feishu_user_id, email, is_admin)
|
||||
// 4. Create the user
|
||||
console.log("\n--- Creating user... ---");
|
||||
await sql`INSERT INTO users (id, name, feishu_user_id, email, is_admin)
|
||||
VALUES (${fakeUserId}, 'Fake User', 'ou_fake', 'fake2@example.com', false)
|
||||
ON CONFLICT (id) DO NOTHING`;
|
||||
|
||||
// 5. Try to subscribe again
|
||||
console.log('\n--- Attempt 2: Subscribe with existing user ---');
|
||||
const res2 = await fetch(`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify({
|
||||
id: fakeUserId,
|
||||
name: 'Fake User',
|
||||
email: 'fake@example.com',
|
||||
isAdmin: false
|
||||
}))}`
|
||||
}
|
||||
});
|
||||
console.log('Status:', res2.status);
|
||||
const text2 = await res2.text();
|
||||
console.log('Response:', text2); // Expect 200
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
// 5. Try to subscribe again
|
||||
console.log("\n--- Attempt 2: Subscribe with existing user ---");
|
||||
const res2 = await fetch(
|
||||
`http://localhost:3000/api/topics/${topic.id}/subscribe/${fakeUserId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `session=${encodeURIComponent(
|
||||
JSON.stringify({
|
||||
id: fakeUserId,
|
||||
name: "Fake User",
|
||||
email: "fake@example.com",
|
||||
isAdmin: false,
|
||||
}),
|
||||
)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log("Status:", res2.status);
|
||||
const text2 = await res2.text();
|
||||
console.log("Response:", text2); // Expect 200
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@@ -1,174 +1,208 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { db } from './db';
|
||||
import { topics, users, subscriptions, alertTasks, topicGroupChats, knownGroupChats } from './db/schema';
|
||||
import { requireAuth, requireAdmin, AuthSession } from './middleware';
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { and, count, desc, eq, gt, sql, sum } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
alertTasks,
|
||||
knownGroupChats,
|
||||
subscriptions,
|
||||
topicGroupChats,
|
||||
topics,
|
||||
users,
|
||||
} from "./db/schema";
|
||||
import { type AuthSession, requireAdmin, requireAuth } from "./middleware";
|
||||
|
||||
const api = new Hono<{ Variables: { session: AuthSession } }>();
|
||||
|
||||
const topicSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const groupBindingSchema = z.object({
|
||||
chatId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
chatId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
feishuUserId: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
name: z.string().min(1),
|
||||
feishuUserId: z.string().min(1),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
// --- Topics ---
|
||||
|
||||
// --- Topics ---
|
||||
|
||||
api.get('/topics', requireAuth, async (c) => {
|
||||
const session = c.get('session');
|
||||
const isAdmin = session.isAdmin;
|
||||
const currentUserId = session.id;
|
||||
api.get("/topics", requireAuth, async (c) => {
|
||||
const session = c.get("session");
|
||||
const isAdmin = session.isAdmin;
|
||||
const currentUserId = session.id;
|
||||
|
||||
const allTopics = await db.query.topics.findMany({
|
||||
where: eq(topics.status, 'approved'),
|
||||
with: {
|
||||
creator: true,
|
||||
approver: true,
|
||||
subscriptions: {
|
||||
where: (subscriptions, { eq }) =>
|
||||
isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined),
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const allTopics = await db.query.topics.findMany({
|
||||
where: eq(topics.status, "approved"),
|
||||
with: {
|
||||
creator: true,
|
||||
approver: true,
|
||||
subscriptions: {
|
||||
where: (subscriptions, { eq }) =>
|
||||
isAdmin
|
||||
? undefined
|
||||
: currentUserId
|
||||
? eq(subscriptions.userId, currentUserId)
|
||||
: undefined,
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(allTopics);
|
||||
return c.json(allTopics);
|
||||
});
|
||||
|
||||
api.get('/topics/requests', requireAdmin, async (c) => {
|
||||
const requests = await db.query.topics.findMany({
|
||||
where: eq(topics.status, 'pending'),
|
||||
with: {
|
||||
creator: true
|
||||
}
|
||||
});
|
||||
return c.json(requests);
|
||||
api.get("/topics/requests", requireAdmin, async (c) => {
|
||||
const requests = await db.query.topics.findMany({
|
||||
where: eq(topics.status, "pending"),
|
||||
with: {
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
return c.json(requests);
|
||||
});
|
||||
|
||||
api.get('/topics/all', requireAdmin, async (c) => {
|
||||
const allTopics = await db.query.topics.findMany({
|
||||
with: {
|
||||
creator: true,
|
||||
approver: true,
|
||||
subscriptions: true
|
||||
},
|
||||
orderBy: [desc(topics.createdAt)]
|
||||
});
|
||||
return c.json(allTopics);
|
||||
api.get("/topics/all", requireAdmin, async (c) => {
|
||||
const allTopics = await db.query.topics.findMany({
|
||||
with: {
|
||||
creator: true,
|
||||
approver: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
orderBy: [desc(topics.createdAt)],
|
||||
});
|
||||
return c.json(allTopics);
|
||||
});
|
||||
|
||||
api.get('/topics/my-requests', requireAuth, async (c) => {
|
||||
const session = c.get('session');
|
||||
const requests = await db.query.topics.findMany({
|
||||
where: eq(topics.createdBy, session.id),
|
||||
orderBy: [desc(topics.createdAt)],
|
||||
with: {
|
||||
approver: true,
|
||||
}
|
||||
});
|
||||
return c.json(requests);
|
||||
api.get("/topics/my-requests", requireAuth, async (c) => {
|
||||
const session = c.get("session");
|
||||
const requests = await db.query.topics.findMany({
|
||||
where: eq(topics.createdBy, session.id),
|
||||
orderBy: [desc(topics.createdAt)],
|
||||
with: {
|
||||
approver: true,
|
||||
},
|
||||
});
|
||||
return c.json(requests);
|
||||
});
|
||||
|
||||
api.post('/topics/:id/approve', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const session = c.get('session');
|
||||
const result = await db.update(topics)
|
||||
.set({ status: 'approved', approvedBy: session.id })
|
||||
.where(eq(topics.id, id))
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
api.post("/topics/:id/approve", requireAdmin, async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const session = c.get("session");
|
||||
const result = await db
|
||||
.update(topics)
|
||||
.set({ status: "approved", approvedBy: session.id })
|
||||
.where(eq(topics.id, id))
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
api.post('/topics/:id/reject', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const result = await db.update(topics)
|
||||
.set({ status: 'rejected' })
|
||||
.where(eq(topics.id, id))
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
api.post("/topics/:id/reject", requireAdmin, async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const result = await db
|
||||
.update(topics)
|
||||
.set({ status: "rejected" })
|
||||
.where(eq(topics.id, id))
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// Only admins can create topics
|
||||
// Authenticated users can create topics (requests)
|
||||
api.post('/topics', requireAuth, zValidator('json', topicSchema), async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const session = c.get('session');
|
||||
api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const session = c.get("session");
|
||||
|
||||
const status = session.isAdmin ? 'approved' : 'pending';
|
||||
const status = session.isAdmin ? "approved" : "pending";
|
||||
|
||||
const result = await db.insert(topics).values({
|
||||
...body,
|
||||
status,
|
||||
createdBy: session.id,
|
||||
approvedBy: session.isAdmin ? session.id : null,
|
||||
}).returning();
|
||||
return c.json(result[0]);
|
||||
const result = await db
|
||||
.insert(topics)
|
||||
.values({
|
||||
...body,
|
||||
status,
|
||||
createdBy: session.id,
|
||||
approvedBy: session.isAdmin ? session.id : null,
|
||||
})
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// Only admins can update topics
|
||||
api.put('/topics/:id', requireAdmin, zValidator('json', topicSchema.partial()), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = c.req.valid('json');
|
||||
const result = await db.update(topics).set(body).where(eq(topics.id, id)).returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
api.put(
|
||||
"/topics/:id",
|
||||
requireAdmin,
|
||||
zValidator("json", topicSchema.partial()),
|
||||
async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const result = await db
|
||||
.update(topics)
|
||||
.set(body)
|
||||
.where(eq(topics.id, id))
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
},
|
||||
);
|
||||
|
||||
// Only admins can delete topics
|
||||
api.delete('/topics/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await db.delete(topics).where(eq(topics.id, id));
|
||||
return c.json({ success: true });
|
||||
api.delete("/topics/:id", requireAdmin, async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await db.delete(topics).where(eq(topics.id, id));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// --- Users ---
|
||||
|
||||
api.get('/users', requireAdmin, async (c) => {
|
||||
const allUsers = await db.query.users.findMany({
|
||||
with: {
|
||||
subscriptions: {
|
||||
with: {
|
||||
topic: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return c.json(allUsers);
|
||||
api.get("/users", requireAdmin, async (c) => {
|
||||
const allUsers = await db.query.users.findMany({
|
||||
with: {
|
||||
subscriptions: {
|
||||
with: {
|
||||
topic: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return c.json(allUsers);
|
||||
});
|
||||
|
||||
api.post('/users', requireAdmin, zValidator('json', userSchema), async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const result = await db.insert(users).values(body).returning();
|
||||
return c.json(result[0]);
|
||||
api.post("/users", requireAdmin, zValidator("json", userSchema), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
const result = await db.insert(users).values(body).returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
api.put('/users/:id', requireAdmin, zValidator('json', userSchema.partial()), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = c.req.valid('json');
|
||||
const result = await db.update(users).set(body).where(eq(users.id, id)).returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
api.put(
|
||||
"/users/:id",
|
||||
requireAdmin,
|
||||
zValidator("json", userSchema.partial()),
|
||||
async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const result = await db
|
||||
.update(users)
|
||||
.set(body)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
},
|
||||
);
|
||||
|
||||
api.delete('/users/:id', requireAdmin, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
return c.json({ success: true });
|
||||
api.delete("/users/:id", requireAdmin, async (c) => {
|
||||
const id = c.req.param("id");
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// --- Subscriptions ---
|
||||
@@ -176,159 +210,184 @@ api.delete('/users/:id', requireAdmin, async (c) => {
|
||||
// --- Subscriptions ---
|
||||
|
||||
// Users can subscribe themselves or admins can subscribe anyone
|
||||
api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
|
||||
const { topicId, userId } = c.req.param();
|
||||
const session = c.get('session');
|
||||
api.post("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
|
||||
const { topicId, userId } = c.req.param();
|
||||
const session = c.get("session");
|
||||
|
||||
// Check if user is subscribing themselves or is an admin
|
||||
if (session.id !== userId && !session.isAdmin) {
|
||||
return c.json({ error: 'You can only subscribe yourself' }, 403);
|
||||
}
|
||||
// Check if user is subscribing themselves or is an admin
|
||||
if (session.id !== userId && !session.isAdmin) {
|
||||
return c.json({ error: "You can only subscribe yourself" }, 403);
|
||||
}
|
||||
|
||||
const result = await db.insert(subscriptions).values({ topicId, userId }).returning();
|
||||
return c.json(result[0]);
|
||||
const result = await db
|
||||
.insert(subscriptions)
|
||||
.values({ topicId, userId })
|
||||
.returning();
|
||||
return c.json(result[0]);
|
||||
});
|
||||
|
||||
// Users can unsubscribe themselves or admins can unsubscribe anyone
|
||||
api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
|
||||
const { topicId, userId } = c.req.param();
|
||||
const session = c.get('session');
|
||||
api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
|
||||
const { topicId, userId } = c.req.param();
|
||||
const session = c.get("session");
|
||||
|
||||
// Check if user is unsubscribing themselves or is an admin
|
||||
if (session.id !== userId && !session.isAdmin) {
|
||||
return c.json({ error: 'You can only unsubscribe yourself' }, 403);
|
||||
}
|
||||
// Check if user is unsubscribing themselves or is an admin
|
||||
if (session.id !== userId && !session.isAdmin) {
|
||||
return c.json({ error: "You can only unsubscribe yourself" }, 403);
|
||||
}
|
||||
|
||||
await db.delete(subscriptions)
|
||||
.where(and(
|
||||
eq(subscriptions.topicId, topicId),
|
||||
eq(subscriptions.userId, userId)
|
||||
));
|
||||
return c.json({ success: true });
|
||||
await db
|
||||
.delete(subscriptions)
|
||||
.where(
|
||||
and(eq(subscriptions.topicId, topicId), eq(subscriptions.userId, userId)),
|
||||
);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// --- Group Bindings (App Bot) ---
|
||||
|
||||
// Get list of known groups (for selection)
|
||||
api.get('/groups', requireAuth, async (c) => {
|
||||
// Return recent active groups
|
||||
const groups = await db.select().from(knownGroupChats)
|
||||
.orderBy(desc(knownGroupChats.lastActiveAt))
|
||||
.limit(50);
|
||||
return c.json(groups);
|
||||
api.get("/groups", requireAuth, async (c) => {
|
||||
// Return recent active groups
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(knownGroupChats)
|
||||
.orderBy(desc(knownGroupChats.lastActiveAt))
|
||||
.limit(50);
|
||||
return c.json(groups);
|
||||
});
|
||||
|
||||
// Get bindings for a topic
|
||||
api.get('/topics/:id/groups', requireAuth, async (c) => {
|
||||
const topicId = c.req.param('id');
|
||||
const groups = await db.select().from(topicGroupChats)
|
||||
.where(eq(topicGroupChats.topicId, topicId))
|
||||
.orderBy(desc(topicGroupChats.createdAt));
|
||||
return c.json(groups);
|
||||
api.get("/topics/:id/groups", requireAuth, async (c) => {
|
||||
const topicId = c.req.param("id");
|
||||
const groups = await db
|
||||
.select()
|
||||
.from(topicGroupChats)
|
||||
.where(eq(topicGroupChats.topicId, topicId))
|
||||
.orderBy(desc(topicGroupChats.createdAt));
|
||||
return c.json(groups);
|
||||
});
|
||||
|
||||
// Bind a group to a topic
|
||||
api.post('/topics/:id/groups', requireAuth, zValidator('json', groupBindingSchema), async (c) => {
|
||||
const topicId = c.req.param('id');
|
||||
const body = c.req.valid('json');
|
||||
const session = c.get('session');
|
||||
api.post(
|
||||
"/topics/:id/groups",
|
||||
requireAuth,
|
||||
zValidator("json", groupBindingSchema),
|
||||
async (c) => {
|
||||
const topicId = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
const session = c.get("session");
|
||||
|
||||
const result = await db.insert(topicGroupChats).values({
|
||||
topicId,
|
||||
chatId: body.chatId,
|
||||
name: body.name,
|
||||
createdBy: session.id,
|
||||
}).returning();
|
||||
const result = await db
|
||||
.insert(topicGroupChats)
|
||||
.values({
|
||||
topicId,
|
||||
chatId: body.chatId,
|
||||
name: body.name,
|
||||
createdBy: session.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(result[0]);
|
||||
});
|
||||
return c.json(result[0]);
|
||||
},
|
||||
);
|
||||
|
||||
// Unbind a group
|
||||
api.delete('/topics/:id/groups/:bindingId', requireAuth, async (c) => {
|
||||
const { id: topicId, bindingId } = c.req.param();
|
||||
api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
|
||||
const { id: topicId, bindingId } = c.req.param();
|
||||
|
||||
await db.delete(topicGroupChats)
|
||||
.where(and(
|
||||
eq(topicGroupChats.id, bindingId),
|
||||
eq(topicGroupChats.topicId, topicId)
|
||||
));
|
||||
await db
|
||||
.delete(topicGroupChats)
|
||||
.where(
|
||||
and(
|
||||
eq(topicGroupChats.id, bindingId),
|
||||
eq(topicGroupChats.topicId, topicId),
|
||||
),
|
||||
);
|
||||
|
||||
return c.json({ success: true });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// --- Alert Tasks ---
|
||||
|
||||
api.get('/alerts/tasks', requireAdmin, async (c) => {
|
||||
const limit = Math.min(Number(c.req.query('limit') || 50), 100);
|
||||
const tasks = await db.query.alertTasks.findMany({
|
||||
orderBy: [desc(alertTasks.createdAt)],
|
||||
limit,
|
||||
with: {
|
||||
sender: true,
|
||||
logs: {
|
||||
limit: 10, // Only show first 10 logs inline
|
||||
}
|
||||
}
|
||||
});
|
||||
return c.json(tasks);
|
||||
api.get("/alerts/tasks", requireAdmin, async (c) => {
|
||||
const limit = Math.min(Number(c.req.query("limit") || 50), 100);
|
||||
const tasks = await db.query.alertTasks.findMany({
|
||||
orderBy: [desc(alertTasks.createdAt)],
|
||||
limit,
|
||||
with: {
|
||||
sender: true,
|
||||
logs: {
|
||||
limit: 10, // Only show first 10 logs inline
|
||||
},
|
||||
},
|
||||
});
|
||||
return c.json(tasks);
|
||||
});
|
||||
|
||||
api.get('/alerts/tasks/:id', requireAuth, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const task = await db.query.alertTasks.findFirst({
|
||||
where: eq(alertTasks.id, id),
|
||||
with: {
|
||||
sender: true,
|
||||
logs: true // Show all logs for detail view
|
||||
}
|
||||
});
|
||||
api.get("/alerts/tasks/:id", requireAuth, async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const task = await db.query.alertTasks.findFirst({
|
||||
where: eq(alertTasks.id, id),
|
||||
with: {
|
||||
sender: true,
|
||||
logs: true, // Show all logs for detail view
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return c.json({ error: 'Task not found' }, 404);
|
||||
}
|
||||
if (!task) {
|
||||
return c.json({ error: "Task not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json(task);
|
||||
return c.json(task);
|
||||
});
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
api.get('/stats', requireAdmin, async (c) => {
|
||||
// 1. Message count per topic
|
||||
const topicStats = await db.select({
|
||||
topicSlug: alertTasks.topicSlug,
|
||||
totalTasks: count(),
|
||||
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
|
||||
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
|
||||
})
|
||||
.from(alertTasks)
|
||||
.groupBy(alertTasks.topicSlug);
|
||||
api.get("/stats", requireAdmin, async (c) => {
|
||||
// 1. Message count per topic
|
||||
const topicStats = await db
|
||||
.select({
|
||||
topicSlug: alertTasks.topicSlug,
|
||||
totalTasks: count(),
|
||||
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
|
||||
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
|
||||
})
|
||||
.from(alertTasks)
|
||||
.groupBy(alertTasks.topicSlug);
|
||||
|
||||
// 2. Recent metrics (last 24h)
|
||||
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const recentStats = await db.select({
|
||||
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
|
||||
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
|
||||
taskCount: count(),
|
||||
})
|
||||
.from(alertTasks)
|
||||
.where(gt(alertTasks.createdAt, last24h));
|
||||
// 2. Recent metrics (last 24h)
|
||||
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const recentStats = await db
|
||||
.select({
|
||||
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
|
||||
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
|
||||
taskCount: count(),
|
||||
})
|
||||
.from(alertTasks)
|
||||
.where(gt(alertTasks.createdAt, last24h));
|
||||
|
||||
const recent = recentStats[0] || { totalRecipients: 0, totalSuccess: 0, taskCount: 0 };
|
||||
const totalRecipients = Number(recent.totalRecipients || 0);
|
||||
const totalSuccess = Number(recent.totalSuccess || 0);
|
||||
const failedCount = totalRecipients - totalSuccess;
|
||||
const successRate = totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100;
|
||||
const recent = recentStats[0] || {
|
||||
totalRecipients: 0,
|
||||
totalSuccess: 0,
|
||||
taskCount: 0,
|
||||
};
|
||||
const totalRecipients = Number(recent.totalRecipients || 0);
|
||||
const totalSuccess = Number(recent.totalSuccess || 0);
|
||||
const failedCount = totalRecipients - totalSuccess;
|
||||
const successRate =
|
||||
totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100;
|
||||
|
||||
return c.json({
|
||||
topics: topicStats,
|
||||
recent: {
|
||||
alertsReceived: Number(recent.taskCount || 0),
|
||||
plannedMessages: totalRecipients,
|
||||
successCount: totalSuccess,
|
||||
failedCount: failedCount,
|
||||
successRate: successRate,
|
||||
}
|
||||
});
|
||||
return c.json({
|
||||
topics: topicStats,
|
||||
recent: {
|
||||
alertsReceived: Number(recent.taskCount || 0),
|
||||
plannedMessages: totalRecipients,
|
||||
successCount: totalSuccess,
|
||||
failedCount: failedCount,
|
||||
successRate: successRate,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
import { Hono } from 'hono';
|
||||
import * as lark from '@larksuiteoapi/node-sdk';
|
||||
import { eventDispatcher } from '../event-handler';
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { Hono } from "hono";
|
||||
import { eventDispatcher } from "../event-handler";
|
||||
|
||||
const feishuEvent = new Hono();
|
||||
|
||||
// Helper to adapt Hono request to Lark SDK request
|
||||
|
||||
feishuEvent.post('/', async (c) => {
|
||||
try {
|
||||
const headers = c.req.raw.headers;
|
||||
const headerRecord: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
headerRecord[key] = value;
|
||||
});
|
||||
feishuEvent.post("/", async (c) => {
|
||||
try {
|
||||
const headers = c.req.raw.headers;
|
||||
const headerRecord: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
headerRecord[key] = value;
|
||||
});
|
||||
|
||||
const body = await c.req.json();
|
||||
const body = await c.req.json();
|
||||
|
||||
// Use the official SDK functions directly for Hono compatibility
|
||||
// 1. Handle URL verification (Challenge)
|
||||
const { isChallenge, challenge } = lark.generateChallenge(body, {
|
||||
encryptKey: process.env.FEISHU_ENCRYPT_KEY || ''
|
||||
});
|
||||
// Use the official SDK functions directly for Hono compatibility
|
||||
// 1. Handle URL verification (Challenge)
|
||||
const { isChallenge, challenge } = lark.generateChallenge(body, {
|
||||
encryptKey: process.env.FEISHU_ENCRYPT_KEY || "",
|
||||
});
|
||||
|
||||
if (isChallenge) {
|
||||
return c.json(challenge);
|
||||
}
|
||||
if (isChallenge) {
|
||||
return c.json(challenge);
|
||||
}
|
||||
|
||||
// 2. Dispatch event
|
||||
// The dispatcher expects an object containing headers and body.
|
||||
// We use Object.create to put headers on the prototype so they are accessible
|
||||
// but not included in JSON.stringify, which preserves signature verification.
|
||||
const payload = Object.assign(Object.create({ headers: headerRecord }), body);
|
||||
const result = await eventDispatcher.invoke(payload);
|
||||
// 2. Dispatch event
|
||||
// The dispatcher expects an object containing headers and body.
|
||||
// We use Object.create to put headers on the prototype so they are accessible
|
||||
// but not included in JSON.stringify, which preserves signature verification.
|
||||
const payload = Object.assign(
|
||||
Object.create({ headers: headerRecord }),
|
||||
body,
|
||||
);
|
||||
const result = await eventDispatcher.invoke(payload);
|
||||
|
||||
return c.json(result || {});
|
||||
} catch (e) {
|
||||
console.error('[Feishu Event] Error:', e);
|
||||
return c.json({ error: 'Internal Server Error' }, 500);
|
||||
}
|
||||
return c.json(result || {});
|
||||
} catch (e) {
|
||||
console.error("[Feishu Event] Error:", e);
|
||||
return c.json({ error: "Internal Server Error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default feishuEvent;
|
||||
|
||||
@@ -1,142 +1,156 @@
|
||||
import { Hono } from 'hono';
|
||||
import { logger } from './lib/logger';
|
||||
import { setCookie, getCookie } from 'hono/cookie';
|
||||
import { db } from './db';
|
||||
import { users } from './db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { feishuClient } from './feishu';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { db } from "./db";
|
||||
import { users } from "./db/schema";
|
||||
import { feishuClient } from "./feishu";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map(email => email.trim()).filter(Boolean);
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "")
|
||||
.split(",")
|
||||
.map((email) => email.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Get the login URL for frontend to redirect
|
||||
auth.get('/login-url', (c) => {
|
||||
const appId = process.env.FEISHU_APP_ID;
|
||||
const redirectUri = encodeURIComponent(process.env.REDIRECT_URI || 'http://localhost:5173/auth/callback');
|
||||
const state = crypto.randomUUID();
|
||||
auth.get("/login-url", (c) => {
|
||||
const appId = process.env.FEISHU_APP_ID;
|
||||
const redirectUri = encodeURIComponent(
|
||||
process.env.REDIRECT_URI || "http://localhost:5173/auth/callback",
|
||||
);
|
||||
const state = crypto.randomUUID();
|
||||
|
||||
// Store state in cookie for CSRF protection
|
||||
setCookie(c, 'oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 600, // 10 minutes
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
// Store state in cookie for CSRF protection
|
||||
setCookie(c, "oauth_state", state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 600, // 10 minutes
|
||||
sameSite: "Lax",
|
||||
});
|
||||
|
||||
const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`;
|
||||
const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`;
|
||||
|
||||
return c.json({ loginUrl });
|
||||
return c.json({ loginUrl });
|
||||
});
|
||||
|
||||
// Handle OAuth callback
|
||||
auth.get('/callback', async (c) => {
|
||||
const code = c.req.query('code');
|
||||
const state = c.req.query('state');
|
||||
const storedState = getCookie(c, 'oauth_state');
|
||||
auth.get("/callback", async (c) => {
|
||||
const code = c.req.query("code");
|
||||
const state = c.req.query("state");
|
||||
const storedState = getCookie(c, "oauth_state");
|
||||
|
||||
// Verify state for CSRF protection
|
||||
if (!state || state !== storedState) {
|
||||
return c.json({ error: 'Invalid state parameter' }, 400);
|
||||
}
|
||||
// Verify state for CSRF protection
|
||||
if (!state || state !== storedState) {
|
||||
return c.json({ error: "Invalid state parameter" }, 400);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return c.json({ error: 'No code provided' }, 400);
|
||||
}
|
||||
if (!code) {
|
||||
return c.json({ error: "No code provided" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for user access token and user info
|
||||
const userData = await feishuClient.getUserAccessToken(code);
|
||||
try {
|
||||
// Exchange code for user access token and user info
|
||||
const userData = await feishuClient.getUserAccessToken(code);
|
||||
|
||||
// Check if user exists, otherwise create
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(users.feishuUserId, userData.open_id),
|
||||
});
|
||||
// Check if user exists, otherwise create
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(users.feishuUserId, userData.open_id),
|
||||
});
|
||||
|
||||
const isAdmin = ADMIN_EMAILS.includes(userData.email || '');
|
||||
const isAdmin = ADMIN_EMAILS.includes(userData.email || "");
|
||||
|
||||
if (!user) {
|
||||
// Create new user
|
||||
const result = await db.insert(users).values({
|
||||
name: userData.name,
|
||||
feishuUserId: userData.open_id,
|
||||
email: userData.email || null,
|
||||
isAdmin,
|
||||
}).returning();
|
||||
user = result[0];
|
||||
} else {
|
||||
// Update user info (in case name or admin status changed)
|
||||
const result = await db.update(users)
|
||||
.set({
|
||||
name: userData.name,
|
||||
email: userData.email || user.email,
|
||||
isAdmin,
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning();
|
||||
user = result[0];
|
||||
}
|
||||
if (!user) {
|
||||
// Create new user
|
||||
const result = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: userData.name,
|
||||
feishuUserId: userData.open_id,
|
||||
email: userData.email || null,
|
||||
isAdmin,
|
||||
})
|
||||
.returning();
|
||||
user = result[0];
|
||||
} else {
|
||||
// Update user info (in case name or admin status changed)
|
||||
const result = await db
|
||||
.update(users)
|
||||
.set({
|
||||
name: userData.name,
|
||||
email: userData.email || user.email,
|
||||
isAdmin,
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning();
|
||||
user = result[0];
|
||||
}
|
||||
|
||||
// Set session cookie
|
||||
setCookie(c, 'session', JSON.stringify({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
personalToken: user.personalToken,
|
||||
}), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
// Set session cookie
|
||||
setCookie(
|
||||
c,
|
||||
"session",
|
||||
JSON.stringify({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
personalToken: user.personalToken,
|
||||
}),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: "Lax",
|
||||
},
|
||||
);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'OAuth callback error');
|
||||
return c.json({ error: 'Authentication failed' }, 500);
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "OAuth callback error");
|
||||
return c.json({ error: "Authentication failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user from session
|
||||
auth.get('/me', (c) => {
|
||||
const sessionCookie = getCookie(c, 'session');
|
||||
auth.get("/me", (c) => {
|
||||
const sessionCookie = getCookie(c, "session");
|
||||
|
||||
if (!sessionCookie) {
|
||||
return c.json({ error: 'Not authenticated' }, 401);
|
||||
}
|
||||
if (!sessionCookie) {
|
||||
return c.json({ error: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = sessionCookie ? JSON.parse(sessionCookie) : null;
|
||||
if (!session) {
|
||||
return c.json({ error: 'Not authenticated' }, 401);
|
||||
}
|
||||
// Normalize user object to ensure id is present (handle legacy session with userId)
|
||||
const user = {
|
||||
...session,
|
||||
id: session.id || session.userId,
|
||||
};
|
||||
return c.json({ user });
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[Auth] Failed to parse session cookie');
|
||||
return c.json({ error: 'Invalid session' }, 401);
|
||||
}
|
||||
try {
|
||||
const session = sessionCookie ? JSON.parse(sessionCookie) : null;
|
||||
if (!session) {
|
||||
return c.json({ error: "Not authenticated" }, 401);
|
||||
}
|
||||
// Normalize user object to ensure id is present (handle legacy session with userId)
|
||||
const user = {
|
||||
...session,
|
||||
id: session.id || session.userId,
|
||||
};
|
||||
return c.json({ user });
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "[Auth] Failed to parse session cookie");
|
||||
return c.json({ error: "Invalid session" }, 401);
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
auth.post('/logout', (c) => {
|
||||
setCookie(c, 'session', '', {
|
||||
maxAge: 0,
|
||||
});
|
||||
return c.json({ success: true });
|
||||
auth.post("/logout", (c) => {
|
||||
setCookie(c, "session", "", {
|
||||
maxAge: 0,
|
||||
});
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export default auth;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center';
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://postgres:password@localhost:5432/alert_message_center";
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -1,131 +1,173 @@
|
||||
import { pgTable, text, integer, primaryKey, boolean, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag,例如 "payment-service",
|
||||
export const topics = pgTable('topics', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
slug: text('slug').notNull().unique(), // 告警发送时使用的 key
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).default('approved').notNull(),
|
||||
createdBy: text('created_by').references(() => users.id),
|
||||
approvedBy: text('approved_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
export const topics = pgTable("topics", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
slug: text("slug").notNull().unique(), // 告警发送时使用的 key
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status", { enum: ["pending", "approved", "rejected"] })
|
||||
.default("approved")
|
||||
.notNull(),
|
||||
createdBy: text("created_by").references(() => users.id),
|
||||
approvedBy: text("approved_by").references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Group Chats: App Bot 所在的群绑定
|
||||
export const topicGroupChats = pgTable('topic_group_chats', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
|
||||
chatId: text('chat_id').notNull(), // 飞书群 chat_id
|
||||
name: text('name').notNull(), // 群名称快照
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
createdBy: text('created_by').references(() => users.id),
|
||||
export const topicGroupChats = pgTable("topic_group_chats", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
topicId: text("topic_id")
|
||||
.notNull()
|
||||
.references(() => topics.id, { onDelete: "cascade" }),
|
||||
chatId: text("chat_id").notNull(), // 飞书群 chat_id
|
||||
name: text("name").notNull(), // 群名称快照
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
createdBy: text("created_by").references(() => users.id),
|
||||
});
|
||||
|
||||
export const topicGroupChatsRelations = relations(topicGroupChats, ({ one }) => ({
|
||||
topic: one(topics, {
|
||||
fields: [topicGroupChats.topicId],
|
||||
references: [topics.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [topicGroupChats.createdBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
export const topicGroupChatsRelations = relations(
|
||||
topicGroupChats,
|
||||
({ one }) => ({
|
||||
topic: one(topics, {
|
||||
fields: [topicGroupChats.topicId],
|
||||
references: [topics.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [topicGroupChats.createdBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Known Group Chats: 机器人已知的群 (通过事件发现)
|
||||
export const knownGroupChats = pgTable('known_group_chats', {
|
||||
chatId: text('chat_id').primaryKey(), // 飞书 chat_id
|
||||
name: text('name').notNull(),
|
||||
lastActiveAt: timestamp('last_active_at').defaultNow(),
|
||||
export const knownGroupChats = pgTable("known_group_chats", {
|
||||
chatId: text("chat_id").primaryKey(), // 飞书 chat_id
|
||||
name: text("name").notNull(),
|
||||
lastActiveAt: timestamp("last_active_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const topicsRelations = relations(topics, ({ many, one }) => ({
|
||||
subscriptions: many(subscriptions),
|
||||
groupChats: many(topicGroupChats),
|
||||
creator: one(users, {
|
||||
fields: [topics.createdBy],
|
||||
references: [users.id],
|
||||
relationName: 'creator',
|
||||
}),
|
||||
approver: one(users, {
|
||||
fields: [topics.approvedBy],
|
||||
references: [users.id],
|
||||
relationName: 'approver',
|
||||
}),
|
||||
subscriptions: many(subscriptions),
|
||||
groupChats: many(topicGroupChats),
|
||||
creator: one(users, {
|
||||
fields: [topics.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "creator",
|
||||
}),
|
||||
approver: one(users, {
|
||||
fields: [topics.approvedBy],
|
||||
references: [users.id],
|
||||
relationName: "approver",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
name: text('name').notNull(),
|
||||
feishuUserId: text('feishu_user_id').notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
|
||||
email: text('email').unique(),
|
||||
isAdmin: boolean('is_admin').default(false),
|
||||
personalToken: text('personal_token').notNull().unique().$defaultFn(() => crypto.randomUUID().replace(/-/g, '')),
|
||||
export const users = pgTable("users", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: text("name").notNull(),
|
||||
feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
|
||||
email: text("email").unique(),
|
||||
isAdmin: boolean("is_admin").default(false),
|
||||
personalToken: text("personal_token")
|
||||
.notNull()
|
||||
.unique()
|
||||
.$defaultFn(() => crypto.randomUUID().replace(/-/g, "")),
|
||||
});
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
subscriptions: many(subscriptions),
|
||||
createdTopics: many(topics, { relationName: 'creator' }),
|
||||
approvedTopics: many(topics, { relationName: 'approver' }),
|
||||
subscriptions: many(subscriptions),
|
||||
createdTopics: many(topics, { relationName: "creator" }),
|
||||
approvedTopics: many(topics, { relationName: "approver" }),
|
||||
}));
|
||||
|
||||
// Subscriptions: 用户直接订阅 Topic
|
||||
export const subscriptions = pgTable('subscriptions', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.userId, t.topicId] }),
|
||||
}));
|
||||
export const subscriptions = pgTable(
|
||||
"subscriptions",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
topicId: text("topic_id")
|
||||
.notNull()
|
||||
.references(() => topics.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.userId, t.topicId] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [subscriptions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
topic: one(topics, {
|
||||
fields: [subscriptions.topicId],
|
||||
references: [topics.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [subscriptions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
topic: one(topics, {
|
||||
fields: [subscriptions.topicId],
|
||||
references: [topics.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// API Tasks: 记录 webhook 请求的处理状态
|
||||
export const alertTasks = pgTable('alert_tasks', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
topicSlug: text('topic_slug'),
|
||||
senderId: text('sender_id').references(() => users.id), // 记录是谁发送的 (通过 personal_token)
|
||||
status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).default('pending').notNull(),
|
||||
recipientCount: integer('recipient_count').default(0),
|
||||
successCount: integer('success_count').default(0),
|
||||
payload: jsonb('payload'), // 存储 webhook body
|
||||
error: text('error'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
export const alertTasks = pgTable("alert_tasks", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
topicSlug: text("topic_slug"),
|
||||
senderId: text("sender_id").references(() => users.id), // 记录是谁发送的 (通过 personal_token)
|
||||
status: text("status", {
|
||||
enum: ["pending", "processing", "completed", "failed"],
|
||||
})
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
recipientCount: integer("recipient_count").default(0),
|
||||
successCount: integer("success_count").default(0),
|
||||
payload: jsonb("payload"), // 存储 webhook body
|
||||
error: text("error"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Logs for each recipient in a task (optional detail)
|
||||
export const alertLogs = pgTable('alert_logs', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
taskId: text('task_id').notNull().references(() => alertTasks.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id'), // Optional, in case user is deleted later
|
||||
status: text('status', { enum: ['sent', 'failed'] }).notNull(),
|
||||
error: text('error'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
export const alertLogs = pgTable("alert_logs", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
taskId: text("task_id")
|
||||
.notNull()
|
||||
.references(() => alertTasks.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id"), // Optional, in case user is deleted later
|
||||
status: text("status", { enum: ["sent", "failed"] }).notNull(),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const alertTasksRelations = relations(alertTasks, ({ many, one }) => ({
|
||||
logs: many(alertLogs),
|
||||
sender: one(users, {
|
||||
fields: [alertTasks.senderId],
|
||||
references: [users.id],
|
||||
}),
|
||||
logs: many(alertLogs),
|
||||
sender: one(users, {
|
||||
fields: [alertTasks.senderId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const alertLogsRelations = relations(alertLogs, ({ one }) => ({
|
||||
task: one(alertTasks, {
|
||||
fields: [alertLogs.taskId],
|
||||
references: [alertTasks.id],
|
||||
}),
|
||||
task: one(alertTasks, {
|
||||
fields: [alertLogs.taskId],
|
||||
references: [alertTasks.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
import { db } from './db';
|
||||
import { knownGroupChats, topicGroupChats } from './db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as lark from '@larksuiteoapi/node-sdk';
|
||||
import { logger } from './lib/logger';
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { knownGroupChats, topicGroupChats } from "./db/schema";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
export const eventDispatcher = new lark.EventDispatcher({
|
||||
encryptKey: process.env.FEISHU_ENCRYPT_KEY,
|
||||
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
|
||||
encryptKey: process.env.FEISHU_ENCRYPT_KEY,
|
||||
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
|
||||
}).register({
|
||||
'im.chat.member.bot.added_v1': async (data) => {
|
||||
const { chat_id, name } = data as any;
|
||||
logger.info({ chat_id, name }, '[Feishu Event] Bot added to group');
|
||||
"im.chat.member.bot.added_v1": async (data) => {
|
||||
const { chat_id, name } = data as any;
|
||||
logger.info({ chat_id, name }, "[Feishu Event] Bot added to group");
|
||||
|
||||
if (chat_id) {
|
||||
await db.insert(knownGroupChats).values({
|
||||
chatId: chat_id,
|
||||
name: name || 'Unknown Group',
|
||||
lastActiveAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: knownGroupChats.chatId,
|
||||
set: {
|
||||
name: name || 'Unknown Group',
|
||||
lastActiveAt: new Date(),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
'im.chat.member.bot.deleted_v1': async (data) => {
|
||||
const { chat_id } = data as any;
|
||||
logger.info({ chat_id }, '[Feishu Event] Bot removed from group');
|
||||
if (chat_id) {
|
||||
await db
|
||||
.insert(knownGroupChats)
|
||||
.values({
|
||||
chatId: chat_id,
|
||||
name: name || "Unknown Group",
|
||||
lastActiveAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: knownGroupChats.chatId,
|
||||
set: {
|
||||
name: name || "Unknown Group",
|
||||
lastActiveAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
"im.chat.member.bot.deleted_v1": async (data) => {
|
||||
const { chat_id } = data as any;
|
||||
logger.info({ chat_id }, "[Feishu Event] Bot removed from group");
|
||||
|
||||
if (chat_id) {
|
||||
await db.delete(knownGroupChats).where(eq(knownGroupChats.chatId, chat_id));
|
||||
await db.delete(topicGroupChats).where(eq(topicGroupChats.chatId, chat_id));
|
||||
}
|
||||
},
|
||||
if (chat_id) {
|
||||
await db
|
||||
.delete(knownGroupChats)
|
||||
.where(eq(knownGroupChats.chatId, chat_id));
|
||||
await db
|
||||
.delete(topicGroupChats)
|
||||
.where(eq(topicGroupChats.chatId, chat_id));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,72 +1,78 @@
|
||||
import * as lark from '@larksuiteoapi/node-sdk';
|
||||
import { logger } from './lib/logger';
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
export class FeishuClient {
|
||||
public client: lark.Client;
|
||||
public appId: string;
|
||||
public appSecret: string;
|
||||
public client: lark.Client;
|
||||
public appId: string;
|
||||
public appSecret: string;
|
||||
|
||||
constructor(appId: string, appSecret: string) {
|
||||
this.appId = appId;
|
||||
this.appSecret = appSecret;
|
||||
this.client = new lark.Client({
|
||||
appId: appId,
|
||||
appSecret: appSecret,
|
||||
disableTokenCache: false,
|
||||
});
|
||||
}
|
||||
constructor(appId: string, appSecret: string) {
|
||||
this.appId = appId;
|
||||
this.appSecret = appSecret;
|
||||
this.client = new lark.Client({
|
||||
appId: appId,
|
||||
appSecret: appSecret,
|
||||
disableTokenCache: false,
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email' | 'chat_id', msgType: string, content: any) {
|
||||
// Content needs to be stringified for 'text' type in API, but SDK might handle it differently?
|
||||
// Actually SDK expects 'content' as string JSON for 'im.v1.messages.create'
|
||||
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
async sendMessage(
|
||||
receiveId: string,
|
||||
receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
|
||||
msgType: string,
|
||||
content: any,
|
||||
) {
|
||||
// Content needs to be stringified for 'text' type in API, but SDK might handle it differently?
|
||||
// Actually SDK expects 'content' as string JSON for 'im.v1.messages.create'
|
||||
const contentStr =
|
||||
typeof content === "string" ? content : JSON.stringify(content);
|
||||
|
||||
try {
|
||||
const response = await this.client.im.message.create({
|
||||
params: {
|
||||
receive_id_type: receiveIdType,
|
||||
},
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: contentStr,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await this.client.im.message.create({
|
||||
params: {
|
||||
receive_id_type: receiveIdType,
|
||||
},
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: contentStr,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
logger.error({ response }, 'Feishu send message error');
|
||||
throw new Error(`Failed to send message: ${response.msg}`);
|
||||
}
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
console.error('Feishu SDK error:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (response.code !== 0) {
|
||||
logger.error({ response }, "Feishu send message error");
|
||||
throw new Error(`Failed to send message: ${response.msg}`);
|
||||
}
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
console.error("Feishu SDK error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserAccessToken(code: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.authen.accessToken.create({
|
||||
data: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
},
|
||||
});
|
||||
async getUserAccessToken(code: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.authen.accessToken.create({
|
||||
data: {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
logger.error({ response }, 'Feishu get user access token error');
|
||||
throw new Error(`Failed to get user access token: ${response.msg}`);
|
||||
}
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
console.error('Feishu SDK error:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (response.code !== 0) {
|
||||
logger.error({ response }, "Feishu get user access token error");
|
||||
throw new Error(`Failed to get user access token: ${response.msg}`);
|
||||
}
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
console.error("Feishu SDK error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const feishuClient = new FeishuClient(
|
||||
process.env.FEISHU_APP_ID || '',
|
||||
process.env.FEISHU_APP_SECRET || ''
|
||||
process.env.FEISHU_APP_ID || "",
|
||||
process.env.FEISHU_APP_SECRET || "",
|
||||
);
|
||||
|
||||
@@ -1,47 +1,52 @@
|
||||
import { Hono } from 'hono';
|
||||
import { logger } from './lib/logger';
|
||||
import { cors } from 'hono/cors';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { db } from './db';
|
||||
import { topics } from './db/schema';
|
||||
import webhook from './webhook';
|
||||
import api from './api';
|
||||
import auth from './auth';
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import api from "./api";
|
||||
import auth from "./auth";
|
||||
import { db } from "./db";
|
||||
import { topics } from "./db/schema";
|
||||
import { logger } from "./lib/logger";
|
||||
import webhook from "./webhook";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Enable CORS for frontend
|
||||
app.use('/*', cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(
|
||||
"/*",
|
||||
cors({
|
||||
origin: process.env.FRONTEND_URL || "http://localhost:5173",
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
import feishuEvent from './api/feishu-event';
|
||||
import feishuEvent from "./api/feishu-event";
|
||||
|
||||
// ...
|
||||
|
||||
// API Routes
|
||||
const routes = app.route('/api/auth', auth)
|
||||
.route('/api', api)
|
||||
.route('/api/feishu/event', feishuEvent)
|
||||
.route('/webhook', webhook);
|
||||
const routes = app
|
||||
.route("/api/auth", auth)
|
||||
.route("/api", api)
|
||||
.route("/api/feishu/event", feishuEvent)
|
||||
.route("/webhook", webhook);
|
||||
|
||||
// Serve static files (Frontend)
|
||||
app.use('/*', serveStatic({ root: './public' }));
|
||||
app.get('*', serveStatic({ path: './public/index.html' }));
|
||||
app.use("/*", serveStatic({ root: "./public" }));
|
||||
app.get("*", serveStatic({ path: "./public/index.html" }));
|
||||
|
||||
app.onError((err, c) => {
|
||||
logger.error({ err, method: c.req.method, url: c.req.url }, 'Global Error');
|
||||
return c.json({ error: err.message || 'Internal Server Error' }, 500);
|
||||
logger.error({ err, method: c.req.method, url: c.req.url }, "Global Error");
|
||||
return c.json({ error: err.message || "Internal Server Error" }, 500);
|
||||
});
|
||||
|
||||
app.get('/topics', async (c) => {
|
||||
const allTopics = await db.select().from(topics);
|
||||
return c.json(allTopics);
|
||||
app.get("/topics", async (c) => {
|
||||
const allTopics = await db.select().from(topics);
|
||||
return c.json(allTopics);
|
||||
});
|
||||
|
||||
// Start WebSocket if enabled
|
||||
import { startWebSocket } from './ws';
|
||||
import { startWebSocket } from "./ws";
|
||||
|
||||
startWebSocket();
|
||||
|
||||
export type AppType = typeof routes;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import pino from 'pino';
|
||||
import pino from "pino";
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: isDevelopment
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport: isDevelopment
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss Z",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export default logger;
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
import { Context, Next } from 'hono';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import type { Context, Next } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
|
||||
export interface AuthSession {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export async function requireAuth(c: Context, next: Next) {
|
||||
const sessionCookie = getCookie(c, 'session');
|
||||
const sessionCookie = getCookie(c, "session");
|
||||
|
||||
if (!sessionCookie) {
|
||||
return c.json({ error: 'Authentication required' }, 401);
|
||||
}
|
||||
if (!sessionCookie) {
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
|
||||
if (!session) {
|
||||
return c.json({ error: 'Authentication required' }, 401);
|
||||
}
|
||||
c.set('session', session);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('[Middleware] Failed to parse session cookie:', error);
|
||||
return c.json({ error: 'Invalid session' }, 401);
|
||||
}
|
||||
try {
|
||||
const session: AuthSession = sessionCookie
|
||||
? JSON.parse(sessionCookie)
|
||||
: null;
|
||||
if (!session) {
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
c.set("session", session);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error("[Middleware] Failed to parse session cookie:", error);
|
||||
return c.json({ error: "Invalid session" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(c: Context, next: Next) {
|
||||
const sessionCookie = getCookie(c, 'session');
|
||||
const sessionCookie = getCookie(c, "session");
|
||||
|
||||
if (!sessionCookie) {
|
||||
return c.json({ error: 'Authentication required' }, 401);
|
||||
}
|
||||
if (!sessionCookie) {
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
|
||||
try {
|
||||
const session: AuthSession = sessionCookie
|
||||
? JSON.parse(sessionCookie)
|
||||
: null;
|
||||
|
||||
if (!session || !session.isAdmin) {
|
||||
return c.json({ error: 'Admin access required' }, 403);
|
||||
}
|
||||
if (!session || !session.isAdmin) {
|
||||
return c.json({ error: "Admin access required" }, 403);
|
||||
}
|
||||
|
||||
c.set('session', session);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error('[Middleware] Failed to parse session cookie in requireAdmin:', error);
|
||||
return c.json({ error: 'Invalid session' }, 401);
|
||||
}
|
||||
c.set("session", session);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Middleware] Failed to parse session cookie in requireAdmin:",
|
||||
error,
|
||||
);
|
||||
return c.json({ error: "Invalid session" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +1,187 @@
|
||||
|
||||
import app from './index';
|
||||
import { db } from './db';
|
||||
import { users, topics, subscriptions } from './db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { subscriptions, topics, users } from "./db/schema";
|
||||
import app from "./index";
|
||||
|
||||
async function verify() {
|
||||
console.log('Starting Verification...');
|
||||
let errors = 0;
|
||||
console.log("Starting Verification...");
|
||||
let errors = 0;
|
||||
|
||||
// 1. Setup Test Data
|
||||
const timestamp = Date.now();
|
||||
// 1. Setup Test Data
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Create Non-Admin User
|
||||
const [userUser] = await db.insert(users).values({
|
||||
name: `TestUser_${timestamp}`,
|
||||
feishuUserId: `test_user_${timestamp}`,
|
||||
email: `test_user_${timestamp}@example.com`,
|
||||
isAdmin: false
|
||||
}).returning();
|
||||
// Create Non-Admin User
|
||||
const [userUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: `TestUser_${timestamp}`,
|
||||
feishuUserId: `test_user_${timestamp}`,
|
||||
email: `test_user_${timestamp}@example.com`,
|
||||
isAdmin: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create Admin User
|
||||
const [adminUser] = await db.insert(users).values({
|
||||
name: `TestAdmin_${timestamp}`,
|
||||
feishuUserId: `test_admin_${timestamp}`,
|
||||
email: `test_admin_${timestamp}@example.com`,
|
||||
isAdmin: true
|
||||
}).returning();
|
||||
// Create Admin User
|
||||
const [adminUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: `TestAdmin_${timestamp}`,
|
||||
feishuUserId: `test_admin_${timestamp}`,
|
||||
email: `test_admin_${timestamp}@example.com`,
|
||||
isAdmin: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create Topic
|
||||
const [topic] = await db.insert(topics).values({
|
||||
name: `TestTopic_${timestamp}`,
|
||||
slug: `test-topic-${timestamp}`,
|
||||
description: 'Test Description'
|
||||
}).returning();
|
||||
// Create Topic
|
||||
const [topic] = await db
|
||||
.insert(topics)
|
||||
.values({
|
||||
name: `TestTopic_${timestamp}`,
|
||||
slug: `test-topic-${timestamp}`,
|
||||
description: "Test Description",
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Subscribe User to Topic
|
||||
await db.insert(subscriptions).values({
|
||||
userId: userUser.id,
|
||||
topicId: topic.id
|
||||
});
|
||||
// Subscribe User to Topic
|
||||
await db.insert(subscriptions).values({
|
||||
userId: userUser.id,
|
||||
topicId: topic.id,
|
||||
});
|
||||
|
||||
console.log('Test Data Created:', { user: userUser.id, admin: adminUser.id, topic: topic.id });
|
||||
console.log("Test Data Created:", {
|
||||
user: userUser.id,
|
||||
admin: adminUser.id,
|
||||
topic: topic.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// 2. Test GET /users (Admin Only)
|
||||
try {
|
||||
// 2. Test GET /users (Admin Only)
|
||||
|
||||
// Test as Non-Admin
|
||||
const sessionUser = { userId: userUser.id, name: userUser.name, email: userUser.email, isAdmin: userUser.isAdmin };
|
||||
const req1 = new Request('http://localhost/api/users', {
|
||||
headers: {
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}`
|
||||
}
|
||||
});
|
||||
const res1 = await app.request(req1);
|
||||
// Test as Non-Admin
|
||||
const sessionUser = {
|
||||
userId: userUser.id,
|
||||
name: userUser.name,
|
||||
email: userUser.email,
|
||||
isAdmin: userUser.isAdmin,
|
||||
};
|
||||
const req1 = new Request("http://localhost/api/users", {
|
||||
headers: {
|
||||
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionUser))}`,
|
||||
},
|
||||
});
|
||||
const res1 = await app.request(req1);
|
||||
|
||||
if (res1.status === 403) {
|
||||
console.log('✅ PASS: GET /users as Non-Admin returned 403');
|
||||
} else {
|
||||
console.error(`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`);
|
||||
errors++;
|
||||
}
|
||||
if (res1.status === 403) {
|
||||
console.log("✅ PASS: GET /users as Non-Admin returned 403");
|
||||
} else {
|
||||
console.error(
|
||||
`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Test as Admin
|
||||
const sessionAdmin = { userId: adminUser.id, name: adminUser.name, email: adminUser.email, isAdmin: adminUser.isAdmin };
|
||||
const req2 = new Request('http://localhost/api/users', {
|
||||
headers: {
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`
|
||||
}
|
||||
});
|
||||
const res2 = await app.request(req2);
|
||||
// Test as Admin
|
||||
const sessionAdmin = {
|
||||
userId: adminUser.id,
|
||||
name: adminUser.name,
|
||||
email: adminUser.email,
|
||||
isAdmin: adminUser.isAdmin,
|
||||
};
|
||||
const req2 = new Request("http://localhost/api/users", {
|
||||
headers: {
|
||||
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`,
|
||||
},
|
||||
});
|
||||
const res2 = await app.request(req2);
|
||||
|
||||
if (res2.status === 200) {
|
||||
console.log('✅ PASS: GET /users as Admin returned 200');
|
||||
} else {
|
||||
console.error(`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`);
|
||||
errors++;
|
||||
}
|
||||
if (res2.status === 200) {
|
||||
console.log("✅ PASS: GET /users as Admin returned 200");
|
||||
} else {
|
||||
console.error(
|
||||
`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
|
||||
// 3. Test GET /topics (Filtered)
|
||||
// 3. Test GET /topics (Filtered)
|
||||
|
||||
// Test as Non-Admin (Should see ONLY their subscription)
|
||||
const req3 = new Request('http://localhost/api/topics', {
|
||||
headers: {
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}`
|
||||
}
|
||||
});
|
||||
const res3 = await app.request(req3);
|
||||
const data3 = await res3.json();
|
||||
// Test as Non-Admin (Should see ONLY their subscription)
|
||||
const req3 = new Request("http://localhost/api/topics", {
|
||||
headers: {
|
||||
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionUser))}`,
|
||||
},
|
||||
});
|
||||
const res3 = await app.request(req3);
|
||||
const data3 = await res3.json();
|
||||
|
||||
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
|
||||
if (targetTopic) {
|
||||
if (targetTopic.subscriptions.length === 1 && targetTopic.subscriptions[0].userId === userUser.id) {
|
||||
console.log('✅ PASS: GET /topics as Non-Admin shows correct personal subscription');
|
||||
} else {
|
||||
console.error('❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:', targetTopic.subscriptions);
|
||||
errors++;
|
||||
}
|
||||
} else {
|
||||
console.error('❌ FAIL: Test topic not found in list');
|
||||
errors++;
|
||||
}
|
||||
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
|
||||
if (targetTopic) {
|
||||
if (
|
||||
targetTopic.subscriptions.length === 1 &&
|
||||
targetTopic.subscriptions[0].userId === userUser.id
|
||||
) {
|
||||
console.log(
|
||||
"✅ PASS: GET /topics as Non-Admin shows correct personal subscription",
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:",
|
||||
targetTopic.subscriptions,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
} else {
|
||||
console.error("❌ FAIL: Test topic not found in list");
|
||||
errors++;
|
||||
}
|
||||
|
||||
// Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too)
|
||||
// Actually, let's just check that Admin sees the User's subscription.
|
||||
// In my logic: isAdmin ? undefined (all) : ...
|
||||
// So Admin should see User's subscription.
|
||||
// Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too)
|
||||
// Actually, let's just check that Admin sees the User's subscription.
|
||||
// In my logic: isAdmin ? undefined (all) : ...
|
||||
// So Admin should see User's subscription.
|
||||
|
||||
const req4 = new Request('http://localhost/api/topics', {
|
||||
headers: {
|
||||
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`
|
||||
}
|
||||
});
|
||||
const res4 = await app.request(req4);
|
||||
const data4 = await res4.json();
|
||||
const req4 = new Request("http://localhost/api/topics", {
|
||||
headers: {
|
||||
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`,
|
||||
},
|
||||
});
|
||||
const res4 = await app.request(req4);
|
||||
const data4 = await res4.json();
|
||||
|
||||
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
|
||||
// Should see the subscription for userUser
|
||||
const hasUserSub = targetTopicAdmin.subscriptions.some((s: any) => s.userId === userUser.id);
|
||||
if (hasUserSub) {
|
||||
console.log('✅ PASS: GET /topics as Admin sees other users subscriptions');
|
||||
} else {
|
||||
console.error('❌ FAIL: GET /topics as Admin did NOT see other users subscriptions');
|
||||
errors++;
|
||||
}
|
||||
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
|
||||
// Should see the subscription for userUser
|
||||
const hasUserSub = targetTopicAdmin.subscriptions.some(
|
||||
(s: any) => s.userId === userUser.id,
|
||||
);
|
||||
if (hasUserSub) {
|
||||
console.log(
|
||||
"✅ PASS: GET /topics as Admin sees other users subscriptions",
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"❌ FAIL: GET /topics as Admin did NOT see other users subscriptions",
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Test Exception:", e);
|
||||
errors++;
|
||||
} finally {
|
||||
// 4. Cleanup
|
||||
await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id));
|
||||
await db.delete(topics).where(eq(topics.id, topic.id));
|
||||
await db.delete(users).where(eq(users.id, userUser.id));
|
||||
await db.delete(users).where(eq(users.id, adminUser.id));
|
||||
console.log("Cleanup Completed");
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Test Exception:', e);
|
||||
errors++;
|
||||
} finally {
|
||||
// 4. Cleanup
|
||||
await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id));
|
||||
await db.delete(topics).where(eq(topics.id, topic.id));
|
||||
await db.delete(users).where(eq(users.id, userUser.id));
|
||||
await db.delete(users).where(eq(users.id, adminUser.id));
|
||||
console.log('Cleanup Completed');
|
||||
}
|
||||
|
||||
if (errors === 0) {
|
||||
console.log('🎉 ALL TESTS PASSED');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('💥 SOME TESTS FAILED');
|
||||
process.exit(1);
|
||||
}
|
||||
if (errors === 0) {
|
||||
console.log("🎉 ALL TESTS PASSED");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("💥 SOME TESTS FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
verify();
|
||||
|
||||
@@ -1,311 +1,362 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { topics, alertTasks, alertLogs, users } from './db/schema';
|
||||
import { feishuClient } from './feishu';
|
||||
import { logger } from './lib/logger';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { db } from "./db";
|
||||
import { alertLogs, alertTasks, topics, users } from "./db/schema";
|
||||
import { feishuClient } from "./feishu";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
const webhook = new Hono();
|
||||
|
||||
webhook.post('/:token/topic/:slug', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const slug = c.req.param('slug');
|
||||
logger.info({ token, slug }, '[Webhook] Received request');
|
||||
webhook.post("/:token/topic/:slug", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const slug = c.req.param("slug");
|
||||
logger.info({ token, slug }, "[Webhook] Received request");
|
||||
|
||||
// 0. Find the User by Token
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.personalToken, token),
|
||||
});
|
||||
// 0. Find the User by Token
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.personalToken, token),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn({ token }, '[Webhook] Invalid personal token');
|
||||
return c.json({ error: 'Invalid personal token' }, 401);
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
const rawBody = await c.req.text();
|
||||
logger.debug({ bodyLength: rawBody.length }, '[Webhook] Received raw body');
|
||||
if (!rawBody || rawBody.trim() === '') {
|
||||
return c.json({ error: 'Empty body' }, 400);
|
||||
}
|
||||
body = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, '[Webhook] Failed to parse JSON body');
|
||||
return c.json({ error: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
if (!user) {
|
||||
logger.warn({ token }, "[Webhook] Invalid personal token");
|
||||
return c.json({ error: "Invalid personal token" }, 401);
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
const rawBody = await c.req.text();
|
||||
logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body");
|
||||
if (!rawBody || rawBody.trim() === "") {
|
||||
return c.json({ error: "Empty body" }, 400);
|
||||
}
|
||||
body = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, "[Webhook] Failed to parse JSON body");
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
// 1. Find the Topic
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: eq(topics.slug, slug),
|
||||
with: {
|
||||
subscriptions: {
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
},
|
||||
groupChats: true
|
||||
}
|
||||
});
|
||||
// 1. Find the Topic
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: eq(topics.slug, slug),
|
||||
with: {
|
||||
subscriptions: {
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
groupChats: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
logger.warn({ slug }, '[Webhook] Topic not found');
|
||||
return c.json({ error: 'Topic not found' }, 404);
|
||||
}
|
||||
if (!topic) {
|
||||
logger.warn({ slug }, "[Webhook] Topic not found");
|
||||
return c.json({ error: "Topic not found" }, 404);
|
||||
}
|
||||
|
||||
logger.info({ topicName: topic.name }, '[Webhook] Found topic');
|
||||
logger.info({ topicName: topic.name }, "[Webhook] Found topic");
|
||||
|
||||
// 2. Collect recipients
|
||||
const userRecipients = topic.subscriptions
|
||||
.map(sub => sub.user)
|
||||
.filter(u => !!u && !!u.feishuUserId)
|
||||
.map(u => ({
|
||||
type: 'user',
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
feishuId: u.feishuUserId,
|
||||
idType: u.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id'
|
||||
}));
|
||||
// 2. Collect recipients
|
||||
const userRecipients = topic.subscriptions
|
||||
.map((sub) => sub.user)
|
||||
.filter((u) => !!u && !!u.feishuUserId)
|
||||
.map((u) => ({
|
||||
type: "user",
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
feishuId: u.feishuUserId,
|
||||
idType: u.feishuUserId.startsWith("ou_") ? "open_id" : "user_id",
|
||||
}));
|
||||
|
||||
const groupRecipients = topic.groupChats.map(g => ({
|
||||
type: 'group',
|
||||
id: g.id, // Binding ID
|
||||
name: g.name,
|
||||
feishuId: g.chatId,
|
||||
idType: 'chat_id'
|
||||
}));
|
||||
const groupRecipients = topic.groupChats.map((g) => ({
|
||||
type: "group",
|
||||
id: g.id, // Binding ID
|
||||
name: g.name,
|
||||
feishuId: g.chatId,
|
||||
idType: "chat_id",
|
||||
}));
|
||||
|
||||
const allRecipients = [...userRecipients, ...groupRecipients];
|
||||
const allRecipients = [...userRecipients, ...groupRecipients];
|
||||
|
||||
const [task] = await db.insert(alertTasks).values({
|
||||
topicSlug: topic.slug,
|
||||
senderId: user.id,
|
||||
status: 'processing',
|
||||
recipientCount: allRecipients.length,
|
||||
successCount: 0,
|
||||
payload: body,
|
||||
}).returning();
|
||||
const [task] = await db
|
||||
.insert(alertTasks)
|
||||
.values({
|
||||
topicSlug: topic.slug,
|
||||
senderId: user.id,
|
||||
status: "processing",
|
||||
recipientCount: allRecipients.length,
|
||||
successCount: 0,
|
||||
payload: body,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (allRecipients.length === 0) {
|
||||
await db.update(alertTasks)
|
||||
.set({ status: 'completed', updatedAt: new Date() })
|
||||
.where(eq(alertTasks.id, task.id));
|
||||
if (allRecipients.length === 0) {
|
||||
await db
|
||||
.update(alertTasks)
|
||||
.set({ status: "completed", updatedAt: new Date() })
|
||||
.where(eq(alertTasks.id, task.id));
|
||||
|
||||
return c.json({
|
||||
message: 'No subscribers for this topic',
|
||||
taskId: task.id,
|
||||
status: 'completed'
|
||||
});
|
||||
}
|
||||
return c.json({
|
||||
message: "No subscribers for this topic",
|
||||
taskId: task.id,
|
||||
status: "completed",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
taskId: task.id,
|
||||
userCount: userRecipients.length,
|
||||
groupCount: groupRecipients.length
|
||||
}, '[Webhook] Dispatching alerts');
|
||||
logger.info(
|
||||
{
|
||||
taskId: task.id,
|
||||
userCount: userRecipients.length,
|
||||
groupCount: groupRecipients.length,
|
||||
},
|
||||
"[Webhook] Dispatching alerts",
|
||||
);
|
||||
|
||||
// 4. Send Private Messages asynchronously
|
||||
Promise.allSettled(allRecipients.map(async (recipient) => {
|
||||
try {
|
||||
// Construct message content
|
||||
let msgType = body.msg_type || 'text';
|
||||
let content = body.content;
|
||||
// 4. Send Private Messages asynchronously
|
||||
Promise.allSettled(
|
||||
allRecipients.map(async (recipient) => {
|
||||
try {
|
||||
// Construct message content
|
||||
let msgType = body.msg_type || "text";
|
||||
let content = body.content;
|
||||
|
||||
if (!content) {
|
||||
msgType = 'text';
|
||||
content = { text: JSON.stringify(body, null, 2) };
|
||||
// Deep copy needed? usually content is new obj if we parsed body
|
||||
} else {
|
||||
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
|
||||
content = JSON.parse(JSON.stringify(content));
|
||||
}
|
||||
if (!content) {
|
||||
msgType = "text";
|
||||
content = { text: JSON.stringify(body, null, 2) };
|
||||
// Deep copy needed? usually content is new obj if we parsed body
|
||||
} else {
|
||||
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
|
||||
content = JSON.parse(JSON.stringify(content));
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
if (msgType === 'text' && content.text) {
|
||||
content.text = `[Topic: ${topic.name}]\n${content.text}`;
|
||||
}
|
||||
if (msgType === 'interactive' && content.header) {
|
||||
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
|
||||
}
|
||||
// Add metadata
|
||||
if (msgType === "text" && content.text) {
|
||||
content.text = `[Topic: ${topic.name}]\n${content.text}`;
|
||||
}
|
||||
if (msgType === "interactive" && content.header) {
|
||||
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
|
||||
}
|
||||
|
||||
await feishuClient.sendMessage(recipient.feishuId, recipient.idType as any, msgType, content);
|
||||
await feishuClient.sendMessage(
|
||||
recipient.feishuId,
|
||||
recipient.idType as any,
|
||||
msgType,
|
||||
content,
|
||||
);
|
||||
|
||||
return { recipientId: recipient.id, status: 'sent', error: null };
|
||||
} catch (error: any) {
|
||||
logger.error({
|
||||
err: error,
|
||||
recipientType: recipient.type,
|
||||
recipientName: recipient.name
|
||||
}, 'Failed to send alert');
|
||||
return { recipientId: recipient.id, status: 'failed', error: error.message };
|
||||
}
|
||||
})).then(async (results) => {
|
||||
const successCount = results.filter(r => r.status === 'fulfilled' && (r.value as any).status === 'sent').length;
|
||||
const failures = results
|
||||
.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && (r.value as any).status === 'failed'))
|
||||
.length;
|
||||
return { recipientId: recipient.id, status: "sent", error: null };
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
recipientType: recipient.type,
|
||||
recipientName: recipient.name,
|
||||
},
|
||||
"Failed to send alert",
|
||||
);
|
||||
return {
|
||||
recipientId: recipient.id,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}),
|
||||
).then(async (results) => {
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === "fulfilled" && (r.value as any).status === "sent",
|
||||
).length;
|
||||
const failures = results.filter(
|
||||
(r) =>
|
||||
r.status === "rejected" ||
|
||||
(r.status === "fulfilled" && (r.value as any).status === "failed"),
|
||||
).length;
|
||||
|
||||
// Determine final status
|
||||
const finalStatus = failures === 0 ? 'completed' : (successCount > 0 ? 'completed' : 'failed');
|
||||
// Determine final status
|
||||
const finalStatus =
|
||||
failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed";
|
||||
|
||||
// Update Task
|
||||
await db.update(alertTasks).set({
|
||||
status: finalStatus,
|
||||
successCount,
|
||||
updatedAt: new Date(),
|
||||
// If fully failed, maybe store the first error in the task record for quick view
|
||||
error: failures > 0 ? `Failed to send to ${failures} recipients` : null,
|
||||
}).where(eq(alertTasks.id, task.id));
|
||||
// Update Task
|
||||
await db
|
||||
.update(alertTasks)
|
||||
.set({
|
||||
status: finalStatus,
|
||||
successCount,
|
||||
updatedAt: new Date(),
|
||||
// If fully failed, maybe store the first error in the task record for quick view
|
||||
error: failures > 0 ? `Failed to send to ${failures} recipients` : null,
|
||||
})
|
||||
.where(eq(alertTasks.id, task.id));
|
||||
|
||||
// Insert Logs
|
||||
const logs = results.map((r, index) => {
|
||||
const recipient = allRecipients[index];
|
||||
if (r.status === 'fulfilled') {
|
||||
const val = r.value as any;
|
||||
return {
|
||||
taskId: task.id,
|
||||
userId: recipient.type === 'user' ? recipient.id : null, // Only link users
|
||||
// We could add connection to group binding if we altered schema, but for now log it
|
||||
status: val.status,
|
||||
error: val.error,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
taskId: task.id,
|
||||
userId: recipient.type === 'user' ? recipient.id : null,
|
||||
status: 'failed',
|
||||
error: r.reason ? String(r.reason) : 'Unknown error',
|
||||
};
|
||||
}
|
||||
});
|
||||
// Insert Logs
|
||||
const logs = results.map((r, index) => {
|
||||
const recipient = allRecipients[index];
|
||||
if (r.status === "fulfilled") {
|
||||
const val = r.value as any;
|
||||
return {
|
||||
taskId: task.id,
|
||||
userId: recipient.type === "user" ? recipient.id : null, // Only link users
|
||||
// We could add connection to group binding if we altered schema, but for now log it
|
||||
status: val.status,
|
||||
error: val.error,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
taskId: task.id,
|
||||
userId: recipient.type === "user" ? recipient.id : null,
|
||||
status: "failed",
|
||||
error: r.reason ? String(r.reason) : "Unknown error",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (logs.length > 0) {
|
||||
await db.insert(alertLogs).values(logs as any);
|
||||
}
|
||||
if (logs.length > 0) {
|
||||
await db.insert(alertLogs).values(logs as any);
|
||||
}
|
||||
|
||||
logger.info({
|
||||
taskId: task.id,
|
||||
successCount,
|
||||
totalCount: allRecipients.length,
|
||||
slug
|
||||
}, '[Webhook] Task processed');
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
taskId: task.id,
|
||||
successCount,
|
||||
totalCount: allRecipients.length,
|
||||
slug,
|
||||
},
|
||||
"[Webhook] Task processed",
|
||||
);
|
||||
});
|
||||
|
||||
return c.json({
|
||||
message: 'Alert received and processing started',
|
||||
taskId: task.id,
|
||||
status: 'processing',
|
||||
recipientCount: allRecipients.length
|
||||
});
|
||||
return c.json({
|
||||
message: "Alert received and processing started",
|
||||
taskId: task.id,
|
||||
status: "processing",
|
||||
recipientCount: allRecipients.length,
|
||||
});
|
||||
});
|
||||
|
||||
webhook.post('/:token/dm', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
logger.info({ token }, '[Webhook] Received DM request');
|
||||
webhook.post("/:token/dm", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
logger.info({ token }, "[Webhook] Received DM request");
|
||||
|
||||
// 0. Find the User by Token
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.personalToken, token),
|
||||
});
|
||||
// 0. Find the User by Token
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.personalToken, token),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn({ token }, '[Webhook] Invalid personal token');
|
||||
return c.json({ error: 'Invalid personal token' }, 401);
|
||||
}
|
||||
if (!user) {
|
||||
logger.warn({ token }, "[Webhook] Invalid personal token");
|
||||
return c.json({ error: "Invalid personal token" }, 401);
|
||||
}
|
||||
|
||||
if (!user.feishuUserId) {
|
||||
return c.json({ error: 'User has no Feishu ID linked' }, 400);
|
||||
}
|
||||
if (!user.feishuUserId) {
|
||||
return c.json({ error: "User has no Feishu ID linked" }, 400);
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
const rawBody = await c.req.text();
|
||||
if (!rawBody || rawBody.trim() === '') {
|
||||
return c.json({ error: 'Empty body' }, 400);
|
||||
}
|
||||
body = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
return c.json({ error: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
const rawBody = await c.req.text();
|
||||
if (!rawBody || rawBody.trim() === "") {
|
||||
return c.json({ error: "Empty body" }, 400);
|
||||
}
|
||||
body = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
// 1. Create Task (topicSlug is null for DM)
|
||||
const [task] = await db.insert(alertTasks).values({
|
||||
topicSlug: null,
|
||||
senderId: user.id,
|
||||
status: 'processing',
|
||||
recipientCount: 1,
|
||||
successCount: 0,
|
||||
payload: body,
|
||||
}).returning();
|
||||
// 1. Create Task (topicSlug is null for DM)
|
||||
const [task] = await db
|
||||
.insert(alertTasks)
|
||||
.values({
|
||||
topicSlug: null,
|
||||
senderId: user.id,
|
||||
status: "processing",
|
||||
recipientCount: 1,
|
||||
successCount: 0,
|
||||
payload: body,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 2. Send Message
|
||||
(async () => {
|
||||
try {
|
||||
let msgType = body.msg_type || 'text';
|
||||
let content = body.content;
|
||||
// 2. Send Message
|
||||
(async () => {
|
||||
try {
|
||||
let msgType = body.msg_type || "text";
|
||||
let content = body.content;
|
||||
|
||||
if (!content) {
|
||||
msgType = 'text';
|
||||
content = { text: JSON.stringify(body, null, 2) };
|
||||
}
|
||||
if (!content) {
|
||||
msgType = "text";
|
||||
content = { text: JSON.stringify(body, null, 2) };
|
||||
}
|
||||
|
||||
// Add metadata
|
||||
if (msgType === 'text' && content.text) {
|
||||
content.text = `[Direct Message]\n${content.text}`;
|
||||
}
|
||||
if (msgType === 'interactive' && content.header) {
|
||||
content.header.title.content = `[DM] ${content.header.title.content}`;
|
||||
}
|
||||
// Add metadata
|
||||
if (msgType === "text" && content.text) {
|
||||
content.text = `[Direct Message]\n${content.text}`;
|
||||
}
|
||||
if (msgType === "interactive" && content.header) {
|
||||
content.header.title.content = `[DM] ${content.header.title.content}`;
|
||||
}
|
||||
|
||||
const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id';
|
||||
await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content);
|
||||
const idType = user.feishuUserId.startsWith("ou_")
|
||||
? "open_id"
|
||||
: "user_id";
|
||||
await feishuClient.sendMessage(
|
||||
user.feishuUserId,
|
||||
idType,
|
||||
msgType,
|
||||
content,
|
||||
);
|
||||
|
||||
// Update Task
|
||||
await db.update(alertTasks).set({
|
||||
status: 'completed',
|
||||
successCount: 1,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(alertTasks.id, task.id));
|
||||
// Update Task
|
||||
await db
|
||||
.update(alertTasks)
|
||||
.set({
|
||||
status: "completed",
|
||||
successCount: 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(alertTasks.id, task.id));
|
||||
|
||||
// Insert Log
|
||||
await db.insert(alertLogs).values({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
status: 'sent',
|
||||
});
|
||||
// Insert Log
|
||||
await db.insert(alertLogs).values({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
status: "sent",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error, userName: user.name }, "Failed to send DM");
|
||||
await db
|
||||
.update(alertTasks)
|
||||
.set({
|
||||
status: "failed",
|
||||
updatedAt: new Date(),
|
||||
error: error.message,
|
||||
})
|
||||
.where(eq(alertTasks.id, task.id));
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error, userName: user.name }, 'Failed to send DM');
|
||||
await db.update(alertTasks).set({
|
||||
status: 'failed',
|
||||
updatedAt: new Date(),
|
||||
error: error.message,
|
||||
}).where(eq(alertTasks.id, task.id));
|
||||
await db.insert(alertLogs).values({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
await db.insert(alertLogs).values({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return c.json({
|
||||
message: 'DM received and processing started',
|
||||
taskId: task.id,
|
||||
status: 'processing',
|
||||
recipientCount: 1
|
||||
});
|
||||
return c.json({
|
||||
message: "DM received and processing started",
|
||||
taskId: task.id,
|
||||
status: "processing",
|
||||
recipientCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Help message for non-POST requests or malformed URLs
|
||||
webhook.all('/:token/topic/:slug', (c) => {
|
||||
return c.json({
|
||||
error: 'Method not allowed',
|
||||
message: 'Please use POST to send alerts to this webhook',
|
||||
format: 'POST /webhook/:token/topic/:slug',
|
||||
example: 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL'
|
||||
}, 405);
|
||||
webhook.all("/:token/topic/:slug", (c) => {
|
||||
return c.json(
|
||||
{
|
||||
error: "Method not allowed",
|
||||
message: "Please use POST to send alerts to this webhook",
|
||||
format: "POST /webhook/:token/topic/:slug",
|
||||
example:
|
||||
'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL',
|
||||
},
|
||||
405,
|
||||
);
|
||||
});
|
||||
|
||||
export default webhook;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import * as lark from '@larksuiteoapi/node-sdk';
|
||||
import { feishuClient } from './feishu';
|
||||
import { eventDispatcher } from './event-handler';
|
||||
import { logger } from './lib/logger';
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { eventDispatcher } from "./event-handler";
|
||||
import { feishuClient } from "./feishu";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
export const startWebSocket = async () => {
|
||||
if (process.env.FEISHU_USE_WS !== 'true') {
|
||||
return;
|
||||
}
|
||||
if (process.env.FEISHU_USE_WS !== "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[Feishu WS] Starting WebSocket connection...');
|
||||
try {
|
||||
const wsClient = new lark.WSClient({
|
||||
appId: feishuClient.appId,
|
||||
appSecret: feishuClient.appSecret,
|
||||
});
|
||||
await wsClient.start({ eventDispatcher });
|
||||
logger.info('[Feishu WS] Connected successfully');
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, '[Feishu WS] Connection failed');
|
||||
}
|
||||
logger.info("[Feishu WS] Starting WebSocket connection...");
|
||||
try {
|
||||
const wsClient = new lark.WSClient({
|
||||
appId: feishuClient.appId,
|
||||
appSecret: feishuClient.appSecret,
|
||||
});
|
||||
await wsClient.start({ eventDispatcher });
|
||||
logger.info("[Feishu WS] Connected successfully");
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, "[Feishu WS] Connection failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"bun-types"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun-types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"name": "@alertmessagecenter/web",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --env-file .env vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"hono": "^4.11.3",
|
||||
"lucide-react": "^0.300.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.0.0",
|
||||
"postcss": "^8.0.0",
|
||||
"tailwindcss": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"zod": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
"name": "@alertmessagecenter/web",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --env-file .env vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"hono": "^4.11.3",
|
||||
"lucide-react": "^0.300.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.0.0",
|
||||
"postcss": "^8.0.0",
|
||||
"tailwindcss": "^3.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"zod": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,125 +1,142 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Hash, Users, Activity, LogIn, LogOut, ShieldCheck, Settings } from 'lucide-react'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import TopicsView from './views/TopicsView'
|
||||
import UsersView from './views/UsersView'
|
||||
import AdminView from './views/AdminView'
|
||||
import {
|
||||
Activity,
|
||||
Hash,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import AdminView from "./views/AdminView";
|
||||
import TopicsView from "./views/TopicsView";
|
||||
import UsersView from "./views/UsersView";
|
||||
|
||||
function App() {
|
||||
const { user, loading, login, logout } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState('topics')
|
||||
const [hasSetDefault, setHasSetDefault] = useState(false)
|
||||
const { user, loading, login, logout } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState("topics");
|
||||
const [hasSetDefault, setHasSetDefault] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user && !hasSetDefault) {
|
||||
setActiveTab(user.isAdmin ? 'admin' : 'topics')
|
||||
setHasSetDefault(true)
|
||||
}
|
||||
}, [user, loading, hasSetDefault])
|
||||
useEffect(() => {
|
||||
if (!loading && user && !hasSetDefault) {
|
||||
setActiveTab(user.isAdmin ? "admin" : "topics");
|
||||
setHasSetDefault(true);
|
||||
}
|
||||
}, [user, loading, hasSetDefault]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<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="text-center">
|
||||
<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>
|
||||
<p className="text-gray-600 mb-6">Please sign in with Feishu to continue</p>
|
||||
<button
|
||||
onClick={login}
|
||||
className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
Sign in with Feishu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) {
|
||||
return (
|
||||
<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="text-center">
|
||||
<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>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Please sign in with Feishu to continue
|
||||
</p>
|
||||
<button
|
||||
onClick={login}
|
||||
className="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition"
|
||||
>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
Sign in with Feishu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<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="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Activity className="h-8 w-8 text-indigo-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">Alert Message Center</span>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
{user.isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('admin')}
|
||||
className={`${activeTab === 'admin'
|
||||
? 'border-indigo-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab('topics')}
|
||||
className={`${activeTab === 'topics'
|
||||
? 'border-indigo-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
|
||||
>
|
||||
<Hash className="mr-2 h-4 w-4" />
|
||||
Topics
|
||||
</button>
|
||||
{user.isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`${activeTab === 'users'
|
||||
? 'border-indigo-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
||||
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
|
||||
>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Users
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<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="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Activity className="h-8 w-8 text-indigo-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">
|
||||
Alert Message Center
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
{user.isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab("admin")}
|
||||
className={`${
|
||||
activeTab === "admin"
|
||||
? "border-indigo-500 text-gray-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab("topics")}
|
||||
className={`${
|
||||
activeTab === "topics"
|
||||
? "border-indigo-500 text-gray-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
|
||||
>
|
||||
<Hash className="mr-2 h-4 w-4" />
|
||||
Topics
|
||||
</button>
|
||||
{user.isAdmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab("users")}
|
||||
className={`${
|
||||
activeTab === "users"
|
||||
? "border-indigo-500 text-gray-900"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
|
||||
>
|
||||
<Users 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-2">
|
||||
<span className="text-sm text-gray-700">{user.name}</span>
|
||||
{user.isAdmin && (
|
||||
<ShieldCheck className="h-5 w-5 text-indigo-600" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="mr-1 h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-700">{user.name}</span>
|
||||
{user.isAdmin && (
|
||||
<ShieldCheck className="h-5 w-5 text-indigo-600" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="mr-1 h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
{activeTab === 'topics' && <TopicsView />}
|
||||
{activeTab === 'users' && user.isAdmin && <UsersView />}
|
||||
{activeTab === 'admin' && user.isAdmin && <AdminView />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
{activeTab === "topics" && <TopicsView />}
|
||||
{activeTab === "users" && user.isAdmin && <UsersView />}
|
||||
{activeTab === "admin" && user.isAdmin && <AdminView />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -1,196 +1,228 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trash2, Plus, MessageCircle } from 'lucide-react';
|
||||
import Modal from './Modal';
|
||||
import { client } from '../lib/client';
|
||||
import { MessageCircle, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { client } from "../lib/client";
|
||||
import Modal from "./Modal";
|
||||
|
||||
interface GroupBinding {
|
||||
id: string;
|
||||
chatId: string;
|
||||
name: string;
|
||||
id: string;
|
||||
chatId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface KnownGroup {
|
||||
chatId: string;
|
||||
name: string;
|
||||
lastActiveAt: string;
|
||||
chatId: string;
|
||||
name: string;
|
||||
lastActiveAt: string;
|
||||
}
|
||||
|
||||
interface GroupBindingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
topicId: string;
|
||||
topicName: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
topicId: string;
|
||||
topicName: string;
|
||||
}
|
||||
|
||||
export default function GroupBindingsModal({ isOpen, onClose, topicId, topicName }: GroupBindingsModalProps) {
|
||||
// const { user } = useAuth(); // Unused
|
||||
const [bindings, setBindings] = useState<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);
|
||||
export default function GroupBindingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
topicId,
|
||||
topicName,
|
||||
}: GroupBindingsModalProps) {
|
||||
// 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(() => {
|
||||
if (isOpen && topicId) {
|
||||
fetchBindings();
|
||||
fetchKnownGroups();
|
||||
setStatus(null);
|
||||
setSelectedChatId('');
|
||||
}
|
||||
}, [isOpen, topicId]);
|
||||
useEffect(() => {
|
||||
if (isOpen && topicId) {
|
||||
fetchBindings();
|
||||
fetchKnownGroups();
|
||||
setStatus(null);
|
||||
setSelectedChatId("");
|
||||
}
|
||||
}, [isOpen, topicId]);
|
||||
|
||||
const fetchBindings = async () => {
|
||||
try {
|
||||
const res = await client.api.topics[':id'].groups.$get({
|
||||
param: { id: topicId }
|
||||
}, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
setBindings(data as any);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
const fetchBindings = async () => {
|
||||
try {
|
||||
const res = await client.api.topics[":id"].groups.$get(
|
||||
{
|
||||
param: { id: topicId },
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
setBindings(data as any);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchKnownGroups = async () => {
|
||||
try {
|
||||
const res = await client.api.groups.$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
// Only verify uniqueness if needed, but here we just list what server returns
|
||||
setKnownGroups(data as any);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
const fetchKnownGroups = async () => {
|
||||
try {
|
||||
const res = await client.api.groups.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
// Only verify uniqueness if needed, but here we just list what server returns
|
||||
setKnownGroups(data as any);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBind = async () => {
|
||||
if (!selectedChatId) return;
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const handleBind = async () => {
|
||||
if (!selectedChatId) return;
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
|
||||
const group = knownGroups.find(g => g.chatId === selectedChatId);
|
||||
if (!group) return;
|
||||
const group = knownGroups.find((g) => g.chatId === selectedChatId);
|
||||
if (!group) return;
|
||||
|
||||
try {
|
||||
const res = await client.api.topics[':id'].groups.$post({
|
||||
param: { id: topicId },
|
||||
json: {
|
||||
chatId: group.chatId,
|
||||
name: group.name,
|
||||
}
|
||||
}, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
try {
|
||||
const res = await client.api.topics[":id"].groups.$post(
|
||||
{
|
||||
param: { id: topicId },
|
||||
json: {
|
||||
chatId: group.chatId,
|
||||
name: group.name,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
setStatus({ type: 'success', message: 'Group bound successfully!' });
|
||||
fetchBindings();
|
||||
setSelectedChatId('');
|
||||
} else {
|
||||
await res.json(); // Consume body
|
||||
setStatus({ type: 'error', message: 'Failed to bind group' });
|
||||
}
|
||||
} catch (_) { // Ignore error
|
||||
setStatus({ type: 'error', message: 'An error occurred' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (res.ok) {
|
||||
setStatus({ type: "success", message: "Group bound successfully!" });
|
||||
fetchBindings();
|
||||
setSelectedChatId("");
|
||||
} else {
|
||||
await res.json(); // Consume body
|
||||
setStatus({ type: "error", message: "Failed to bind group" });
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore error
|
||||
setStatus({ type: "error", message: "An error occurred" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbind = async (bindingId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this group binding?')) return;
|
||||
const handleUnbind = async (bindingId: string) => {
|
||||
if (!confirm("Are you sure you want to remove this group binding?")) return;
|
||||
|
||||
try {
|
||||
const res = await client.api.topics[':id'].groups[':bindingId'].$delete({
|
||||
param: { id: topicId, bindingId }
|
||||
}, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
try {
|
||||
const res = await client.api.topics[":id"].groups[":bindingId"].$delete(
|
||||
{
|
||||
param: { id: topicId, bindingId },
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
setBindings(prev => prev.filter(b => b.id !== bindingId));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
if (res.ok) {
|
||||
setBindings((prev) => prev.filter((b) => b.id !== bindingId));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out groups that are already bound
|
||||
const availableGroups = knownGroups.filter(
|
||||
kg => !bindings.some(b => b.chatId === kg.chatId)
|
||||
);
|
||||
// Filter out groups that are already bound
|
||||
const availableGroups = knownGroups.filter(
|
||||
(kg) => !bindings.some((b) => b.chatId === kg.chatId),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Manage Group Chats for ${topicName}`}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Bound Groups</h4>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">No groups bound to this topic yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 border rounded-md">
|
||||
{bindings.map(binding => (
|
||||
<li key={binding.id} className="flex justify-between items-center p-3">
|
||||
<div className="flex items-center">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-700">{binding.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnbind(binding.id)}
|
||||
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>
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Manage Group Chats for ${topicName}`}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
||||
Bound Groups
|
||||
</h4>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
No groups bound to this topic yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 border rounded-md">
|
||||
{bindings.map((binding) => (
|
||||
<li
|
||||
key={binding.id}
|
||||
className="flex justify-between items-center p-3"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{binding.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnbind(binding.id)}
|
||||
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">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Add Group Binding</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Select a group where the Feishu Bot has been added. If your group is not listed, try removing and re-adding the bot to the group.
|
||||
</p>
|
||||
<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>
|
||||
<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">
|
||||
<select
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
|
||||
value={selectedChatId}
|
||||
onChange={(e) => setSelectedChatId(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select a group...</option>
|
||||
{availableGroups.map(group => (
|
||||
<option key={group.chatId} value={group.chatId}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleBind}
|
||||
disabled={!selectedChatId || loading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p className={`mt-2 text-xs ${status.type === 'success' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2 text-gray-900"
|
||||
value={selectedChatId}
|
||||
onChange={(e) => setSelectedChatId(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select a group...</option>
|
||||
{availableGroups.map((group) => (
|
||||
<option key={group.chatId} value={group.chatId}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleBind}
|
||||
disabled={!selectedChatId || loading}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${status.type === "success" ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { X } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-gray-500 opacity-75" onClick={onClose}></div>
|
||||
</div>
|
||||
return (
|
||||
<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="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<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">​</span>
|
||||
<span
|
||||
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<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="flex justify-between items-start">
|
||||
<h3
|
||||
className="text-lg leading-6 font-medium text-gray-900"
|
||||
id="modal-title"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +1,92 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { client } from '../lib/client';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { client } from "../lib/client";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
personalToken: string;
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
personalToken: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.api.auth.me.$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const checkAuth = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.api.auth.me.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auth check failed:", error);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.api.auth['login-url'].$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
window.location.href = data.loginUrl;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
}, []);
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
const res = await client.api.auth["login-url"].$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
window.location.href = data.loginUrl;
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await client.api.auth.logout.$post(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
}, []);
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await client.api.auth.logout.$post(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, checkAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hc } from 'hono/client';
|
||||
import type { AppType } from '../../../server/src/index';
|
||||
import { hc } from "hono/client";
|
||||
import type { AppType } from "../../../server/src/index";
|
||||
|
||||
export const client = hc<AppType>('/') as any;
|
||||
export const client = hc<AppType>("/") as any;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import AuthCallback from './views/AuthCallback.tsx'
|
||||
import { AuthProvider } from './contexts/AuthContext.tsx'
|
||||
import './index.css'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { AuthProvider } from "./contexts/AuthContext.tsx";
|
||||
import AuthCallback from "./views/AuthCallback.tsx";
|
||||
import "./index.css";
|
||||
|
||||
// Simple routing based on pathname
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
{pathname === '/auth/callback' ? <AuthCallback /> : <App />}
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
{pathname === "/auth/callback" ? <AuthCallback /> : <App />}
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,236 +1,285 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { client } from '../lib/client';
|
||||
import SystemLoadView from './SystemLoadView';
|
||||
import { useEffect, useState } from "react";
|
||||
import { client } from "../lib/client";
|
||||
import SystemLoadView from "./SystemLoadView";
|
||||
|
||||
export default function AdminView() {
|
||||
const [activeTab, setActiveTab] = useState('load');
|
||||
const [activeTab, setActiveTab] = useState("load");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2>
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Admin Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('load')}
|
||||
className={`${activeTab === 'load'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
System Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('requests')}
|
||||
className={`${activeTab === 'requests'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
Topic Requests
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('topics')}
|
||||
className={`${activeTab === 'topics'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
All Topics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("load")}
|
||||
className={`${
|
||||
activeTab === "load"
|
||||
? "border-indigo-500 text-indigo-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
System Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("requests")}
|
||||
className={`${
|
||||
activeTab === "requests"
|
||||
? "border-indigo-500 text-indigo-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
Topic Requests
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("topics")}
|
||||
className={`${
|
||||
activeTab === "topics"
|
||||
? "border-indigo-500 text-indigo-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
All Topics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{activeTab === 'load' && <SystemLoadView />}
|
||||
{activeTab === 'requests' && <TopicRequestsList />}
|
||||
{activeTab === 'topics' && <TopicsManagement />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{activeTab === "load" && <SystemLoadView />}
|
||||
{activeTab === "requests" && <TopicRequestsList />}
|
||||
{activeTab === "topics" && <TopicsManagement />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopicsManagement() {
|
||||
const [topics, setTopics] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [topics, setTopics] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchAllTopics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.api.topics.all.$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
setTopics(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchAllTopics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.api.topics.all.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setTopics(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllTopics();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetchAllTopics();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`)) {
|
||||
return;
|
||||
}
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete topic "${name}"? This will also remove all subscriptions.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } });
|
||||
fetchAllTopics();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await client.api.topics[":id"].$delete(
|
||||
{ param: { id } },
|
||||
{ init: { credentials: "include" } },
|
||||
);
|
||||
fetchAllTopics();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading topics...</div>;
|
||||
if (loading) return <div>Loading topics...</div>;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<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">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Subscribers</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">Approved By</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{topics.map((topic) => (
|
||||
<tr key={topic.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{topic.name}</div>
|
||||
<div className="text-sm text-gray-500 font-mono">{topic.slug}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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' :
|
||||
topic.status === 'rejected' ? 'bg-red-100 text-red-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{topic.status}
|
||||
</span>
|
||||
</td>
|
||||
<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>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<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">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Subscribers
|
||||
</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">
|
||||
Approved By
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{topics.map((topic) => (
|
||||
<tr key={topic.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{topic.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 font-mono">
|
||||
{topic.slug}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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"
|
||||
: topic.status === "rejected"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{topic.status}
|
||||
</span>
|
||||
</td>
|
||||
<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() {
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.api.topics.requests.$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchRequests = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.api.topics.requests.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, []);
|
||||
|
||||
const handleAction = async (id: string, action: 'approve' | 'reject' | 'delete', name?: string) => {
|
||||
try {
|
||||
if (action === 'approve') {
|
||||
await client.api.topics[':id'].approve.$post({ param: { id } }, { init: { credentials: 'include' } });
|
||||
} else if (action === 'reject') {
|
||||
await client.api.topics[':id'].reject.$post({ param: { id } }, { init: { credentials: 'include' } });
|
||||
} else if (action === 'delete') {
|
||||
if (!confirm(`Are you sure you want to delete request "${name}"?`)) return;
|
||||
await client.api.topics[':id'].$delete({ param: { id } }, { init: { credentials: 'include' } });
|
||||
}
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const handleAction = async (
|
||||
id: string,
|
||||
action: "approve" | "reject" | "delete",
|
||||
name?: string,
|
||||
) => {
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await client.api.topics[":id"].approve.$post(
|
||||
{ param: { id } },
|
||||
{ init: { credentials: "include" } },
|
||||
);
|
||||
} else if (action === "reject") {
|
||||
await client.api.topics[":id"].reject.$post(
|
||||
{ param: { id } },
|
||||
{ init: { credentials: "include" } },
|
||||
);
|
||||
} else if (action === "delete") {
|
||||
if (!confirm(`Are you sure you want to delete request "${name}"?`))
|
||||
return;
|
||||
await client.api.topics[":id"].$delete(
|
||||
{ param: { id } },
|
||||
{ init: { credentials: "include" } },
|
||||
);
|
||||
}
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading requests...</div>;
|
||||
if (loading) return <div>Loading requests...</div>;
|
||||
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No pending topic requests.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No pending topic requests.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{requests.map(req => (
|
||||
<li key={req.id} className="py-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{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">
|
||||
Requested by: {req.creator?.name || 'Unknown'}
|
||||
{req.creator?.email ? ` (${req.creator.email})` : ''}
|
||||
</p>
|
||||
{req.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 italic">"{req.description}"</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{requests.map((req) => (
|
||||
<li key={req.id} className="py-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{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">
|
||||
Requested by: {req.creator?.name || "Unknown"}
|
||||
{req.creator?.email ? ` (${req.creator.email})` : ""}
|
||||
</p>
|
||||
{req.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 italic">
|
||||
"{req.description}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,83 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { client } from '../lib/client';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { client } from "../lib/client";
|
||||
|
||||
export default function AuthCallback() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { checkAuth } = useAuth();
|
||||
const processed = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { checkAuth } = useAuth();
|
||||
const processed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (processed.current) return;
|
||||
processed.current = true;
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (processed.current) return;
|
||||
processed.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
|
||||
if (!code) {
|
||||
setError('No authorization code received');
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
setError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await client.api.auth.callback.$get({
|
||||
query: {
|
||||
code,
|
||||
state: state || undefined
|
||||
}
|
||||
}, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
try {
|
||||
const res = await client.api.auth.callback.$get(
|
||||
{
|
||||
query: {
|
||||
code,
|
||||
state: state || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
await checkAuth();
|
||||
// Redirect to home
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Authentication failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Authentication failed');
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
if (res.ok) {
|
||||
await checkAuth();
|
||||
// Redirect to home
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Authentication failed");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Authentication failed");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [checkAuth]);
|
||||
handleCallback();
|
||||
}, [checkAuth]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Authentication Error</h2>
|
||||
<p className="text-gray-700">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
|
||||
>
|
||||
Return to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">
|
||||
Authentication Error
|
||||
</h2>
|
||||
<p className="text-gray-700">{error}</p>
|
||||
<button
|
||||
onClick={() => (window.location.href = "/")}
|
||||
className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
|
||||
>
|
||||
Return to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Authenticating...</h2>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Authenticating...
|
||||
</h2>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,291 +1,380 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { client } from '../lib/client';
|
||||
import { Activity, CheckCircle, XCircle, BarChart3, Clock } from 'lucide-react';
|
||||
import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { client } from "../lib/client";
|
||||
|
||||
interface Stats {
|
||||
topics: {
|
||||
topicSlug: string;
|
||||
totalTasks: number;
|
||||
totalRecipients: number;
|
||||
totalSuccess: number;
|
||||
}[];
|
||||
recent: {
|
||||
alertsReceived: number;
|
||||
plannedMessages: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
successRate: number;
|
||||
};
|
||||
tasks: any[];
|
||||
topics: {
|
||||
topicSlug: string;
|
||||
totalTasks: number;
|
||||
totalRecipients: number;
|
||||
totalSuccess: number;
|
||||
}[];
|
||||
recent: {
|
||||
alertsReceived: number;
|
||||
plannedMessages: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
successRate: number;
|
||||
};
|
||||
tasks: any[];
|
||||
}
|
||||
|
||||
export default function SystemLoadView() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await client.api.stats.$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await client.api.stats.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// Fetch recent tasks as well
|
||||
const tasksRes = await client.api.alerts.tasks.$get({ query: { limit: '10' } }, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const tasks = await tasksRes.json();
|
||||
// Fetch recent tasks as well
|
||||
const tasksRes = await client.api.alerts.tasks.$get(
|
||||
{ query: { limit: "10" } },
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
const tasks = await tasksRes.json();
|
||||
|
||||
setStats({ ...data, tasks } as Stats);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
setStats({ ...data, tasks } as Stats);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 10000); // 10s refresh for dynamic feel
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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 (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<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="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">Live Feedback</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Last updated: {lastUpdated.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<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="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||
Live Feedback
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Top Row: General Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<MetricCard
|
||||
title="Alerts Received"
|
||||
value={stats.recent.alertsReceived}
|
||||
icon={<Activity className="w-5 h-5 text-purple-500" />}
|
||||
color="purple"
|
||||
description="Total webhook hits"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Planned Deliveries"
|
||||
value={stats.recent.plannedMessages}
|
||||
icon={<Clock className="w-5 h-5 text-blue-500" />}
|
||||
color="blue"
|
||||
description="Total subscribers"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Success"
|
||||
value={stats.recent.successCount}
|
||||
icon={<CheckCircle className="w-5 h-5 text-green-500" />}
|
||||
color="green"
|
||||
description="Successfully sent"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Failed"
|
||||
value={stats.recent.failedCount}
|
||||
icon={<XCircle className="w-5 h-5 text-red-500" />}
|
||||
color="red"
|
||||
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">
|
||||
<span className="text-xs font-medium text-gray-500 mb-2">Success Rate</span>
|
||||
<Gauge value={stats.recent.successRate} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Top Row: General Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<MetricCard
|
||||
title="Alerts Received"
|
||||
value={stats.recent.alertsReceived}
|
||||
icon={<Activity className="w-5 h-5 text-purple-500" />}
|
||||
color="purple"
|
||||
description="Total webhook hits"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Planned Deliveries"
|
||||
value={stats.recent.plannedMessages}
|
||||
icon={<Clock className="w-5 h-5 text-blue-500" />}
|
||||
color="blue"
|
||||
description="Total subscribers"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Success"
|
||||
value={stats.recent.successCount}
|
||||
icon={<CheckCircle className="w-5 h-5 text-green-500" />}
|
||||
color="green"
|
||||
description="Successfully sent"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Failed"
|
||||
value={stats.recent.failedCount}
|
||||
icon={<XCircle className="w-5 h-5 text-red-500" />}
|
||||
color="red"
|
||||
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">
|
||||
<span className="text-xs font-medium text-gray-500 mb-2">
|
||||
Success Rate
|
||||
</span>
|
||||
<Gauge value={stats.recent.successRate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Row: Topic Message Counts */}
|
||||
<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="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-semibold text-gray-800">Historical Topic Metrics</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Alerts (Tasks)</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Planned (Recipients)</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Distributed (Success)</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Health Rate</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{stats.topics.map((topic) => {
|
||||
const rate = topic.totalRecipients > 0 ? (topic.totalSuccess / topic.totalRecipients) * 100 : 100;
|
||||
return (
|
||||
<tr key={topic.topicSlug} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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`}>
|
||||
{topic.topicSlug || '[Private DM]'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">{topic.totalTasks}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalRecipients}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">{topic.totalSuccess}</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>
|
||||
{/* Middle Row: Topic Message Counts */}
|
||||
<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="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
Historical Topic Metrics
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">
|
||||
Alerts (Tasks)
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">
|
||||
Planned (Recipients)
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">
|
||||
Distributed (Success)
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">
|
||||
Health Rate
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{stats.topics.map((topic) => {
|
||||
const rate =
|
||||
topic.totalRecipients > 0
|
||||
? (topic.totalSuccess / topic.totalRecipients) * 100
|
||||
: 100;
|
||||
return (
|
||||
<tr
|
||||
key={topic.topicSlug}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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`}
|
||||
>
|
||||
{topic.topicSlug || "[Private DM]"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">
|
||||
{topic.totalTasks}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{topic.totalRecipients}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{topic.totalSuccess}
|
||||
</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 */}
|
||||
<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="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-semibold text-gray-800">Recent Alerts (Audit Log)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Time</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Sender</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Recipients</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{stats.tasks.map((task: any) => (
|
||||
<tr key={task.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium">
|
||||
{new Date(task.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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`}>
|
||||
{task.topicSlug || '[Private DM]'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">{task.sender?.name || 'Unknown'}</span>
|
||||
<span className="text-[10px] text-gray-400">{task.sender?.email || 'N/A'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{task.successCount} / {task.recipientCount}
|
||||
</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 ${task.status === 'completed' ? 'bg-green-100 text-green-700' : task.status === 'failed' ? '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>
|
||||
);
|
||||
{/* Bottom Row: Recent Alerts with Sender Info */}
|
||||
<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="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-indigo-500" />
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
Recent Alerts (Audit Log)
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 text-[10px] uppercase font-bold text-gray-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Time</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Topic</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Sender</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">
|
||||
Recipients
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{stats.tasks.map((task: any) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-xs text-gray-500 font-medium">
|
||||
{new Date(task.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<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`}
|
||||
>
|
||||
{task.topicSlug || "[Private DM]"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{task.sender?.name || "Unknown"}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{task.sender?.email || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{task.successCount} / {task.recipientCount}
|
||||
</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 ${
|
||||
task.status === "completed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: task.status === "failed"
|
||||
? "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 }) {
|
||||
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 MetricCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
description?: string;
|
||||
}) {
|
||||
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 }) {
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
|
||||
// Determine color based on value
|
||||
const getColor = (v: number) => {
|
||||
if (v >= 95) return '#10b981'; // green-500
|
||||
if (v >= 80) return '#f59e0b'; // yellow-500
|
||||
return '#ef4444'; // red-500
|
||||
};
|
||||
// Determine color based on value
|
||||
const getColor = (v: number) => {
|
||||
if (v >= 95) return "#10b981"; // green-500
|
||||
if (v >= 80) return "#f59e0b"; // yellow-500
|
||||
return "#ef4444"; // red-500
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<circle
|
||||
className="text-gray-100"
|
||||
strokeWidth="8"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="64"
|
||||
cy="64"
|
||||
/>
|
||||
<circle
|
||||
className="transition-all duration-1000 ease-out"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
stroke={getColor(value)}
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="64"
|
||||
cy="64"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<circle
|
||||
className="text-gray-100"
|
||||
strokeWidth="8"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="64"
|
||||
cy="64"
|
||||
/>
|
||||
<circle
|
||||
className="transition-all duration-1000 ease-out"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
stroke={getColor(value)}
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="64"
|
||||
cy="64"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +1,218 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import Modal from '../components/Modal';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { client } from '../lib/client';
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "../components/Modal";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { client } from "../lib/client";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
feishuUserId?: string;
|
||||
email?: string;
|
||||
personalToken?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
feishuUserId?: string;
|
||||
email?: string;
|
||||
personalToken?: string;
|
||||
}
|
||||
|
||||
export default function UsersView() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState<Partial<User>>({
|
||||
name: '',
|
||||
feishuUserId: '',
|
||||
email: '',
|
||||
});
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState<Partial<User>>({
|
||||
name: "",
|
||||
feishuUserId: "",
|
||||
email: "",
|
||||
});
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.api.users.$get(undefined, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const data = await res.json();
|
||||
setUsers(data as unknown as User[]);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.api.users.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setUsers(data as unknown as User[]);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await client.api.users.$post({
|
||||
json: formData as any
|
||||
}, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await client.api.users.$post(
|
||||
{
|
||||
json: formData as any,
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false);
|
||||
setFormData({ name: '', feishuUserId: '', email: '' });
|
||||
fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
}
|
||||
};
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false);
|
||||
setFormData({ name: "", feishuUserId: "", email: "" });
|
||||
fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
try {
|
||||
await client.api.users[':id'].$delete({
|
||||
param: { id }
|
||||
}, {
|
||||
init: { credentials: 'include' }
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
}
|
||||
};
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||
try {
|
||||
await client.api.users[":id"].$delete(
|
||||
{
|
||||
param: { id },
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-4">Loading...</div>;
|
||||
if (loading) return <div className="p-4">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Users</h2>
|
||||
{currentUser?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
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" />
|
||||
Add User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Users</h2>
|
||||
{currentUser?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
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" />
|
||||
Add User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">{user.name}</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="sm:flex flex-col">
|
||||
<p className="flex items-center text-sm text-gray-500">
|
||||
Feishu ID: <span className="font-mono ml-1 bg-gray-100 px-1 rounded">{user.feishuUserId || 'N/A'}</span>
|
||||
</p>
|
||||
<p className="flex items-center text-sm text-gray-500 mt-1">
|
||||
Email: {user.email || 'N/A'}
|
||||
</p>
|
||||
<p className="flex items-center text-sm text-gray-500 mt-1">
|
||||
Personal Token: <span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">{user.personalToken || 'N/A'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{currentUser?.isAdmin && (
|
||||
<div className="ml-4 flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900 p-2"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-indigo-600 truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<div className="mt-2 sm:flex sm:justify-between">
|
||||
<div className="sm:flex flex-col">
|
||||
<p className="flex items-center text-sm text-gray-500">
|
||||
Feishu ID:{" "}
|
||||
<span className="font-mono ml-1 bg-gray-100 px-1 rounded">
|
||||
{user.feishuUserId || "N/A"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-center text-sm text-gray-500 mt-1">
|
||||
Email: {user.email || "N/A"}
|
||||
</p>
|
||||
<p className="flex items-center text-sm text-gray-500 mt-1">
|
||||
Personal Token:{" "}
|
||||
<span className="font-mono ml-1 bg-blue-50 text-blue-700 px-1 rounded">
|
||||
{user.personalToken || "N/A"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{currentUser?.isAdmin && (
|
||||
<div className="ml-4 flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-red-600 hover:text-red-900 p-2"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Add New User"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
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}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Feishu User ID</label>
|
||||
<input
|
||||
type="text"
|
||||
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.feishuUserId}
|
||||
onChange={e => setFormData({ ...formData, feishuUserId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
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"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
);
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Add New User"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
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}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Feishu User ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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.feishuUserId}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, feishuUserId: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="mr-3 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/webhook': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_API_URL || "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/webhook": {
|
||||
target: process.env.VITE_API_URL || "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
62
biome.json
Normal file
62
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
package.json
31
package.json
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "alertmessagecenter",
|
||||
"version": "1.1.1",
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run --filter '*' dev",
|
||||
"build": "bun run --filter '*' build",
|
||||
"start": "bun run --filter '@alertmessagecenter/server' start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
"name": "alertmessagecenter",
|
||||
"version": "1.1.1",
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run --filter '*' dev",
|
||||
"build": "bun run --filter '*' build",
|
||||
"start": "bun run --filter '@alertmessagecenter/server' start",
|
||||
"lint": "bunx @biomejs/biome lint .",
|
||||
"format": "bunx @biomejs/biome format . --write",
|
||||
"check": "bunx @biomejs/biome check --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user