diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a148d1f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# Agent Guide for Alert Message Center + +This document provides instructions for AI agents working on the Alert Message Center codebase. + +## 🛠 Commands + +### Development & Build +- **Install**: `bun install` +- **Root Dev**: `bun run dev` (starts both server and web) +- **Root Build**: `bun run build` +- **Lint & Format**: + - `bun run lint`: Run Biome linter + - `bun run format`: Fix formatting issues + - `bun run check`: Linter + Formatter fix + +### Backend (`apps/server`) +- **Dev**: `bun run dev` +- **Migrations**: + - `bun run db:generate`: Generate migration files + - `bun run db:push`: Push schema changes directly (dev only) + - `bun run db:migrate:deploy`: Run migrations in production/Docker +- **Verification**: `bun run src/verify_permissions.ts` (Manual verification script) + +### Frontend (`apps/web`) +- **Dev**: `bun run dev` +- **Build**: `bun run build` + +--- + +## 📜 Code Style & Conventions + +### 1. General +- **Runtime**: Bun is the primary runtime. Use `node:` protocol for built-in modules (e.g., `import fs from "node:fs"`). +- **Formatting**: Enforced by Biome. Use **tabs** for indentation and **double quotes**. +- **Naming**: + - Variables/Functions: `camelCase` + - Components/Classes/Interfaces: `PascalCase` + - Database Tables: `snake_case` (e.g., `topic_group_chats`) + - URL Slugs: `kebab-case` + +### 2. TypeScript & Type Safety +- **Strict Mode**: No `any` allowed. Use explicit interfaces or Zod schemas. +- **Interfaces vs Types**: Prefer `interface` for object definitions and `type` for unions/aliases. +- **RPC**: Use `hono/client` for type-safe communication between frontend and backend. +- **Vite Env**: Always use optional chaining when accessing `import.meta.env?.VITE_...`. + +### 3. Backend (Hono + Drizzle) +- **Routing**: Group logic into sub-apps (e.g., `api.ts`, `auth.ts`, `webhook.ts`). +- **Database**: Use Drizzle ORM. Prefer relational queries where possible. +- **Validation**: Use `@hono/zod-validator` for request validation. +- **Logging**: Use the structured logger in `src/lib/logger.ts` (Pino). + - **Pattern**: `logger.error({ err, context }, "Message")`. + +### 4. Frontend (React + Tailwind) +- **Components**: Functional components with hooks. +- **Styling**: Tailwind CSS utility classes. Avoid custom CSS files. +- **Icons**: Use `lucide-react`. +- **Resilience**: + - Always check `res.ok` before parsing API responses. + - Provide fallback states (e.g., `[]`) for data fetching. + +### 5. Error Handling +- **Backend**: Wrap critical logic in `try/catch`. Log errors with context. Return meaningful JSON errors with appropriate HTTP status codes. +- **Frontend**: Use `useState` to track error states and display user-friendly messages. + +--- + +## 🏗 Architecture Context +- **Topic Model**: Alerts are sent to "Topics". Users and Group Chats subscribe to Topics. +- **Personal Inbox**: Each user has a `personalToken` (8-character hex) for direct `/dm` alerts. +- **Feishu Integration**: Uses `@larksuiteoapi/node-sdk`. Supports both Webhook and WebSocket modes for events. +- **Auth**: Feishu SSO (OAuth2). Admin status is assigned via `ADMIN_EMAILS` env var on first login. + +## 🤖 Rules for Agents +- **NO `any`**: This is a hard requirement. Research types or define them. +- **Biome First**: Always run `bun run check` before concluding a task. +- **Preserve Patterns**: Follow existing structure in `apps/server/src` and `apps/web/src`. +- **Minimalism**: Fix bugs minimally. Avoid large refactors unless requested. diff --git a/apps/server/src/api.ts b/apps/server/src/api.ts index e5adde1..16c95f1 100644 --- a/apps/server/src/api.ts +++ b/apps/server/src/api.ts @@ -18,8 +18,15 @@ const api = new Hono<{ Variables: { session: AuthSession } }>(); const topicSchema = z.object({ name: z.string().min(1), - slug: z.string().min(1), + slug: z + .string() + .min(1) + .regex( + /^[a-z0-9-]+$/i, + "Slug must only contain alphanumeric characters and hyphens", + ), description: z.string().optional(), + isGlobal: z.boolean().optional(), }); const groupBindingSchema = z.object({ @@ -143,6 +150,7 @@ api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => { .insert(topics) .values({ ...body, + isGlobal: session.isAdmin ? (body.isGlobal ?? false) : false, status, createdBy: session.id, approvedBy: session.isAdmin || session.isTrusted ? session.id : null, @@ -170,7 +178,10 @@ api.put( const body = c.req.valid("json"); const result = await db .update(topics) - .set(body) + .set({ + ...body, + isGlobal: body.isGlobal ?? (undefined as any), + }) .where(eq(topics.id, id)) .returning(); return c.json(result[0]); diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 2d7b55f..8517914 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -20,6 +20,7 @@ export const topics = pgTable("topics", { status: text("status", { enum: ["pending", "approved", "rejected"] }) .default("approved") .notNull(), + isGlobal: boolean("is_global").default(false).notNull(), createdBy: text("created_by").references(() => users.id), approvedBy: text("approved_by").references(() => users.id), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/apps/server/src/webhook.ts b/apps/server/src/webhook.ts index 18d3e5d..4b4c620 100644 --- a/apps/server/src/webhook.ts +++ b/apps/server/src/webhook.ts @@ -17,58 +17,16 @@ interface Recipient { 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"); - - // 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); - } - // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON - let body: Record; - 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, - }, - }); - - 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"); - +const dispatchAlert = async ( + c: any, + topic: any, + body: any, + user: any | null, +) => { // 2. Collect recipients - const userRecipients: Recipient[] = topic.subscriptions - .map((sub) => sub.user) - .map((u) => { + const userRecipients: Recipient[] = (topic.subscriptions || []) + .map((sub: any) => sub.user) + .map((u: any) => { if (!u || !u.feishuUserId) return null; return { type: "user" as const, @@ -80,11 +38,11 @@ webhook.post("/:token/topic/:slug", async (c) => { : "user_id") as FeishuReceiveIdType, }; }) - .filter((u): u is NonNullable => u !== null); + .filter((u: any): u is Recipient => u !== null); - const groupRecipients: Recipient[] = topic.groupChats - .filter((g) => g.status === "approved") - .map((g) => ({ + const groupRecipients: Recipient[] = (topic.groupChats || []) + .filter((g: any) => g.status === "approved") + .map((g: any) => ({ type: "group", id: g.id, // Binding ID name: g.name, @@ -98,7 +56,7 @@ webhook.post("/:token/topic/:slug", async (c) => { .insert(alertTasks) .values({ topicSlug: topic.slug, - senderId: user.id, + senderId: user?.id || null, // Global topic might not have a sender status: "processing", recipientCount: allRecipients.length, successCount: 0, @@ -272,7 +230,7 @@ webhook.post("/:token/topic/:slug", async (c) => { taskId: task.id, successCount, totalCount: allRecipients.length, - slug, + slug: topic.slug, }, "[Webhook] Task processed", ); @@ -284,6 +242,115 @@ webhook.post("/:token/topic/:slug", async (c) => { status: "processing", recipientCount: allRecipients.length, }); +}; + +webhook.post("/topic/:slug", async (c) => { + const slug = c.req.param("slug"); + logger.info({ slug }, "[Webhook] Received global request"); + + // 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.isGlobal) { + logger.warn({ slug }, "[Webhook] Topic is not global"); + return c.json( + { error: "This topic requires a personal token to send alerts" }, + 401, + ); + } + + // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON + let body: Record; + 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); + } + + return dispatchAlert(c, topic, body, null); +}); + +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"); + + // 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); + } + + let user: any = null; + if (!topic.isGlobal) { + // 0. Find the User by Token + 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); + } + } + + // biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON + let body: Record; + 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); + } + + return dispatchAlert(c, topic, body, user); +}); + +webhook.all("/topic/:slug", (c) => { + return c.json( + { + error: "Method not allowed", + message: "Please use POST to send alerts to this webhook", + format: "POST /webhook/topic/:slug", + example: + 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL', + }, + 405, + ); }); webhook.post("/:token/dm", async (c) => { diff --git a/apps/web/src/views/AdminView.tsx b/apps/web/src/views/AdminView.tsx index b21b841..5b4d524 100644 --- a/apps/web/src/views/AdminView.tsx +++ b/apps/web/src/views/AdminView.tsx @@ -13,6 +13,7 @@ interface Topic { name: string; slug: string; description?: string; + isGlobal?: boolean; status: "pending" | "approved" | "rejected"; subscriptions?: { id: string }[]; creator?: TopicUser; @@ -282,8 +283,15 @@ function TopicsManagement() { {topics.map((topic) => ( -
- {topic.name} +
+
+ {topic.name} +
+ {topic.isGlobal && ( + + Global + + )}
{topic.slug} @@ -407,7 +415,14 @@ function TopicRequestsList() { {requests.map((req) => (
  • -

    {req.name}

    +
    +

    {req.name}

    + {req.isGlobal && ( + + Global + + )} +

    Slug: {req.slug}

    diff --git a/apps/web/src/views/TopicsView.tsx b/apps/web/src/views/TopicsView.tsx index e14f8df..22bcf35 100644 --- a/apps/web/src/views/TopicsView.tsx +++ b/apps/web/src/views/TopicsView.tsx @@ -35,6 +35,7 @@ interface Topic { creator?: TopicUser; approver?: TopicUser; createdBy?: string; + isGlobal?: boolean; status?: string; createdAt?: string; } @@ -55,6 +56,7 @@ export default function TopicsView() { name: "", slug: "", description: "", + isGlobal: false, }); const [submitStatus, setSubmitStatus] = useState<{ type: "success" | "error"; @@ -137,6 +139,7 @@ export default function TopicsView() { name: string; slug: string; description?: string; + isGlobal?: boolean; }, }, { @@ -151,7 +154,7 @@ export default function TopicsView() { ? "Topic created successfully!" : "Request submitted! Waiting for approval.", }); - setFormData({ name: "", slug: "", description: "" }); + setFormData({ name: "", slug: "", description: "", isGlobal: false }); fetchTopics(); fetchMyRequests(); setTimeout(() => { @@ -294,6 +297,15 @@ export default function TopicsView() { return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`; }; + const getGlobalWebhookUrl = (topicSlug: string) => { + // biome-ignore lint/suspicious/noExplicitAny: Vite env access + const meta = import.meta as any; + const baseUrl = ( + meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin + ).replace(/\/$/, ""); + return `${baseUrl}/webhook/topic/${topicSlug}`; + }; + const getDmWebhookUrl = () => { if (!currentUser?.personalToken) return ""; // biome-ignore lint/suspicious/noExplicitAny: Vite env access @@ -424,6 +436,11 @@ export default function TopicsView() {

    {topic.name} + {topic.isGlobal && ( + + Global + + )}

    -
    -
    - {getWebhookUrl(topic.slug)} +
    +
    +
    + + Your Personal Webhook + + +
    +
    + {getWebhookUrl(topic.slug)} +
    + + {topic.isGlobal && ( +
    +
    + + Global Webhook (No Token Required) + + +
    +
    + {getGlobalWebhookUrl(topic.slug)} +
    +
    + )}
    )}
    @@ -665,6 +719,8 @@ export default function TopicsView() { id="topic-slug" type="text" required + pattern="[a-zA-Z0-9-]+" + title="Slug must only contain alphanumeric characters and hyphens" 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 text-gray-900" value={formData.slug} onChange={(e) => @@ -672,6 +728,25 @@ export default function TopicsView() { } />
    + {currentUser?.isAdmin && ( +
    + + setFormData({ ...formData, isGlobal: e.target.checked }) + } + /> + +
    + )}