Skip to content
Open
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
92 changes: 92 additions & 0 deletions frontend/src/app/api/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import {
addBookmark,
listBookmarks,
removeBookmarkByTask,
resetBookmarkStore,
} from "@/lib/bookmark-store";

/**
* GET /api/bookmarks?userId=<wallet>
* List all bookmarks for a user.
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId") ?? "";

if (!userId.trim()) {
return NextResponse.json(
{ error: "userId query parameter is required." },
{ status: 400 },
);
}

const result = listBookmarks(userId);
return NextResponse.json(result);
}

/**
* POST /api/bookmarks
* Body: { userId, taskId, action?: "add" | "remove" | "toggle" }
* Default action is "add".
*/
export async function POST(request: NextRequest) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body." },
{ status: 400 },
);
}

const userId = String(body.userId ?? "").trim();
const taskId = String(body.taskId ?? "").trim();
const action = String(body.action ?? "add") as "add" | "remove" | "toggle";

if (!userId || !taskId) {
return NextResponse.json(
{ error: "userId and taskId are required." },
{ status: 400 },
);
}

if (action === "remove") {
const result = removeBookmarkByTask(userId, taskId);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ bookmarked: false });
}

// default: add
const result = addBookmark(userId, taskId);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ bookmarked: true, bookmark: result.bookmark });
}

/**
* DELETE /api/bookmarks?userId=<wallet>&taskId=<taskId>
* Remove a bookmark by user+task pair.
*/
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId") ?? "";
const taskId = searchParams.get("taskId") ?? "";

if (!userId.trim() || !taskId.trim()) {
return NextResponse.json(
{ error: "userId and taskId are required." },
{ status: 400 },
);
}

const result = removeBookmarkByTask(userId, taskId);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ bookmarked: false });
}
111 changes: 111 additions & 0 deletions frontend/src/app/api/drafts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import {
saveDraft,
getDraft,
listDrafts,
deleteDraftByTask,
getLastSavedAt,
MAX_FORM_DATA_SIZE,
} from "@/lib/draft-autosave";

/**
* GET /api/drafts?userId=<wallet>&taskId=<taskId>
*
* - If taskId is provided: returns the single draft for that user+task pair.
* - If taskId is omitted: returns all drafts for the user.
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId") ?? "";
const taskId = searchParams.get("taskId");

if (!userId.trim()) {
return NextResponse.json(
{ error: "userId query parameter is required." },
{ status: 400 },
);
}

if (taskId) {
const draft = getDraft(userId, taskId);
if (!draft) {
return NextResponse.json(
{ error: "Draft not found." },
{ status: 404 },
);
}
return NextResponse.json({ draft });
}

const result = listDrafts(userId);
return NextResponse.json(result);
}

/**
* POST /api/drafts
* Body: { userId, taskId, formData, autoSaved? }
*
* Creates or updates a draft (upsert).
*/
export async function POST(request: NextRequest) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Invalid JSON body." },
{ status: 400 },
);
}

const userId = String(body.userId ?? "").trim();
const taskId = String(body.taskId ?? "").trim();
const formData = String(body.formData ?? "");
const autoSaved = body.autoSaved !== false;

if (!userId || !taskId) {
return NextResponse.json(
{ error: "userId and taskId are required." },
{ status: 400 },
);
}

if (formData.length > MAX_FORM_DATA_SIZE) {
return NextResponse.json(
{ error: `Form data exceeds maximum size of ${MAX_FORM_DATA_SIZE} bytes.` },
{ status: 400 },
);
}

const result = saveDraft({ userId, taskId, formData, autoSaved });
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}

return NextResponse.json({ draft: result.draft });
}

/**
* DELETE /api/drafts?userId=<wallet>&taskId=<taskId>
*
* Removes a draft for the specified user+task pair.
*/
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId") ?? "";
const taskId = searchParams.get("taskId") ?? "";

if (!userId.trim() || !taskId.trim()) {
return NextResponse.json(
{ error: "userId and taskId are required." },
{ status: 400 },
);
}

const result = deleteDraftByTask(userId, taskId);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}

return NextResponse.json({ deleted: true });
}
107 changes: 107 additions & 0 deletions frontend/src/app/api/moderation/[itemId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
approveModerationItem,
rejectModerationItem,
dismissModerationItem,
getModerationItem,
type ModerationItem,
} from "@/lib/moderation-queue";
import { buildNoStoreJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(
request: Request,
{ params }: { params: Promise<{ itemId: string }> },
) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
if (rateLimitResponse) {
return rateLimitResponse;
}

const { itemId } = await params;
const item = getModerationItem(itemId);

if (!item) {
return buildNoStoreJson(
{ ok: false, error: "Moderation item not found." },
404,
rateLimitHeaders,
);
}

return buildNoStoreJson(
{ ok: true, item },
200,
rateLimitHeaders,
);
}

export async function POST(
request: Request,
{ params }: { params: Promise<{ itemId: string }> },
) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
if (rateLimitResponse) {
return rateLimitResponse;
}

const { itemId } = await params;
let body: unknown;

try {
body = await request.json();
} catch {
return buildNoStoreJson(
{ ok: false, error: "Request body must be valid JSON." },
400,
rateLimitHeaders,
);
}

const payload = body as Record<string, unknown>;
const action = String(payload.action ?? "");
const adminAddress = String(payload.adminAddress ?? "");
const note = payload.note ? String(payload.note) : undefined;

if (!adminAddress.trim()) {
return buildNoStoreJson(
{ ok: false, error: "Admin address is required." },
400,
rateLimitHeaders,
);
}

let modResult: { ok: true; item: ModerationItem } | { ok: false; error: string };

if (action === "approve") {
modResult = approveModerationItem(itemId, adminAddress, note);
} else if (action === "reject") {
modResult = rejectModerationItem(itemId, adminAddress, note);
} else if (action === "dismiss") {
modResult = dismissModerationItem(itemId, adminAddress, note);
} else {
return buildNoStoreJson(
{ ok: false, error: `Invalid action: ${action}. Use approve, reject, or dismiss.` },
400,
rateLimitHeaders,
);
}

if (!modResult.ok) {
return buildNoStoreJson(
{ ok: false, error: modResult.error },
409,
rateLimitHeaders,
);
}

return buildNoStoreJson(
{ ok: true, item: modResult.item },
200,
rateLimitHeaders,
);
}
57 changes: 57 additions & 0 deletions frontend/src/app/api/moderation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { listModerationItems } from "@/lib/moderation-queue";
import { buildNoStoreJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import type { ModerationStatus, ModerationItemType } from "@/lib/moderation-queue";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
if (rateLimitResponse) {
return rateLimitResponse;
}

const { searchParams } = new URL(request.url);
const statusParam = searchParams.get("status");
const typeParam = searchParams.get("type");

const filter: { status?: ModerationStatus; type?: ModerationItemType } = {};

if (
statusParam &&
["pending", "approved", "rejected", "dismissed"].includes(statusParam)
) {
filter.status = statusParam as ModerationStatus;
}

if (
typeParam &&
["task_created", "task_updated", "duplicate_flag", "user_report"].includes(
typeParam,
)
) {
filter.type = typeParam as ModerationItemType;
}

const result = listModerationItems(
Object.keys(filter).length > 0 ? filter : undefined,
);

return buildNoStoreJson(
{
ok: true,
items: result.items,
stats: {
total: result.total,
pending: result.pending,
approved: result.approved,
rejected: result.rejected,
dismissed: result.dismissed,
},
},
200,
rateLimitHeaders,
);
}
Loading