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
30 changes: 23 additions & 7 deletions app/api/postcards/[id]/claim/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { bumpReceivedCount, ensurePostcardHiddenColumn } from "@/lib/schema";

// POST /api/postcards/[id]/claim -> claim an available postcard
export async function POST(
Expand Down Expand Up @@ -43,17 +44,26 @@ export async function DELETE(

const { id } = await params;

// 取消认领后隐藏权归零:hidden_by_claimer 是认领人专属的隐私开关,
// 认领关系解除后明信片重新回到广场公开池,不应该继续隐藏。
const hiddenReady = await ensurePostcardHiddenColumn();
const resetHidden = hiddenReady ? ", hidden_by_claimer = false" : "";

// 捕获取消前的状态:若原本是 received,取消认领同时撤销了签收,需 -1。
const result = await pool.query(
`
update postcards
with target as (
select id, status from postcards
where id = $1 and claimer_id = $2 and status in ('claimed', 'received')
)
update postcards p
set status = 'available',
claimer_id = null,
claimed_at = null,
received_at = null
where id = $1
and claimer_id = $2
and status in ('claimed', 'received')
returning *
received_at = null${resetHidden}
from target
where p.id = target.id
returning p.*, target.status as prev_status
`,
[id, user.id]
);
Expand All @@ -65,5 +75,11 @@ export async function DELETE(
);
}

return NextResponse.json({ postcard: result.rows[0] });
if (result.rows[0].prev_status === "received") {
await bumpReceivedCount(-1); // 取消认领时若已签收,累计 -1
}

const { prev_status, ...postcard } = result.rows[0];
void prev_status;
return NextResponse.json({ postcard });
}
65 changes: 65 additions & 0 deletions app/api/postcards/[id]/hide/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { ensurePostcardHiddenColumn } from "@/lib/schema";

// 认领人对自己认领/已收到的明信片切换「隐藏」。
// 隐藏后广场对所有人不可见,认领人仍能在「我的」看到。
async function setHidden(
id: string,
userId: string,
hidden: boolean
): Promise<{ ok: boolean; postcard?: unknown }> {
const ready = await ensurePostcardHiddenColumn();
if (!ready) return { ok: false };

const result = await pool.query(
`
update postcards
set hidden_by_claimer = $3
where id = $1 and claimer_id = $2 and status in ('claimed', 'received')
returning *
`,
[id, userId, hidden]
);
if (result.rows.length === 0) return { ok: false };
return { ok: true, postcard: result.rows[0] };
}

// POST -> 隐藏
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 });

const { id } = await params;
const { ok, postcard } = await setHidden(id, user.id, true);
Comment on lines +34 to +38
if (!ok) {
return NextResponse.json(
{ error: "隐藏失败:只有认领人可隐藏自己认领的明信片" },
{ status: 409 }
);
}
return NextResponse.json({ postcard });
}

// DELETE -> 取消隐藏
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 });

const { id } = await params;
const { ok, postcard } = await setHidden(id, user.id, false);
Comment on lines +53 to +57
if (!ok) {
return NextResponse.json(
{ error: "取消隐藏失败:只有认领人可操作" },
{ status: 409 }
);
}
return NextResponse.json({ postcard });
}
3 changes: 3 additions & 0 deletions app/api/postcards/[id]/receive/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { bumpReceivedCount } from "@/lib/schema";

// POST /api/postcards/[id]/receive -> confirm receipt (claimer only)
export async function POST(
Expand Down Expand Up @@ -29,6 +30,7 @@ export async function POST(
);
}

await bumpReceivedCount(1); // 累计签收 +1
return NextResponse.json({ postcard: result.rows[0] });
}

Expand Down Expand Up @@ -59,5 +61,6 @@ export async function DELETE(
);
}

