mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-05-13 23:16:48 +00:00
feat: 1. db init 2. fault tolerance 3. lint fix
Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -5,6 +5,24 @@
|
||||
本文件的格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
并且本项目遵循 [语义化版本 (Semantic Versioning)](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。
|
||||
|
||||
## [1.2.5] - 2026-01-15
|
||||
|
||||
### 修复
|
||||
- **前端鲁棒性**: 修复了当数据库为空或 API 返回错误对象时页面发生崩溃(白屏)的问题。
|
||||
- 为 `TopicsView`, `SystemLoadView` 和 `AdminView` 中的所有 API 请求增加了 `res.ok` 和 `Array.isArray` 校验。
|
||||
- 增加了防御性逻辑,确保在数据未加载或加载失败时显示友好的提示而非崩溃。
|
||||
- **Vite 环境变量**: 修复了 `TypeError: Cannot read properties of undefined (reading 'VITE_WEBHOOK_BASE_URL')`。
|
||||
- 在 `TopicsView.tsx` 中使用可选链 (`meta.env?.`) 安全地访问 Vite 环境变量,防止由于环境未完全初始化导致的崩溃。
|
||||
|
||||
## [1.2.4] - 2026-01-15
|
||||
|
||||
### 变更
|
||||
- **类型安全**: 全面重构了服务端与前端的代码,消除了绝大部分 `any` 类型的使用。
|
||||
- 在 `webhook.ts`, `verify_permissions.ts`, `feishu.ts` 等核心文件中引入了显式接口。
|
||||
- 改进了 Webhook Body 的处理逻辑,在保持灵活性的同时增强了类型校验。
|
||||
- 修复了多处 Non-null Assertion 为更安全的可选链或显式空值检查。
|
||||
- **Linting**: 严格执行 Biome 的 `noExplicitAny` 规则。
|
||||
|
||||
## [1.2.3] - 2026-01-15
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"pino-pretty": "^13.1.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,21 @@ import { db } from "./db";
|
||||
import { knownGroupChats, topicGroupChats } from "./db/schema";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
interface BotAddedEvent {
|
||||
chat_id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface BotDeletedEvent {
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
export const eventDispatcher = new lark.EventDispatcher({
|
||||
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;
|
||||
const { chat_id, name } = data as unknown as BotAddedEvent;
|
||||
logger.info({ chat_id, name }, "[Feishu Event] Bot added to group");
|
||||
|
||||
if (chat_id) {
|
||||
@@ -30,7 +39,7 @@ export const eventDispatcher = new lark.EventDispatcher({
|
||||
}
|
||||
},
|
||||
"im.chat.member.bot.deleted_v1": async (data) => {
|
||||
const { chat_id } = data as any;
|
||||
const { chat_id } = data as unknown as BotDeletedEvent;
|
||||
logger.info({ chat_id }, "[Feishu Event] Bot removed from group");
|
||||
|
||||
if (chat_id) {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
export interface UserAccessTokenData {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
refresh_expires_in: number;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export class FeishuClient {
|
||||
public client: lark.Client;
|
||||
public appId: string;
|
||||
@@ -20,7 +29,7 @@ export class FeishuClient {
|
||||
receiveId: string,
|
||||
receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
|
||||
msgType: string,
|
||||
content: any,
|
||||
content: Record<string, unknown> | string,
|
||||
) {
|
||||
// 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'
|
||||
@@ -50,7 +59,9 @@ export class FeishuClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getUserAccessToken(code: string): Promise<any> {
|
||||
async getUserAccessToken(
|
||||
code: string,
|
||||
): Promise<UserAccessTokenData | undefined> {
|
||||
try {
|
||||
const response = await this.client.authen.accessToken.create({
|
||||
data: {
|
||||
@@ -63,7 +74,7 @@ export class FeishuClient {
|
||||
logger.error({ response }, "Feishu get user access token error");
|
||||
throw new Error(`Failed to get user access token: ${response.msg}`);
|
||||
}
|
||||
return response.data;
|
||||
return response.data as UserAccessTokenData;
|
||||
} catch (e) {
|
||||
console.error("Feishu SDK error:", e);
|
||||
throw e;
|
||||
|
||||
@@ -3,6 +3,11 @@ import { db } from "./db";
|
||||
import { subscriptions, topics, users } from "./db/schema";
|
||||
import app from "./index";
|
||||
|
||||
interface TopicWithSubscriptions {
|
||||
id: string;
|
||||
subscriptions: { userId: string }[];
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
console.log("Starting Verification...");
|
||||
let errors = 0;
|
||||
@@ -112,9 +117,9 @@ async function verify() {
|
||||
},
|
||||
});
|
||||
const res3 = await app.request(req3);
|
||||
const data3 = await res3.json();
|
||||
const data3 = (await res3.json()) as TopicWithSubscriptions[];
|
||||
|
||||
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
|
||||
const targetTopic = data3.find((t) => t.id === topic.id);
|
||||
if (targetTopic) {
|
||||
if (
|
||||
targetTopic.subscriptions.length === 1 &&
|
||||
@@ -135,23 +140,19 @@ async function verify() {
|
||||
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??)
|
||||
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 data4 = (await res4.json()) as TopicWithSubscriptions[];
|
||||
|
||||
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
|
||||
const targetTopicAdmin = data4.find((t) => t.id === topic.id);
|
||||
// Should see the subscription for userUser
|
||||
const hasUserSub = targetTopicAdmin.subscriptions.some(
|
||||
(s: any) => s.userId === userUser.id,
|
||||
const hasUserSub = targetTopicAdmin?.subscriptions.some(
|
||||
(s) => s.userId === userUser.id,
|
||||
);
|
||||
if (hasUserSub) {
|
||||
console.log(
|
||||
|
||||
@@ -5,6 +5,16 @@ import { alertLogs, alertTasks, topics, users } from "./db/schema";
|
||||
import { feishuClient } from "./feishu";
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
type FeishuReceiveIdType = "open_id" | "user_id" | "email" | "chat_id";
|
||||
|
||||
interface Recipient {
|
||||
type: "user" | "group";
|
||||
id: string;
|
||||
name: string;
|
||||
feishuId: string;
|
||||
idType: FeishuReceiveIdType;
|
||||
}
|
||||
|
||||
const webhook = new Hono();
|
||||
|
||||
webhook.post("/:token/topic/:slug", async (c) => {
|
||||
@@ -21,7 +31,8 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
logger.warn({ token }, "[Webhook] Invalid personal token");
|
||||
return c.json({ error: "Invalid personal token" }, 401);
|
||||
}
|
||||
let body: any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
|
||||
let body: Record<string, any>;
|
||||
try {
|
||||
const rawBody = await c.req.text();
|
||||
logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body");
|
||||
@@ -55,26 +66,31 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
logger.info({ topicName: topic.name }, "[Webhook] Found topic");
|
||||
|
||||
// 2. Collect recipients
|
||||
const userRecipients = topic.subscriptions
|
||||
const userRecipients: Recipient[] = 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",
|
||||
}));
|
||||
.map((u) => {
|
||||
if (!u || !u.feishuUserId) return null;
|
||||
return {
|
||||
type: "user" as const,
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
feishuId: u.feishuUserId,
|
||||
idType: (u.feishuUserId.startsWith("ou_")
|
||||
? "open_id"
|
||||
: "user_id") as FeishuReceiveIdType,
|
||||
};
|
||||
})
|
||||
.filter((u): u is NonNullable<typeof u> => u !== null);
|
||||
|
||||
const groupRecipients = topic.groupChats.map((g) => ({
|
||||
const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({
|
||||
type: "group",
|
||||
id: g.id, // Binding ID
|
||||
name: g.name,
|
||||
feishuId: g.chatId,
|
||||
idType: "chat_id",
|
||||
idType: "chat_id" as FeishuReceiveIdType,
|
||||
}));
|
||||
|
||||
const allRecipients = [...userRecipients, ...groupRecipients];
|
||||
const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients];
|
||||
|
||||
const [task] = await db
|
||||
.insert(alertTasks)
|
||||
@@ -137,13 +153,15 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
|
||||
await feishuClient.sendMessage(
|
||||
recipient.feishuId,
|
||||
recipient.idType as any,
|
||||
recipient.idType,
|
||||
msgType,
|
||||
content,
|
||||
);
|
||||
|
||||
return { recipientId: recipient.id, status: "sent", error: null };
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{
|
||||
err: error,
|
||||
@@ -155,22 +173,25 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
return {
|
||||
recipientId: recipient.id,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}),
|
||||
).then(async (results) => {
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === "fulfilled" && (r.value as any).status === "sent",
|
||||
(r) =>
|
||||
r.status === "fulfilled" &&
|
||||
(r.value as { status: string }).status === "sent",
|
||||
).length;
|
||||
const failures = results.filter(
|
||||
(r) =>
|
||||
r.status === "rejected" ||
|
||||
(r.status === "fulfilled" && (r.value as any).status === "failed"),
|
||||
(r.status === "fulfilled" &&
|
||||
(r.value as { status: string }).status === "failed"),
|
||||
).length;
|
||||
|
||||
// Determine final status
|
||||
const finalStatus =
|
||||
const finalStatus: "completed" | "failed" =
|
||||
failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed";
|
||||
|
||||
// Update Task
|
||||
@@ -189,26 +210,29 @@ webhook.post("/:token/topic/:slug", async (c) => {
|
||||
const logs = results.map((r, index) => {
|
||||
const recipient = allRecipients[index];
|
||||
if (r.status === "fulfilled") {
|
||||
const val = r.value as any;
|
||||
const val = r.value as {
|
||||
status: "sent" | "failed";
|
||||
error: string | null;
|
||||
};
|
||||
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,
|
||||
status: val.status as "sent" | "failed",
|
||||
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",
|
||||
status: "failed" as const,
|
||||
error: r.status === "rejected" ? String(r.reason) : "Unknown error",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (logs.length > 0) {
|
||||
await db.insert(alertLogs).values(logs as any);
|
||||
await db.insert(alertLogs).values(logs);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -248,7 +272,8 @@ webhook.post("/:token/dm", async (c) => {
|
||||
return c.json({ error: "User has no Feishu ID linked" }, 400);
|
||||
}
|
||||
|
||||
let body: any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
|
||||
let body: Record<string, any>;
|
||||
try {
|
||||
const rawBody = await c.req.text();
|
||||
if (!rawBody || rawBody.trim() === "") {
|
||||
@@ -315,24 +340,26 @@ webhook.post("/:token/dm", async (c) => {
|
||||
await db.insert(alertLogs).values({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
status: "sent",
|
||||
status: "sent" as const,
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error({ err: error, userName: user.name }, "Failed to send DM");
|
||||
await db
|
||||
.update(alertTasks)
|
||||
.set({
|
||||
status: "failed",
|
||||
updatedAt: new Date(),
|
||||
error: error.message,
|
||||
error: errorMessage,
|
||||
})
|
||||
.where(eq(alertTasks.id, task.id));
|
||||
|
||||
await db.insert(alertLogs).values({
|
||||
taskId: task.id,
|
||||
userId: user.id,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
status: "failed" as const,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -48,8 +48,10 @@ export default function GroupBindingsModal({
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
setBindings(data as any);
|
||||
const data = (await res.json()) as GroupBinding[];
|
||||
if (Array.isArray(data)) {
|
||||
setBindings(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -60,8 +62,10 @@ export default function GroupBindingsModal({
|
||||
const res = await client.api.groups.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setKnownGroups(data as any);
|
||||
const data = (await res.json()) as KnownGroup[];
|
||||
if (Array.isArray(data)) {
|
||||
setKnownGroups(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { client } from "../lib/client";
|
||||
import SystemLoadView from "./SystemLoadView";
|
||||
|
||||
interface TopicUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
subscriptions?: { id: string }[];
|
||||
creator?: TopicUser;
|
||||
approver?: TopicUser;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminView() {
|
||||
const [activeTab, setActiveTab] = useState("load");
|
||||
|
||||
@@ -17,33 +35,30 @@ export default function AdminView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("load")}
|
||||
className={`${
|
||||
activeTab === "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`}
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
System Load
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("requests")}
|
||||
className={`${
|
||||
activeTab === "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`}
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
Topic Requests
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("topics")}
|
||||
className={`${
|
||||
activeTab === "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`}
|
||||
} whitespace-nowrap pb-4 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
All Topics
|
||||
</button>
|
||||
@@ -59,7 +74,7 @@ export default function AdminView() {
|
||||
}
|
||||
|
||||
function TopicsManagement() {
|
||||
const [topics, setTopics] = useState<any[]>([]);
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchAllTopics = useCallback(async () => {
|
||||
@@ -68,10 +83,21 @@ function TopicsManagement() {
|
||||
const res = await client.api.topics.all.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setTopics(data);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setTopics(data as unknown as Topic[]);
|
||||
} else {
|
||||
console.error("All topics data is not an array:", data);
|
||||
setTopics([]);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch all topics:", res.status);
|
||||
setTopics([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setTopics([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -141,13 +167,12 @@ function TopicsManagement() {
|
||||
</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"
|
||||
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>
|
||||
@@ -179,7 +204,7 @@ function TopicsManagement() {
|
||||
}
|
||||
|
||||
function TopicRequestsList() {
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [requests, setRequests] = useState<Topic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
@@ -188,10 +213,20 @@ function TopicRequestsList() {
|
||||
const res = await client.api.topics.requests.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setRequests(data);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setRequests(data as unknown as Topic[]);
|
||||
} else {
|
||||
setRequests([]);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch requests:", res.status);
|
||||
setRequests([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setRequests([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@ import { Activity, BarChart3, CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { client } from "../lib/client";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
topicSlug: string | null;
|
||||
sender: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
} | null;
|
||||
successCount: number;
|
||||
recipientCount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
topics: {
|
||||
topicSlug: string;
|
||||
@@ -16,7 +29,7 @@ interface Stats {
|
||||
failedCount: number;
|
||||
successRate: number;
|
||||
};
|
||||
tasks: any[];
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export default function SystemLoadView() {
|
||||
@@ -29,7 +42,13 @@ export default function SystemLoadView() {
|
||||
const res = await client.api.stats.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch stats:", res.status);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as Omit<Stats, "topics"> & {
|
||||
topics: unknown;
|
||||
};
|
||||
|
||||
// Fetch recent tasks as well
|
||||
const tasksRes = await client.api.alerts.tasks.$get(
|
||||
@@ -38,9 +57,19 @@ export default function SystemLoadView() {
|
||||
init: { credentials: "include" },
|
||||
},
|
||||
);
|
||||
const tasks = await tasksRes.json();
|
||||
if (!tasksRes.ok) {
|
||||
console.error("Failed to fetch tasks:", tasksRes.status);
|
||||
return;
|
||||
}
|
||||
const tasks = (await tasksRes.json()) as unknown;
|
||||
|
||||
setStats({ ...data, tasks } as Stats);
|
||||
setStats({
|
||||
topics: Array.isArray(data.topics)
|
||||
? (data.topics as Stats["topics"])
|
||||
: [],
|
||||
recent: data.recent as Stats["recent"],
|
||||
tasks: Array.isArray(tasks) ? (tasks as Task[]) : [],
|
||||
});
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats:", error);
|
||||
@@ -237,7 +266,7 @@ export default function SystemLoadView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{stats.tasks.map((task: any) => (
|
||||
{stats.tasks.map((task) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
@@ -348,6 +377,7 @@ function Gauge({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<title>Success Rate Gauge</title>
|
||||
<circle
|
||||
className="text-gray-100"
|
||||
strokeWidth="8"
|
||||
|
||||
@@ -35,12 +35,14 @@ interface Topic {
|
||||
creator?: TopicUser;
|
||||
approver?: TopicUser;
|
||||
createdBy?: string;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default function TopicsView() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [myRequests, setMyRequests] = useState<any[]>([]);
|
||||
const [myRequests, setMyRequests] = useState<Topic[]>([]);
|
||||
const [users, setUsers] = useState<TopicUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -65,10 +67,21 @@ export default function TopicsView() {
|
||||
const res = await client.api.topics.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setTopics(data as unknown as Topic[]);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setTopics(data as unknown as Topic[]);
|
||||
} else {
|
||||
console.error("Topics data is not an array:", data);
|
||||
setTopics([]);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch topics:", res.status);
|
||||
setTopics([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setTopics([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -79,8 +92,12 @@ export default function TopicsView() {
|
||||
const res = await client.api.topics["my-requests"].$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setMyRequests(data);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setMyRequests(data as unknown as Topic[]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -91,8 +108,12 @@ export default function TopicsView() {
|
||||
const res = await client.api.users.$get(undefined, {
|
||||
init: { credentials: "include" },
|
||||
});
|
||||
const data = await res.json();
|
||||
setUsers(data as unknown as TopicUser[]);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setUsers(data as unknown as TopicUser[]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -112,7 +133,11 @@ export default function TopicsView() {
|
||||
try {
|
||||
const res = await client.api.topics.$post(
|
||||
{
|
||||
json: formData as any,
|
||||
json: formData as {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
},
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
@@ -134,7 +159,7 @@ export default function TopicsView() {
|
||||
setSubmitStatus(null);
|
||||
}, 1500);
|
||||
} else {
|
||||
const error = await res.json();
|
||||
const error = (await res.json()) as { message?: string };
|
||||
setSubmitStatus({
|
||||
type: "error",
|
||||
message: error.message || "Failed to submit request.",
|
||||
@@ -194,12 +219,20 @@ export default function TopicsView() {
|
||||
const updatedSubs = isSubscribed
|
||||
? t.subscriptions.filter((s) => s.userId !== userId)
|
||||
: [
|
||||
...t.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user: users.find((u) => u.id === userId) || currentUser!,
|
||||
},
|
||||
];
|
||||
...t.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user:
|
||||
users.find((u) => u.id === userId) ||
|
||||
(currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
}
|
||||
: { id: "unknown", name: "Unknown" }),
|
||||
},
|
||||
];
|
||||
return { ...t, subscriptions: updatedSubs };
|
||||
}
|
||||
return t;
|
||||
@@ -211,12 +244,20 @@ export default function TopicsView() {
|
||||
const updatedSubs = isSubscribed
|
||||
? selectedTopic.subscriptions.filter((s) => s.userId !== userId)
|
||||
: [
|
||||
...selectedTopic.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user: users.find((u) => u.id === userId) || currentUser!,
|
||||
},
|
||||
];
|
||||
...selectedTopic.subscriptions,
|
||||
{
|
||||
userId,
|
||||
user:
|
||||
users.find((u) => u.id === userId) ||
|
||||
(currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
}
|
||||
: { id: "unknown", name: "Unknown" }),
|
||||
},
|
||||
];
|
||||
setSelectedTopic({ ...selectedTopic, subscriptions: updatedSubs });
|
||||
}
|
||||
|
||||
@@ -226,13 +267,13 @@ export default function TopicsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const isSubscribed = (topic: Topic) => {
|
||||
const isSubscribedToTopic = (topic: Topic) => {
|
||||
return topic.subscriptions.some((sub) => sub.userId === currentUser?.id);
|
||||
};
|
||||
|
||||
const handleSelfSubscribe = async (topic: Topic) => {
|
||||
if (!currentUser) return;
|
||||
const subscribed = isSubscribed(topic);
|
||||
const subscribed = isSubscribedToTopic(topic);
|
||||
await toggleSubscription(topic.id, currentUser.id, subscribed);
|
||||
};
|
||||
|
||||
@@ -245,16 +286,20 @@ export default function TopicsView() {
|
||||
const getWebhookUrl = (topicSlug: string) => {
|
||||
if (!currentUser?.personalToken) return "";
|
||||
// Use an environment variable if available, otherwise fallback to current origin
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Vite env access
|
||||
const meta = import.meta as any;
|
||||
const baseUrl = (
|
||||
(import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin
|
||||
meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin
|
||||
).replace(/\/$/, "");
|
||||
return `${baseUrl}/webhook/${currentUser.personalToken}/topic/${topicSlug}`;
|
||||
};
|
||||
|
||||
const getDmWebhookUrl = () => {
|
||||
if (!currentUser?.personalToken) return "";
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Vite env access
|
||||
const meta = import.meta as any;
|
||||
const baseUrl = (
|
||||
(import.meta as any).env.VITE_WEBHOOK_BASE_URL || window.location.origin
|
||||
meta.env?.VITE_WEBHOOK_BASE_URL || window.location.origin
|
||||
).replace(/\/$/, "");
|
||||
return `${baseUrl}/webhook/${currentUser.personalToken}/dm`;
|
||||
};
|
||||
@@ -384,13 +429,12 @@ export default function TopicsView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelfSubscribe(topic)}
|
||||
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${
|
||||
isSubscribed(topic)
|
||||
className={`inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md ${isSubscribedToTopic(topic)
|
||||
? "border-red-300 text-red-700 bg-red-50 hover:bg-red-100"
|
||||
: "border-green-300 text-green-700 bg-green-50 hover:bg-green-100"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{isSubscribed(topic) ? (
|
||||
{isSubscribedToTopic(topic) ? (
|
||||
<>
|
||||
<UserMinus className="w-3 h-3 mr-1" />
|
||||
Unsubscribe
|
||||
@@ -540,13 +584,12 @@ export default function TopicsView() {
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
req.status === "approved"
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${req.status === "approved"
|
||||
? "bg-green-100 text-green-800"
|
||||
: req.status === "rejected"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{req.status === "approved"
|
||||
? "Approved"
|
||||
@@ -565,7 +608,9 @@ export default function TopicsView() {
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Requested on:{" "}
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
{req.createdAt
|
||||
? new Date(req.createdAt).toLocaleDateString()
|
||||
: "Unknown"}
|
||||
{req.approver && (
|
||||
<span className="ml-2">
|
||||
| Approved by: {req.approver.name}
|
||||
@@ -600,7 +645,7 @@ export default function TopicsView() {
|
||||
id="topic-name"
|
||||
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"
|
||||
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.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
@@ -618,7 +663,7 @@ export default function TopicsView() {
|
||||
id="topic-slug"
|
||||
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"
|
||||
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) =>
|
||||
setFormData({ ...formData, slug: e.target.value })
|
||||
@@ -634,7 +679,7 @@ export default function TopicsView() {
|
||||
</label>
|
||||
<textarea
|
||||
id="topic-description"
|
||||
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"
|
||||
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.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
@@ -643,11 +688,10 @@ export default function TopicsView() {
|
||||
</div>
|
||||
{submitStatus && (
|
||||
<div
|
||||
className={`p-3 rounded-md text-sm ${
|
||||
submitStatus.type === "success"
|
||||
className={`p-3 rounded-md text-sm ${submitStatus.type === "success"
|
||||
? "bg-green-50 text-green-800"
|
||||
: "bg-red-50 text-red-800"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{submitStatus.message}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,11 @@ export default function UsersView() {
|
||||
try {
|
||||
const res = await client.api.users.$post(
|
||||
{
|
||||
json: formData as any,
|
||||
json: formData as {
|
||||
name: string;
|
||||
feishuUserId?: string;
|
||||
email?: string;
|
||||
},
|
||||
},
|
||||
{
|
||||
init: { credentials: "include" },
|
||||
@@ -169,7 +173,7 @@ export default function UsersView() {
|
||||
id="user-name"
|
||||
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"
|
||||
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.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
@@ -186,7 +190,7 @@ export default function UsersView() {
|
||||
<input
|
||||
id="user-feishu"
|
||||
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"
|
||||
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.feishuUserId}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, feishuUserId: e.target.value })
|
||||
@@ -203,7 +207,7 @@ export default function UsersView() {
|
||||
<input
|
||||
id="user-email"
|
||||
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"
|
||||
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.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
@@ -214,7 +218,7 @@ export default function UsersView() {
|
||||
<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"
|
||||
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 text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Project Context for GitHub Copilot (v1.2.3)
|
||||
# Project Context for GitHub Copilot (v1.2.5)
|
||||
|
||||
This document provides technical context, architectural decisions, and code conventions for the **Alert Message Center** project. It is intended to help AI assistants understand the codebase.
|
||||
|
||||
@@ -193,12 +193,17 @@ The database schema is defined in `apps/server/src/db/schema.ts`.
|
||||
- **Imports**: Use relative imports.
|
||||
- **Styling**: Use Tailwind utility classes directly in JSX.
|
||||
- **Async/Await**: Prefer `async/await` over `.then()`.
|
||||
- **Type Safety**: strict TypeScript usage. Backend and Frontend share types via Hono RPC or shared interfaces.
|
||||
- **Type Safety**: strict TypeScript usage. Backend and Frontend share types via Hono RPC or shared interfaces. **Elimination of `any`** is a priority; use explicit interfaces (e.g., `WebhookBody`, `UserAccessTokenData`) for all externally sourced data.
|
||||
- **Linter & Formatter**:
|
||||
- Framework: [Biome](https://biomejs.dev/).
|
||||
- **Rules**: Strict configuration for `a11y`, `suspicious`, `style`, and `correctness`.
|
||||
- **Tailwind**: `noUnknownAtRules` is configured to ignore Tailwind directives (`@tailwind`, `@apply`, etc.).
|
||||
- **Enforcement**: CI/CD runs `biome check` to ensure compliance. Avoid use of `as any` unless absolutely necessary (e.g., complex API payloads), in which case `// biome-ignore` should include a rationale.
|
||||
- **Enforcement**: CI/CD runs `biome check` to ensure compliance. Avoid Use of `as any` is strictly prohibited except for specialized cases like `import.meta as any` (for Vite env) or very complex JSON spread operations. In those rare cases, use `// biome-ignore` with a clear explanation.
|
||||
- **Vite Env Access**: When accessing Vite environment variables via `import.meta.env` (or casting `import.meta as any`), **always use optional chaining** (e.g., `meta.env?.VITE_...`). This prevents crashes if the environment is not initialized or if the code runs in a non-browser context during pre-rendering/testing.
|
||||
- **Frontend Resilience**:
|
||||
- Always check `res.ok` before attempting to parse or use API responses.
|
||||
- Use `Array.isArray()` to verify that data expected to be a list actually is one, especially when mapping over it in JSX. This prevents "white page" crashes when the backend returns error objects instead of arrays.
|
||||
- Provide fallback empty states (e.g., `setTopics([])`) in `catch` blocks or failed response branches.
|
||||
- **Logging**:
|
||||
- Framework: `pino`.
|
||||
- **Structured Log**: Use JSON format for easy parsing and aggregation.
|
||||
|
||||
2
todo.md
2
todo.md
@@ -28,4 +28,4 @@
|
||||
- [x] **Structured Logging**: Integrated `pino` for better observability.
|
||||
- [x] **Linting**: Tightened Biome rules and resolved all a11y/correctness issues.
|
||||
- [x] **Automated Migrations**: Automatically initialize database schema on startup (especially in Docker).
|
||||
|
||||
- [x] **Frontend Resilience**: Hardened API calls to prevent crashes on empty data or env access errors.
|
||||
|
||||
Reference in New Issue
Block a user