diff --git a/.gitignore b/.gitignore index 9382702..9bbd897 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Generated dependency vulnerability scan reports (local + CI artifacts) reports/ +# But allow the API route directory +!frontend/src/app/api/reports/ +!frontend/src/app/api/reports/** # Environment / secrets — never commit real credentials .env diff --git a/frontend/src/app/api/bookmarks/route.ts b/frontend/src/app/api/bookmarks/route.ts new file mode 100644 index 0000000..f21c1c0 --- /dev/null +++ b/frontend/src/app/api/bookmarks/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + addBookmark, + listBookmarks, + removeBookmarkByTask, + resetBookmarkStore, +} from "@/lib/bookmark-store"; + +/** + * GET /api/bookmarks?userId= + * 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; + 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=&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 }); +} diff --git a/frontend/src/app/api/comparison/route.ts b/frontend/src/app/api/comparison/route.ts new file mode 100644 index 0000000..edaf686 --- /dev/null +++ b/frontend/src/app/api/comparison/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createComparison, + getComparison, + addToComparison, + removeFromComparison, + clearComparison, +} from "@/lib/grant-comparison"; + +/** + * GET /api/comparison?userId= + * Get the user's current comparison set. + */ +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 comparison = getComparison(userId); + return NextResponse.json({ comparison }); +} + +/** + * POST /api/comparison + * Body: { userId, taskIds?, taskId?, action? } + * + * Actions: + * - "create" (default): create/replace comparison with taskIds array + * - "add": add taskId to comparison + * - "remove": remove taskId from comparison + * - "clear": clear comparison set + */ +export async function POST(request: NextRequest) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body." }, + { status: 400 }, + ); + } + + const userId = String(body.userId ?? "").trim(); + const action = String(body.action ?? "create"); + + if (!userId) { + return NextResponse.json( + { error: "userId is required." }, + { status: 400 }, + ); + } + + if (action === "clear") { + clearComparison(userId); + return NextResponse.json({ comparison: null }); + } + + if (action === "add" || action === "remove") { + const taskId = String(body.taskId ?? "").trim(); + if (!taskId) { + return NextResponse.json( + { error: "taskId is required for add/remove actions." }, + { status: 400 }, + ); + } + + const result = + action === "add" + ? addToComparison(userId, taskId) + : removeFromComparison(userId, taskId); + + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ comparison: result.comparison }); + } + + // default: create + const taskIds = Array.isArray(body.taskIds) + ? body.taskIds.map(String) + : []; + + const result = createComparison(userId, taskIds); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ comparison: result.comparison }); +} diff --git a/frontend/src/app/api/drafts/route.ts b/frontend/src/app/api/drafts/route.ts new file mode 100644 index 0000000..5e031bb --- /dev/null +++ b/frontend/src/app/api/drafts/route.ts @@ -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=&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; + 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=&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 }); +} diff --git a/frontend/src/app/api/export/route.ts b/frontend/src/app/api/export/route.ts new file mode 100644 index 0000000..890d19b --- /dev/null +++ b/frontend/src/app/api/export/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { exportTasksToCSV, generateExportFilename } from "@/lib/csv-export"; +import { listTasks } from "@/lib/task-workflow"; + +/** + * GET /api/export?format=csv&status=&difficulty= + * + * Exports grant data in CSV format. + * Sensitive fields (poster wallet address) are excluded by default. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const format = searchParams.get("format") ?? "csv"; + const status = searchParams.get("status"); + const difficulty = searchParams.get("difficulty"); + + if (format !== "csv") { + return NextResponse.json( + { error: "Only CSV format is supported." }, + { status: 400 }, + ); + } + + // Fetch all tasks (use large pageSize to get everything) + const result = listTasks({ pageSize: 50, page: 1 }); + + let tasks = result.tasks; + + // Apply filters + if (status) { + tasks = tasks.filter((t) => t.status === status); + } + if (difficulty) { + tasks = tasks.filter((t) => t.difficulty === difficulty); + } + + const csv = exportTasksToCSV(tasks); + const filename = generateExportFilename(); + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +} diff --git a/frontend/src/app/api/import/route.ts b/frontend/src/app/api/import/route.ts new file mode 100644 index 0000000..f17cab0 --- /dev/null +++ b/frontend/src/app/api/import/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + validateImportData, + parseCSV, + GRANT_IMPORT_SCHEMA, + type FieldSchema, +} from "@/lib/import-validation"; + +/** + * POST /api/import + * Body: { format: "csv" | "json", data: string | Record[] } + * + * Validates bulk grant data before import. + * Returns { valid, errors, totalRows, validCount, errorCount }. + */ +export async function POST(request: NextRequest) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body." }, + { status: 400 }, + ); + } + + const format = String(body.format ?? "json"); + const rawData = body.data; + + let rows: Record[] = []; + + if (format === "csv") { + if (typeof rawData !== "string") { + return NextResponse.json( + { error: "CSV format requires 'data' to be a string." }, + { status: 400 }, + ); + } + rows = parseCSV(rawData); + } else { + if (!Array.isArray(rawData)) { + return NextResponse.json( + { error: "JSON format requires 'data' to be an array of objects." }, + { status: 400 }, + ); + } + rows = rawData as Record[]; + } + + if (rows.length === 0) { + return NextResponse.json({ + valid: [], + errors: [], + totalRows: 0, + validCount: 0, + errorCount: 0, + }); + } + + // Use default schema (can be extended to accept custom schema in the future) + const result = validateImportData(rows, GRANT_IMPORT_SCHEMA); + + return NextResponse.json(result); +} 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/app/api/reminders/route.ts b/frontend/src/app/api/reminders/route.ts new file mode 100644 index 0000000..2930afe --- /dev/null +++ b/frontend/src/app/api/reminders/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getReminderSettings, + updateReminderSettings, + getPendingReminders, + type ReminderTiming, + VALID_TIMINGS, +} from "@/lib/deadline-reminder"; + +/** + * GET /api/reminders?userId= + * Get the user's reminder settings and pending reminders. + */ +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 settings = getReminderSettings(userId); + const pending = getPendingReminders(userId); + + return NextResponse.json({ settings, pendingReminders: pending }); +} + +/** + * PATCH /api/reminders + * Body: { userId, enabled?, timings? } + * Update reminder settings. + */ +export async function PATCH(request: NextRequest) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body." }, + { status: 400 }, + ); + } + + const userId = String(body.userId ?? "").trim(); + if (!userId) { + return NextResponse.json( + { error: "userId is required." }, + { status: 400 }, + ); + } + + const updates: { enabled?: boolean; timings?: ReminderTiming[] } = {}; + + if (typeof body.enabled === "boolean") { + updates.enabled = body.enabled; + } + + if (Array.isArray(body.timings)) { + const timings = body.timings.map(String) as ReminderTiming[]; + const invalid = timings.filter((t) => !VALID_TIMINGS.includes(t)); + if (invalid.length > 0) { + return NextResponse.json( + { error: `Invalid timings: ${invalid.join(", ")}. Valid: ${VALID_TIMINGS.join(", ")}` }, + { status: 400 }, + ); + } + updates.timings = timings; + } + + const result = updateReminderSettings(userId, updates); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json({ settings: result.settings }); +} diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts new file mode 100644 index 0000000..18d2389 --- /dev/null +++ b/frontend/src/app/api/reports/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + submitReport, + listReports, + type ReportStatus, + type ReportReason, + VALID_REPORT_REASONS, +} from "@/lib/grant-report-store"; + +/** + * GET /api/reports?status=&taskId= + * List reports, optionally filtered. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as ReportStatus | null; + const taskId = searchParams.get("taskId"); + + const result = listReports({ + status: status ?? undefined, + taskId: taskId ?? undefined, + }); + + return NextResponse.json(result); +} + +/** + * POST /api/reports + * Body: { taskId, reportedBy, reason, description } + * Submit a new grant report. + */ +export async function POST(request: NextRequest) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body." }, + { status: 400 }, + ); + } + + const taskId = String(body.taskId ?? "").trim(); + const reportedBy = String(body.reportedBy ?? "").trim(); + const reason = String(body.reason ?? "") as ReportReason; + const description = String(body.description ?? "").trim(); + + if (!VALID_REPORT_REASONS.includes(reason)) { + return NextResponse.json( + { error: `Invalid reason. Valid reasons: ${VALID_REPORT_REASONS.join(", ")}.` }, + { status: 400 }, + ); + } + + const result = submitReport({ taskId, reportedBy, reason, description }); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + + return NextResponse.json({ report: result.report }, { status: 201 }); +} diff --git a/frontend/src/app/api/submission-history/route.ts b/frontend/src/app/api/submission-history/route.ts new file mode 100644 index 0000000..9a74cdc --- /dev/null +++ b/frontend/src/app/api/submission-history/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getSubmissionHistory, + getSubmissionCount, + getSubmissionStatusBreakdown, + type SubmissionHistoryStatus, +} from "@/lib/submission-history"; + +/** + * GET /api/submission-history?userId=&status=&sort=&page=&pageSize= + * + * Returns the user's submission history with filtering, sorting, and pagination. + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const userId = searchParams.get("userId") ?? ""; + const status = (searchParams.get("status") ?? "all") as SubmissionHistoryStatus; + const sort = (searchParams.get("sort") ?? "newest") as + | "newest" + | "oldest" + | "status"; + const page = Number(searchParams.get("page")) || undefined; + const pageSize = Number(searchParams.get("pageSize")) || undefined; + + if (!userId.trim()) { + return NextResponse.json( + { error: "userId query parameter is required." }, + { status: 400 }, + ); + } + + const result = getSubmissionHistory({ userId, status, sort, page, pageSize }); + + // Include summary counts in the response + const totalCount = getSubmissionCount(userId); + const statusBreakdown = getSubmissionStatusBreakdown(userId); + + return NextResponse.json({ + ...result, + totalCount, + statusBreakdown, + }); +} diff --git a/frontend/src/lib/bookmark-store.test.ts b/frontend/src/lib/bookmark-store.test.ts new file mode 100644 index 0000000..3842749 --- /dev/null +++ b/frontend/src/lib/bookmark-store.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + addBookmark, + removeBookmark, + removeBookmarkByTask, + listBookmarks, + isBookmarked, + toggleBookmark, + resetBookmarkStore, +} from "@/lib/bookmark-store"; + +describe("bookmark-store", () => { + beforeEach(() => { + resetBookmarkStore(); + }); + + describe("addBookmark", () => { + it("creates a bookmark with valid input", () => { + const result = addBookmark("user1", "task1"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.bookmark.userId).toBe("user1"); + expect(result.bookmark.taskId).toBe("task1"); + expect(result.bookmark.id).toBeTruthy(); + expect(result.bookmark.createdAt).toBeTruthy(); + } + }); + + it("returns 400 for empty userId", () => { + const result = addBookmark("", "task1"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 for empty taskId", () => { + const result = addBookmark("user1", ""); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + } + }); + + it("returns 409 when bookmarking the same task twice", () => { + addBookmark("user1", "task1"); + const result = addBookmark("user1", "task1"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(409); + } + }); + + it("allows different users to bookmark the same task", () => { + addBookmark("user1", "task1"); + const result = addBookmark("user2", "task1"); + expect(result.ok).toBe(true); + }); + + it("allows same user to bookmark different tasks", () => { + addBookmark("user1", "task1"); + const result = addBookmark("user1", "task2"); + expect(result.ok).toBe(true); + }); + + it("trims whitespace from inputs", () => { + const result = addBookmark(" user1 ", " task1 "); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.bookmark.userId).toBe("user1"); + expect(result.bookmark.taskId).toBe("task1"); + } + }); + }); + + describe("removeBookmark", () => { + it("removes an existing bookmark", () => { + const addResult = addBookmark("user1", "task1"); + if (addResult.ok) { + const result = removeBookmark("user1", addResult.bookmark.id); + expect(result.ok).toBe(true); + } + }); + + it("returns 404 for non-existent bookmark", () => { + const result = removeBookmark("user1", "nonexistent"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(404); + } + }); + + it("returns 404 when removing another user's bookmark", () => { + const addResult = addBookmark("user1", "task1"); + if (addResult.ok) { + const result = removeBookmark("user2", addResult.bookmark.id); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(404); + } + } + }); + }); + + describe("removeBookmarkByTask", () => { + it("removes a bookmark by task ID", () => { + addBookmark("user1", "task1"); + const result = removeBookmarkByTask("user1", "task1"); + expect(result.ok).toBe(true); + expect(isBookmarked("user1", "task1")).toBe(false); + }); + + it("returns 404 when bookmark not found for task", () => { + const result = removeBookmarkByTask("user1", "task1"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(404); + } + }); + }); + + describe("listBookmarks", () => { + it("returns empty list for user with no bookmarks", () => { + const result = listBookmarks("user1"); + expect(result.total).toBe(0); + expect(result.bookmarks).toEqual([]); + }); + + it("returns bookmarks sorted newest first", () => { + const r1 = addBookmark("user1", "task1", new Date("2026-01-01")); + const r2 = addBookmark("user1", "task2", new Date("2026-01-02")); + const result = listBookmarks("user1"); + expect(result.total).toBe(2); + expect(result.bookmarks[0].taskId).toBe("task2"); + expect(result.bookmarks[1].taskId).toBe("task1"); + }); + + it("only returns bookmarks for the specified user", () => { + addBookmark("user1", "task1"); + addBookmark("user2", "task2"); + const result = listBookmarks("user1"); + expect(result.total).toBe(1); + expect(result.bookmarks[0].taskId).toBe("task1"); + }); + }); + + describe("isBookmarked", () => { + it("returns true for a bookmarked task", () => { + addBookmark("user1", "task1"); + expect(isBookmarked("user1", "task1")).toBe(true); + }); + + it("returns false for a non-bookmarked task", () => { + expect(isBookmarked("user1", "task1")).toBe(false); + }); + }); + + describe("toggleBookmark", () => { + it("adds a bookmark when not bookmarked", () => { + const result = toggleBookmark("user1", "task1"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.bookmarked).toBe(true); + } + }); + + it("removes the bookmark when already bookmarked", () => { + addBookmark("user1", "task1"); + const result = toggleBookmark("user1", "task1"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.bookmarked).toBe(false); + } + }); + }); +}); diff --git a/frontend/src/lib/bookmark-store.ts b/frontend/src/lib/bookmark-store.ts new file mode 100644 index 0000000..0e20637 --- /dev/null +++ b/frontend/src/lib/bookmark-store.ts @@ -0,0 +1,225 @@ +/** + * Issue #152: Implement Grant Bookmarking + * + * In-memory bookmark store following the same Map-based pattern as + * `task-workflow.ts` and `notification-store.ts`. + * + * Users can bookmark / unbookmark grants (tasks) and retrieve their + * saved list. Bookmarks persist for the lifetime of the server process + * (same as all other stores in this project). + */ + +/** A single bookmark record. */ +export interface BookmarkRecord { + id: string; + userId: string; + taskId: string; + createdAt: string; +} + +export interface BookmarkListResult { + bookmarks: BookmarkRecord[]; + total: number; +} + +type BookmarkSuccess = { ok: true } & T; + +type BookmarkFailure = { + ok: false; + status: 400 | 404 | 409; + error: string; +}; + +export type BookmarkResult = BookmarkSuccess | BookmarkFailure; + +// --- in-memory store --- + +const bookmarks = new Map(); +/** userId -> Set for O(1) lookups per user. */ +const userBookmarks = new Map>(); +/** userId -> Set to prevent duplicates. */ +const userTaskSet = new Map>(); + +let nextBookmarkId = 1; + +function getUserBookmarkSet(userId: string): Set { + let set = userBookmarks.get(userId); + if (!set) { + set = new Set(); + userBookmarks.set(userId, set); + } + return set; +} + +function getUserTaskSet(userId: string): Set { + let set = userTaskSet.get(userId); + if (!set) { + set = new Set(); + userTaskSet.set(userId, set); + } + return set; +} + +/** + * Add a bookmark for `taskId` on behalf of `userId`. + * Returns 409 if the user has already bookmarked this task. + */ +export function addBookmark( + userId: string, + taskId: string, + now: Date = new Date(), +): BookmarkResult<{ bookmark: BookmarkRecord }> { + const uid = userId.trim(); + const tid = taskId.trim(); + + if (!uid) { + return { ok: false, status: 400, error: "User ID is required." }; + } + if (!tid) { + return { ok: false, status: 400, error: "Task ID is required." }; + } + + const taskSet = getUserTaskSet(uid); + if (taskSet.has(tid)) { + return { + ok: false, + status: 409, + error: "Task is already bookmarked.", + }; + } + + const bookmark: BookmarkRecord = { + id: String(nextBookmarkId++), + userId: uid, + taskId: tid, + createdAt: now.toISOString(), + }; + + bookmarks.set(bookmark.id, bookmark); + getUserBookmarkSet(uid).add(bookmark.id); + taskSet.add(tid); + + return { ok: true, bookmark }; +} + +/** + * Remove a bookmark. Returns 404 if the bookmark doesn't exist or + * doesn't belong to `userId`. + */ +export function removeBookmark( + userId: string, + bookmarkId: string, +): BookmarkResult<{ deleted: true }> { + const uid = userId.trim(); + const bid = bookmarkId.trim(); + + if (!uid || !bid) { + return { ok: false, status: 400, error: "User ID and bookmark ID are required." }; + } + + const bookmark = bookmarks.get(bid); + if (!bookmark || bookmark.userId !== uid) { + return { ok: false, status: 404, error: "Bookmark not found." }; + } + + bookmarks.delete(bid); + const bset = userBookmarks.get(uid); + if (bset) bset.delete(bid); + const tset = userTaskSet.get(uid); + if (tset) tset.delete(bookmark.taskId); + + return { ok: true, deleted: true }; +} + +/** + * Remove a bookmark by task ID (useful for toggle UIs). + */ +export function removeBookmarkByTask( + userId: string, + taskId: string, +): BookmarkResult<{ deleted: true }> { + const uid = userId.trim(); + const tid = taskId.trim(); + + if (!uid || !tid) { + return { ok: false, status: 400, error: "User ID and task ID are required." }; + } + + const tset = userTaskSet.get(uid); + if (!tset || !tset.has(tid)) { + return { ok: false, status: 404, error: "Bookmark not found for this task." }; + } + + // Find the bookmark entry to remove + for (const [bid, bookmark] of bookmarks.entries()) { + if (bookmark.userId === uid && bookmark.taskId === tid) { + bookmarks.delete(bid); + const bset = userBookmarks.get(uid); + if (bset) bset.delete(bid); + tset.delete(tid); + return { ok: true, deleted: true }; + } + } + + // Shouldn't reach here, but clean up just in case + tset.delete(tid); + return { ok: true, deleted: true }; +} + +/** + * List all bookmarks for a user, newest first. + */ +export function listBookmarks(userId: string): BookmarkListResult { + const uid = userId.trim(); + const bset = userBookmarks.get(uid); + + if (!bset || bset.size === 0) { + return { bookmarks: [], total: 0 }; + } + + const userBookmarkRecords = Array.from(bset) + .map((bid) => bookmarks.get(bid)) + .filter((b): b is BookmarkRecord => b !== undefined) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return { bookmarks: userBookmarkRecords, total: userBookmarkRecords.length }; +} + +/** + * Check whether a user has bookmarked a specific task. + */ +export function isBookmarked(userId: string, taskId: string): boolean { + const tset = userTaskSet.get(userId.trim()); + return tset ? tset.has(taskId.trim()) : false; +} + +/** + * Toggle a bookmark on/off. Returns the new state. + */ +export function toggleBookmark( + userId: string, + taskId: string, + now: Date = new Date(), +): BookmarkResult<{ bookmarked: boolean; bookmark?: BookmarkRecord }> { + if (isBookmarked(userId, taskId)) { + const result = removeBookmarkByTask(userId, taskId); + if (result.ok) { + return { ok: true, bookmarked: false }; + } + return result; + } + + const result = addBookmark(userId, taskId, now); + if (result.ok) { + return { ok: true, bookmarked: true, bookmark: result.bookmark }; + } + return result; +} + +export function resetBookmarkStore() { + bookmarks.clear(); + userBookmarks.clear(); + userTaskSet.clear(); + nextBookmarkId = 1; +} + diff --git a/frontend/src/lib/csv-export.test.ts b/frontend/src/lib/csv-export.test.ts new file mode 100644 index 0000000..afa9f71 --- /dev/null +++ b/frontend/src/lib/csv-export.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { + escapeCSVValue, + taskToCSVRow, + exportTasksToCSV, + generateExportFilename, + getExportableFieldInfo, + EXPORTABLE_FIELDS, + EXCLUDED_FIELDS, +} from "@/lib/csv-export"; +import type { TaskRecord } from "@/types/task-workflow"; + +function makeTask(overrides: Partial = {}): TaskRecord { + return { + id: "1", + poster: "wallet_address_123", + title: "Test Grant", + description: "A test grant", + reward: 2_000_000, + deadline: 1893456000, + maxSubmissions: 5, + submissionCount: 2, + status: "open", + createdAt: "2026-01-01T00:00:00Z", + difficulty: "intermediate", + technologies: ["Rust", "Soroban"], + organization: "TestOrg", + ...overrides, + }; +} + +describe("csv-export", () => { + describe("escapeCSVValue", () => { + it("returns empty string for null/undefined", () => { + expect(escapeCSVValue(null)).toBe(""); + expect(escapeCSVValue(undefined)).toBe(""); + }); + + it("returns plain string without quotes", () => { + expect(escapeCSVValue("hello")).toBe("hello"); + }); + + it("quotes values with commas", () => { + expect(escapeCSVValue("hello, world")).toBe('"hello, world"'); + }); + + it("quotes values with quotes and escapes them", () => { + expect(escapeCSVValue('say "hi"')).toBe('"say ""hi"""'); + }); + + it("quotes values with newlines", () => { + expect(escapeCSVValue("line1\nline2")).toBe('"line1\nline2"'); + }); + }); + + describe("taskToCSVRow", () => { + it("converts a task to a CSV row", () => { + const task = makeTask(); + const row = taskToCSVRow(task, [...EXPORTABLE_FIELDS]); + expect(row).toContain("Test Grant"); + expect(row).toContain("2000000"); + }); + + it("joins technologies with semicolons", () => { + const task = makeTask({ technologies: ["Rust", "Soroban", "Stellar"] }); + const row = taskToCSVRow(task, [...EXPORTABLE_FIELDS]); + expect(row).toContain("Rust; Soroban; Stellar"); + }); + }); + + describe("exportTasksToCSV", () => { + it("generates CSV with header", () => { + const tasks = [makeTask(), makeTask({ id: "2", title: "Grant 2" })]; + const csv = exportTasksToCSV(tasks); + expect(csv.split("\n")[0]).toContain("id"); + expect(csv.split("\n")[0]).toContain("title"); + }); + + it("excludes poster (sensitive field) by default", () => { + const tasks = [makeTask()]; + const csv = exportTasksToCSV(tasks); + expect(csv).not.toContain("wallet_address_123"); + }); + + it("can skip header", () => { + const tasks = [makeTask()]; + const csv = exportTasksToCSV(tasks, { includeHeader: false }); + expect(csv.split("\n")[0]).not.toContain("id"); + }); + + it("supports filtering", () => { + const tasks = [ + makeTask({ id: "1", status: "open" }), + makeTask({ id: "2", status: "completed" }), + ]; + const csv = exportTasksToCSV(tasks, { + filter: (t) => t.status === "open", + }); + expect(csv).toContain("Test Grant"); + // Should only have one data row (plus header) + expect(csv.split("\n").length).toBe(2); + }); + + it("handles empty input", () => { + const csv = exportTasksToCSV([]); + expect(csv.split("\n").length).toBe(1); // just header + }); + }); + + describe("generateExportFilename", () => { + it("generates a filename with date", () => { + const filename = generateExportFilename("grants", new Date("2026-01-15")); + expect(filename).toBe("grants_export_2026-01-15.csv"); + }); + + it("uses default prefix", () => { + const filename = generateExportFilename(undefined, new Date("2026-01-15")); + expect(filename).toBe("grants_export_2026-01-15.csv"); + }); + }); + + describe("getExportableFieldInfo", () => { + it("returns field info array", () => { + const info = getExportableFieldInfo(); + expect(info.length).toBe(EXPORTABLE_FIELDS.length); + expect(info[0].field).toBe("id"); + expect(info[0].label).toBe("ID"); + }); + }); + + describe("EXCLUDED_FIELDS", () => { + it("includes poster", () => { + expect(EXCLUDED_FIELDS).toContain("poster"); + }); + }); +}); diff --git a/frontend/src/lib/csv-export.ts b/frontend/src/lib/csv-export.ts new file mode 100644 index 0000000..d6604cc --- /dev/null +++ b/frontend/src/lib/csv-export.ts @@ -0,0 +1,143 @@ +/** + * Issue #161: Add CSV Export for Grant Data + * + * Exports grant (task) records to CSV format. Sensitive or restricted + * information is excluded by default. + */ + +import type { TaskRecord } from "@/types/task-workflow"; + +/** Fields that are considered safe to export. */ +export const EXPORTABLE_FIELDS = [ + "id", + "title", + "description", + "reward", + "deadline", + "maxSubmissions", + "submissionCount", + "status", + "createdAt", + "difficulty", + "technologies", + "organization", +] as const; + +/** Fields that are explicitly excluded (sensitive/restricted). */ +export const EXCLUDED_FIELDS = [ + "poster", // wallet address — PII +] as const; + +export type ExportField = (typeof EXPORTABLE_FIELDS)[number]; + +export interface ExportOptions { + fields?: ExportField[]; + includeHeader?: boolean; + /** Optional filter to apply before exporting. */ + filter?: (task: TaskRecord) => boolean; +} + +/** + * Escape a value for CSV output. + * Wraps in quotes if it contains commas, quotes, or newlines. + */ +export function escapeCSVValue(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = String(value); + + if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) { + return `"${str.replace(/"/g, '""')}"`; + } + + return str; +} + +/** + * Convert a single TaskRecord to a CSV row string. + */ +export function taskToCSVRow( + task: TaskRecord, + fields: ExportField[], +): string { + return fields + .map((field) => { + const value = task[field]; + if (field === "technologies" && Array.isArray(value)) { + return escapeCSVValue(value.join("; ")); + } + return escapeCSVValue(value); + }) + .join(","); +} + +/** + * Export an array of TaskRecords to CSV string. + * + * Excludes sensitive fields (poster wallet address) by default. + * Custom field selection is supported via the `fields` option. + */ +export function exportTasksToCSV( + tasks: TaskRecord[], + options: ExportOptions = {}, +): string { + const fields = options.fields ?? [...EXPORTABLE_FIELDS]; + const includeHeader = options.includeHeader ?? true; + + // Verify no excluded fields are in the export + const safeFields = fields.filter( + (f) => !EXCLUDED_FIELDS.includes(f as never), + ); + + let filtered = tasks; + if (options.filter) { + filtered = tasks.filter(options.filter); + } + + const rows: string[] = []; + + if (includeHeader) { + rows.push(safeFields.join(",")); + } + + for (const task of filtered) { + rows.push(taskToCSVRow(task, safeFields)); + } + + return rows.join("\n"); +} + +/** + * Generate a filename for the CSV download. + */ +export function generateExportFilename( + prefix: string = "grants", + now: Date = new Date(), +): string { + const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD + return `${prefix}_export_${dateStr}.csv`; +} + +/** + * Get the list of available export fields with descriptions. + * Useful for UI rendering. + */ +export function getExportableFieldInfo(): Array<{ + field: ExportField; + label: string; + description: string; +}> { + return [ + { field: "id", label: "ID", description: "Unique grant identifier" }, + { field: "title", label: "Title", description: "Grant title" }, + { field: "description", label: "Description", description: "Grant description" }, + { field: "reward", label: "Reward", description: "Reward amount in stroops" }, + { field: "deadline", label: "Deadline", description: "Unix timestamp of deadline" }, + { field: "maxSubmissions", label: "Max Submissions", description: "Maximum allowed submissions" }, + { field: "submissionCount", label: "Submission Count", description: "Current number of submissions" }, + { field: "status", label: "Status", description: "Current grant status" }, + { field: "createdAt", label: "Created At", description: "Creation timestamp" }, + { field: "difficulty", label: "Difficulty", description: "Difficulty level" }, + { field: "technologies", label: "Technologies", description: "Required technologies" }, + { field: "organization", label: "Organization", description: "Posting organization" }, + ]; +} diff --git a/frontend/src/lib/deadline-reminder.test.ts b/frontend/src/lib/deadline-reminder.test.ts new file mode 100644 index 0000000..52708b0 --- /dev/null +++ b/frontend/src/lib/deadline-reminder.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getReminderSettings, + updateReminderSettings, + scheduleReminders, + processDueReminders, + getPendingReminders, + cancelRemindersForTask, + resetDeadlineReminderStore, + DEFAULT_TIMINGS, + VALID_TIMINGS, + TIMING_CONFIG, +} from "@/lib/deadline-reminder"; + +describe("deadline-reminder", () => { + beforeEach(() => { + resetDeadlineReminderStore(); + }); + + describe("getReminderSettings", () => { + it("returns default settings for new user", () => { + const settings = getReminderSettings("user1"); + expect(settings.enabled).toBe(true); + expect(settings.timings).toEqual(DEFAULT_TIMINGS); + }); + + it("returns the same settings object on subsequent calls", () => { + const s1 = getReminderSettings("user1"); + const s2 = getReminderSettings("user1"); + expect(s1).toBe(s2); + }); + }); + + describe("updateReminderSettings", () => { + it("updates enabled flag", () => { + const result = updateReminderSettings("user1", { enabled: false }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.settings.enabled).toBe(false); + } + }); + + it("updates timings", () => { + const result = updateReminderSettings("user1", { timings: ["1d", "14d"] }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.settings.timings).toEqual(["1d", "14d"]); + } + }); + + it("deduplicates timings", () => { + const result = updateReminderSettings("user1", { timings: ["1d", "1d", "3d"] }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.settings.timings).toEqual(["1d", "3d"]); + } + }); + + it("returns 400 for invalid timing", () => { + const result = updateReminderSettings("user1", { timings: ["1d", "fake" as never] }); + expect(result.ok).toBe(false); + }); + }); + + describe("scheduleReminders", () => { + it("schedules reminders based on user settings", () => { + const futureDeadline = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; // 30 days + const result = scheduleReminders( + { id: "t1", title: "Task 1", deadline: futureDeadline }, + "user1", + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.scheduled).toBe(3); // DEFAULT_TIMINGS = 7d, 3d, 1d + } + }); + + it("schedules 0 reminders for expired tasks", () => { + const pastDeadline = Math.floor(Date.now() / 1000) - 100; + const result = scheduleReminders( + { id: "t1", title: "Task 1", deadline: pastDeadline }, + "user1", + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.scheduled).toBe(0); + } + }); + + it("schedules 0 reminders when user has reminders disabled", () => { + updateReminderSettings("user1", { enabled: false }); + const futureDeadline = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; + const result = scheduleReminders( + { id: "t1", title: "Task 1", deadline: futureDeadline }, + "user1", + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.scheduled).toBe(0); + } + }); + + it("does not create duplicate reminders", () => { + const futureDeadline = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; + scheduleReminders({ id: "t1", title: "Task 1", deadline: futureDeadline }, "user1"); + const result = scheduleReminders( + { id: "t1", title: "Task 1", deadline: futureDeadline }, + "user1", + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.scheduled).toBe(0); + } + }); + }); + + describe("processDueReminders", () => { + it("sends notifications for due reminders", () => { + const reminderTime = Math.floor(Date.now() / 1000) - 100; // already passed + const futureDeadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour + scheduleReminders( + { id: "t1", title: "Task 1", deadline: futureDeadline }, + "user1", + ); + + // Override reminderTime to be in the past + // We manually call processDueReminders which should find unsent reminders + // with reminderTime <= now + const pending = getPendingReminders("user1"); + // The 1d reminder (24h before deadline) should have reminderTime in the past + // since deadline is only 1h away + expect(pending.length).toBeGreaterThan(0); + + const result = processDueReminders(); + expect(result.processed).toBeGreaterThan(0); + }); + + it("marks expired task reminders without sending", () => { + const pastDeadline = Math.floor(Date.now() / 1000) - 100; + // Manually create a reminder for an expired task + scheduleReminders( + { id: "t1", title: "Task 1", deadline: pastDeadline + 200 }, + "user1", + ); + // This won't schedule because deadline is in the past + // So let's test with a very close deadline + const closeDeadline = Math.floor(Date.now() / 1000) - 50; + const result = scheduleReminders( + { id: "t2", title: "Task 2", deadline: closeDeadline }, + "user2", + ); + // No reminders scheduled for expired tasks + if (result.ok) { + expect(result.scheduled).toBe(0); + } + }); + }); + + describe("cancelRemindersForTask", () => { + it("cancels unsent reminders for a task", () => { + const futureDeadline = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; + scheduleReminders( + { id: "t1", title: "Task 1", deadline: futureDeadline }, + "user1", + ); + const cancelled = cancelRemindersForTask("user1", "t1"); + expect(cancelled).toBe(3); // 3 default timings + expect(getPendingReminders("user1").length).toBe(0); + }); + + it("returns 0 when no reminders exist", () => { + expect(cancelRemindersForTask("user1", "t1")).toBe(0); + }); + }); + + describe("constants", () => { + it("exports VALID_TIMINGS", () => { + expect(VALID_TIMINGS).toEqual(["1d", "3d", "7d", "14d", "30d"]); + }); + + it("TIMING_CONFIG has correct hoursBefore values", () => { + expect(TIMING_CONFIG["1d"].hoursBefore).toBe(24); + expect(TIMING_CONFIG["3d"].hoursBefore).toBe(72); + expect(TIMING_CONFIG["7d"].hoursBefore).toBe(168); + }); + }); +}); diff --git a/frontend/src/lib/deadline-reminder.ts b/frontend/src/lib/deadline-reminder.ts new file mode 100644 index 0000000..21f8899 --- /dev/null +++ b/frontend/src/lib/deadline-reminder.ts @@ -0,0 +1,304 @@ +/** + * Issue #151: Add Deadline Reminder Notifications + * + * Creates a deadline reminder system that notifies users when a + * saved or active grant deadline is approaching. Integrates with + * the existing notification-store and bookmark-store. + */ + +import { createNotification } from "@/lib/notification-store"; +import type { TaskRecord } from "@/types/task-workflow"; + +export type ReminderTiming = "1d" | "3d" | "7d" | "14d" | "30d"; + +export interface ReminderConfig { + /** Hours before deadline to send reminder. */ + hoursBefore: number; + label: string; +} + +export interface DeadlineReminder { + id: string; + taskId: string; + taskTitle: string; + userId: string; + deadline: number; + reminderTime: number; + sent: boolean; + sentAt: string | null; + createdAt: string; +} + +export interface ReminderSettings { + userId: string; + enabled: boolean; + timings: ReminderTiming[]; +} + +type ReminderSuccess = { ok: true } & T; + +type ReminderFailure = { + ok: false; + status: 400 | 404; + error: string; +}; + +export type ReminderResult = ReminderSuccess | ReminderFailure; + +/** Default reminder timings: 7 days, 3 days, and 1 day before deadline. */ +export const DEFAULT_TIMINGS: ReminderTiming[] = ["7d", "3d", "1d"]; + +export const TIMING_CONFIG: Record = { + "30d": { hoursBefore: 30 * 24, label: "30 days" }, + "14d": { hoursBefore: 14 * 24, label: "14 days" }, + "7d": { hoursBefore: 7 * 24, label: "7 days" }, + "3d": { hoursBefore: 3 * 24, label: "3 days" }, + "1d": { hoursBefore: 1 * 24, label: "1 day" }, +}; + +export const VALID_TIMINGS: ReminderTiming[] = ["1d", "3d", "7d", "14d", "30d"]; + +// --- in-memory stores --- + +const reminders = new Map(); +const userReminders = new Map>(); +const userSettings = new Map(); +let nextReminderId = 1; + +function getUserReminderSet(userId: string): Set { + let set = userReminders.get(userId); + if (!set) { + set = new Set(); + userReminders.set(userId, set); + } + return set; +} + +/** + * Get or create default reminder settings for a user. + */ +export function getReminderSettings(userId: string): ReminderSettings { + const uid = userId.trim(); + let settings = userSettings.get(uid); + if (!settings) { + settings = { + userId: uid, + enabled: true, + timings: [...DEFAULT_TIMINGS], + }; + userSettings.set(uid, settings); + } + return settings; +} + +/** + * Update a user's reminder settings. + */ +export function updateReminderSettings( + userId: string, + updates: { enabled?: boolean; timings?: ReminderTiming[] }, +): ReminderResult<{ settings: ReminderSettings }> { + const uid = userId.trim(); + if (!uid) { + return { ok: false, status: 400, error: "User ID is required." }; + } + + const current = getReminderSettings(uid); + + if (updates.timings) { + const invalid = updates.timings.filter((t) => !VALID_TIMINGS.includes(t)); + if (invalid.length > 0) { + return { + ok: false, + status: 400, + error: `Invalid timings: ${invalid.join(", ")}. Valid: ${VALID_TIMINGS.join(", ")}`, + }; + } + current.timings = [...new Set(updates.timings)]; + } + + if (typeof updates.enabled === "boolean") { + current.enabled = updates.enabled; + } + + userSettings.set(uid, current); + return { ok: true, settings: current }; +} + +/** + * Schedule reminders for a task. Called when a user bookmarks a task + * or when a new task is created. + */ +export function scheduleReminders( + task: Pick, + userId: string, + now: Date = new Date(), +): ReminderResult<{ scheduled: number }> { + const uid = userId.trim(); + if (!uid) { + return { ok: false, status: 400, error: "User ID is required." }; + } + + const settings = getReminderSettings(uid); + if (!settings.enabled) { + return { ok: true, scheduled: 0 }; + } + + const nowSeconds = Math.floor(now.getTime() / 1000); + + // Don't schedule reminders for already-expired tasks + if (task.deadline <= nowSeconds) { + return { ok: true, scheduled: 0 }; + } + + const rset = getUserReminderSet(uid); + let scheduled = 0; + + for (const timing of settings.timings) { + const config = TIMING_CONFIG[timing]; + const reminderTime = task.deadline - config.hoursBefore * 3600; + + // Skip if reminder time has already passed + if (reminderTime <= nowSeconds) continue; + + // Check if a reminder for this task+timing already exists + const exists = Array.from(rset) + .map((id) => reminders.get(id)) + .some( + (r) => + r && + r.taskId === task.id && + r.userId === uid && + Math.abs(r.reminderTime - reminderTime) < 3600, + ); + if (exists) continue; + + const reminder: DeadlineReminder = { + id: String(nextReminderId++), + taskId: task.id, + taskTitle: task.title, + userId: uid, + deadline: task.deadline, + reminderTime, + sent: false, + sentAt: null, + createdAt: now.toISOString(), + }; + + reminders.set(reminder.id, reminder); + rset.add(reminder.id); + scheduled++; + } + + return { ok: true, scheduled }; +} + +/** + * Process due reminders: send notifications for reminders whose time has come. + * Should be called periodically (e.g., every minute). + */ +export function processDueReminders( + now: Date = new Date(), +): { processed: number; expired: number } { + const nowSeconds = Math.floor(now.getTime() / 1000); + let processed = 0; + let expired = 0; + + for (const [id, reminder] of reminders.entries()) { + if (reminder.sent) continue; + + // If the deadline has passed, mark as expired (don't send) + if (reminder.deadline <= nowSeconds) { + const updated: DeadlineReminder = { + ...reminder, + sent: true, + sentAt: now.toISOString(), + }; + reminders.set(id, updated); + expired++; + continue; + } + + // If reminder time has arrived, send notification + if (reminder.reminderTime <= nowSeconds) { + const hoursLeft = Math.ceil( + (reminder.deadline - nowSeconds) / 3600, + ); + + let message: string; + if (hoursLeft >= 24) { + const daysLeft = Math.ceil(hoursLeft / 24); + message = `"${reminder.taskTitle}" deadline is in ${daysLeft} day(s).`; + } else { + message = `"${reminder.taskTitle}" deadline is in ${hoursLeft} hour(s)!`; + } + + createNotification( + { + userId: reminder.userId, + type: "comment_added", + title: "Deadline approaching", + message, + taskId: reminder.taskId, + }, + now, + ); + + const updated: DeadlineReminder = { + ...reminder, + sent: true, + sentAt: now.toISOString(), + }; + reminders.set(id, updated); + processed++; + } + } + + return { processed, expired }; +} + +/** + * Get all pending (unsent) reminders for a user. + */ +export function getPendingReminders(userId: string): DeadlineReminder[] { + const uid = userId.trim(); + const rset = userReminders.get(uid); + if (!rset) return []; + + return Array.from(rset) + .map((id) => reminders.get(id)) + .filter((r): r is DeadlineReminder => r !== undefined && !r.sent) + .sort((a, b) => a.reminderTime - b.reminderTime); +} + +/** + * Cancel all reminders for a specific task+user (e.g., when unbookmarking). + */ +export function cancelRemindersForTask( + userId: string, + taskId: string, +): number { + const uid = userId.trim(); + const tid = taskId.trim(); + const rset = userReminders.get(uid); + if (!rset) return 0; + + let cancelled = 0; + for (const id of Array.from(rset)) { + const reminder = reminders.get(id); + if (reminder && reminder.taskId === tid && !reminder.sent) { + reminders.delete(id); + rset.delete(id); + cancelled++; + } + } + + return cancelled; +} + +export function resetDeadlineReminderStore() { + reminders.clear(); + userReminders.clear(); + userSettings.clear(); + nextReminderId = 1; +} diff --git a/frontend/src/lib/draft-autosave.test.ts b/frontend/src/lib/draft-autosave.test.ts new file mode 100644 index 0000000..3f4a349 --- /dev/null +++ b/frontend/src/lib/draft-autosave.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + saveDraft, + getDraft, + getLastSavedAt, + listDrafts, + deleteDraft, + deleteDraftByTask, + resetDraftStore, + DEFAULT_AUTOSAVE_INTERVAL_MS, + MAX_FORM_DATA_SIZE, +} from "@/lib/draft-autosave"; + +describe("draft-autosave", () => { + beforeEach(() => { + resetDraftStore(); + }); + + describe("saveDraft", () => { + it("creates a new draft", () => { + const result = saveDraft({ + userId: "user1", + taskId: "task1", + formData: '{"title":"My Application"}', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.draft.userId).toBe("user1"); + expect(result.draft.taskId).toBe("task1"); + expect(result.draft.formData).toBe('{"title":"My Application"}'); + expect(result.draft.autoSaved).toBe(true); + expect(result.draft.lastSavedAt).toBeTruthy(); + } + }); + + it("updates an existing draft (upsert) for same user+task", () => { + saveDraft({ + userId: "user1", + taskId: "task1", + formData: "version1", + }); + const result = saveDraft({ + userId: "user1", + taskId: "task1", + formData: "version2", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.draft.formData).toBe("version2"); + } + const list = listDrafts("user1"); + expect(list.total).toBe(1); + }); + + it("preserves createdAt on update but changes lastSavedAt", () => { + const r1 = saveDraft({ + userId: "user1", + taskId: "task1", + formData: "v1", + }, new Date("2026-01-01")); + const r2 = saveDraft({ + userId: "user1", + taskId: "task1", + formData: "v2", + }, new Date("2026-01-02")); + expect(r2.ok).toBe(true); + if (r1.ok && r2.ok) { + expect(r2.draft.createdAt).toBe(r1.draft.createdAt); + expect(r2.draft.lastSavedAt).not.toBe(r1.draft.lastSavedAt); + expect(r2.draft.lastSavedAt).toBe("2026-01-02T00:00:00.000Z"); + } + }); + + it("returns 400 for empty userId", () => { + const result = saveDraft({ + userId: "", + taskId: "task1", + formData: "data", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it("returns 400 for empty taskId", () => { + const result = saveDraft({ + userId: "user1", + taskId: "", + formData: "data", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it("returns 400 when form data exceeds max size", () => { + const result = saveDraft({ + userId: "user1", + taskId: "task1", + formData: "x".repeat(MAX_FORM_DATA_SIZE + 1), + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it("sets autoSaved to false when autoSaved is false", () => { + const result = saveDraft({ + userId: "user1", + taskId: "task1", + formData: "data", + autoSaved: false, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.draft.autoSaved).toBe(false); + } + }); + }); + + describe("getDraft", () => { + it("retrieves a saved draft", () => { + saveDraft({ userId: "user1", taskId: "task1", formData: "data" }); + const draft = getDraft("user1", "task1"); + expect(draft).not.toBeNull(); + expect(draft!.formData).toBe("data"); + }); + + it("returns null for non-existent draft", () => { + const draft = getDraft("user1", "task1"); + expect(draft).toBeNull(); + }); + }); + + describe("getLastSavedAt", () => { + it("returns the last saved timestamp", () => { + saveDraft( + { userId: "user1", taskId: "task1", formData: "data" }, + new Date("2026-01-01T12:00:00Z"), + ); + const ts = getLastSavedAt("user1", "task1"); + expect(ts).toBe("2026-01-01T12:00:00.000Z"); + }); + + it("returns null when no draft exists", () => { + expect(getLastSavedAt("user1", "task1")).toBeNull(); + }); + }); + + describe("listDrafts", () => { + it("returns all drafts for a user sorted newest first", () => { + saveDraft({ userId: "user1", taskId: "t1", formData: "d1" }, new Date("2026-01-01")); + saveDraft({ userId: "user1", taskId: "t2", formData: "d2" }, new Date("2026-01-02")); + const result = listDrafts("user1"); + expect(result.total).toBe(2); + expect(result.drafts[0].taskId).toBe("t2"); + expect(result.drafts[1].taskId).toBe("t1"); + }); + + it("returns empty for user with no drafts", () => { + const result = listDrafts("nobody"); + expect(result.total).toBe(0); + expect(result.drafts).toEqual([]); + }); + }); + + describe("deleteDraft", () => { + it("deletes a draft by ID", () => { + const saveResult = saveDraft({ userId: "user1", taskId: "task1", formData: "data" }); + if (saveResult.ok) { + const result = deleteDraft("user1", saveResult.draft.id); + expect(result.ok).toBe(true); + expect(getDraft("user1", "task1")).toBeNull(); + } + }); + + it("returns 404 for non-existent draft", () => { + const result = deleteDraft("user1", "nonexistent"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(404); + }); + + it("returns 404 when deleting another user's draft", () => { + const saveResult = saveDraft({ userId: "user1", taskId: "task1", formData: "data" }); + if (saveResult.ok) { + const result = deleteDraft("user2", saveResult.draft.id); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(404); + } + }); + }); + + describe("deleteDraftByTask", () => { + it("deletes a draft by user+task pair", () => { + saveDraft({ userId: "user1", taskId: "task1", formData: "data" }); + const result = deleteDraftByTask("user1", "task1"); + expect(result.ok).toBe(true); + expect(getDraft("user1", "task1")).toBeNull(); + }); + + it("returns 404 for non-existent draft", () => { + const result = deleteDraftByTask("user1", "task1"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(404); + }); + }); + + describe("constants", () => { + it("exports DEFAULT_AUTOSAVE_INTERVAL_MS", () => { + expect(DEFAULT_AUTOSAVE_INTERVAL_MS).toBe(30_000); + }); + + it("exports MAX_FORM_DATA_SIZE as 1MB", () => { + expect(MAX_FORM_DATA_SIZE).toBe(1_048_576); + }); + }); +}); diff --git a/frontend/src/lib/draft-autosave.ts b/frontend/src/lib/draft-autosave.ts new file mode 100644 index 0000000..6023b78 --- /dev/null +++ b/frontend/src/lib/draft-autosave.ts @@ -0,0 +1,241 @@ +/** + * Issue #149: Add Draft Auto-Save for Grant Applications + * + * In-memory draft store that automatically saves a user's grant + * application form data at periodic intervals. Drafts persist for the + * server process lifetime and can be resumed by the user at any time. + * + * Design: + * - `saveDraft()` is idempotent: calling it with the same userId+taskId + * updates the existing draft rather than creating duplicates. + * - `getDraft()` retrieves the latest saved draft for a user+task pair. + * - `listDrafts()` returns all drafts for a user. + * - `deleteDraft()` manually removes a draft. + * - `getLastSavedAt()` returns when the draft was last saved (for UI display). + */ + +export interface DraftRecord { + id: string; + userId: string; + taskId: string; + /** Arbitrary form data serialized as a JSON string. */ + formData: string; + /** Auto-save indicator: true if saved by the auto-save timer, false if manual. */ + autoSaved: boolean; + lastSavedAt: string; + createdAt: string; +} + +export interface DraftListResult { + drafts: DraftRecord[]; + total: number; +} + +type DraftSuccess = { ok: true } & T; + +type DraftFailure = { + ok: false; + status: 400 | 404; + error: string; +}; + +export type DraftResult = DraftSuccess | DraftFailure; + +/** Default auto-save interval in milliseconds (30 seconds). */ +export const DEFAULT_AUTOSAVE_INTERVAL_MS = 30_000; + +/** Maximum form data size (1 MB to prevent abuse). */ +export const MAX_FORM_DATA_SIZE = 1_048_576; + +// --- in-memory store --- + +const drafts = new Map(); +/** userId+taskId -> draftId for O(1) upserts */ +const userTaskDrafts = new Map(); +/** userId -> Set */ +const userDrafts = new Map>(); + +let nextDraftId = 1; + +function draftKey(userId: string, taskId: string): string { + return `${userId}::${taskId}`; +} + +function getUserDraftSet(userId: string): Set { + let set = userDrafts.get(userId); + if (!set) { + set = new Set(); + userDrafts.set(userId, set); + } + return set; +} + +/** + * Save (create or update) a draft for a user+task pair. + * If a draft already exists for this pair, it is updated in place. + */ +export function saveDraft( + input: { + userId: string; + taskId: string; + formData: string; + autoSaved?: boolean; + }, + now: Date = new Date(), +): DraftResult<{ draft: DraftRecord }> { + const userId = input.userId.trim(); + const taskId = input.taskId.trim(); + + if (!userId) { + return { ok: false, status: 400, error: "User ID is required." }; + } + if (!taskId) { + return { ok: false, status: 400, error: "Task ID is required." }; + } + if (input.formData.length > MAX_FORM_DATA_SIZE) { + return { + ok: false, + status: 400, + error: `Form data exceeds maximum size of ${MAX_FORM_DATA_SIZE} bytes.`, + }; + } + + const key = draftKey(userId, taskId); + const existingId = userTaskDrafts.get(key); + + if (existingId) { + const existing = drafts.get(existingId); + if (existing) { + const updated: DraftRecord = { + ...existing, + formData: input.formData, + autoSaved: input.autoSaved ?? true, + lastSavedAt: now.toISOString(), + }; + drafts.set(existingId, updated); + return { ok: true, draft: updated }; + } + } + + const draft: DraftRecord = { + id: String(nextDraftId++), + userId, + taskId, + formData: input.formData, + autoSaved: input.autoSaved ?? true, + lastSavedAt: now.toISOString(), + createdAt: now.toISOString(), + }; + + drafts.set(draft.id, draft); + userTaskDrafts.set(key, draft.id); + getUserDraftSet(userId).add(draft.id); + + return { ok: true, draft }; +} + +/** + * Retrieve the latest saved draft for a user+task pair. + */ +export function getDraft( + userId: string, + taskId: string, +): DraftRecord | null { + const key = draftKey(userId.trim(), taskId.trim()); + const draftId = userTaskDrafts.get(key); + if (!draftId) return null; + return drafts.get(draftId) ?? null; +} + +/** + * Get the last-saved timestamp for a user+task pair. + * Returns null if no draft exists. + */ +export function getLastSavedAt( + userId: string, + taskId: string, +): string | null { + const draft = getDraft(userId, taskId); + return draft ? draft.lastSavedAt : null; +} + +/** + * List all drafts for a user, newest first. + */ +export function listDrafts(userId: string): DraftListResult { + const uid = userId.trim(); + const dset = userDrafts.get(uid); + if (!dset || dset.size === 0) { + return { drafts: [], total: 0 }; + } + + const userDraftRecords = Array.from(dset) + .map((did) => drafts.get(did)) + .filter((d): d is DraftRecord => d !== undefined) + .sort((a, b) => b.lastSavedAt.localeCompare(a.lastSavedAt)); + + return { drafts: userDraftRecords, total: userDraftRecords.length }; +} + +/** + * Delete a draft by ID. + */ +export function deleteDraft( + userId: string, + draftId: string, +): DraftResult<{ deleted: true }> { + const uid = userId.trim(); + const did = draftId.trim(); + + if (!uid || !did) { + return { ok: false, status: 400, error: "User ID and draft ID are required." }; + } + + const draft = drafts.get(did); + if (!draft || draft.userId !== uid) { + return { ok: false, status: 404, error: "Draft not found." }; + } + + drafts.delete(did); + userTaskDrafts.delete(draftKey(uid, draft.taskId)); + const dset = userDrafts.get(uid); + if (dset) dset.delete(did); + + return { ok: true, deleted: true }; +} + +/** + * Delete a draft by user+task pair (useful for form cleanup after submission). + */ +export function deleteDraftByTask( + userId: string, + taskId: string, +): DraftResult<{ deleted: true }> { + const uid = userId.trim(); + const tid = taskId.trim(); + + if (!uid || !tid) { + return { ok: false, status: 400, error: "User ID and task ID are required." }; + } + + const key = draftKey(uid, tid); + const draftId = userTaskDrafts.get(key); + + if (!draftId) { + return { ok: false, status: 404, error: "Draft not found for this task." }; + } + + drafts.delete(draftId); + userTaskDrafts.delete(key); + const dset = userDrafts.get(uid); + if (dset) dset.delete(draftId); + + return { ok: true, deleted: true }; +} + +export function resetDraftStore() { + drafts.clear(); + userTaskDrafts.clear(); + userDrafts.clear(); + nextDraftId = 1; +} 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/grant-comparison.test.ts b/frontend/src/lib/grant-comparison.test.ts new file mode 100644 index 0000000..9b19d5b --- /dev/null +++ b/frontend/src/lib/grant-comparison.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + createComparison, + getComparison, + addToComparison, + removeFromComparison, + clearComparison, + resetComparisonStore, + MAX_COMPARISON_SIZE, +} from "@/lib/grant-comparison"; + +describe("grant-comparison", () => { + beforeEach(() => { + resetComparisonStore(); + }); + + describe("createComparison", () => { + it("creates a comparison with valid input", () => { + const result = createComparison("user1", ["task1", "task2"]); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.comparison.taskIds).toEqual(["task1", "task2"]); + } + }); + + it("returns 400 for fewer than 2 tasks", () => { + const result = createComparison("user1", ["task1"]); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it("returns 400 for more than MAX_COMPARISON_SIZE tasks", () => { + const tasks = Array.from({ length: MAX_COMPARISON_SIZE + 1 }, (_, i) => `t${i}`); + const result = createComparison("user1", tasks); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(400); + }); + + it("deduplicates task IDs", () => { + const result = createComparison("user1", ["task1", "task1", "task2"]); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.comparison.taskIds).toEqual(["task1", "task2"]); + } + }); + + it("returns 400 for empty userId", () => { + const result = createComparison("", ["t1", "t2"]); + expect(result.ok).toBe(false); + }); + }); + + describe("getComparison", () => { + it("returns null when no comparison exists", () => { + expect(getComparison("user1")).toBeNull(); + }); + + it("returns the created comparison", () => { + createComparison("user1", ["t1", "t2"]); + const result = getComparison("user1"); + expect(result).not.toBeNull(); + expect(result!.taskIds).toEqual(["t1", "t2"]); + }); + }); + + describe("addToComparison", () => { + it("adds a task to existing comparison", () => { + createComparison("user1", ["t1", "t2"]); + const result = addToComparison("user1", "t3"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.comparison.taskIds).toContain("t3"); + expect(result.comparison.taskIds.length).toBe(3); + } + }); + + it("does not add duplicates", () => { + createComparison("user1", ["t1", "t2"]); + const result = addToComparison("user1", "t1"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.comparison.taskIds).toEqual(["t1", "t2"]); + } + }); + + it("returns 409 when exceeding max size", () => { + const tasks = Array.from({ length: MAX_COMPARISON_SIZE }, (_, i) => `t${i}`); + createComparison("user1", tasks); + const result = addToComparison("user1", "t_new"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(409); + }); + }); + + describe("removeFromComparison", () => { + it("removes a task from the comparison", () => { + createComparison("user1", ["t1", "t2", "t3"]); + const result = removeFromComparison("user1", "t2"); + expect(result.ok).toBe(true); + if (result.ok && result.comparison) { + expect(result.comparison.taskIds).not.toContain("t2"); + expect(result.comparison.taskIds.length).toBe(2); + } + }); + + it("clears comparison when all tasks removed", () => { + createComparison("user1", ["t1", "t2"]); + removeFromComparison("user1", "t1"); + removeFromComparison("user1", "t2"); + expect(getComparison("user1")).toBeNull(); + }); + }); + + describe("clearComparison", () => { + it("clears the comparison set", () => { + createComparison("user1", ["t1", "t2"]); + clearComparison("user1"); + expect(getComparison("user1")).toBeNull(); + }); + }); +}); diff --git a/frontend/src/lib/grant-comparison.ts b/frontend/src/lib/grant-comparison.ts new file mode 100644 index 0000000..6c59e4d --- /dev/null +++ b/frontend/src/lib/grant-comparison.ts @@ -0,0 +1,183 @@ +/** + * Issue #153: Add Grant Comparison Functionality + * + * Allows users to select multiple grants (tasks) and compare their + * key details side by side. Uses an in-memory comparison set per user. + */ + +import type { TaskRecord } from "@/types/task-workflow"; + +export interface ComparisonSet { + id: string; + userId: string; + taskIds: string[]; + createdAt: string; + updatedAt: string; +} + +export interface ComparisonResult { + comparison: ComparisonSet; + tasks: TaskRecord[]; +} + +type ComparisonSuccess = { ok: true } & T; + +type ComparisonFailure = { + ok: false; + status: 400 | 404 | 409; + error: string; +}; + +export type ComparisonResponse = ComparisonSuccess | ComparisonFailure; + +/** Maximum number of grants that can be compared at once. */ +export const MAX_COMPARISON_SIZE = 5; + +// --- in-memory store --- + +const comparisons = new Map(); +const userComparisons = new Map(); +let nextComparisonId = 1; + +/** + * Create or replace a comparison set for a user. + * The user must have at least 2 task IDs to compare. + */ +export function createComparison( + userId: string, + taskIds: string[], + now: Date = new Date(), +): ComparisonResponse<{ comparison: ComparisonSet }> { + const uid = userId.trim(); + + if (!uid) { + return { ok: false, status: 400, error: "User ID is required." }; + } + + const unique = [...new Set(taskIds.map((t) => t.trim()).filter(Boolean))]; + + if (unique.length < 2) { + return { + ok: false, + status: 400, + error: "At least 2 grants are required for comparison.", + }; + } + + if (unique.length > MAX_COMPARISON_SIZE) { + return { + ok: false, + status: 400, + error: `Cannot compare more than ${MAX_COMPARISON_SIZE} grants at once.`, + }; + } + + const existingId = userComparisons.get(uid); + const id = existingId ?? String(nextComparisonId++); + const ts = now.toISOString(); + + const comparison: ComparisonSet = { + id, + userId: uid, + taskIds: unique, + createdAt: existingId + ? comparisons.get(existingId)?.createdAt ?? ts + : ts, + updatedAt: ts, + }; + + comparisons.set(id, comparison); + userComparisons.set(uid, id); + + return { ok: true, comparison }; +} + +/** + * Get the current comparison set for a user. + */ +export function getComparison(userId: string): ComparisonSet | null { + const uid = userId.trim(); + const id = userComparisons.get(uid); + if (!id) return null; + return comparisons.get(id) ?? null; +} + +/** + * Add a task to the user's comparison set. + */ +export function addToComparison( + userId: string, + taskId: string, + now: Date = new Date(), +): ComparisonResponse<{ comparison: ComparisonSet }> { + const uid = userId.trim(); + const tid = taskId.trim(); + + if (!uid || !tid) { + return { ok: false, status: 400, error: "User ID and task ID are required." }; + } + + const existing = getComparison(uid); + if (existing) { + if (existing.taskIds.includes(tid)) { + return { ok: true, comparison: existing }; + } + if (existing.taskIds.length >= MAX_COMPARISON_SIZE) { + return { + ok: false, + status: 409, + error: `Cannot compare more than ${MAX_COMPARISON_SIZE} grants.`, + }; + } + return createComparison(uid, [...existing.taskIds, tid], now); + } + + return createComparison(uid, [tid], now); +} + +/** + * Remove a task from the user's comparison set. + */ +export function removeFromComparison( + userId: string, + taskId: string, + now: Date = new Date(), +): ComparisonResponse<{ comparison: ComparisonSet | null }> { + const uid = userId.trim(); + const tid = taskId.trim(); + + if (!uid || !tid) { + return { ok: false, status: 400, error: "User ID and task ID are required." }; + } + + const existing = getComparison(uid); + if (!existing) { + return { ok: false, status: 404, error: "No comparison set found." }; + } + + const newTaskIds = existing.taskIds.filter((t) => t !== tid); + if (newTaskIds.length === 0) { + clearComparison(uid); + return { ok: true, comparison: null }; + } + + return createComparison(uid, newTaskIds, now); +} + +/** + * Clear the user's comparison set. + */ +export function clearComparison(userId: string): void { + const uid = userId.trim(); + const id = userComparisons.get(uid); + if (id) { + comparisons.delete(id); + userComparisons.delete(uid); + } +} + +export function resetComparisonStore() { + comparisons.clear(); + userComparisons.clear(); + nextComparisonId = 1; +} diff --git a/frontend/src/lib/grant-report-store.test.ts b/frontend/src/lib/grant-report-store.test.ts new file mode 100644 index 0000000..e8b0e08 --- /dev/null +++ b/frontend/src/lib/grant-report-store.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + submitReport, + listReports, + getReport, + getReportsForTask, + resolveReport, + markReviewing, + resetReportStore, + VALID_REPORT_REASONS, +} from "@/lib/grant-report-store"; + +describe("grant-report-store", () => { + beforeEach(() => { + resetReportStore(); + }); + + describe("submitReport", () => { + it("creates a report with valid input", () => { + const result = submitReport({ + taskId: "task1", + reportedBy: "user1", + reason: "incorrect", + description: "The reward amount is wrong.", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.report.taskId).toBe("task1"); + expect(result.report.reason).toBe("incorrect"); + expect(result.report.status).toBe("pending"); + } + }); + + it("returns 400 for empty taskId", () => { + const result = submitReport({ + taskId: "", + reportedBy: "user1", + reason: "incorrect", + description: "desc", + }); + expect(result.ok).toBe(false); + }); + + it("returns 400 for empty reportedBy", () => { + const result = submitReport({ + taskId: "task1", + reportedBy: "", + reason: "incorrect", + description: "desc", + }); + expect(result.ok).toBe(false); + }); + + it("returns 400 for invalid reason", () => { + const result = submitReport({ + taskId: "task1", + reportedBy: "user1", + reason: "fake_reason" as never, + description: "desc", + }); + expect(result.ok).toBe(false); + }); + + it("returns 400 for empty description", () => { + const result = submitReport({ + taskId: "task1", + reportedBy: "user1", + reason: "spam", + description: "", + }); + expect(result.ok).toBe(false); + }); + + it("returns 400 for description over 2000 chars", () => { + const result = submitReport({ + taskId: "task1", + reportedBy: "user1", + reason: "spam", + description: "x".repeat(2001), + }); + expect(result.ok).toBe(false); + }); + }); + + describe("listReports", () => { + it("returns all reports sorted newest first", () => { + submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }, + new Date("2026-01-01")); + submitReport({ taskId: "t2", reportedBy: "u2", reason: "incorrect", description: "d2" }, + new Date("2026-01-02")); + const result = listReports(); + expect(result.total).toBe(2); + expect(result.reports[0].taskId).toBe("t2"); + }); + + it("filters by status", () => { + const r1 = submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + submitReport({ taskId: "t2", reportedBy: "u2", reason: "incorrect", description: "d2" }); + if (r1.ok) { + resolveReport(r1.report.id, "admin", "resolved"); + } + const result = listReports({ status: "resolved" }); + expect(result.total).toBe(1); + }); + + it("filters by taskId", () => { + submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + submitReport({ taskId: "t2", reportedBy: "u2", reason: "incorrect", description: "d2" }); + const result = listReports({ taskId: "t1" }); + expect(result.total).toBe(1); + }); + }); + + describe("getReport", () => { + it("returns a report by ID", () => { + const r = submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + if (r.ok) { + const found = getReport(r.report.id); + expect(found).not.toBeNull(); + expect(found!.id).toBe(r.report.id); + } + }); + + it("returns null for non-existent ID", () => { + expect(getReport("nonexistent")).toBeNull(); + }); + }); + + describe("getReportsForTask", () => { + it("returns all reports for a task", () => { + submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + submitReport({ taskId: "t1", reportedBy: "u2", reason: "incorrect", description: "d2" }); + submitReport({ taskId: "t2", reportedBy: "u3", reason: "spam", description: "d3" }); + const reports = getReportsForTask("t1"); + expect(reports.length).toBe(2); + }); + }); + + describe("resolveReport", () => { + it("resolves a report", () => { + const r = submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + if (r.ok) { + const result = resolveReport(r.report.id, "admin", "resolved", "Fixed"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.report.status).toBe("resolved"); + expect(result.report.resolvedAt).toBeTruthy(); + expect(result.report.resolvedBy).toBe("admin"); + expect(result.report.resolutionNote).toBe("Fixed"); + } + } + }); + + it("returns 404 for non-existent report", () => { + const result = resolveReport("nonexistent", "admin", "resolved"); + expect(result.ok).toBe(false); + }); + }); + + describe("markReviewing", () => { + it("marks a pending report as reviewing", () => { + const r = submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + if (r.ok) { + const result = markReviewing(r.report.id); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.report.status).toBe("reviewing"); + } + } + }); + + it("returns 400 when report is not pending", () => { + const r = submitReport({ taskId: "t1", reportedBy: "u1", reason: "spam", description: "d1" }); + if (r.ok) { + resolveReport(r.report.id, "admin", "resolved"); + const result = markReviewing(r.report.id); + expect(result.ok).toBe(false); + } + }); + }); + + describe("VALID_REPORT_REASONS", () => { + it("includes all expected reasons", () => { + expect(VALID_REPORT_REASONS).toContain("outdated"); + expect(VALID_REPORT_REASONS).toContain("incorrect"); + expect(VALID_REPORT_REASONS).toContain("suspicious"); + expect(VALID_REPORT_REASONS).toContain("incomplete"); + expect(VALID_REPORT_REASONS).toContain("spam"); + expect(VALID_REPORT_REASONS).toContain("other"); + }); + }); +}); diff --git a/frontend/src/lib/grant-report-store.ts b/frontend/src/lib/grant-report-store.ts new file mode 100644 index 0000000..0537e1a --- /dev/null +++ b/frontend/src/lib/grant-report-store.ts @@ -0,0 +1,230 @@ +/** + * Issue #158: Add Grant Reporting for Incorrect Information + * + * Allows users to report grants containing outdated, incorrect, + * suspicious, or incomplete information. Reports are reviewed by + * administrators via the moderation queue. + */ + +export type ReportReason = + | "outdated" + | "incorrect" + | "suspicious" + | "incomplete" + | "spam" + | "other"; + +export type ReportStatus = "pending" | "reviewing" | "resolved" | "dismissed"; + +export interface GrantReport { + id: string; + taskId: string; + reportedBy: string; + reason: ReportReason; + description: string; + status: ReportStatus; + createdAt: string; + resolvedAt: string | null; + resolvedBy: string | null; + resolutionNote: string | null; +} + +export interface ReportListResult { + reports: GrantReport[]; + total: number; +} + +type ReportSuccess = { ok: true } & T; + +type ReportFailure = { + ok: false; + status: 400 | 404; + error: string; +}; + +export type ReportResult = ReportSuccess | ReportFailure; + +export const VALID_REPORT_REASONS: ReportReason[] = [ + "outdated", + "incorrect", + "suspicious", + "incomplete", + "spam", + "other", +]; + +// --- in-memory store --- + +const reports = new Map(); +const taskReports = new Map>(); +let nextReportId = 1; + +function getTaskReportSet(taskId: string): Set { + let set = taskReports.get(taskId); + if (!set) { + set = new Set(); + taskReports.set(taskId, set); + } + return set; +} + +/** + * Submit a new report for a grant. + */ +export function submitReport( + input: { + taskId: string; + reportedBy: string; + reason: ReportReason; + description: string; + }, + now: Date = new Date(), +): ReportResult<{ report: GrantReport }> { + const taskId = input.taskId.trim(); + const reportedBy = input.reportedBy.trim(); + const description = input.description.trim(); + const reason = input.reason; + + if (!taskId) { + return { ok: false, status: 400, error: "Task ID is required." }; + } + if (!reportedBy) { + return { ok: false, status: 400, error: "Reporter address is required." }; + } + if (!VALID_REPORT_REASONS.includes(reason)) { + return { + ok: false, + status: 400, + error: `Invalid reason. Valid reasons: ${VALID_REPORT_REASONS.join(", ")}.`, + }; + } + if (!description) { + return { ok: false, status: 400, error: "Description is required." }; + } + if (description.length > 2000) { + return { + ok: false, + status: 400, + error: "Description must be 2000 characters or less.", + }; + } + + const report: GrantReport = { + id: String(nextReportId++), + taskId, + reportedBy, + reason, + description, + status: "pending", + createdAt: now.toISOString(), + resolvedAt: null, + resolvedBy: null, + resolutionNote: null, + }; + + reports.set(report.id, report); + getTaskReportSet(taskId).add(report.id); + + return { ok: true, report }; +} + +/** + * List all reports, optionally filtered by status or taskId. + */ +export function listReports( + filter?: { status?: ReportStatus; taskId?: string }, +): ReportListResult { + let all = Array.from(reports.values()); + + if (filter?.status) { + all = all.filter((r) => r.status === filter.status); + } + if (filter?.taskId) { + all = all.filter((r) => r.taskId === filter.taskId.trim()); + } + + all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return { reports: all, total: all.length }; +} + +/** + * Get a single report by ID. + */ +export function getReport(reportId: string): GrantReport | null { + return reports.get(reportId.trim()) ?? null; +} + +/** + * Get all reports for a specific task. + */ +export function getReportsForTask(taskId: string): GrantReport[] { + const set = taskReports.get(taskId.trim()); + if (!set) return []; + return Array.from(set) + .map((id) => reports.get(id)) + .filter((r): r is GrantReport => r !== undefined) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +/** + * Resolve a report (admin action). + */ +export function resolveReport( + reportId: string, + resolvedBy: string, + status: "resolved" | "dismissed", + note?: string, + now: Date = new Date(), +): ReportResult<{ report: GrantReport }> { + const report = reports.get(reportId.trim()); + if (!report) { + return { ok: false, status: 404, error: "Report not found." }; + } + + const updated: GrantReport = { + ...report, + status, + resolvedAt: now.toISOString(), + resolvedBy: resolvedBy.trim(), + resolutionNote: note?.trim() || null, + }; + + reports.set(report.id, updated); + return { ok: true, report: updated }; +} + +/** + * Mark a report as "reviewing" (admin started reviewing). + */ +export function markReviewing( + reportId: string, + now: Date = new Date(), +): ReportResult<{ report: GrantReport }> { + const report = reports.get(reportId.trim()); + if (!report) { + return { ok: false, status: 404, error: "Report not found." }; + } + + if (report.status !== "pending") { + return { + ok: false, + status: 400, + error: `Report is already ${report.status}.`, + }; + } + + const updated: GrantReport = { + ...report, + status: "reviewing", + }; + + reports.set(report.id, updated); + return { ok: true, report: updated }; +} + +export function resetReportStore() { + reports.clear(); + taskReports.clear(); + nextReportId = 1; +} diff --git a/frontend/src/lib/import-validation.test.ts b/frontend/src/lib/import-validation.test.ts new file mode 100644 index 0000000..a987885 --- /dev/null +++ b/frontend/src/lib/import-validation.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { + validateField, + validateRow, + validateImportData, + parseCSV, + GRANT_IMPORT_SCHEMA, +} from "@/lib/import-validation"; + +describe("import-validation", () => { + describe("validateField", () => { + it("returns null for valid required string", () => { + expect(validateField("My Title", { name: "title", type: "string", required: true })).toBeNull(); + }); + + it("returns error for missing required field", () => { + expect(validateField("", { name: "title", type: "string", required: true })).toContain("required"); + }); + + it("returns null for missing optional field", () => { + expect(validateField("", { name: "org", type: "string", required: false })).toBeNull(); + }); + + it("validates maxLength", () => { + expect( + validateField("x".repeat(201), { name: "title", type: "string", required: true, maxLength: 200 }), + ).toContain("at most 200"); + }); + + it("validates enum values", () => { + expect( + validateField("expert", { name: "difficulty", type: "string", required: false, enum: ["beginner", "intermediate", "advanced"] }), + ).toContain("one of"); + }); + + it("validates number min", () => { + expect( + validateField(500, { name: "reward", type: "number", required: true, min: 1_000_000 }), + ).toContain("at least"); + }); + + it("returns null for valid number", () => { + expect( + validateField(2_000_000, { name: "reward", type: "number", required: true, min: 1_000_000 }), + ).toBeNull(); + }); + + it("validates unix_timestamp is in the future", () => { + const pastTimestamp = Math.floor(Date.now() / 1000) - 1000; + expect( + validateField(pastTimestamp, { name: "deadline", type: "unix_timestamp", required: true }), + ).toContain("future"); + }); + + it("validates unix_timestamp is not too far in the future", () => { + const farFuture = Math.floor(Date.now() / 1000) + 400 * 24 * 3600; + expect( + validateField(farFuture, { name: "deadline", type: "unix_timestamp", required: true }), + ).toContain("365 days"); + }); + }); + + describe("validateRow", () => { + it("returns no errors for a valid row", () => { + const errors = validateRow( + { + title: "Test Grant", + description: "A test grant", + reward: 2_000_000, + deadline: Math.floor(Date.now() / 1000) + 7 * 24 * 3600, + maxSubmissions: 5, + poster: "user1", + }, + GRANT_IMPORT_SCHEMA, + 1, + ); + expect(errors.length).toBe(0); + }); + + it("returns errors for missing required fields", () => { + const errors = validateRow({}, GRANT_IMPORT_SCHEMA, 1); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.field === "title")).toBe(true); + expect(errors.some((e) => e.field === "description")).toBe(true); + expect(errors.some((e) => e.field === "reward")).toBe(true); + }); + }); + + describe("validateImportData", () => { + it("returns valid rows and errors separately", () => { + const futureDeadline = Math.floor(Date.now() / 1000) + 7 * 24 * 3600; + const rows = [ + { title: "Valid", description: "desc", reward: 2_000_000, deadline: futureDeadline, maxSubmissions: 3, poster: "u1" }, + { title: "Invalid", description: "", reward: 500, deadline: "not_a_number", maxSubmissions: 0, poster: "" }, + ]; + + const result = validateImportData(rows, GRANT_IMPORT_SCHEMA); + expect(result.totalRows).toBe(2); + expect(result.validCount).toBe(1); + expect(result.errorCount).toBeGreaterThan(0); + expect(result.valid[0].data.title).toBe("Valid"); + }); + + it("handles empty input", () => { + const result = validateImportData([], GRANT_IMPORT_SCHEMA); + expect(result.totalRows).toBe(0); + expect(result.validCount).toBe(0); + expect(result.errorCount).toBe(0); + }); + }); + + describe("parseCSV", () => { + it("parses simple CSV", () => { + const csv = "title,description\nGrant 1,Description 1\nGrant 2,Description 2"; + const rows = parseCSV(csv); + expect(rows.length).toBe(2); + expect(rows[0].title).toBe("Grant 1"); + expect(rows[1].description).toBe("Description 2"); + }); + + it("handles quoted fields with commas", () => { + const csv = 'title,description\n"Grant, 1","Description, with comma"'; + const rows = parseCSV(csv); + expect(rows[0].title).toBe("Grant, 1"); + expect(rows[0].description).toBe("Description, with comma"); + }); + + it("handles escaped quotes", () => { + const csv = 'title\n"Grant ""quoted"" 1"'; + const rows = parseCSV(csv); + expect(rows[0].title).toBe('Grant "quoted" 1'); + }); + + it("handles empty input", () => { + expect(parseCSV("")).toEqual([]); + }); + }); +}); diff --git a/frontend/src/lib/import-validation.ts b/frontend/src/lib/import-validation.ts new file mode 100644 index 0000000..d942d00 --- /dev/null +++ b/frontend/src/lib/import-validation.ts @@ -0,0 +1,242 @@ +/** + * Issue #162: Implement Import Validation for Bulk Grant Uploads + * + * Validates uploaded grant data before records are added to the system. + * Parses CSV/JSON arrays, identifies invalid rows, and returns clear + * validation errors. Only valid records are returned for creation. + */ + +export type FieldType = "string" | "number" | "boolean" | "iso_date" | "unix_timestamp"; + +export interface FieldSchema { + name: string; + type: FieldType; + required: boolean; + min?: number; + max?: number; + maxLength?: number; + enum?: string[]; +} + +export interface ValidationError { + row: number; + field: string; + message: string; + value: unknown; +} + +export interface ValidRow { + row: number; + data: Record; +} + +export interface ImportValidationResult { + valid: ValidRow[]; + errors: ValidationError[]; + totalRows: number; + validCount: number; + errorCount: number; +} + +/** + * Default schema for a grant/task bulk import. + * Matches the `CreateTaskInput` fields. + */ +export const GRANT_IMPORT_SCHEMA: FieldSchema[] = [ + { name: "title", type: "string", required: true, maxLength: 200 }, + { name: "description", type: "string", required: true, maxLength: 5000 }, + { name: "reward", type: "number", required: true, min: 1_000_000 }, + { name: "deadline", type: "unix_timestamp", required: true }, + { name: "maxSubmissions", type: "number", required: true, min: 1, max: 100 }, + { name: "difficulty", type: "string", required: false, enum: ["beginner", "intermediate", "advanced"] }, + { name: "organization", type: "string", required: false, maxLength: 200 }, + { name: "poster", type: "string", required: true, maxLength: 100 }, + { name: "technologies", type: "string", required: false }, +]; + +/** + * Validate a single field value against its schema. + */ +export function validateField( + value: unknown, + schema: FieldSchema, +): string | null { + if (value === null || value === undefined || value === "") { + return schema.required ? `${schema.name} is required.` : null; + } + + switch (schema.type) { + case "string": { + const str = String(value).trim(); + if (schema.maxLength && str.length > schema.maxLength) { + return `${schema.name} must be at most ${schema.maxLength} characters.`; + } + if (schema.enum && !schema.enum.includes(str)) { + return `${schema.name} must be one of: ${schema.enum.join(", ")}.`; + } + return null; + } + case "number": { + const num = Number(value); + if (!Number.isFinite(num)) { + return `${schema.name} must be a valid number.`; + } + if (schema.min !== undefined && num < schema.min) { + return `${schema.name} must be at least ${schema.min}.`; + } + if (schema.max !== undefined && num > schema.max) { + return `${schema.name} must be at most ${schema.max}.`; + } + return null; + } + case "boolean": { + if (typeof value === "boolean") return null; + if (["true", "false", "1", "0", "yes", "no"].includes(String(value).toLowerCase())) { + return null; + } + return `${schema.name} must be a boolean value.`; + } + case "iso_date": { + const date = new Date(String(value)); + if (isNaN(date.getTime())) { + return `${schema.name} must be a valid ISO date string.`; + } + return null; + } + case "unix_timestamp": { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) { + return `${schema.name} must be a valid Unix timestamp.`; + } + const now = Math.floor(Date.now() / 1000); + if (num <= now) { + return `${schema.name} must be in the future.`; + } + const maxFuture = now + 365 * 24 * 60 * 60; + if (num > maxFuture) { + return `${schema.name} cannot be more than 365 days from now.`; + } + return null; + } + default: + return null; + } +} + +/** + * Validate a single row of data against the schema. + * Returns an array of validation errors (empty if valid). + */ +export function validateRow( + rowData: Record, + schema: FieldSchema[], + rowIndex: number, +): ValidationError[] { + const errors: ValidationError[] = []; + + for (const field of schema) { + const value = rowData[field.name]; + const error = validateField(value, field); + if (error) { + errors.push({ row: rowIndex, field: field.name, message: error, value }); + } + } + + return errors; +} + +/** + * Validate an array of rows for bulk import. + * Returns valid rows and errors separately. + */ +export function validateImportData( + rows: Record[], + schema: FieldSchema[] = GRANT_IMPORT_SCHEMA, +): ImportValidationResult { + const valid: ValidRow[] = []; + const errors: ValidationError[] = []; + + rows.forEach((rowData, index) => { + const rowIndex = index + 1; // 1-based + const rowErrors = validateRow(rowData, schema, rowIndex); + + if (rowErrors.length > 0) { + errors.push(...rowErrors); + } else { + valid.push({ row: rowIndex, data: { ...rowData } }); + } + }); + + return { + valid, + errors, + totalRows: rows.length, + validCount: valid.length, + errorCount: errors.length, + }; +} + +/** + * Parse a CSV string into rows for validation. + * Handles quoted fields, commas inside quotes, and newlines inside quotes. + */ +export function parseCSV( + csv: string, +): Record[] { + const lines: string[][] = []; + let currentField = ""; + let currentRow: string[] = []; + let inQuotes = false; + + for (let i = 0; i < csv.length; i++) { + const char = csv[i]; + + if (inQuotes) { + if (char === '"') { + if (csv[i + 1] === '"') { + currentField += '"'; + i++; + } else { + inQuotes = false; + } + } else { + currentField += char; + } + } else { + if (char === '"') { + inQuotes = true; + } else if (char === ",") { + currentRow.push(currentField); + currentField = ""; + } else if (char === "\n" || char === "\r") { + if (char === "\r" && csv[i + 1] === "\n") i++; + currentRow.push(currentField); + lines.push(currentRow); + currentField = ""; + currentRow = []; + } else { + currentField += char; + } + } + } + + if (currentField || currentRow.length > 0) { + currentRow.push(currentField); + lines.push(currentRow); + } + + if (lines.length === 0) return []; + + const headers = lines[0].map((h) => h.trim()); + const rows: Record[] = []; + + for (let i = 1; i < lines.length; i++) { + const row: Record = {}; + for (let j = 0; j < headers.length; j++) { + row[headers[j]] = lines[i][j] ?? ""; + } + rows.push(row); + } + + return rows; +} 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/status-transitions.test.ts b/frontend/src/lib/status-transitions.test.ts new file mode 100644 index 0000000..27972fa --- /dev/null +++ b/frontend/src/lib/status-transitions.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { + isValidTransition, + transitionStatus, + getNextStatuses, + getStatusWorkflowDescription, + isTerminalStatus, + VALID_TRANSITIONS, +} from "@/lib/status-transitions"; +import type { TaskStatus } from "@/types/task-workflow"; + +describe("status-transitions", () => { + describe("isValidTransition", () => { + it("allows same-status (no-op)", () => { + expect(isValidTransition("open", "open")).toBe(true); + expect(isValidTransition("completed", "completed")).toBe(true); + }); + + it("allows open → in_progress", () => { + expect(isValidTransition("open", "in_progress")).toBe(true); + }); + + it("allows open → cancelled", () => { + expect(isValidTransition("open", "cancelled")).toBe(true); + }); + + it("allows in_progress → completed", () => { + expect(isValidTransition("in_progress", "completed")).toBe(true); + }); + + it("allows in_progress → disputed", () => { + expect(isValidTransition("in_progress", "disputed")).toBe(true); + }); + + it("allows completed → disputed", () => { + expect(isValidTransition("completed", "disputed")).toBe(true); + }); + + it("allows disputed → completed", () => { + expect(isValidTransition("disputed", "completed")).toBe(true); + }); + + it("allows disputed → cancelled", () => { + expect(isValidTransition("disputed", "cancelled")).toBe(true); + }); + + it("allows cancelled → open", () => { + expect(isValidTransition("cancelled", "open")).toBe(true); + }); + + it("rejects open → completed (must go through in_progress)", () => { + expect(isValidTransition("open", "completed")).toBe(false); + }); + + it("rejects completed → open", () => { + expect(isValidTransition("completed", "open")).toBe(false); + }); + + it("rejects cancelled → completed", () => { + expect(isValidTransition("cancelled", "completed")).toBe(false); + }); + }); + + describe("transitionStatus", () => { + it("returns ok for valid transition", () => { + const result = transitionStatus("open", "in_progress"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.from).toBe("open"); + expect(result.to).toBe("in_progress"); + } + }); + + it("returns ok for same-status (no-op)", () => { + const result = transitionStatus("open", "open"); + expect(result.ok).toBe(true); + }); + + it("returns error for invalid transition", () => { + const result = transitionStatus("open", "completed"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Invalid"); + expect(result.error).toContain("open"); + expect(result.error).toContain("completed"); + } + }); + + it("includes allowed transitions in error message", () => { + const result = transitionStatus("completed", "open"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("disputed"); + } + }); + }); + + describe("getNextStatuses", () => { + it("returns valid next statuses for open", () => { + const next = getNextStatuses("open"); + expect(next).toContain("in_progress"); + expect(next).toContain("cancelled"); + expect(next.length).toBe(2); + }); + + it("returns valid next statuses for in_progress", () => { + const next = getNextStatuses("in_progress"); + expect(next).toContain("completed"); + expect(next).toContain("cancelled"); + expect(next).toContain("disputed"); + }); + + it("returns empty for terminal status", () => { + // No status is truly terminal in this design except if a status has no outgoing transitions + // Let's verify all statuses have defined transitions + const allStatuses: TaskStatus[] = ["open", "in_progress", "completed", "cancelled", "disputed"]; + for (const status of allStatuses) { + expect(VALID_TRANSITIONS[status]).toBeDefined(); + } + }); + }); + + describe("getStatusWorkflowDescription", () => { + it("returns all valid transitions with descriptions", () => { + const workflow = getStatusWorkflowDescription(); + expect(workflow.length).toBeGreaterThan(0); + expect(workflow[0].from).toBe("open"); + expect(workflow[0].to).toBe("in_progress"); + expect(workflow[0].description).toBeTruthy(); + }); + }); + + describe("isTerminalStatus", () => { + it("returns false for open (has outgoing transitions)", () => { + expect(isTerminalStatus("open")).toBe(false); + }); + + it("returns false for in_progress", () => { + expect(isTerminalStatus("in_progress")).toBe(false); + }); + }); +}); diff --git a/frontend/src/lib/status-transitions.ts b/frontend/src/lib/status-transitions.ts new file mode 100644 index 0000000..afe1a49 --- /dev/null +++ b/frontend/src/lib/status-transitions.ts @@ -0,0 +1,107 @@ +/** + * Issue #160: Implement Grant Status Transitions + * + * Introduces a controlled status workflow for grants (tasks). + * Only valid transitions are allowed; invalid ones are rejected. + */ + +import type { TaskStatus } from "@/types/task-workflow"; + +export type TransitionResult = + | { ok: true; from: TaskStatus; to: TaskStatus } + | { ok: false; from: TaskStatus; to: TaskStatus; error: string }; + +/** + * Valid status transitions. + * A grant can go through these stages: + * + * open → in_progress (first submission received) + * open → cancelled (poster cancels before any submission) + * in_progress → completed (submission approved) + * in_progress → cancelled (poster cancels) + * in_progress → disputed (issue raised) + * completed → disputed (issue raised post-completion) + * disputed → completed (resolved in favor of contributor) + * disputed → cancelled (resolved in favor of poster) + * cancelled → open (reopened) + * + * Same-status transitions (no-op) are also allowed. + */ +export const VALID_TRANSITIONS: Record = { + open: ["in_progress", "cancelled"], + in_progress: ["completed", "cancelled", "disputed"], + completed: ["disputed"], + cancelled: ["open"], + disputed: ["completed", "cancelled"], +}; + +/** + * Check if a transition is valid. + */ +export function isValidTransition( + from: TaskStatus, + to: TaskStatus, +): boolean { + if (from === to) return true; // no-op + const allowed = VALID_TRANSITIONS[from] ?? []; + return allowed.includes(to); +} + +/** + * Attempt a status transition. Returns an error if the transition is not valid. + */ +export function transitionStatus( + from: TaskStatus, + to: TaskStatus, +): TransitionResult { + if (from === to) { + return { ok: true, from, to }; + } + + if (!isValidTransition(from, to)) { + return { + ok: false, + from, + to, + error: `Invalid status transition: ${from} → ${to}. Allowed transitions from "${from}": ${(VALID_TRANSITIONS[from] ?? []).join(", ") || "none"}.`, + }; + } + + return { ok: true, from, to }; +} + +/** + * Get all valid next statuses from the current status. + */ +export function getNextStatuses(current: TaskStatus): TaskStatus[] { + return VALID_TRANSITIONS[current] ?? []; +} + +/** + * Get a human-readable description of the status workflow. + */ +export function getStatusWorkflowDescription(): Array<{ + from: TaskStatus; + to: TaskStatus; + description: string; +}> { + return [ + { from: "open", to: "in_progress", description: "First submission received" }, + { from: "open", to: "cancelled", description: "Poster cancels before any submission" }, + { from: "in_progress", to: "completed", description: "Submission approved" }, + { from: "in_progress", to: "cancelled", description: "Poster cancels" }, + { from: "in_progress", to: "disputed", description: "Issue raised" }, + { from: "completed", to: "disputed", description: "Issue raised post-completion" }, + { from: "disputed", to: "completed", description: "Resolved in favor of contributor" }, + { from: "disputed", to: "cancelled", description: "Resolved in favor of poster" }, + { from: "cancelled", to: "open", description: "Reopened" }, + ]; +} + +/** + * Check if a status is terminal (no outgoing transitions except to itself). + */ +export function isTerminalStatus(status: TaskStatus): boolean { + const next = VALID_TRANSITIONS[status] ?? []; + return next.length === 0; +} diff --git a/frontend/src/lib/submission-history.test.ts b/frontend/src/lib/submission-history.test.ts new file mode 100644 index 0000000..4da5ee6 --- /dev/null +++ b/frontend/src/lib/submission-history.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + recordSubmission, + updateSubmissionStatus, + getSubmissionHistory, + getSubmissionEntry, + getSubmissionCount, + getSubmissionStatusBreakdown, + resetSubmissionHistoryStore, +} from "@/lib/submission-history"; + +describe("submission-history", () => { + beforeEach(() => { + resetSubmissionHistoryStore(); + }); + + describe("recordSubmission", () => { + it("records a submission entry", () => { + recordSubmission( + "sub1", "task1", "Test Task", "user1", + "https://example.com", "My submission", + "2026-01-01T00:00:00Z", "pending", + 1000000, "TestOrg", + ); + const result = getSubmissionHistory({ userId: "user1" }); + expect(result.total).toBe(1); + expect(result.entries[0].submissionId).toBe("sub1"); + expect(result.entries[0].taskTitle).toBe("Test Task"); + }); + + it("records multiple submissions for the same user", () => { + recordSubmission("s1", "t1", "Task 1", "user1", "", "", "2026-01-01", "pending", 0, ""); + recordSubmission("s2", "t2", "Task 2", "user1", "", "", "2026-01-02", "pending", 0, ""); + const result = getSubmissionHistory({ userId: "user1" }); + expect(result.total).toBe(2); + }); + + it("separates submissions by user", () => { + recordSubmission("s1", "t1", "Task 1", "user1", "", "", "2026-01-01", "pending", 0, ""); + recordSubmission("s2", "t2", "Task 2", "user2", "", "", "2026-01-01", "pending", 0, ""); + expect(getSubmissionHistory({ userId: "user1" }).total).toBe(1); + expect(getSubmissionHistory({ userId: "user2" }).total).toBe(1); + }); + }); + + describe("updateSubmissionStatus", () => { + it("updates the status of an existing submission", () => { + recordSubmission("s1", "t1", "Task 1", "user1", "", "", "2026-01-01", "pending", 0, ""); + updateSubmissionStatus("s1", "approved"); + const entry = getSubmissionEntry("s1"); + expect(entry).not.toBeNull(); + expect(entry!.status).toBe("approved"); + }); + + it("does nothing for non-existent submission", () => { + updateSubmissionStatus("nonexistent", "approved"); + expect(getSubmissionEntry("nonexistent")).toBeNull(); + }); + }); + + describe("getSubmissionHistory", () => { + beforeEach(() => { + recordSubmission("s1", "t1", "Task 1", "user1", "", "desc1", "2026-01-01T00:00:00Z", "pending", 100, "OrgA"); + recordSubmission("s2", "t2", "Task 2", "user1", "", "desc2", "2026-01-02T00:00:00Z", "approved", 200, "OrgB"); + recordSubmission("s3", "t3", "Task 3", "user1", "", "desc3", "2026-01-03T00:00:00Z", "rejected", 300, "OrgC"); + }); + + it("returns all entries sorted newest first by default", () => { + const result = getSubmissionHistory({ userId: "user1" }); + expect(result.total).toBe(3); + expect(result.entries[0].submittedAt).toBe("2026-01-03T00:00:00Z"); + expect(result.entries[2].submittedAt).toBe("2026-01-01T00:00:00Z"); + }); + + it("sorts oldest first", () => { + const result = getSubmissionHistory({ userId: "user1", sort: "oldest" }); + expect(result.entries[0].submittedAt).toBe("2026-01-01T00:00:00Z"); + }); + + it("sorts by status (pending -> approved -> rejected)", () => { + const result = getSubmissionHistory({ userId: "user1", sort: "status" }); + expect(result.entries[0].status).toBe("pending"); + expect(result.entries[1].status).toBe("approved"); + expect(result.entries[2].status).toBe("rejected"); + }); + + it("filters by status", () => { + const result = getSubmissionHistory({ userId: "user1", status: "approved" }); + expect(result.total).toBe(1); + expect(result.entries[0].status).toBe("approved"); + }); + + it("paginates results", () => { + const result = getSubmissionHistory({ userId: "user1", page: 1, pageSize: 2 }); + expect(result.entries.length).toBe(2); + expect(result.total).toBe(3); + expect(result.totalPages).toBe(2); + + const page2 = getSubmissionHistory({ userId: "user1", page: 2, pageSize: 2 }); + expect(page2.entries.length).toBe(1); + }); + + it("returns empty for user with no submissions", () => { + const result = getSubmissionHistory({ userId: "nobody" }); + expect(result.total).toBe(0); + expect(result.entries).toEqual([]); + }); + }); + + describe("getSubmissionCount", () => { + it("returns total count for a user", () => { + recordSubmission("s1", "t1", "Task 1", "user1", "", "", "2026-01-01", "pending", 0, ""); + recordSubmission("s2", "t2", "Task 2", "user1", "", "", "2026-01-02", "approved", 0, ""); + expect(getSubmissionCount("user1")).toBe(2); + }); + + it("returns 0 for user with no submissions", () => { + expect(getSubmissionCount("nobody")).toBe(0); + }); + }); + + describe("getSubmissionStatusBreakdown", () => { + it("returns counts by status", () => { + recordSubmission("s1", "t1", "T1", "user1", "", "", "2026-01-01", "pending", 0, ""); + recordSubmission("s2", "t2", "T2", "user1", "", "", "2026-01-02", "pending", 0, ""); + recordSubmission("s3", "t3", "T3", "user1", "", "", "2026-01-03", "approved", 0, ""); + const breakdown = getSubmissionStatusBreakdown("user1"); + expect(breakdown.pending).toBe(2); + expect(breakdown.approved).toBe(1); + expect(breakdown.rejected).toBe(0); + }); + }); +}); diff --git a/frontend/src/lib/submission-history.ts b/frontend/src/lib/submission-history.ts new file mode 100644 index 0000000..72e68fe --- /dev/null +++ b/frontend/src/lib/submission-history.ts @@ -0,0 +1,226 @@ +/** + * Issue #150: Implement Grant Application Submission History + * + * Provides a read-only view of a user's submitted grant applications + * across all tasks. Leverages the existing `submissions` and `tasks` + * stores from `task-workflow.ts` but exposes a dedicated history + * interface with sorting, filtering, and pagination. + * + * This module is a thin query layer — it does not duplicate data. + */ + +import { listTasks } from "@/lib/task-workflow"; +import type { TaskRecord } from "@/types/task-workflow"; + +export type SubmissionHistoryStatus = + | "all" + | "pending" + | "approved" + | "rejected"; + +export interface SubmissionHistoryEntry { + submissionId: string; + taskId: string; + taskTitle: string; + contributor: string; + workUrl: string; + description: string; + submittedAt: string; + status: string; + taskReward: number; + taskOrganization: string; +} + +export interface SubmissionHistoryResult { + entries: SubmissionHistoryEntry[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface SubmissionHistoryQuery { + userId: string; + status?: SubmissionHistoryStatus; + sort?: "newest" | "oldest" | "status"; + page?: number; + pageSize?: number; +} + +const DEFAULT_HISTORY_PAGE_SIZE = 10; +const MAX_HISTORY_PAGE_SIZE = 50; + +/** + * Build a submission history for a user from the task-workflow store. + * + * Since `task-workflow.ts` keeps submissions in a module-private Map, + * we expose a bridge via `listTasks` + a new export. However, to avoid + * circular imports and keep the pattern consistent, we maintain our own + * index here that is populated when submissions are created. + */ + +// --- in-memory index --- + +const historyIndex = new Map(); +/** submissionId -> entry for O(1) status updates */ +const submissionIndex = new Map(); + +/** + * Record a new submission in the history index. + * Called by `submitTaskWork` in task-workflow.ts. + */ +export function recordSubmission( + submissionId: string, + taskId: string, + taskTitle: string, + contributor: string, + workUrl: string, + description: string, + submittedAt: string, + status: string, + taskReward: number, + taskOrganization: string, +): void { + const entry: SubmissionHistoryEntry = { + submissionId, + taskId, + taskTitle, + contributor, + workUrl, + description, + submittedAt, + status, + taskReward, + taskOrganization, + }; + + submissionIndex.set(submissionId, entry); + + const userEntries = historyIndex.get(contributor) ?? []; + userEntries.push(entry); + historyIndex.set(contributor, userEntries); +} + +/** + * Update the status of a submission in the history index. + * Called when a submission is approved or rejected. + */ +export function updateSubmissionStatus( + submissionId: string, + newStatus: string, +): void { + const entry = submissionIndex.get(submissionId); + if (entry) { + entry.status = newStatus; + } +} + +/** + * Retrieve a user's submission history with optional filtering, + * sorting, and pagination. + */ +export function getSubmissionHistory( + query: SubmissionHistoryQuery, +): SubmissionHistoryResult { + const userId = query.userId.trim(); + const userEntries = historyIndex.get(userId) ?? []; + + // Filter by status + let filtered = userEntries; + if (query.status && query.status !== "all") { + filtered = filtered.filter((e) => e.status === query.status); + } + + // Sort + const sort = query.sort ?? "newest"; + const sorted = [...filtered].sort((a, b) => { + switch (sort) { + case "oldest": + return a.submittedAt.localeCompare(b.submittedAt); + case "status": + // Group by status: pending -> approved -> rejected, then by date + const statusOrder: Record = { + pending: 0, + approved: 1, + rejected: 2, + }; + const sa = statusOrder[a.status] ?? 99; + const sb = statusOrder[b.status] ?? 99; + if (sa !== sb) return sa - sb; + return b.submittedAt.localeCompare(a.submittedAt); + case "newest": + default: + return b.submittedAt.localeCompare(a.submittedAt); + } + }); + + // Paginate + const total = sorted.length; + const pageSize = Math.min( + MAX_HISTORY_PAGE_SIZE, + Math.max( + 1, + Number.isFinite(query.pageSize) && query.pageSize + ? Math.floor(query.pageSize) + : DEFAULT_HISTORY_PAGE_SIZE, + ), + ); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min( + totalPages, + Math.max( + 1, + Number.isFinite(query.page) && query.page ? Math.floor(query.page) : 1, + ), + ); + + const start = (page - 1) * pageSize; + const pageEntries = sorted.slice(start, start + pageSize); + + return { + entries: pageEntries, + total, + page, + pageSize, + totalPages, + }; +} + +/** + * Get a single submission's history entry. + */ +export function getSubmissionEntry( + submissionId: string, +): SubmissionHistoryEntry | null { + return submissionIndex.get(submissionId) ?? null; +} + +/** + * Get total count of submissions for a user (all statuses). + */ +export function getSubmissionCount(userId: string): number { + return (historyIndex.get(userId.trim()) ?? []).length; +} + +/** + * Get breakdown of submission statuses for a user. + */ +export function getSubmissionStatusBreakdown( + userId: string, +): Record { + const entries = historyIndex.get(userId.trim()) ?? []; + const breakdown: Record = { + pending: 0, + approved: 0, + rejected: 0, + }; + for (const entry of entries) { + breakdown[entry.status] = (breakdown[entry.status] ?? 0) + 1; + } + return breakdown; +} + +export function resetSubmissionHistoryStore() { + historyIndex.clear(); + submissionIndex.clear(); +} diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts index e1a3337..7de0ca4 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -15,6 +15,28 @@ import { BROADCAST_USER_ID, createNotification, } from "@/lib/notification-store"; +import { detectDuplicates } from "@/lib/duplicate-detection"; +import { enqueueModeration, resetModerationStore } from "@/lib/moderation-queue"; +import { + recordSubmission, + updateSubmissionStatus, + resetSubmissionHistoryStore, +} from "@/lib/submission-history"; +import { + resetBookmarkStore, +} from "@/lib/bookmark-store"; +import { + resetDraftStore, +} from "@/lib/draft-autosave"; +import { + resetComparisonStore, +} from "@/lib/grant-comparison"; +import { + resetReportStore, +} from "@/lib/grant-report-store"; +import { + resetDeadlineReminderStore, +} from "@/lib/deadline-reminder"; export const MIN_TASK_REWARD = 1_000_000; export const MAX_TASK_DEADLINE_OFFSET_SECONDS = 365 * 24 * 60 * 60; @@ -121,6 +143,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, @@ -316,6 +366,20 @@ export function submitTaskWork( contributors.add(contributor); contributorSubmissions.set(task.id, contributors); + // Issue #150: Record submission in history index + recordSubmission( + submissionId, + task.id, + task.title, + contributor, + submission.workUrl, + submission.description, + submission.submittedAt, + submission.status, + task.reward, + task.organization, + ); + const nextStatus: TaskStatus = task.status === "open" ? "in_progress" : task.status; const updatedTask: TaskRecord = { ...task, @@ -382,6 +446,9 @@ export function approveSubmission( const updatedSubmission: SubmissionRecord = { ...submission, status: "approved" }; submissions.set(submissionId, updatedSubmission); + // Issue #150: Update submission history status + updateSubmissionStatus(submissionId, "approved"); + const updatedTask: TaskRecord = { ...task, status: "completed" }; tasks.set(taskId, updatedTask); @@ -450,6 +517,9 @@ export function rejectSubmission( const updatedSubmission: SubmissionRecord = { ...submission, status: "rejected" }; submissions.set(submissionId, updatedSubmission); + // Issue #150: Update submission history status + updateSubmissionStatus(submissionId, "rejected"); + createNotification( { userId: submission.contributor, @@ -536,4 +606,12 @@ export function resetTaskWorkflowStore() { nextTaskId = 1; nextSubmissionId = 1; nextCommentId = 1; + // Also reset related stores (imported in this module) + resetModerationStore(); + resetSubmissionHistoryStore(); + resetBookmarkStore(); + resetDraftStore(); + resetComparisonStore(); + resetReportStore(); + resetDeadlineReminderStore(); }