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
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,
);
}
118 changes: 118 additions & 0 deletions frontend/src/lib/duplicate-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { detectDuplicates } from "@/lib/duplicate-detection";
import type { TaskRecord } from "@/types/task-workflow";

function makeTask(overrides: Partial<TaskRecord> = {}): TaskRecord {
return {
id: "1",
poster: "addr1",
title: "Build a DeFi lending protocol",
description: "A protocol for lending on Stellar",
reward: 5_000_000,
deadline: Math.floor(Date.now() / 1000) + 86400,
maxSubmissions: 5,
submissionCount: 0,
status: "open",
createdAt: new Date().toISOString(),
difficulty: "intermediate",
technologies: ["Rust"],
organization: "DeFi Corp",
...overrides,
};
}

describe("detectDuplicates", () => {
it("returns no matches when no existing tasks", () => {
const result = detectDuplicates(
{ title: "New Task", organization: "", description: "" },
[],
);
expect(result.hasDuplicates).toBe(false);
expect(result.matches).toHaveLength(0);
});

it("detects identical title", () => {
const existing = makeTask({ id: "1", title: "Build a DEX" });
const result = detectDuplicates(
{ title: "Build a DEX", organization: "", description: "" },
[existing],
);
expect(result.hasDuplicates).toBe(true);
expect(result.matches[0].confidence).toBeGreaterThanOrEqual(0.9);
expect(result.matches[0].reason).toContain("Identical title");
});

it("detects similar title with minor typo", () => {
const existing = makeTask({ id: "1", title: "Build a DEX" });
const result = detectDuplicates(
{ title: "Build aDEX", organization: "", description: "" },
[existing],
);
expect(result.hasDuplicates).toBe(true);
});

it("detects same organization + description overlap", () => {
const existing = makeTask({
id: "1",
title: "Task A",
organization: "OrgX",
description: "Build a payment system with Soroban smart contracts on Stellar",
});
const result = detectDuplicates(
{
title: "Task B",
organization: "OrgX",
description: "Build a payment system with Soroban smart contracts on Stellar",
},
[existing],
);
expect(result.hasDuplicates).toBe(true);
});

it("does not flag different tasks from same org", () => {
const existing = makeTask({
id: "1",
title: "Build a DEX",
organization: "OrgX",
description: "A decentralized exchange",
});
const result = detectDuplicates(
{
title: "Build a lending protocol",
organization: "OrgX",
description: "A lending platform with flash loans",
},
[existing],
);
// Same org alone is weak signal (0.3 confidence), should still be flagged
// but with low confidence
if (result.hasDuplicates) {
expect(result.matches[0].confidence).toBeLessThanOrEqual(0.5);
}
});

it("handles empty title gracefully", () => {
const existing = makeTask({ id: "1", title: "Some Task" });
const result = detectDuplicates(
{ title: "", organization: "", description: "" },
[existing],
);
expect(result.hasDuplicates).toBe(false);
});

it("sorts matches by confidence descending", () => {
const existing1 = makeTask({ id: "1", title: "Build a DEX" });
const existing2 = makeTask({ id: "2", title: "Build a DEX with AMM" });
const result = detectDuplicates(
{ title: "Build a DEX", organization: "", description: "" },
[existing1, existing2],
);
expect(result.hasDuplicates).toBe(true);
expect(result.matches.length).toBeGreaterThanOrEqual(1);
for (let i = 1; i < result.matches.length; i++) {
expect(result.matches[i - 1].confidence).toBeGreaterThanOrEqual(
result.matches[i].confidence,
);
}
});
});
Loading