From 1403baaeb66a64b2ffcbaa005a911d88f87337a2 Mon Sep 17 00:00:00 2001 From: d0zingcat Date: Fri, 16 Jan 2026 21:00:14 +0800 Subject: [PATCH 1/5] feat: add group binding management Signed-off-by: d0zingcat --- apps/server/src/api.ts | 104 +++++++++++- apps/server/src/auth.ts | 6 +- apps/server/src/db/schema.ts | 4 + apps/server/src/lib/admin-notifier.ts | 85 ++++++++++ apps/server/src/middleware.ts | 1 + apps/server/src/webhook.ts | 16 +- .../web/src/components/GroupBindingsModal.tsx | 24 ++- apps/web/src/views/AdminView.tsx | 148 ++++++++++++++++-- apps/web/src/views/UsersView.tsx | 55 ++++++- docs/copilot-context.md | 10 +- todo.md | 2 + 11 files changed, 420 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/lib/admin-notifier.ts diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts index 006f9d3..3d1a7c4 100644 --- a/apps/server/src/api.ts +++ b/apps/server/src/api.ts @@ -12,6 +12,7 @@ import { users, } from "./db/schema"; import { type AuthSession, requireAdmin, requireAuth } from "./middleware"; +import { notifyAdminsOfNewTopic } from "./lib/admin-notifier"; const api = new Hono<{ Variables: { session: AuthSession } }>(); @@ -30,6 +31,7 @@ const userSchema = z.object({ name: z.string().min(1), feishuUserId: z.string().min(1), email: z.string().email().optional().or(z.literal("")), + isTrusted: z.boolean().optional(), }); // --- Topics --- @@ -73,6 +75,17 @@ api.get("/topics/requests", requireAdmin, async (c) => { return c.json(requests); }); +api.get("/topics/groups/requests", requireAdmin, async (c) => { + const requests = await db.query.topicGroupChats.findMany({ + where: eq(topicGroupChats.status, "pending"), + with: { + topic: true, + creator: true, + }, + }); + return c.json(requests); +}); + api.get("/topics/all", requireAdmin, async (c) => { const allTopics = await db.query.topics.findMany({ with: { @@ -124,7 +137,7 @@ 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 || session.isTrusted ? "approved" : "pending"; const result = await db .insert(topics) @@ -132,9 +145,18 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => { ...body, status, createdBy: session.id, - approvedBy: session.isAdmin ? session.id : null, + approvedBy: session.isAdmin || session.isTrusted ? session.id : null, }) .returning(); + if (status === "pending") { + await notifyAdminsOfNewTopic({ + id: result[0].id, + name: result[0].name, + slug: result[0].slug, + createdBy: session.id, + }); + } + return c.json(result[0]); }); @@ -278,6 +300,21 @@ api.post( const body = c.req.valid("json"); const session = c.get("session"); + // Check topic ownership or admin + const topic = await db.query.topics.findFirst({ + where: eq(topics.id, topicId), + }); + + if (!topic) { + return c.json({ error: "Topic not found" }, 404); + } + + if (topic.createdBy !== session.id && !session.isAdmin) { + return c.json({ error: "Only topic owner or admin can bind groups" }, 403); + } + + const status = session.isAdmin || session.isTrusted ? "approved" : "pending"; + const result = await db .insert(topicGroupChats) .values({ @@ -285,9 +322,23 @@ api.post( chatId: body.chatId, name: body.name, createdBy: session.id, + status, }) .returning(); + if (status === "pending") { + // Notify admins about the new group binding request + await notifyAdminsOfNewTopic({ + id: topic.id, + name: topic.name, + slug: topic.slug, + createdBy: session.id, + // Metadata passed to notifier for better context + isGroupBinding: true, + groupName: body.name, + } as any); + } + return c.json(result[0]); }, ); @@ -295,6 +346,23 @@ api.post( // Unbind a group api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => { const { id: topicId, bindingId } = c.req.param(); + const session = c.get("session"); + + // Check topic ownership or admin + const topic = await db.query.topics.findFirst({ + where: eq(topics.id, topicId), + }); + + if (!topic) { + return c.json({ error: "Topic not found" }, 404); + } + + if (topic.createdBy !== session.id && !session.isAdmin) { + return c.json( + { error: "Only topic owner or admin can unbind groups" }, + 403, + ); + } await db .delete(topicGroupChats) @@ -308,6 +376,38 @@ api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => { return c.json({ success: true }); }); +// Approve a group binding +api.post("/topics/:id/groups/:bindingId/approve", requireAdmin, async (c) => { + const { id: topicId, bindingId } = c.req.param(); + const result = await db + .update(topicGroupChats) + .set({ status: "approved" }) + .where( + and( + eq(topicGroupChats.id, bindingId), + eq(topicGroupChats.topicId, topicId), + ), + ) + .returning(); + return c.json(result[0]); +}); + +// Reject a group binding +api.post("/topics/:id/groups/:bindingId/reject", requireAdmin, async (c) => { + const { id: topicId, bindingId } = c.req.param(); + const result = await db + .update(topicGroupChats) + .set({ status: "rejected" }) + .where( + and( + eq(topicGroupChats.id, bindingId), + eq(topicGroupChats.topicId, topicId), + ), + ) + .returning(); + return c.json(result[0]); +}); + // --- Alert Tasks --- api.get("/alerts/tasks", requireAdmin, async (c) => { diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index cfcc02a..a275abc 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -78,13 +78,13 @@ auth.get("/callback", async (c) => { .returning(); user = result[0]; } else { - // Update user info (in case name or admin status changed) + // Update user info (don't overwrite admin/trusted status from feishu logic unless it's a new admin) const result = await db .update(users) .set({ name: userData.name, email: userData.email || user.email, - isAdmin, + isAdmin: user.isAdmin || isAdmin, // Keep admin if already admin or in ADMIN_EMAILS }) .where(eq(users.id, user.id)) .returning(); @@ -100,6 +100,7 @@ auth.get("/callback", async (c) => { name: user.name, email: user.email, isAdmin: user.isAdmin, + isTrusted: user.isTrusted, personalToken: user.personalToken, }), { @@ -117,6 +118,7 @@ auth.get("/callback", async (c) => { name: user.name, email: user.email, isAdmin: user.isAdmin, + isTrusted: user.isTrusted, }, }); } catch (error) { diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index a503c7d..2d7b55f 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -35,6 +35,9 @@ export const topicGroupChats = pgTable("topic_group_chats", { .references(() => topics.id, { onDelete: "cascade" }), chatId: text("chat_id").notNull(), // 飞书群 chat_id name: text("name").notNull(), // 群名称快照 + status: text("status", { enum: ["pending", "approved", "rejected"] }) + .default("approved") + .notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), createdBy: text("created_by").references(() => users.id), }); @@ -83,6 +86,7 @@ export const users = pgTable("users", { feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id) email: text("email").unique(), isAdmin: boolean("is_admin").default(false), + isTrusted: boolean("is_trusted").default(false), personalToken: text("personal_token") .notNull() .unique() diff --git a/apps/server/src/lib/admin-notifier.ts b/apps/server/src/lib/admin-notifier.ts new file mode 100644 index 0000000..06f58c7 --- /dev/null +++ b/apps/server/src/lib/admin-notifier.ts @@ -0,0 +1,85 @@ +import { db } from "../db"; +import { users } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { feishuClient } from "../feishu"; +import { logger } from "./logger"; + +export async function notifyAdminsOfNewTopic(topic: { + id: string; + name: string; + slug: string; + createdBy: string | null; + isGroupBinding?: boolean; + groupName?: string; +}) { + try { + // 1. Get all admins + const admins = await db.query.users.findMany({ + where: eq(users.isAdmin, true), + }); + + if (admins.length === 0) { + logger.warn("No admins found to notify"); + return; + } + + // 2. Get creator name + let creatorName = "Unknown"; + if (topic.createdBy) { + const creator = await db.query.users.findFirst({ + where: eq(users.id, topic.createdBy), + }); + if (creator) creatorName = creator.name; + } + + // 3. Prepare message content + const title = topic.isGroupBinding + ? "🔗 新的群聊绑定申请" + : "🆕 新的 Topic 申请"; + const detailContent = topic.isGroupBinding + ? `**Topic:** ${topic.name}\n**群聊:** ${topic.groupName}\n**创建者:** ${creatorName}` + : `**名称:** ${topic.name}\n**Slug:** ${topic.slug}\n**创建者:** ${creatorName}`; + + const content = { + config: { wide_screen_mode: true }, + header: { + template: topic.isGroupBinding ? "blue" : "orange", + title: { content: title, tag: "plain_text" }, + }, + elements: [ + { + tag: "div", + text: { + content: detailContent, + tag: "lark_md", + }, + }, + { + tag: "action", + actions: [ + { + tag: "button", + text: { content: "前往审批", tag: "plain_text" }, + type: "primary", + url: `${process.env.FRONTEND_URL || "http://localhost:5173"}/admin/topics`, + }, + ], + }, + ], + }; + + // 4. Send notification to each admin + for (const admin of admins) { + if (admin.feishuUserId) { + await feishuClient.sendMessage( + admin.feishuUserId, + "open_id", + "interactive", + content, + ); + } + } + } catch (error) { + logger.error({ err: error, topicId: topic.id }, "Failed to notify admins"); + } +} diff --git a/apps/server/src/middleware.ts b/apps/server/src/middleware.ts index 6741dd7..c8e6f78 100644 --- a/apps/server/src/middleware.ts +++ b/apps/server/src/middleware.ts @@ -6,6 +6,7 @@ export interface AuthSession { name: string; email: string | null; isAdmin: boolean; + isTrusted: boolean; } export async function requireAuth(c: Context, next: Next) { diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index dfc3eb9..18d3e5d 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -82,13 +82,15 @@ webhook.post("/:token/topic/:slug", async (c) => { }) .filter((u): u is NonNullable => u !== null); - const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({ - type: "group", - id: g.id, // Binding ID - name: g.name, - feishuId: g.chatId, - idType: "chat_id" as FeishuReceiveIdType, - })); + const groupRecipients: Recipient[] = topic.groupChats + .filter((g) => g.status === "approved") + .map((g) => ({ + type: "group", + id: g.id, // Binding ID + name: g.name, + feishuId: g.chatId, + idType: "chat_id" as FeishuReceiveIdType, + })); const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients]; diff --git a/apps/web/src/components/GroupBindingsModal.tsx b/apps/web/src/components/GroupBindingsModal.tsx index 12d43ab..4fe1c6f 100644 --- a/apps/web/src/components/GroupBindingsModal.tsx +++ b/apps/web/src/components/GroupBindingsModal.tsx @@ -7,6 +7,7 @@ interface GroupBinding { id: string; chatId: string; name: string; + status: "pending" | "approved" | "rejected"; } interface KnownGroup { @@ -103,7 +104,14 @@ export default function GroupBindingsModal({ ); if (res.ok) { - setStatus({ type: "success", message: "Group bound successfully!" }); + const data = (await res.json()) as GroupBinding; + setStatus({ + type: "success", + message: + data.status === "approved" + ? "Group bound successfully!" + : "Request submitted! Waiting for approval.", + }); fetchBindings(); setSelectedChatId(""); } else { @@ -166,11 +174,21 @@ export default function GroupBindingsModal({ key={binding.id} className="flex justify-between items-center p-3" > -
+
- + {binding.name} + + {binding.status} +
+ @@ -70,12 +77,126 @@ export default function AdminView() { {activeTab === "load" && } {activeTab === "requests" && } + {activeTab === "group-requests" && } {activeTab === "topics" && }
); } +interface GroupRequest { + id: string; + topicId: string; + chatId: string; + name: string; + status: string; + createdAt: string; + topic?: Topic; + creator?: TopicUser; +} + +function GroupRequestsList() { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchRequests = useCallback(async () => { + setLoading(true); + try { + // @ts-ignore - groups requests might not be in the generated client yet + const res = await client.api.topics.groups.requests.$get(undefined, { + init: { credentials: "include" }, + }); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) { + setRequests(data as unknown as GroupRequest[]); + } else { + setRequests([]); + } + } else { + setRequests([]); + } + } catch (error) { + console.error(error); + setRequests([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRequests(); + }, [fetchRequests]); + + const handleAction = async ( + req: GroupRequest, + action: "approve" | "reject", + ) => { + try { + // @ts-ignore + await client.api.topics[":id"].groups[":bindingId"][action].$post( + { param: { id: req.topicId, bindingId: req.id } }, + { init: { credentials: "include" } }, + ); + fetchRequests(); + } catch (error) { + console.error(error); + } + }; + + if (loading) return
Loading group requests...
; + + if (requests.length === 0) { + return ( +
+ No pending group binding requests. +
+ ); + } + + return ( +
+
    + {requests.map((req) => ( +
  • +
    +

    + Group: {req.name} +

    +

    + Topic: {req.topic?.name} ( + {req.topic?.slug}) +

    +

    + Requested by: {req.creator?.name || "Unknown"} +

    +

    + ID: {req.chatId} +

    +
    +
    + + +
    +
  • + ))} +
+
+ ); +} + function TopicsManagement() { const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); @@ -170,13 +291,12 @@ function TopicsManagement() { {topic.status} diff --git a/apps/web/src/views/UsersView.tsx b/apps/web/src/views/UsersView.tsx index f819374..57770f7 100644 --- a/apps/web/src/views/UsersView.tsx +++ b/apps/web/src/views/UsersView.tsx @@ -10,6 +10,8 @@ interface User { feishuUserId?: string; email?: string; personalToken?: string; + isTrusted?: boolean; + isAdmin?: boolean; } export default function UsersView() { @@ -85,6 +87,28 @@ export default function UsersView() { } }; + const handleToggleTrusted = async (user: User) => { + try { + await client.api.users[":id"].$put( + { + param: { id: user.id }, + json: { + name: user.name, + feishuUserId: user.feishuUserId, + email: user.email, + isTrusted: !user.isTrusted, + }, + }, + { + init: { credentials: "include" }, + }, + ); + fetchUsers(); + } catch (error) { + console.error("Error toggling trusted status:", error); + } + }; + if (loading) return
Loading...
; return ( @@ -110,9 +134,16 @@ export default function UsersView() {
-

- {user.name} -

+
+

+ {user.name} +

+ {user.isAdmin && ( + + Admin + + )} +

@@ -134,11 +165,25 @@ export default function UsersView() {

{currentUser?.isAdmin && ( -
+
+
+