Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.development.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_DATABASE_ID=
CLOUDFLARE_TOKEN=
CLOUDFLARE_TOKEN=
# 本地开发时取消下面的注释
# CLOUDFLARE_ENV=development
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a trailing newline.

Per the dotenv-linter hint, the file is missing an ending blank line.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 5-5: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.development.example at line 5, The file ends without a trailing newline
after the line "# CLOUDFLARE_ENV=development"; add a single blank line (newline
character) at the end of the file so the file terminates with a trailing newline
to satisfy dotenv-linter.

70 changes: 6 additions & 64 deletions app/api/settings/account/route.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,38 @@
/**
* 账户设置 API 路由
*
* 该模块提供用户账户设置的获取和更新功能,包括:
* - 时区设置
* - 通知时间设置(每天哪些小时发送通知)
*
* @module api/settings/account
*/

import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/db";
import { userSettings } from "@/db/schema";
import { requireApiAuth } from "@/lib/session";
import { eq } from "drizzle-orm";
import { z } from "zod";

/**
* 更新设置的验证模式
*
* - timezone: 用户所在时区,非空字符串
* - notifyHours: 通知小时数组,值为 0-23 的整数,至少包含一个值
*/
const updateSchema = z.object({
timezone: z.string().min(1).optional(),
notifyHours: z.array(z.number().int().min(0).max(23)).min(1).optional(),
notifyDeliveryMode: z.enum(["every_slot", "once_per_day"]).optional(),
});

/**
* GET /api/settings/account
*
* 获取当前用户的账户设置
*
* @returns {Promise<NextResponse>} 返回包含以下字段的 JSON 响应:
* - timezone: 用户时区(默认 "UTC")
* - notifyHours: 通知小时数组(默认 [0])
*
* @throws {Error} 如果用户未认证, 会抛出错误
*
* @example
* // 响应示例
* { "timezone": "Asia/Shanghai", "notifyHours": [9, 18] }
*/
export async function GET() {
// 验证用户身份
const session = await requireApiAuth();
const db = await getDb();

// 查询用户的设置记录
const [settings] = await db
.select()
.from(userSettings)
.where(eq(userSettings.userId, session.userId))
.limit(1);

// 如果用户没有设置记录,返回默认值
if (!settings) {
return NextResponse.json({ timezone: "UTC", notifyHours: [0], notifyDeliveryMode: "every_slot" });
return NextResponse.json({
timezone: "UTC",
notifyHours: [0],
notifyDeliveryMode: "every_slot",
});
}

// 解析通知小时 JSON 字符串
let notifyHours: number[] = [0];
try {
notifyHours = JSON.parse(settings.notifyHoursJson) as number[];
} catch { /* 解析失败时使用默认值 */ }
} catch {}

return NextResponse.json({
timezone: settings.timezone,
Expand All @@ -72,58 +41,31 @@ export async function GET() {
});
}

/**
* PUT /api/settings/account
*
* 更新当前用户的账户设置
*
* @param {NextRequest} request - 请求对象,包含 JSON 格式的设置数据
* @returns {Promise<NextResponse>} 返回更新结果:
* - 成功: { success: true }
* - 验证失败: { error: ZodError } (状态码 400)
*
* @throws {Error} 如果用户未认证, 会抛出错误
*
* @example
* // 请求体示例
* { "timezone": "Asia/Shanghai", "notifyHours": [9, 12, 18] }
*
* // 成功响应
* { "success": true }
*/
export async function PUT(request: NextRequest) {
// 验证用户身份
const session = await requireApiAuth();
const db = await getDb();

// 解析并验证请求体
const body = await request.json() as unknown;
const parsed = updateSchema.safeParse(body);

// 验证失败时返回错误详情
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}

// 检查用户是否已有设置记录
const [existing] = await db
.select({ id: userSettings.id })
.from(userSettings)
.where(eq(userSettings.userId, session.userId))
.limit(1);

// 构建更新值对象
const values: Record<string, unknown> = { updatedAt: new Date() };
if (parsed.data.timezone) values.timezone = parsed.data.timezone;
if (parsed.data.notifyHours) values.notifyHoursJson = JSON.stringify(parsed.data.notifyHours);
if (parsed.data.notifyDeliveryMode) values.notifyDeliveryMode = parsed.data.notifyDeliveryMode;

