feat: 1. db init 2. fault tolerance 3. lint fix

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-15 19:32:24 +08:00
parent fc3435dc80
commit 9965b3e1ce
13 changed files with 310 additions and 122 deletions

View File

@@ -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
### 新增

View File

@@ -25,4 +25,4 @@
"drizzle-kit": "^0.31.8",
"pino-pretty": "^13.1.3"
}
}
}

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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.

View File

@@ -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.