From 682cf1d49db3fcd7a7093f604a1d3682e2e6d72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E7=8F=AD=E4=B8=83=E5=8F=B7?= <9159450+luban-71@user.noreply.gitee.com> Date: Sat, 29 Aug 2026 00:58:29 +0800 Subject: [PATCH] feat: add duplicate detection (#154), eligibility criteria (#157), moderation queue (#159) - #154: Levenshtein-based title similarity + org/description overlap detection Flags potential duplicates for admin review without blocking creation - #157: Structured eligibility fields (location, applicant type, project stage, team size, custom requirements) with validation and display formatting - #159: Admin moderation queue with enqueue/list/approve/reject/dismiss Auto-enqueues new tasks and duplicate flags for review - Integrated into createTask: duplicate detection + auto-moderation on creation - Added API routes: GET /api/moderation, GET/POST /api/moderation/:itemId - 30+ unit tests covering all three features --- .../src/app/api/moderation/[itemId]/route.ts | 107 +++++++++ frontend/src/app/api/moderation/route.ts | 57 +++++ frontend/src/lib/duplicate-detection.test.ts | 118 ++++++++++ frontend/src/lib/duplicate-detection.ts | 165 ++++++++++++++ frontend/src/lib/eligibility.test.ts | 116 ++++++++++ frontend/src/lib/eligibility.ts | 211 ++++++++++++++++++ frontend/src/lib/moderation-queue.test.ts | 171 ++++++++++++++ frontend/src/lib/moderation-queue.ts | 208 +++++++++++++++++ frontend/src/lib/task-workflow.ts | 32 +++ 9 files changed, 1185 insertions(+) create mode 100644 frontend/src/app/api/moderation/[itemId]/route.ts create mode 100644 frontend/src/app/api/moderation/route.ts create mode 100644 frontend/src/lib/duplicate-detection.test.ts create mode 100644 frontend/src/lib/duplicate-detection.ts create mode 100644 frontend/src/lib/eligibility.test.ts create mode 100644 frontend/src/lib/eligibility.ts create mode 100644 frontend/src/lib/moderation-queue.test.ts create mode 100644 frontend/src/lib/moderation-queue.ts diff --git a/frontend/src/app/api/moderation/[itemId]/route.ts b/frontend/src/app/api/moderation/[itemId]/route.ts new file mode 100644 index 0000000..925e03c --- /dev/null +++ b/frontend/src/app/api/moderation/[itemId]/route.ts @@ -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; + 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, + ); +} diff --git a/frontend/src/app/api/moderation/route.ts b/frontend/src/app/api/moderation/route.ts new file mode 100644 index 0000000..ebdad92 --- /dev/null +++ b/frontend/src/app/api/moderation/route.ts @@ -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, + ); +} diff --git a/frontend/src/lib/duplicate-detection.test.ts b/frontend/src/lib/duplicate-detection.test.ts new file mode 100644 index 0000000..ebc1463 --- /dev/null +++ b/frontend/src/lib/duplicate-detection.test.ts @@ -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 { + 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, + ); + } + }); +}); diff --git a/frontend/src/lib/duplicate-detection.ts b/frontend/src/lib/duplicate-detection.ts new file mode 100644 index 0000000..eea050f --- /dev/null +++ b/frontend/src/lib/duplicate-detection.ts @@ -0,0 +1,165 @@ +/** + * Duplicate grant/task detection logic. + * + * Compares tasks by title similarity, organization, and description + * to flag potential duplicates for admin review. + * Does NOT delete or block creation — only flags for review. + */ + +import type { TaskRecord } from "@/types/task-workflow"; + +/** Minimum normalized-title similarity ratio (0–1) to flag as a potential duplicate. */ +const TITLE_SIMILARITY_THRESHOLD = 0.8; + +/** Maximum Levenshtein distance for short-title comparison. */ +const TITLE_DISTANCE_THRESHOLD = 3; + +export interface DuplicateMatch { + /** ID of the existing task that the new task resembles. */ + existingTaskId: string; + /** Title of the existing task. */ + existingTitle: string; + /** Human-readable reason for the flag. */ + reason: string; + /** Confidence score 0–1. */ + confidence: number; +} + +export interface DuplicateDetectionResult { + /** True if at least one potential duplicate was found. */ + hasDuplicates: boolean; + /** Matches sorted by confidence descending. */ + matches: DuplicateMatch[]; +} + +/** + * Normalize a string for comparison: lowercase, trim, collapse whitespace, + * remove common punctuation. + */ +function normalize(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s]/g, " ") + .replace(/\s+/g, " "); +} + +/** + * Compute Levenshtein distance between two strings. + * Used for short-title comparison. + */ +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + if (m === 0) return n; + if (n === 0) return m; + + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost, + ); + } + } + return dp[m][n]; +} + +/** + * Compute a similarity ratio (0–1) between two normalized strings. + * Uses Levenshtein distance relative to the longer string. + */ +function similarity(a: string, b: string): number { + if (a === b) return 1; + if (a.length === 0 || b.length === 0) return 0; + const dist = levenshtein(a, b); + const maxLen = Math.max(a.length, b.length); + return 1 - dist / maxLen; +} + +/** + * Check a set of fields for a duplicate against existing tasks. + * + * @param newTask — the task being created (not yet stored) + * @param existingTasks — all current tasks to compare against + */ +export function detectDuplicates( + newTask: Pick, + existingTasks: TaskRecord[], +): DuplicateDetectionResult { + const matches: DuplicateMatch[] = []; + const newTitleNorm = normalize(newTask.title); + const newOrgNorm = normalize(newTask.organization); + const newDescNorm = normalize(newTask.description); + + for (const existing of existingTasks) { + const reasons: string[] = []; + let confidence = 0; + + // Exact title match + const existTitleNorm = normalize(existing.title); + if (newTitleNorm === existTitleNorm && newTitleNorm.length > 0) { + reasons.push("Identical title"); + confidence = Math.max(confidence, 0.95); + } else if (newTitleNorm.length > 0 && existTitleNorm.length > 0) { + const sim = similarity(newTitleNorm, existTitleNorm); + if (sim >= TITLE_SIMILARITY_THRESHOLD) { + reasons.push(`Title similarity ${(sim * 100).toFixed(0)}%`); + confidence = Math.max(confidence, sim); + } else if ( + newTitleNorm.length <= 50 && + existTitleNorm.length <= 50 + ) { + const dist = levenshtein(newTitleNorm, existTitleNorm); + if (dist > 0 && dist <= TITLE_DISTANCE_THRESHOLD) { + const simFromDist = 1 - dist / Math.max(newTitleNorm.length, existTitleNorm.length); + reasons.push(`Title edit distance ${dist}`); + confidence = Math.max(confidence, simFromDist); + } + } + } + + // Same organization + high description overlap + if ( + newOrgNorm.length > 0 && + newOrgNorm === existTitleNorm || + (newOrgNorm.length > 0 && newOrgNorm === normalize(existing.organization)) + ) { + const existDescNorm = normalize(existing.description); + if (newDescNorm.length > 0 && existDescNorm.length > 0) { + const descSim = similarity(newDescNorm, existDescNorm); + if (descSim >= 0.7) { + reasons.push(`Same organization + ${Math.round(descSim * 100)}% description overlap`); + confidence = Math.max(confidence, descSim * 0.9); + } + } + // Same org alone is a weak signal + if (reasons.length === 0 && newOrgNorm === normalize(existing.organization) && newOrgNorm.length > 0) { + reasons.push("Same organization"); + confidence = Math.max(confidence, 0.3); + } + } + + if (reasons.length > 0 && confidence >= 0.3) { + matches.push({ + existingTaskId: existing.id, + existingTitle: existing.title, + reason: reasons.join("; "), + confidence: Math.round(confidence * 100) / 100, + }); + } + } + + matches.sort((a, b) => b.confidence - a.confidence); + + return { + hasDuplicates: matches.length > 0, + matches, + }; +} diff --git a/frontend/src/lib/eligibility.test.ts b/frontend/src/lib/eligibility.test.ts new file mode 100644 index 0000000..d5cf41c --- /dev/null +++ b/frontend/src/lib/eligibility.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { + validateEligibility, + normalizeEligibility, + formatEligibilityForDisplay, + DEFAULT_ELIGIBILITY, +} from "@/lib/eligibility"; + +describe("validateEligibility", () => { + it("returns valid for empty input", () => { + const result = validateEligibility({}); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("returns valid for correct input", () => { + const result = validateEligibility({ + applicantType: "individual", + projectStage: "alpha", + minTeamSize: 1, + maxTeamSize: 5, + eligibleLocations: ["US", "EU"], + customRequirements: ["Must be 18+"], + }); + expect(result.valid).toBe(true); + }); + + it("rejects invalid applicantType", () => { + const result = validateEligibility({ applicantType: "invalid" as never }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("applicantType"); + }); + + it("rejects invalid projectStage", () => { + const result = validateEligibility({ projectStage: "invalid" as never }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("projectStage"); + }); + + it("rejects negative team sizes", () => { + const result = validateEligibility({ minTeamSize: -1 }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("minTeamSize"); + }); + + it("rejects minTeamSize > maxTeamSize", () => { + const result = validateEligibility({ minTeamSize: 10, maxTeamSize: 5 }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("exceed"); + }); + + it("rejects empty location strings", () => { + const result = validateEligibility({ eligibleLocations: ["US", " "] }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("location"); + }); +}); + +describe("normalizeEligibility", () => { + it("returns defaults for undefined", () => { + const result = normalizeEligibility(undefined); + expect(result).toEqual(DEFAULT_ELIGIBILITY); + }); + + it("trims and filters locations", () => { + const result = normalizeEligibility({ + eligibleLocations: [" US ", "", " EU "], + }); + expect(result.eligibleLocations).toEqual(["US", "EU"]); + }); + + it("trims and filters custom requirements", () => { + const result = normalizeEligibility({ + customRequirements: [" Must be 18+ ", " ", " KYC required "], + }); + expect(result.customRequirements).toEqual(["Must be 18+", "KYC required"]); + }); +}); + +describe("formatEligibilityForDisplay", () => { + it("returns empty array for default eligibility", () => { + const result = formatEligibilityForDisplay(DEFAULT_ELIGIBILITY); + expect(result).toHaveLength(0); + }); + + it("formats locations", () => { + const result = formatEligibilityForDisplay({ + ...DEFAULT_ELIGIBILITY, + eligibleLocations: ["US", "EU"], + }); + const loc = result.find((r) => r.label === "Eligible Locations"); + expect(loc).toBeDefined(); + expect(loc!.value).toBe("US, EU"); + }); + + it("formats applicant type", () => { + const result = formatEligibilityForDisplay({ + ...DEFAULT_ELIGIBILITY, + applicantType: "individual", + }); + const at = result.find((r) => r.label === "Applicant Type"); + expect(at).toBeDefined(); + expect(at!.value).toBe("Individual"); + }); + + it("formats team size range", () => { + const result = formatEligibilityForDisplay({ + ...DEFAULT_ELIGIBILITY, + minTeamSize: 2, + maxTeamSize: 5, + }); + const ts = result.find((r) => r.label === "Team Size"); + expect(ts).toBeDefined(); + expect(ts!.value).toBe("2–5 members"); + }); +}); diff --git a/frontend/src/lib/eligibility.ts b/frontend/src/lib/eligibility.ts new file mode 100644 index 0000000..f3ceb17 --- /dev/null +++ b/frontend/src/lib/eligibility.ts @@ -0,0 +1,211 @@ +/** + * Grant eligibility criteria — structured fields that grant creators + * can define to communicate who is eligible for a given grant/task. + * + * Issue #157: Implement Grant Eligibility Criteria Fields + */ + +export type EligibilityApplicantType = + | "individual" + | "organization" + | "team" + | "student" + | "any"; + +export type EligibilityProjectStage = + | "idea" + | "prototype" + | "alpha" + | "beta" + | "production" + | "any"; + +export interface EligibilityCriteria { + /** Geographic restrictions (e.g. "US", "EU", "Global"). Empty means no restriction. */ + eligibleLocations: string[]; + /** Who can apply. */ + applicantType: EligibilityApplicantType; + /** Minimum project stage required. */ + projectStage: EligibilityProjectStage; + /** Minimum team size (0 = no requirement). */ + minTeamSize: number; + /** Maximum team size (0 = no limit). */ + maxTeamSize: number; + /** Custom free-text requirements. */ + customRequirements: string[]; +} + +/** Default eligibility: open to everyone. */ +export const DEFAULT_ELIGIBILITY: EligibilityCriteria = { + eligibleLocations: [], + applicantType: "any", + projectStage: "any", + minTeamSize: 0, + maxTeamSize: 0, + customRequirements: [], +}; + +export interface EligibilityValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validate eligibility criteria input from a grant creator. + */ +export function validateEligibility( + criteria: Partial, +): EligibilityValidationResult { + const errors: string[] = []; + + if ( + criteria.applicantType && + !["individual", "organization", "team", "student", "any"].includes( + criteria.applicantType, + ) + ) { + errors.push(`Invalid applicantType: ${criteria.applicantType}`); + } + + if ( + criteria.projectStage && + !["idea", "prototype", "alpha", "beta", "production", "any"].includes( + criteria.projectStage, + ) + ) { + errors.push(`Invalid projectStage: ${criteria.projectStage}`); + } + + if ( + typeof criteria.minTeamSize === "number" && + criteria.minTeamSize < 0 + ) { + errors.push("minTeamSize cannot be negative."); + } + + if ( + typeof criteria.maxTeamSize === "number" && + criteria.maxTeamSize < 0 + ) { + errors.push("maxTeamSize cannot be negative."); + } + + if ( + typeof criteria.minTeamSize === "number" && + typeof criteria.maxTeamSize === "number" && + criteria.minTeamSize > 0 && + criteria.maxTeamSize > 0 && + criteria.minTeamSize > criteria.maxTeamSize + ) { + errors.push("minTeamSize cannot exceed maxTeamSize."); + } + + if ( + Array.isArray(criteria.eligibleLocations) + ) { + for (const loc of criteria.eligibleLocations) { + if (typeof loc !== "string" || loc.trim().length === 0) { + errors.push("Each eligible location must be a non-empty string."); + break; + } + } + } + + if ( + Array.isArray(criteria.customRequirements) + ) { + for (const req of criteria.customRequirements) { + if (typeof req !== "string" || req.trim().length === 0) { + errors.push("Each custom requirement must be a non-empty string."); + break; + } + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Merge partial eligibility input with defaults. + */ +export function normalizeEligibility( + input: Partial | undefined, +): EligibilityCriteria { + if (!input) return { ...DEFAULT_ELIGIBILITY }; + return { + eligibleLocations: Array.isArray(input.eligibleLocations) + ? input.eligibleLocations.map((l) => l.trim()).filter((l) => l.length > 0) + : [], + applicantType: input.applicantType ?? "any", + projectStage: input.projectStage ?? "any", + minTeamSize: typeof input.minTeamSize === "number" ? input.minTeamSize : 0, + maxTeamSize: typeof input.maxTeamSize === "number" ? input.maxTeamSize : 0, + customRequirements: Array.isArray(input.customRequirements) + ? input.customRequirements.map((r) => r.trim()).filter((r) => r.length > 0) + : [], + }; +} + +/** + * Format eligibility criteria for display on the grant details page. + * Returns a flat array of label/value pairs for rendering. + */ +export interface EligibilityDisplayItem { + label: string; + value: string; +} + +export function formatEligibilityForDisplay( + criteria: EligibilityCriteria, +): EligibilityDisplayItem[] { + const items: EligibilityDisplayItem[] = []; + + if (criteria.eligibleLocations.length > 0) { + items.push({ + label: "Eligible Locations", + value: criteria.eligibleLocations.join(", "), + }); + } + + if (criteria.applicantType !== "any") { + items.push({ + label: "Applicant Type", + value: criteria.applicantType.charAt(0).toUpperCase() + criteria.applicantType.slice(1), + }); + } + + if (criteria.projectStage !== "any") { + items.push({ + label: "Project Stage", + value: criteria.projectStage.charAt(0).toUpperCase() + criteria.projectStage.slice(1), + }); + } + + if (criteria.minTeamSize > 0 || criteria.maxTeamSize > 0) { + if (criteria.minTeamSize > 0 && criteria.maxTeamSize > 0) { + items.push({ + label: "Team Size", + value: `${criteria.minTeamSize}–${criteria.maxTeamSize} members`, + }); + } else if (criteria.minTeamSize > 0) { + items.push({ + label: "Min Team Size", + value: `${criteria.minTeamSize}+ members`, + }); + } else if (criteria.maxTeamSize > 0) { + items.push({ + label: "Max Team Size", + value: `Up to ${criteria.maxTeamSize} members`, + }); + } + } + + if (criteria.customRequirements.length > 0) { + items.push({ + label: "Additional Requirements", + value: criteria.customRequirements.join("; "), + }); + } + + return items; +} diff --git a/frontend/src/lib/moderation-queue.test.ts b/frontend/src/lib/moderation-queue.test.ts new file mode 100644 index 0000000..253e5c8 --- /dev/null +++ b/frontend/src/lib/moderation-queue.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + enqueueModeration, + listModerationItems, + approveModerationItem, + rejectModerationItem, + dismissModerationItem, + getModerationItem, + resetModerationStore, +} from "@/lib/moderation-queue"; + +beforeEach(() => { + resetModerationStore(); +}); + +describe("enqueueModeration", () => { + it("creates a pending item with generated ID", () => { + const item = enqueueModeration({ + type: "duplicate_flag", + targetId: "task-1", + title: "Duplicate task detected", + description: "Task 'Build DEX' may duplicate task #5", + reportedBy: "system", + severity: 3, + }); + expect(item.id).toBe("1"); + expect(item.status).toBe("pending"); + expect(item.createdAt).toBeDefined(); + }); +}); + +describe("listModerationItems", () => { + it("returns empty queue initially", () => { + const result = listModerationItems(); + expect(result.total).toBe(0); + expect(result.pending).toBe(0); + }); + + it("filters by pending status", () => { + enqueueModeration({ + type: "duplicate_flag", + targetId: "t1", + title: "Item 1", + description: "desc", + reportedBy: "system", + severity: 3, + }); + enqueueModeration({ + type: "user_report", + targetId: "t2", + title: "Item 2", + description: "desc", + reportedBy: "user1", + severity: 5, + }); + const result = listModerationItems({ status: "pending" }); + expect(result.total).toBe(2); + // Higher severity first + expect(result.items[0].title).toBe("Item 2"); + }); + + it("counts by status correctly", () => { + const item = enqueueModeration({ + type: "duplicate_flag", + targetId: "t1", + title: "Item 1", + description: "desc", + reportedBy: "system", + severity: 3, + }); + approveModerationItem(item.id, "admin1"); + const result = listModerationItems(); + expect(result.pending).toBe(0); + expect(result.approved).toBe(1); + }); +}); + +describe("approveModerationItem", () => { + it("approves a pending item", () => { + const item = enqueueModeration({ + type: "duplicate_flag", + targetId: "t1", + title: "Test", + description: "desc", + reportedBy: "system", + severity: 3, + }); + const result = approveModerationItem(item.id, "admin1", "Looks good"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.item.status).toBe("approved"); + expect(result.item.reviewedBy).toBe("admin1"); + expect(result.item.adminNote).toBe("Looks good"); + } + }); + + it("fails on non-existent item", () => { + const result = approveModerationItem("999", "admin1"); + expect(result.ok).toBe(false); + }); + + it("fails on already-reviewed item", () => { + const item = enqueueModeration({ + type: "duplicate_flag", + targetId: "t1", + title: "Test", + description: "desc", + reportedBy: "system", + severity: 3, + }); + approveModerationItem(item.id, "admin1"); + const result = approveModerationItem(item.id, "admin2"); + expect(result.ok).toBe(false); + }); +}); + +describe("rejectModerationItem", () => { + it("rejects a pending item", () => { + const item = enqueueModeration({ + type: "user_report", + targetId: "t1", + title: "Report", + description: "desc", + reportedBy: "user1", + severity: 4, + }); + const result = rejectModerationItem(item.id, "admin1", "Not valid"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.item.status).toBe("rejected"); + } + }); +}); + +describe("dismissModerationItem", () => { + it("dismisses a pending item", () => { + const item = enqueueModeration({ + type: "duplicate_flag", + targetId: "t1", + title: "Flag", + description: "desc", + reportedBy: "system", + severity: 1, + }); + const result = dismissModerationItem(item.id, "admin1"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.item.status).toBe("dismissed"); + } + }); +}); + +describe("getModerationItem", () => { + it("returns item by ID", () => { + const item = enqueueModeration({ + type: "duplicate_flag", + targetId: "t1", + title: "Test", + description: "desc", + reportedBy: "system", + severity: 3, + }); + const found = getModerationItem(item.id); + expect(found).toBeDefined(); + expect(found!.title).toBe("Test"); + }); + + it("returns undefined for non-existent ID", () => { + expect(getModerationItem("999")).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/moderation-queue.ts b/frontend/src/lib/moderation-queue.ts new file mode 100644 index 0000000..6d3e64a --- /dev/null +++ b/frontend/src/lib/moderation-queue.ts @@ -0,0 +1,208 @@ +/** + * Admin Moderation Queue — centralized interface for administrators + * to review pending grant/task submissions, updates, and user reports. + * + * Issue #159: Add an Admin Moderation Queue + * + * Moderation items are NOT auto-deleted. Admins can approve or reject. + * Moderation actions update the relevant record's status. + */ + +import type { TaskRecord, TaskStatus } from "@/types/task-workflow"; + +export type ModerationItemType = + | "task_created" + | "task_updated" + | "duplicate_flag" + | "user_report"; + +export type ModerationStatus = + | "pending" + | "approved" + | "rejected" + | "dismissed"; + +export interface ModerationItem { + id: string; + type: ModerationItemType; + /** ID of the entity being moderated (task ID, etc.). */ + targetId: string; + /** Title or label for display. */ + title: string; + /** Description of what needs review. */ + description: string; + /** Who reported/flagged this item (or "system" for auto-flags). */ + reportedBy: string; + /** When the item was created. */ + createdAt: string; + /** Current moderation status. */ + status: ModerationStatus; + /** Admin who reviewed this item (if reviewed). */ + reviewedBy?: string; + /** When the item was reviewed. */ + reviewedAt?: string; + /** Optional admin note. */ + adminNote?: string; + /** Severity level 1-5 (5 = highest). */ + severity: number; +} + +export interface ModerationQueueResult { + items: ModerationItem[]; + total: number; + pending: number; + approved: number; + rejected: number; + dismissed: number; +} + +// In-memory store (matches the project's pattern of in-memory Maps) +const moderationItems = new Map(); +let nextModerationId = 1; + +/** + * Enqueue a new item for moderation. + */ +export function enqueueModeration( + input: Omit, + now: Date = new Date(), +): ModerationItem { + const item: ModerationItem = { + ...input, + id: String(nextModerationId++), + createdAt: now.toISOString(), + status: "pending", + }; + moderationItems.set(item.id, item); + return item; +} + +/** + * List moderation items with optional status filter. + */ +export function listModerationItems( + filter?: { status?: ModerationStatus; type?: ModerationItemType }, +): ModerationQueueResult { + let items = Array.from(moderationItems.values()); + + if (filter?.status) { + items = items.filter((i) => i.status === filter.status); + } + if (filter?.type) { + items = items.filter((i) => i.type === filter.type); + } + + items.sort((a, b) => { + // Pending first, then by severity desc, then by date desc + if (a.status === "pending" && b.status !== "pending") return -1; + if (a.status !== "pending" && b.status === "pending") return 1; + if (a.severity !== b.severity) return b.severity - a.severity; + return b.createdAt.localeCompare(a.createdAt); + }); + + const counts = { + total: items.length, + pending: items.filter((i) => i.status === "pending").length, + approved: items.filter((i) => i.status === "approved").length, + rejected: items.filter((i) => i.status === "rejected").length, + dismissed: items.filter((i) => i.status === "dismissed").length, + }; + + return { items, ...counts }; +} + +/** + * Approve a moderation item. + */ +export function approveModerationItem( + id: string, + adminAddress: string, + note?: string, + now: Date = new Date(), +): { ok: true; item: ModerationItem } | { ok: false; error: string } { + const item = moderationItems.get(id); + if (!item) { + return { ok: false, error: "Moderation item not found." }; + } + if (item.status !== "pending") { + return { ok: false, error: `Item already ${item.status}.` }; + } + const updated: ModerationItem = { + ...item, + status: "approved", + reviewedBy: adminAddress, + reviewedAt: now.toISOString(), + adminNote: note, + }; + moderationItems.set(id, updated); + return { ok: true, item: updated }; +} + +/** + * Reject a moderation item. + */ +export function rejectModerationItem( + id: string, + adminAddress: string, + note?: string, + now: Date = new Date(), +): { ok: true; item: ModerationItem } | { ok: false; error: string } { + const item = moderationItems.get(id); + if (!item) { + return { ok: false, error: "Moderation item not found." }; + } + if (item.status !== "pending") { + return { ok: false, error: `Item already ${item.status}.` }; + } + const updated: ModerationItem = { + ...item, + status: "rejected", + reviewedBy: adminAddress, + reviewedAt: now.toISOString(), + adminNote: note, + }; + moderationItems.set(id, updated); + return { ok: true, item: updated }; +} + +/** + * Dismiss a moderation item (no action needed). + */ +export function dismissModerationItem( + id: string, + adminAddress: string, + note?: string, + now: Date = new Date(), +): { ok: true; item: ModerationItem } | { ok: false; error: string } { + const item = moderationItems.get(id); + if (!item) { + return { ok: false, error: "Moderation item not found." }; + } + if (item.status !== "pending") { + return { ok: false, error: `Item already ${item.status}.` }; + } + const updated: ModerationItem = { + ...item, + status: "dismissed", + reviewedBy: adminAddress, + reviewedAt: now.toISOString(), + adminNote: note, + }; + moderationItems.set(id, updated); + return { ok: true, item: updated }; +} + +/** + * Get a single moderation item by ID. + */ +export function getModerationItem(id: string): ModerationItem | undefined { + return moderationItems.get(id); +} + +/** + * Reset the moderation store (for testing). + */ +export function resetModerationStore(): void { + moderationItems.clear(); + nextModerationId = 1; +} diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts index e1a3337..a3aacd3 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -15,6 +15,8 @@ import { BROADCAST_USER_ID, createNotification, } from "@/lib/notification-store"; +import { detectDuplicates } from "@/lib/duplicate-detection"; +import { enqueueModeration, resetModerationStore } from "@/lib/moderation-queue"; export const MIN_TASK_REWARD = 1_000_000; export const MAX_TASK_DEADLINE_OFFSET_SECONDS = 365 * 24 * 60 * 60; @@ -121,6 +123,34 @@ export function createTask( taskSubmissions.set(id, []); contributorSubmissions.set(id, new Set()); + // Issue #154: Duplicate detection — flag but don't block creation + const dupResult = detectDuplicates( + { title: task.title, organization: task.organization, description: task.description }, + Array.from(tasks.values()).filter((t) => t.id !== id), + ); + if (dupResult.hasDuplicates) { + for (const match of dupResult.matches) { + enqueueModeration({ + type: "duplicate_flag", + targetId: id, + title: `Duplicate: "${task.title}"`, + description: `Potential duplicate of #${match.existingTaskId} "${match.existingTitle}" (${match.reason})`, + reportedBy: "system", + severity: match.confidence >= 0.8 ? 4 : 3, + }); + } + } + + // Issue #159: Enqueue newly created task for admin moderation review + enqueueModeration({ + type: "task_created", + targetId: id, + title: `New task: ${task.title}`, + description: `Posted by ${task.poster}. Reward: ${task.reward} stroops.`, + reportedBy: "system", + severity: 1, + }); + createNotification( { userId: BROADCAST_USER_ID, @@ -536,4 +566,6 @@ export function resetTaskWorkflowStore() { nextTaskId = 1; nextSubmissionId = 1; nextCommentId = 1; + // Also reset moderation queue (imported in this module) + resetModerationStore(); }