// 根据是否存在记录执行更新或插入操作
if (existing) {
// 更新现有记录
await db.update(userSettings).set(values).where(eq(userSettings.userId, session.userId));
} else {
// 创建新记录,使用传入值或默认值
await db.insert(userSettings).values({
userId: session.userId,
...values,
Expand Down
73 changes: 73 additions & 0 deletions app/api/settings/notification-events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import { getDb } from "@/db";
import { notificationChannels, notificationEvents, subscriptions } from "@/db/schema";
import { requireApiAuth } from "@/lib/session";
import { z } from "zod";

const cleanupSchema = z.object({
months: z.number().int().min(1),
});

export async function GET() {
const session = await requireApiAuth();
const db = await getDb();

const items = await db
.select({
id: notificationEvents.id,
createdAt: notificationEvents.createdAt,
sentAt: notificationEvents.sentAt,
subscriptionName: subscriptions.name,
channelName: notificationChannels.name,
status: notificationEvents.status,
offsetDays: notificationEvents.offsetDays,
error: notificationEvents.error,
})
.from(notificationEvents)
.innerJoin(subscriptions, eq(notificationEvents.subscriptionId, subscriptions.id))
.innerJoin(notificationChannels, eq(notificationEvents.channelId, notificationChannels.id))
.where(eq(subscriptions.userId, session.userId))
.orderBy(desc(notificationEvents.createdAt))
.limit(50);

return NextResponse.json({ items });
}

export async function DELETE(request: NextRequest) {
const session = await requireApiAuth();
const db = await getDb();

const body = await request.json() as unknown;
const parsed = cleanupSchema.safeParse(body);

if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
Comment on lines +41 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against malformed JSON bodies.

await request.json() throws SyntaxError on invalid/empty bodies, which will surface as a 500 instead of the intended 400 validation response. Wrap the parse or read the raw text first.

🛡️ Proposed fix
-  const body = await request.json() as unknown;
-  const parsed = cleanupSchema.safeParse(body);
+  let body: unknown;
+  try {
+    body = await request.json();
+  } catch {
+    return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+  }
+  const parsed = cleanupSchema.safeParse(body);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const body = await request.json() as unknown;
const parsed = cleanupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = cleanupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/settings/notification-events/route.ts` around lines 41 - 46, The
handler currently does await request.json() which throws on malformed/empty
JSON, causing a 500; modify the code in the route handler to read the raw
request text (e.g., await request.text()) and then try to JSON.parse it inside a
try/catch, or wrap await request.json() in a try/catch, and on parse errors
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) before
calling cleanupSchema.safeParse(body); keep references to body, parsed,
cleanupSchema and ensure any thrown SyntaxError is caught and mapped to a 400
response instead of letting it bubble up.


const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - parsed.data.months);
Comment on lines +48 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Date.setMonth has end-of-month pitfalls.

Subtracting months from a date like Mar 31 yields Mar 3 (since Feb has no 31st), so the cutoff can end up slightly inside the current month. Typically harmless for a retention/cleanup window, but worth being aware of; consider normalizing to day 1 or using a day-based offset if exact semantics matter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/settings/notification-events/route.ts` around lines 48 - 49, The
current cutoff computation in route.ts uses Date.setMonth on the cutoff variable
which can produce end-of-month rollovers (e.g., Mar 31 -> Mar 3); update the
logic that constructs cutoff to normalize to a stable boundary—either set cutoff
to the first day of the month before subtracting months (e.g., cutoff.setDate(1)
then setMonth(... - parsed.data.months)) or use a day-based subtraction (compute
parsed.data.months * 30 or use a library/date arithmetic to subtract exact
months) so the retention window is deterministic; adjust the code around the
cutoff variable in the notification-events route accordingly.


const ids = await db
.select({ id: notificationEvents.id })
.from(notificationEvents)
.innerJoin(subscriptions, eq(notificationEvents.subscriptionId, subscriptions.id))
.where(eq(subscriptions.userId, session.userId));

const deletableIds = ids.map((item) => item.id);
if (deletableIds.length === 0) {
return NextResponse.json({ success: true, deletedCount: 0 });
}

const deleted = await db
.delete(notificationEvents)
.where(
and(
inArray(notificationEvents.id, deletableIds),
lt(notificationEvents.createdAt, cutoff)
)
)
.returning({ id: notificationEvents.id });
Comment on lines +51 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current cleanup logic is inefficient and does not scale well. It fetches all notification event IDs for the user into application memory (without even filtering by the cutoff date) and then performs a deletion using an inArray filter. This approach can lead to high memory usage and may exceed SQLite's parameter limit (typically 999) if a user has many events.

It is recommended to perform the deletion in a single query using a subquery. This avoids fetching unnecessary data and reduces database round-trips.

  const deleted = await db
    .delete(notificationEvents)
    .where(
      and(
        lt(notificationEvents.createdAt, cutoff),
        inArray(
          notificationEvents.subscriptionId,
          db.select({ id: subscriptions.id })
            .from(subscriptions)
            .where(eq(subscriptions.userId, session.userId))
        )
      )
    )
    .returning({ id: notificationEvents.id });

Comment on lines +51 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Drizzle ORM inArray subquery support and Cloudflare D1 SQLITE_MAX_VARIABLE_NUMBER bound parameter limit

💡 Result:

Drizzle ORM's inArray does not directly support subqueries as the second argument. Users have reported issues attempting this, with no TypeScript errors but runtime failures. Instead, Drizzle supports subqueries in WHERE clauses via exists, notExists, or raw sqlcolumn IN (subquery) templates. For example: ts const userIds = db.select({ id: posts.userId }).from(posts).as('user_ids'); await db.select.from(users).where(inArray(users.id, sql(SELECT id FROM user_ids))); // or use exists However, the standard workaround for large arrays in inArray is manual chunking due to Cloudflare D1's limit. Cloudflare D1 enforces a maximum of 100 bound parameters per query (SQLITE_MAX_VARIABLE_NUMBER equivalent), as stated in official docs. This affects Drizzle's inArray, where each array element becomes a bound parameter (e.g., WHERE id IN (?, ?, ...) with N parameters for N IDs). For inArray with >100 IDs on D1, queries fail with "too many SQL variables" errors. Drizzle does not auto-chunk; users must manually chunk arrays and execute multiple queries or use db.batch. Examples from issues: ts const BATCH_SIZE = 90; // Conservative, accounting for other params const chunks = chunk(ids, BATCH_SIZE); for (const chunk of chunks) { await db.select.from(table).where(inArray(table.id, chunk)); } Subqueries avoid parameter limits entirely since they execute separately, making them ideal for large/dynamic sets on D1. Use subqueries or chunking to work around the 100-parameter limit.

Citations:


🏁 Script executed:

head -50 app/api/settings/notification-events/route.ts

Repository: Merack/subflare-vinext

Length of output: 1727


🏁 Script executed:

rg "exists|inArray|sql\(" app/api/settings/notification-events/route.ts -A 2

Repository: Merack/subflare-vinext

Length of output: 354


🏁 Script executed:

rg "from drizzle|import.*drizzle" app/api/settings/notification-events/route.ts

Repository: Merack/subflare-vinext

Length of output: 123


Use a subquery with exists() to filter owned events instead of fetching all IDs into memory.

The current approach loads every notification event ID belonging to the user into a JavaScript array and passes it to inArray. For users with many events, this is unnecessarily expensive and on Cloudflare D1 (which enforces a 100 bound-parameter limit) will cause the query to fail with "too many SQL variables" errors—each array element becomes a bound parameter, so 101+ IDs will break the query.

Instead, use a subquery with exists() to filter by subscription ownership directly in the WHERE clause. This avoids loading IDs into memory and executes the ownership check server-side.

♻️ Proposed refactor
+import { exists } from "drizzle-orm";
 import { and, desc, eq, inArray, lt } from "drizzle-orm";

-  const ids = await db
-    .select({ id: notificationEvents.id })
-    .from(notificationEvents)
-    .innerJoin(subscriptions, eq(notificationEvents.subscriptionId, subscriptions.id))
-    .where(eq(subscriptions.userId, session.userId));
-
-  const deletableIds = ids.map((item) => item.id);
-  if (deletableIds.length === 0) {
-    return NextResponse.json({ success: true, deletedCount: 0 });
-  }
-
   const deleted = await db
     .delete(notificationEvents)
     .where(
       and(
-        inArray(notificationEvents.id, deletableIds),
+        exists(
+          db.select({ id: subscriptions.id })
+            .from(subscriptions)
+            .where(
+              and(
+                eq(subscriptions.userId, session.userId),
+                eq(subscriptions.id, notificationEvents.subscriptionId)
+              )
+            )
+        ),
         lt(notificationEvents.createdAt, cutoff)
       )
     )
     .returning({ id: notificationEvents.id });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/settings/notification-events/route.ts` around lines 51 - 70, The code
currently selects all notificationEvents IDs into deletableIds and uses
inArray(notificationEvents.id, deletableIds) which loads many IDs into memory
and breaks on DB parameter limits; instead remove the initial ids query and the
inArray check and change the delete's WHERE to use an exists() subquery that
checks subscriptions where eq(subscriptions.id,
notificationEvents.subscriptionId) and eq(subscriptions.userId, session.userId),
combined with lt(notificationEvents.createdAt, cutoff); update the delete call
that references notificationEvents, subscriptions, session.userId, and cutoff to
use this server-side ownership check.


return NextResponse.json({ success: true, deletedCount: deleted.length });
}
2 changes: 1 addition & 1 deletion components/dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export default function DashboardClient({ initialData, initialExchangeRates }: D
>
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={(v: number) => `${sym}${v.toFixed(0)}`} />
<YAxis type="category" dataKey="category" tick={{ fontSize: 11 }} width={52} />
<Tooltip formatter={(v: number) => [`${sym}${v.toFixed(2)}`, "支出"]} />
<Tooltip formatter={(value) => [typeof value === "number" ? `${sym}${value.toFixed(2)}` : `${sym}${value ?? ""}`, "支出"]} />
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{categoryChartData.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
Expand Down
Loading