feat: add group binding management

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-16 21:00:14 +08:00
parent 2f971290ce
commit 1403baaeb6
11 changed files with 420 additions and 35 deletions

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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");
}
}

View File

@@ -6,6 +6,7 @@ export interface AuthSession {
name: string;
email: string | null;
isAdmin: boolean;
isTrusted: boolean;
}
export async function requireAuth(c: Context, next: Next) {

View File

@@ -82,13 +82,15 @@ webhook.post("/:token/topic/:slug", async (c) => {
})
.filter((u): u is NonNullable<typeof u> => 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];