await bumpReceivedCount(-1); // 取消签收 -1
return NextResponse.json({ postcard: result.rows[0] });
}
73 changes: 63 additions & 10 deletions app/api/postcards/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { ensureImageHashColumn, normalizeImageHash } from "@/lib/postcard-image-hash";
import { ensurePostcardMetadataColumns } from "@/lib/schema";
import { ensurePostcardMetadataColumns, ensurePostcardHiddenColumn } from "@/lib/schema";
import type { PostcardCounts } from "@/lib/types";

// GET /api/postcards -> all postcards (newest first)
// GET /api/postcards?status=available
// GET /api/postcards?mine=1 -> postcards claimed by the current user
// GET /api/postcards -> plaza, all postcards (newest first)
// GET /api/postcards?status=available -> plaza, filtered by status
// GET /api/postcards?scope=claimed -> "我的" 里我认领的明信片(分页)
// GET /api/postcards?scope=uploaded -> "我的" 里我上传的明信片(分页)
function positiveInt(value: string | null, fallback: number) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
Expand All @@ -19,18 +20,70 @@ export async function GET(request: Request) {

const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const mine = searchParams.get("mine");
const scope = searchParams.get("scope"); // "claimed" | "uploaded" | null (plaza)
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = Math.min(100, positiveInt(searchParams.get("pageSize"), 21));
const offset = (page - 1) * pageSize;

if (scope === "claimed" || scope === "uploaded") {
const orderBy =
scope === "claimed"
? "p.claimed_at desc nulls last, p.created_at desc"
: "p.created_at desc";
const conditions = [`p.${scope === "claimed" ? "claimer_id" : "uploader_id"} = $1`];
const params: unknown[] = [user.id];
if (status && ["available", "claimed", "received"].includes(status)) {
params.push(status);
conditions.push(`p.status = $${params.length}`);
}
const where = `where ${conditions.join(" and ")}`;
Comment on lines +33 to +39
params.push(pageSize, offset);
const limitParam = params.length - 1;
const offsetParam = params.length;

const [result, claimedCount, uploadedCount] = await Promise.all([
pool.query(
`
select
p.*,
up.nickname as uploader_nickname,
cl.nickname as claimer_nickname
from postcards p
left join users up on p.uploader_id = up.id
left join users cl on p.claimer_id = cl.id
${where}
order by ${orderBy}
limit $${limitParam} offset $${offsetParam}
`,
params
),
pool.query("select count(*)::int as count from postcards where claimer_id = $1", [
user.id,
]),
pool.query("select count(*)::int as count from postcards where uploader_id = $1", [
user.id,
]),
]);

const scopeCounts = {
claimed: claimedCount.rows[0]?.count ?? 0,
uploaded: uploadedCount.rows[0]?.count ?? 0,
};
const total = scopeCounts[scope];

return NextResponse.json({
postcards: result.rows,
scopeCounts,
pagination: { page, pageSize, total },
currentUserId: user.id,
});
}

// 广场视图:排除被认领人隐藏的明信片。
const baseConditions: string[] = [];
const baseParams: unknown[] = [];

if (mine === "1") {
baseParams.push(user.id);
baseConditions.push(`p.claimer_id = $${baseParams.length}`);
}
const hiddenReady = await ensurePostcardHiddenColumn();
if (hiddenReady) baseConditions.push("p.hidden_by_claimer is not true");

const conditions = [...baseConditions];
const params = [...baseParams];
Expand Down
20 changes: 20 additions & 0 deletions app/help/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readFile } from "fs/promises";
import path from "path";
import Nav from "@/components/nav";
import { renderMarkdown } from "@/lib/markdown";

export const dynamic = "force-dynamic";

export default async function HelpPage() {
const filePath = path.join(process.cwd(), "content", "help.md");
const markdown = await readFile(filePath, "utf8");

Comment on lines +8 to +11
return (
<>
<Nav />
<main className="container max-w-3xl py-8">
<article className="space-y-5">{renderMarkdown(markdown)}</article>
</main>
</>
);
}
Loading