diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index 6c998fa..41a45b3 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -1,4 +1,4 @@ -import { createDb, type DB } from "@cnode/db"; +import { createDb, type Database, type DB } from "@cnode/db"; import { users, topics, @@ -21,7 +21,7 @@ import { boolEq, boolValue } from "./db-compat"; import { deleteReplyWithStore, type ReplyDeletionStore } from "./reply-deletion"; import { createReplyWithStore, type ReplyCreationStore } from "./reply-creation"; -let dbInstance: DB = null; +let dbInstance: DB | null = null; const INTERNAL_TABS = ["dev", "test"]; function getDb(): DB { @@ -121,7 +121,7 @@ export const userQueries = { async clearGithubInfo(userId: number, githubId: string) { const db = getDb(); - return db.transaction(async (tx: DB) => { + return db.transaction(async (tx) => { const [updated] = await tx .update(users) .set({ githubId: null, githubUsername: null, githubAccessToken: null }) @@ -379,7 +379,7 @@ export const topicQueries = { }, }; -export function buildTopicsByQuery(db: DB, where: any, opt?: any) { +export function buildTopicsByQuery(db: Database, where: any, opt?: any) { let q = db.select().from(topics).$dynamic(); const conditions = topicConditions(where); if (conditions.length > 0) { @@ -424,7 +424,7 @@ export const replyQueries = { const db = getDb(); const store: ReplyCreationStore = { transaction: (callback) => - db.transaction(async (tx: DB) => + db.transaction(async (tx) => callback({ async lockTopic(id) { const rows = await tx @@ -441,7 +441,12 @@ export const replyQueries = { .insert(replies) .values({ ...input, createAt: now, updateAt: now }) .returning(); - return reply; + return { + id: reply.id, + topicId: reply.topicId, + authorId: reply.authorId, + createAt: reply.createAt ?? now, + }; }, async incrementAuthor(id) { await tx @@ -501,7 +506,7 @@ export const replyQueries = { const db = getDb(); const store: ReplyDeletionStore = { transaction: (callback) => - db.transaction(async (tx: DB) => + db.transaction(async (tx) => callback({ async lockReply(id) { const rows = await tx @@ -591,7 +596,7 @@ export const replyQueries = { }, }; -export function buildRepliesByTopicQuery(db: DB, topicId: number) { +export function buildRepliesByTopicQuery(db: Database, topicId: number) { return db .select() .from(replies) diff --git a/apps/api/src/lib/message.ts b/apps/api/src/lib/message.ts index 252e345..0036d42 100644 --- a/apps/api/src/lib/message.ts +++ b/apps/api/src/lib/message.ts @@ -100,7 +100,6 @@ export async function getMessageRelations(msg: typeof messages.$inferSelect) { msg.topicId ? topicQueries.getById(msg.topicId) : null, msg.replyId ? replyQueries.getById(msg.replyId) : null, ]); - return { ...msg, author, diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 8542ff4..e4b37d3 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -4,7 +4,7 @@ import { roleQueries, userQueries } from "../lib/db"; import { resolveUserAccess } from "../lib/user-access"; export interface AuthVars { - user: Awaited>; + user: Awaited> | null; isLogin: boolean; isAdmin: boolean; isMod: boolean; diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index da4dcf5..f48d97f 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -399,7 +399,7 @@ async function getReportTargetSummary(targetType: string, targetId: number) { target_id: String(reply.id), topic_id: String(topic.id), title: topic.title, - summary: reply.content.slice(0, 160), + summary: (reply.content ?? "").slice(0, 160), }; } return null; @@ -693,7 +693,7 @@ admin.post("/admin/topics/permanent-delete", adminRequired(), async (c) => { if (!result) continue; results.push({ topic_id: id, - title: result.topic.title, + title: result.topic.title ?? "", deleted_replies: result.deletedReplies, }); } @@ -763,7 +763,7 @@ admin.post("/topic/:tid/top", modRequired(), async (c) => { user.id, user.loginname, topic.top ? "untop" : "top", - { type: "topic", id: String(tid), name: topic.title }, + { type: "topic", id: String(tid), name: topic.title ?? undefined }, "success", ); return c.json({ success: true, message: topic.top ? "已取消置顶" : "已置顶" }); @@ -783,7 +783,7 @@ admin.post("/topic/:tid/good", modRequired(), async (c) => { user.id, user.loginname, topic.good ? "ungood" : "good", - { type: "topic", id: String(tid), name: topic.title }, + { type: "topic", id: String(tid), name: topic.title ?? undefined }, "success", ); return c.json({ success: true, message: topic.good ? "已取消加精" : "已加精" }); @@ -803,7 +803,7 @@ admin.post("/topic/:tid/lock", modRequired(), async (c) => { user.id, user.loginname, topic.lock ? "unlock" : "lock", - { type: "topic", id: String(tid), name: topic.title }, + { type: "topic", id: String(tid), name: topic.title ?? undefined }, "success", ); return c.json({ success: true, message: topic.lock ? "已解锁" : "已锁定" }); @@ -830,7 +830,7 @@ admin.post("/topic/:tid/delete", async (c) => { user.id, user.loginname, "delete_topic", - { type: "topic", id: String(tid), name: topic.title }, + { type: "topic", id: String(tid), name: topic.title ?? undefined }, "success", ); return c.json({ success: true, message: "话题已删除" }); @@ -1361,7 +1361,7 @@ admin.openapi(createReportRoute, async (c) => { user.id, user.loginname, "report_auto_hide", - { type: targetType, id: String(tid), name: summary.title }, + { type: targetType, id: String(tid), name: summary.title ?? undefined }, "success", JSON.stringify({ reporter_count: Number(reporterCount), threshold }), ); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 741d66c..5740ce1 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -201,7 +201,7 @@ auth.openapi(loginRoute, async (c) => { let user = await userQueries.getByLoginName(name.toLowerCase()); if (!user) user = await userQueries.getByEmail(name); if (!user) return c.json({ success: false as const, error_msg: "用户名或密码错误" }, 403); - const equal = await bcryptjs.compare(pass, user.pass); + const equal = user.pass ? await bcryptjs.compare(pass, user.pass) : false; if (!equal) return c.json({ success: false as const, error_msg: "用户名或密码错误" }, 403); if (!user.active) return c.json({ success: false as const, error_msg: "账号未激活" }, 403); setSessionCookie(c, user.id); @@ -572,7 +572,7 @@ auth.openapi(githubCreateRoute, async (c) => { if (!profile) return c.json({ success: false as const, error_msg: "GitHub 登录状态已过期,请重新授权" }, 401); const body = c.req.valid("json"); - let user: Awaited> = null; + let user: Awaited> | null = null; if (body.isnew) { const loginname = profile.login.toLowerCase(); if (await userQueries.getByLoginName(loginname)) @@ -910,7 +910,7 @@ auth.openapi(changePassRoute, async (c) => { const user = c.get("user"); if (!user) return c.json({ success: false as const, error_msg: "未登录" }, 401); const { oldPass, newPass } = c.req.valid("json"); - if (!(await bcryptjs.compare(oldPass, user.pass))) + if (!user.pass || !(await bcryptjs.compare(oldPass, user.pass))) return c.json({ success: false as const, error_msg: "原密码错误" }, 403); if (newPass.length < 8 || !/[a-zA-Z]/.test(newPass) || !/[0-9]/.test(newPass)) return c.json( diff --git a/apps/api/src/routes/collect.ts b/apps/api/src/routes/collect.ts index a408ab3..428d8b0 100644 --- a/apps/api/src/routes/collect.ts +++ b/apps/api/src/routes/collect.ts @@ -83,7 +83,7 @@ collect.openapi(listCollectRoute, async (c) => { visit_count: t.visitCount, create_at: t.createAt, author: author - ? { loginname: author.loginname, avatar_url: author.avatar } + ? { loginname: author.loginname, avatar_url: author.avatar ?? "" } : { loginname: "", avatar_url: "" }, }; }), diff --git a/apps/api/src/routes/message.ts b/apps/api/src/routes/message.ts index 811bedf..71d75ef 100644 --- a/apps/api/src/routes/message.ts +++ b/apps/api/src/routes/message.ts @@ -61,45 +61,40 @@ message.openapi(listMessagesRoute, async (c) => { getReadMessagesByUserId(currentUser.id), getUnreadMessagesByUserId(currentUser.id), ]); - const formatMessage = async (msg: any) => { + const formatMessage = async (msg: (typeof readMsgs)[number]) => { const relations = await getMessageRelations(msg); + if (!relations.author?.loginname || !relations.topic) return null; + const { author, topic } = relations; return { id: String(relations.id), - type: relations.type, + // Legacy rows migrated from Mongo may carry other type values; they pass + // through on the wire unchanged, matching the previous behavior. + type: relations.type as "at" | "reply" | "reply2", has_read: !!relations.hasRead, - create_at: relations.createAt, - author: relations.author - ? { loginname: relations.author.loginname, avatar_url: relations.author.avatar } - : { loginname: "", avatar_url: "" }, - topic: relations.topic - ? { - id: String(relations.topic.id), - author: relations.topic.author - ? { - loginname: relations.topic.author.loginname, - avatar_url: relations.topic.author.avatar, - } - : { loginname: "", avatar_url: "" }, - title: relations.topic.title, - last_reply_at: relations.topic.lastReplyAt, - } - : null, + create_at: relations.createAt?.toISOString() ?? "", + author: { loginname: author.loginname, avatar_url: author.avatar ?? "" }, + topic: { + id: String(topic.id), + author: { loginname: "", avatar_url: "" }, + title: topic.title ?? "", + last_reply_at: topic.lastReplyAt ? topic.lastReplyAt.toISOString() : null, + }, reply: relations.reply ? { id: String(relations.reply.id), content: renderMarkdown(relations.reply.content, mdrender), ups: [], - create_at: relations.reply.createAt, + create_at: relations.reply.createAt?.toISOString() ?? "", } - : {}, + : { id: "", content: "", ups: [], create_at: "" }, }; }; const [hasReadRaw, hasUnreadRaw] = await Promise.all([ Promise.all(readMsgs.map(formatMessage)), Promise.all(unreadMsgs.map(formatMessage)), ]); - const hasRead = hasReadRaw.filter((msg: any) => msg.author?.loginname && msg.topic); - const hasUnread = hasUnreadRaw.filter((msg: any) => msg.author?.loginname && msg.topic); + const hasRead = hasReadRaw.filter((msg) => msg !== null); + const hasUnread = hasUnreadRaw.filter((msg) => msg !== null); return c.json( { success: true as const, diff --git a/apps/api/src/routes/reply.ts b/apps/api/src/routes/reply.ts index 4a59ab9..ee339a8 100644 --- a/apps/api/src/routes/reply.ts +++ b/apps/api/src/routes/reply.ts @@ -70,11 +70,11 @@ const createReplyRoute = createRoute({ }); reply.openapi(createReplyRoute, async (c) => { - let user = c.get("user"); - if (!user) { + const sessionUser = c.get("user"); + if (!sessionUser) { return c.json({ success: false as const, error_msg: "未登录" }, 401); } - user = await ensureMuteNotExpired(user); + const user: NonNullable = await ensureMuteNotExpired(sessionUser); if (user.isMuted || user.isBlock) { return c.json({ success: false as const, error_msg: "您已被禁言" }, 403); } @@ -200,9 +200,9 @@ reply.openapi(getReplyRoute, async (c) => { data: { id: String(replyData.id), topic_id: String(replyData.topicId), - content: replyData.content, - create_at: replyData.createAt, - update_at: replyData.updateAt, + content: replyData.content ?? "", + create_at: replyData.createAt?.toISOString() ?? "", + update_at: replyData.updateAt ? replyData.updateAt.toISOString() : null, }, }, 200, diff --git a/apps/api/src/routes/topic.ts b/apps/api/src/routes/topic.ts index 14c3366..485187d 100644 --- a/apps/api/src/routes/topic.ts +++ b/apps/api/src/routes/topic.ts @@ -25,7 +25,6 @@ import { createTopicBodySchema, updateTopicBodySchema, errorResponseSchema, - type TopicDTO, } from "@cnode/shared"; import { z } from "zod"; @@ -138,17 +137,17 @@ topic.openapi(listTopicsRoute, async (c) => { return { id: String(t.id), author_id: String(t.authorId), - tab: t.tab, + tab: t.tab ?? "", content: renderMarkdown(t.content, mdrender), - title: t.title, - last_reply_at: t.lastReplyAt, + title: t.title ?? "", + last_reply_at: t.lastReplyAt ? t.lastReplyAt.toISOString() : null, good: !!t.good, top: !!t.top, - reply_count: t.replyCount, - visit_count: t.visitCount, - create_at: t.createAt, + reply_count: t.replyCount ?? 0, + visit_count: t.visitCount ?? 0, + create_at: t.createAt?.toISOString() ?? "", author: userSummary(author), - } as TopicDTO; + }; }), ); @@ -336,11 +335,11 @@ const createTopicRoute = createRoute({ }); topic.openapi(createTopicRoute, async (c) => { - let user = c.get("user"); - if (!user) { + const sessionUser = c.get("user"); + if (!sessionUser) { return c.json({ success: false as const, error_msg: "未登录" }, 401); } - user = await ensureMuteNotExpired(user); + const user: NonNullable = await ensureMuteNotExpired(sessionUser); if (user.isMuted || user.isBlock) { return c.json({ success: false as const, error_msg: "您已被禁言" }, 403); } diff --git a/apps/api/src/routes/user.ts b/apps/api/src/routes/user.ts index acba770..b1649b2 100644 --- a/apps/api/src/routes/user.ts +++ b/apps/api/src/routes/user.ts @@ -70,7 +70,7 @@ async function formatTopic(t: any) { visit_count: t.visitCount, create_at: t.createAt, author: author - ? { loginname: author.loginname, avatar_url: author.avatar } + ? { loginname: author.loginname, avatar_url: author.avatar ?? "" } : { loginname: "", avatar_url: "" }, }; } @@ -374,7 +374,7 @@ user.openapi(userDetailRoute, async (c) => { } const fmtTopic = (t: any) => ({ id: String(t.id), - author: { loginname: userData.loginname, avatar_url: userData.avatar }, + author: { loginname: userData.loginname, avatar_url: userData.avatar ?? "" }, title: t.title, last_reply_at: t.lastReplyAt, }); @@ -384,13 +384,13 @@ user.openapi(userDetailRoute, async (c) => { success: true as const, data: { loginname: userData.loginname, - avatar_url: userData.avatar, + avatar_url: userData.avatar ?? "", githubUsername: userData.githubUsername?.trim() || "", location: userData.location?.trim() || null, url: userData.url?.trim() || null, signature: userData.signature?.trim() || null, identities, - create_at: userData.createAt, + create_at: userData.createAt?.toISOString() ?? "", score: userData.score || 0, topic_count: userData.topicCount || 0, reply_count: userData.replyCount || 0, @@ -450,7 +450,7 @@ user.openapi(accessTokenRoute, async (c) => { { success: true as const, loginname: userData.loginname, - avatar_url: userData.avatar, + avatar_url: userData.avatar ?? "", id: String(userData.id), is_block: !!userData.isBlock, is_muted: !!userData.isMuted || !!userData.isBlock, diff --git a/apps/api/test/reply-data-consistency.test.ts b/apps/api/test/reply-data-consistency.test.ts index 31e9991..a4c8ade 100644 --- a/apps/api/test/reply-data-consistency.test.ts +++ b/apps/api/test/reply-data-consistency.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; import { drizzle } from "drizzle-orm/node-postgres"; -import * as schema from "@cnode/db"; +import { schema } from "@cnode/db"; import { buildRepliesByTopicQuery, buildTopicsByQuery } from "../src/lib/db"; import { deleteReplyWithStore, diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 302f354..e0d617f 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,14 +1,21 @@ -import { drizzle } from "drizzle-orm/node-postgres"; +import { drizzle, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres"; +import type { PgDatabase } from "drizzle-orm/pg-core"; import { Pool } from "pg"; import { parsePostgresConfig, type RuntimeEnv } from "@cnode/shared"; import * as schema from "./schema/index"; -// TODO: switch to `export type DB = ReturnType`; doing so -// surfaces ~40 type errors in @cnode/api (see PR notes) that need their own pass. -export type DB = any; - export function createDb(env: RuntimeEnv = process.env) { const pool = new Pool(parsePostgresConfig(env)); return drizzle(pool, { schema }); } + +/** Full database client, including the underlying pg Pool as `$client`. */ +export type DB = ReturnType; + +/** + * Query surface shared by the client, transactions, and `drizzle.mock()`. + * Accept this in helpers that only run queries, so they work inside + * transactions and unit tests too. + */ +export type Database = PgDatabase; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5caf8f5..fc27575 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,4 @@ export * from "./schema/index"; +export * as schema from "./schema/index"; export * from "./client"; export * from "./topic-reply-repair";