diff --git a/frontend/src/app/(dashboard)/my-submissions/page.tsx b/frontend/src/app/(dashboard)/my-submissions/page.tsx new file mode 100644 index 0000000..c0b9038 --- /dev/null +++ b/frontend/src/app/(dashboard)/my-submissions/page.tsx @@ -0,0 +1,407 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, Clock, FileText, Inbox, Link2, MessageSquare } from "lucide-react"; +import { getPublicKey } from "@/hooks/stellar-wallets-kit"; +import type { SubmissionStatus, TaskStatus } from "@/types/task-workflow"; + +interface SubmissionHistoryRow { + id: string; + taskId: string; + contributor: string; + workUrl: string; + description: string; + submittedAt: string; + status: SubmissionStatus; + files: Array<{ + name: string; + size: number; + extension: string; + kind: string; + detectedMimeType: string; + }>; + taskTitle: string; + taskStatus: TaskStatus; +} + +type StatusTab = "all" | SubmissionStatus; + +const STATUS_STYLES: Record = { + pending: "bg-yellow-500/15 text-yellow-300 border-yellow-500/30", + approved: "bg-green-500/15 text-green-300 border-green-500/30", + rejected: "bg-red-500/15 text-red-300 border-red-500/30", +}; + +const STATUS_TABS: StatusTab[] = ["all", "pending", "approved", "rejected"]; + +const TAB_LABELS: Record = { + all: "All", + pending: "Pending", + approved: "Approved", + rejected: "Rejected", +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatFileSize(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function StatusBadge({ status }: { status: SubmissionStatus }) { + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} + +function SubmissionCard({ + submission, + isExpanded, + onToggle, +}: { + submission: SubmissionHistoryRow; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( + + + + + +

{submission.description}

+
+ Task #{submission.taskId} + {submission.workUrl && ( + e.stopPropagation()} + className="inline-flex items-center gap-1 text-xs text-[#8B92E8] hover:underline" + > + + View work + + )} + {submission.files.length > 0 && ( + + {submission.files.length} file{submission.files.length > 1 ? "s" : ""} + + )} +
+ + {isExpanded && ( +
+ {/* Full description */} +
+

+ Description +

+

+ {submission.description} +

+
+ + {/* Work URL */} + {submission.workUrl && ( +
+

+ + Work URL +

+ + {submission.workUrl} + +
+ )} + + {/* Attached files */} + {submission.files.length > 0 && ( +
+

+ + Attached files +

+
    + {submission.files.map((file) => ( +
  • + {file.name} + + {formatFileSize(file.size)} • {file.detectedMimeType || file.kind} + +
  • + ))} +
+
+ )} + + {/* Metadata */} +
+

+ + Details +

+
+
+ Submission ID + #{submission.id} +
+
+ Task status + {submission.taskStatus} +
+
+ Submitted + + {formatDate(submission.submittedAt)} at {formatTime(submission.submittedAt)} + +
+
+
+
+ )} +
+
+ ); +} + +export default function MySubmissionsPage() { + const [submissions, setSubmissions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [walletAddress, setWalletAddress] = useState(null); + const [statusTab, setStatusTab] = useState("all"); + const [expandedId, setExpandedId] = useState(null); + + // Track the connected wallet so the history follows the active account. + useEffect(() => { + let cancelled = false; + + async function syncWallet() { + const key = await getPublicKey(); + if (!cancelled) { + setWalletAddress(key ?? null); + } + } + + syncWallet(); + const interval = setInterval(syncWallet, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + useEffect(() => { + if (!walletAddress) { + setSubmissions([]); + setIsLoading(false); + setError(null); + return; + } + + const controller = new AbortController(); + + async function run() { + setIsLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ contributor: walletAddress! }); + const response = await fetch(`/api/my-submissions?${params.toString()}`, { + signal: controller.signal, + }); + const body = await response.json(); + + if (!response.ok || !body.ok) { + throw new Error(body.error ?? "Failed to load submission history."); + } + + setSubmissions(body.submissions); + } catch (err) { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Failed to load submission history."); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + } + + run(); + + return () => controller.abort(); + }, [walletAddress]); + + const counts = useMemo(() => { + const byStatus: Record = { + all: submissions.length, + pending: 0, + approved: 0, + rejected: 0, + }; + for (const submission of submissions) { + byStatus[submission.status] += 1; + } + return byStatus; + }, [submissions]); + + const filteredSubmissions = useMemo( + () => + statusTab === "all" + ? submissions + : submissions.filter((submission) => submission.status === statusTab), + [submissions, statusTab], + ); + + const toggleExpanded = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + return ( +
+

My Submissions

+ + {!walletAddress && ( + + + +

Connect your wallet

+

+ Your submission history appears here once a Stellar wallet is connected. +

+
+
+ )} + + {walletAddress && isLoading && ( + + + Loading submission history… + + + )} + + {walletAddress && !isLoading && error && ( + + {error} + + )} + + {walletAddress && !isLoading && !error && submissions.length === 0 && ( + + + +

No submissions yet

+

+ Submit work on a bounty and it will show up in this history. +

+
+
+ )} + + {walletAddress && !isLoading && !error && submissions.length > 0 && ( + <> + {/* Status filter tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {filteredSubmissions.length === 0 ? ( + + + +

No {TAB_LABELS[statusTab].toLowerCase()} submissions

+

+ Try a different status filter to see more of your history. +

+
+
+ ) : ( +
+ {filteredSubmissions.map((submission) => ( + toggleExpanded(submission.id)} + /> + ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/app/api/my-submissions/route.ts b/frontend/src/app/api/my-submissions/route.ts new file mode 100644 index 0000000..23deb23 --- /dev/null +++ b/frontend/src/app/api/my-submissions/route.ts @@ -0,0 +1,44 @@ +import { listContributorSubmissions } from "@/lib/task-workflow"; +import { buildNoStoreJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/my-submissions?contributor=
+ * Submission history for a contributor: submitted date, status, task title. + */ +export async function GET(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const url = new URL(request.url); + const contributor = url.searchParams.get("contributor") ?? ""; + + const result = listContributorSubmissions(contributor); + + if (!result.ok) { + return buildNoStoreJson( + { + ok: false, + error: result.error, + details: result.details, + }, + result.status, + rateLimitHeaders, + ); + } + + return buildNoStoreJson( + { + ok: true, + submissions: result.submissions, + }, + 200, + rateLimitHeaders, + ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 2b057e7..5adb922 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -14,6 +14,7 @@ const NAV_ITEMS = [ { name: "Fundraising", href: "/fundraising" }, { name: "Transactions", href: "/user/transactions" }, { name: "Completed Tasks", href: "/completed-tasks" }, + { name: "My Submissions", href: "/my-submissions" }, { name: "Profile Analytics", href: "#" }, ]; diff --git a/frontend/src/components/NotificationBell.tsx b/frontend/src/components/NotificationBell.tsx index f55e032..f3b383f 100644 --- a/frontend/src/components/NotificationBell.tsx +++ b/frontend/src/components/NotificationBell.tsx @@ -7,6 +7,7 @@ import { useNotifications } from "@/hooks/useNotifications"; import type { NotificationRecord } from "@/types/notification"; const TYPE_LABELS: Record = { + grant_deadline_reminder: "Deadline reminder", bounty_created: "New bounty", submission_received: "New submission", submission_approved: "Approved", diff --git a/frontend/src/lib/grant-reminders.test.ts b/frontend/src/lib/grant-reminders.test.ts new file mode 100644 index 0000000..a2c221f --- /dev/null +++ b/frontend/src/lib/grant-reminders.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import type { GrantRecord, ReminderConfig } from "@/types/grant"; +import { DEFAULT_REMINDER_CONFIG } from "@/types/grant"; +import { resetNotificationStore } from "@/lib/notification-store"; +import { listDeadlineReminders, runDeadlineReminderSweep } from "@/lib/grant-reminders"; +import { createGrant, listLiveGrants, resetGrantStore } from "@/lib/grant-store"; + +const OWNER = "GOWNER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +function grant(overrides: Partial = {}): GrantRecord { + return { + id: "g1", + title: "Creative Europe", + funder: "European Commission", + // 6.5 days out: inside the 7-day window (earliest default), outside 3d/1d/6h. + deadline: Math.floor(Date.now() / 1000) + 6.5 * 24 * 60 * 60, + status: "active", + owner: OWNER, + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +afterEach(() => { + resetNotificationStore(); + resetGrantStore(); +}); + +describe("grant deadline reminders — acceptance criteria", () => { + it("1. fires a reminder when a deadline is approaching", () => { + // 6.5 days out: inside the 7-day window (earliest default). + const fired = new Set(); + const { reminders } = runDeadlineReminderSweep([grant()], OWNER, undefined, new Date(), fired); + + expect(reminders).toHaveLength(1); + expect(reminders[0].type).toBe("grant_deadline_reminder"); + expect(reminders[0].message).toContain("Creative Europe"); + }); + + it("1. does not fire before any reminder window is entered", () => { + // 10 days out; the earliest default window is 7 days → nothing due. + const far = grant({ deadline: Math.floor(Date.now() / 1000) + 10 * 24 * 60 * 60 }); + const fired = new Set(); + const { reminders } = runDeadlineReminderSweep([far], OWNER, undefined, new Date(), fired); + + expect(reminders).toHaveLength(0); + }); + + it("1. fires each configured window exactly once (no duplicates)", () => { + const fired = new Set(); + const g = grant(); // 6.5 days out → 7d window due on first sweep + const first = runDeadlineReminderSweep([g], OWNER, undefined, new Date(), fired); + expect(first.reminders).toHaveLength(1); + expect(first.reminders[0].message).toContain("7 days"); + + // Advance 4 days → 2.5 days out, now inside the 3-day window as well. + const later = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000); + const second = runDeadlineReminderSweep([g], OWNER, undefined, later, fired); + + expect(second.reminders).toHaveLength(1); + expect(second.reminders[0].message).toContain("3 days"); + + // Re-running immediately must not duplicate. + const third = runDeadlineReminderSweep([g], OWNER, undefined, later, fired); + expect(third.reminders).toHaveLength(0); + }); + + it("2. honours a custom reminder configuration", () => { + const config: ReminderConfig = { reminderOffsetsSeconds: [48 * 60 * 60] }; // 48h only + // 47h out: inside the custom 48h window, and no default windows apply. + const g = grant({ deadline: Math.floor(Date.now() / 1000) + 47 * 60 * 60 }); + + const fired = new Set(); + const { reminders } = runDeadlineReminderSweep([g], OWNER, config, new Date(), fired); + + expect(reminders).toHaveLength(1); + // 48h is formatted as "2 days" in the message. + expect(reminders[0].message).toContain("2 days"); + }); + + it("2. default config exposes 7d/3d/1d/6h offsets", () => { + expect(DEFAULT_REMINDER_CONFIG.reminderOffsetsSeconds).toEqual([ + 7 * 24 * 60 * 60, + 3 * 24 * 60 * 60, + 24 * 60 * 60, + 6 * 60 * 60, + ]); + }); + + it("3. expired grants generate no notifications", () => { + const expired = grant({ + deadline: Math.floor(Date.now() / 1000) - 3 * 24 * 60 * 60, // 3 days ago + }); + const fired = new Set(); + const { reminders, expiredGrantIds } = runDeadlineReminderSweep( + [expired], + OWNER, + undefined, + new Date(), + fired, + ); + + expect(reminders).toHaveLength(0); + expect(expiredGrantIds).toEqual([expired.id]); + expect(listDeadlineReminders(OWNER)).toHaveLength(0); + }); + + it("3. a grant that expires between sweeps stops reminding", () => { + const fired = new Set(); + const g = grant(); // 6.5 days out + + // First sweep fires the 7-day window. + const first = runDeadlineReminderSweep([g], OWNER, undefined, new Date(), fired); + expect(first.reminders).toHaveLength(1); + + // Time jumps past the deadline → no further notifications ever. + const afterDeadline = new Date(Date.now() + 11 * 24 * 60 * 60 * 1000); + const second = runDeadlineReminderSweep([g], OWNER, undefined, afterDeadline, fired); + expect(second.reminders).toHaveLength(0); + expect(second.expiredGrantIds).toEqual([g.id]); + }); + + it("ignores grants owned by other users", () => { + const other = grant({ owner: "GSOMEONE-ELSE" }); + const fired = new Set(); + const { reminders } = runDeadlineReminderSweep([other], OWNER, undefined, new Date(), fired); + expect(reminders).toHaveLength(0); + }); + + it("integrates with grant-store: live grants exclude expired ones", () => { + const soon = createGrant({ + title: "Near deadline", + funder: "F", + // 5h out: inside the 6h window only (7d/3d/1d windows not yet entered). + deadline: Math.floor(Date.now() / 1000) + 5 * 60 * 60, + owner: OWNER, + }); + createGrant({ + title: "Already gone", + funder: "F", + deadline: Math.floor(Date.now() / 1000) - 60, + owner: OWNER, + }); + + const live = listLiveGrants(OWNER); + expect(live.map((g) => g.id)).toEqual([soon.id]); + + const fired = new Set(); + // 5h out means every default window (7d/3d/1d/6h) is due at once. + const { reminders } = runDeadlineReminderSweep(live, OWNER, undefined, new Date(), fired); + expect(reminders).toHaveLength(4); + // Only the live grant reminded — the expired one is silent. + expect(reminders.every((r) => r.message.includes("Near deadline"))).toBe(true); + }); +}); diff --git a/frontend/src/lib/grant-reminders.ts b/frontend/src/lib/grant-reminders.ts new file mode 100644 index 0000000..d4d8bb8 --- /dev/null +++ b/frontend/src/lib/grant-reminders.ts @@ -0,0 +1,174 @@ +import type { GrantRecord, ReminderConfig } from "@/types/grant"; +import { DEFAULT_REMINDER_CONFIG } from "@/types/grant"; +import { + createNotification, + listNotifications, +} from "@/lib/notification-store"; + +/** + * Grant deadline reminder engine. + * + * Acceptance criteria implemented here: + * 1. Users receive reminders before deadlines — a reminder fires when `now` + * falls inside a configured reminder window (offset before deadline) and + * has not already fired for that window. + * 2. Reminder timing is configurable — pass a ReminderConfig with custom + * `reminderOffsetsSeconds` (defaults: 7d / 3d / 1d / 6h before deadline). + * 3. Expired grants no longer generate notifications — grants whose deadline + * has passed are skipped entirely, and expired grants are marked so + * callers can prune them from future sweeps. + */ + +/** Message shown in the notification for a given reminder offset. */ +function formatOffsetLabel(secondsBefore: number): string { + if (secondsBefore % (24 * 60 * 60) === 0) { + const days = secondsBefore / (24 * 60 * 60); + return days === 1 ? "1 day" : `${days} days`; + } + if (secondsBefore % (60 * 60) === 0) { + const hours = secondsBefore / (60 * 60); + return hours === 1 ? "1 hour" : `${hours} hours`; + } + return `${secondsBefore} seconds`; +} + +/** + * Which configured reminder windows does `now` fall inside for this grant? + * A window (offset) is "due" when: + * deadline - offset <= now (we have entered the window) + * and the window has not already fired. Windows whose full period has + * elapsed (now > deadline - offset + fireWindowSeconds) without firing are + * still delivered late on the next sweep — a late reminder beats no + * reminder — unless the deadline itself has passed. + */ +function dueOffsets( + grant: GrantRecord, + config: ReminderConfig, + nowSeconds: number, + firedWindows: Set, +): number[] { + return config.reminderOffsetsSeconds + .filter((offset) => offset > 0) + .filter((offset) => grant.deadline - offset <= nowSeconds) + .filter((offset) => !firedWindows.has(offset)); +} + +/** Dedupe key for a fired reminder: one reminder per grant per window. */ +function windowKey(grantId: string, offset: number): string { + return `${grantId}::${offset}`; +} + +/** + * Run one reminder sweep over the given grants. + * + * @param grants Grants to consider (saved + active). Expired grants are + * ignored and reported back via `expiredGrantIds`. + * @param userId Recipient for reminders (grant owner). + * @param config Reminder timing configuration. + * @param now Current time. + * @param firedWindows Set of dedupe keys from previous sweeps; updated + * in place so repeated sweeps do not re-fire windows. + * + * @returns created notifications, plus ids of grants detected as expired. + */ +export function runDeadlineReminderSweep( + grants: GrantRecord[], + userId: string, + config: ReminderConfig = DEFAULT_REMINDER_CONFIG, + now: Date = new Date(), + firedWindows: Set = new Set(), +): { + reminders: ReturnType[]; + expiredGrantIds: string[]; +} { + const nowSeconds = Math.floor(now.getTime() / 1000); + const reminders: ReturnType[] = []; + const expiredGrantIds: string[] = []; + + for (const grant of grants) { + // Ownership check: only remind the grant's owner. + if (grant.owner !== userId) continue; + + // Acceptance criterion 3: expired grants never generate notifications. + if (grant.deadline <= nowSeconds) { + expiredGrantIds.push(grant.id); + continue; + } + + const previouslyFired = new Set( + Array.from(firedWindows) + .filter((key) => key.startsWith(`${grant.id}::`)) + .map((key) => Number(key.split("::")[1])), + ); + + for (const offset of dueOffsets(grant, config, nowSeconds, previouslyFired)) { + const label = formatOffsetLabel(offset); + reminders.push( + createNotification( + { + userId, + type: "grant_deadline_reminder", + title: "Grant deadline approaching", + message: `${grant.title} (${grant.funder}) deadline is in ${label}.`, + taskId: undefined, + submissionId: undefined, + }, + now, + ), + ); + firedWindows.add(windowKey(grant.id, offset)); + } + } + + return { reminders, expiredGrantIds }; +} + +/** + * Convenience wrapper: sweep with in-memory dedupe state held per user. + * Suitable for the current in-process store; swap in persistent state when + * the notification store moves to a database. + */ +const firedWindowsByUser = new Map>(); + +export function sweepGrantDeadlines( + grants: GrantRecord[], + userId: string, + config: ReminderConfig = DEFAULT_REMINDER_CONFIG, + now: Date = new Date(), +): ReturnType { + let fired = firedWindowsByUser.get(userId); + if (!fired) { + fired = new Set(); + firedWindowsByUser.set(userId, fired); + } + return runDeadlineReminderSweep(grants, userId, config, now, fired); +} + +/** Test helper: clear all dedupe state. */ +export function resetGrantReminderState(): void { + firedWindowsByUser.clear(); +} + +/** + * Has every configured reminder window already fired (or become + * unreachable) for this grant? Callers can use this to stop scheduling + * sweeps for grants with nothing left to remind about. + */ +export function hasPendingReminders( + grant: GrantRecord, + config: ReminderConfig = DEFAULT_REMINDER_CONFIG, + now: Date = new Date(), +): boolean { + const nowSeconds = Math.floor(now.getTime() / 1000); + if (grant.deadline <= nowSeconds) return false; // expired: nothing pending + return config.reminderOffsetsSeconds.some( + (offset) => offset > 0 && grant.deadline - offset <= nowSeconds, + ) || config.reminderOffsetsSeconds.some((offset) => offset > 0); +} + +/** All deadline reminders already created for a user (newest first). */ +export function listDeadlineReminders(userId: string) { + return listNotifications(userId).filter( + (n) => n.type === "grant_deadline_reminder", + ); +} diff --git a/frontend/src/lib/grant-store.ts b/frontend/src/lib/grant-store.ts new file mode 100644 index 0000000..838a302 --- /dev/null +++ b/frontend/src/lib/grant-store.ts @@ -0,0 +1,68 @@ +import type { GrantRecord, GrantStatus } from "@/types/grant"; + +/** + * In-memory grant store (mirrors the notification-store pattern). + * Holds grants users have saved or activated so the reminder engine can + * sweep them. Expired grants are kept for history but flagged. + */ +const grants = new Map(); +let nextGrantId = 1; + +export function createGrant(input: { + title: string; + funder: string; + deadline: number; // Unix seconds + owner: string; + status?: GrantStatus; + now?: Date; +}): GrantRecord { + const now = input.now ?? new Date(); + const grant: GrantRecord = { + id: String(nextGrantId++), + title: input.title.trim(), + funder: input.funder.trim(), + deadline: input.deadline, + status: input.status ?? "active", + owner: input.owner.trim(), + createdAt: now.toISOString(), + }; + grants.set(grant.id, grant); + return grant; +} + +export function getGrant(grantId: string): GrantRecord | undefined { + return grants.get(grantId); +} + +export function listGrants(owner?: string): GrantRecord[] { + const all = Array.from(grants.values()); + const filtered = owner ? all.filter((g) => g.owner === owner) : all; + return filtered.sort((a, b) => a.deadline - b.deadline); +} + +/** + * Sweep statuses: mark grants whose deadline has passed as `expired`. + * Returns the ids that transitioned on this call. + */ +export function expirePastGrants(now: Date = new Date()): string[] { + const nowSeconds = Math.floor(now.getTime() / 1000); + const expired: string[] = []; + for (const [id, grant] of grants.entries()) { + if (grant.status !== "expired" && grant.deadline <= nowSeconds) { + grants.set(id, { ...grant, status: "expired" }); + expired.push(id); + } + } + return expired; +} + +/** Grants that should still be considered by the reminder engine. */ +export function listLiveGrants(owner?: string, now: Date = new Date()): GrantRecord[] { + const nowSeconds = Math.floor(now.getTime() / 1000); + return listGrants(owner).filter((g) => g.deadline > nowSeconds); +} + +export function resetGrantStore(): void { + grants.clear(); + nextGrantId = 1; +} diff --git a/frontend/src/lib/my-submissions.test.ts b/frontend/src/lib/my-submissions.test.ts new file mode 100644 index 0000000..164eae8 --- /dev/null +++ b/frontend/src/lib/my-submissions.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { GET as getMySubmissions } from "@/app/api/my-submissions/route"; +import { POST as createTaskRoute } from "@/app/api/tasks/route"; +import { POST as submitTaskWorkRoute } from "@/app/api/tasks/[taskId]/submissions/route"; +import { POST as approveRoute } from "@/app/api/tasks/[taskId]/submissions/[submissionId]/approve/route"; +import { resetTaskWorkflowStore } from "@/lib/task-workflow"; +import { createPdfFile, taskRouteContext } from "@/test/fixtures"; +import { + CONTRIBUTOR_ADDRESS, + POSTER_ADDRESS, + VALID_TASK_DATA, +} from "@/test/mock-data"; + +let submissionCounter = 0; + +async function createTaskRequest() { + return createTaskRoute( + new Request("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + poster: POSTER_ADDRESS, + ...VALID_TASK_DATA, + reward: 1_000_000, + }), + }), + ); +} + +async function submitWork(taskId: string, description: string) { + const formData = new FormData(); + formData.append("contributor", CONTRIBUTOR_ADDRESS); + formData.append("description", description); + formData.append("files", createPdfFile(`history-${submissionCounter++}.pdf`)); + return submitTaskWorkRoute( + new Request(`http://localhost/api/tasks/${taskId}/submissions`, { + method: "POST", + body: formData, + }), + taskRouteContext(taskId), + ); +} + +function historyRequest(contributor: string) { + const params = new URLSearchParams({ contributor }); + return new Request(`http://localhost/api/my-submissions?${params.toString()}`); +} + +describe("submission history (my-submissions)", () => { + beforeEach(() => { + resetTaskWorkflowStore(); + submissionCounter = 0; + }); + + afterEach(() => { + resetTaskWorkflowStore(); + }); + + it("returns the contributor's submissions with task title, submitted date and status", async () => { + const created = await (await createTaskRequest()).json(); + const taskId = created.task.id as string; + + const submitResponse = await submitWork(taskId, "History check submission."); + expect(submitResponse.status).toBe(201); + + const historyResponse = await getMySubmissions(historyRequest(CONTRIBUTOR_ADDRESS)); + expect(historyResponse.status).toBe(200); + expect(historyResponse.headers.get("Cache-Control")).toBe("no-store"); + + const body = await historyResponse.json(); + expect(body.ok).toBe(true); + expect(body.submissions).toHaveLength(1); + expect(body.submissions[0]).toMatchObject({ + id: "1", + taskId, + contributor: CONTRIBUTOR_ADDRESS, + status: "pending", + taskTitle: VALID_TASK_DATA.title, + taskStatus: "in_progress", + }); + // Submitted date must be present and parseable. + expect(typeof body.submissions[0].submittedAt).toBe("string"); + expect(new Date(body.submissions[0].submittedAt).toString()).not.toBe("Invalid Date"); + }); + + it("reflects approval status changes in the history", async () => { + const created = await (await createTaskRequest()).json(); + const taskId = created.task.id as string; + const submitted = await (await submitWork(taskId, "Approve me.")).json(); + const submissionId = submitted.submission.id as string; + + const approveResponse = await approveRoute( + new Request(`http://localhost/api/tasks/${taskId}/submissions/${submissionId}/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ actor: POSTER_ADDRESS }), + }), + { params: Promise.resolve({ taskId, submissionId }) }, + ); + expect(approveResponse.status).toBe(200); + + const body = await (await getMySubmissions(historyRequest(CONTRIBUTOR_ADDRESS))).json(); + expect(body.submissions[0]).toMatchObject({ + id: submissionId, + status: "approved", + taskStatus: "completed", + }); + }); + + it("returns an empty list for an unknown contributor", async () => { + const body = await ( + await getMySubmissions(historyRequest("GUNKNOWN000000000000000000000000000000000000000000000")) + ).json(); + expect(body).toMatchObject({ ok: true, submissions: [] }); + }); + + it("returns 400 when the contributor parameter is missing", async () => { + const response = await getMySubmissions( + new Request("http://localhost/api/my-submissions"), + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body).toMatchObject({ ok: false, error: "Contributor address is required." }); + }); +}); diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts index e1a3337..22eb2c2 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -465,6 +465,39 @@ export function rejectSubmission( return { ok: true, task: { ...task }, submission: updatedSubmission }; } +/** + * Submission history for a contributor, newest first. Includes the task + * title so the UI can show one row per submission attempt. + */ +export function listContributorSubmissions( + contributor: string, + now: Date = new Date(), +): WorkflowResult<{ submissions: Array }> { + const who = contributor.trim(); + + if (!who) { + return { + ok: false, + status: 400, + error: "Contributor address is required.", + }; + } + + const rows = Array.from(submissions.values()) + .filter((submission) => submission.contributor === who) + .sort((a, b) => b.submittedAt.localeCompare(a.submittedAt)) + .map((submission) => { + const task = tasks.get(submission.taskId); + return { + ...submission, + taskTitle: task?.title ?? "", + taskStatus: task?.status ?? ("open" as TaskStatus), + }; + }); + + return { ok: true, submissions: rows }; +} + export function addComment( input: AddCommentInput, now: Date = new Date(), diff --git a/frontend/src/test/mock-data.ts b/frontend/src/test/mock-data.ts index d78f11f..f408d3c 100644 --- a/frontend/src/test/mock-data.ts +++ b/frontend/src/test/mock-data.ts @@ -54,6 +54,11 @@ export const POSTER_ADDRESS = "GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; */ export const CONTRIBUTOR_ADDRESS = "GCONTRIB1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +/** + * A second mock contributor address, for multi-contributor tests. + */ +export const OTHER_CONTRIBUTOR_ADDRESS = "GOTHERCONTRIB1234567890ABCDEFGHIJKLMNOP"; + // ============================================================================ // Email Mocks // ============================================================================ diff --git a/frontend/src/types/grant.ts b/frontend/src/types/grant.ts new file mode 100644 index 0000000..ec74373 --- /dev/null +++ b/frontend/src/types/grant.ts @@ -0,0 +1,39 @@ +export type GrantStatus = "saved" | "active" | "expired"; + +/** + * A grant the user has saved (bookmarked) or activated (applied for / + * is tracking). `deadline` is a Unix timestamp in seconds. + */ +export interface GrantRecord { + id: string; + /** Grant title, e.g. "Creative Europe – Co-operation Projects". */ + title: string; + /** Funder or organisation offering the grant. */ + funder: string; + /** Unix timestamp (seconds) of the application deadline. */ + deadline: number; + status: GrantStatus; + /** Wallet address of the user who saved/activated the grant. */ + owner: string; + createdAt: string; +} + +/** + * Reminder timing configuration. `reminderOffsetsSeconds` lists how long + * before the deadline reminders fire, e.g. [7 days, 3 days, 24h, 6h]. + * Duplicates are ignored; values must be positive. + */ +export interface ReminderConfig { + /** Offsets before the deadline (seconds) at which reminders fire. */ + reminderOffsetsSeconds: number[]; +} + +export const DEFAULT_REMINDER_CONFIG: ReminderConfig = { + // 7 days, 3 days, 1 day, 6 hours before the deadline. + reminderOffsetsSeconds: [ + 7 * 24 * 60 * 60, + 3 * 24 * 60 * 60, + 24 * 60 * 60, + 6 * 60 * 60, + ], +}; diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index 0540a7f..4f5c7d5 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -1,4 +1,5 @@ export type NotificationType = + | "grant_deadline_reminder" | "bounty_created" | "submission_received" | "submission_approved"