Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 407 additions & 0 deletions frontend/src/app/(dashboard)/my-submissions/page.tsx

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions frontend/src/app/api/my-submissions/route.ts
Original file line number Diff line number Diff line change
@@ -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=<address>
* 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,
);
}
1 change: 1 addition & 0 deletions frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "#" },
];

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/NotificationBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useNotifications } from "@/hooks/useNotifications";
import type { NotificationRecord } from "@/types/notification";

const TYPE_LABELS: Record<NotificationRecord["type"], string> = {
grant_deadline_reminder: "Deadline reminder",
bounty_created: "New bounty",
submission_received: "New submission",
submission_approved: "Approved",
Expand Down
156 changes: 156 additions & 0 deletions frontend/src/lib/grant-reminders.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
// 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);
});
});
174 changes: 174 additions & 0 deletions frontend/src/lib/grant-reminders.ts
Original file line number Diff line number Diff line change
@@ -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>,
): 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<string> = new Set<string>(),
): {
reminders: ReturnType<typeof createNotification>[];
expiredGrantIds: string[];
} {
const nowSeconds = Math.floor(now.getTime() / 1000);
const reminders: ReturnType<typeof createNotification>[] = [];
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<string, Set<string>>();

export function sweepGrantDeadlines(
grants: GrantRecord[],
userId: string,
config: ReminderConfig = DEFAULT_REMINDER_CONFIG,
now: Date = new Date(),
): ReturnType<typeof runDeadlineReminderSweep> {
let fired = firedWindowsByUser.get(userId);
if (!fired) {
fired = new Set<string>();
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",
);
}
Loading
Loading