diff --git a/frontend/src/app/api/notifications/[id]/read/route.ts b/frontend/src/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..eae5344 --- /dev/null +++ b/frontend/src/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,55 @@ +import { markAsRead } from "@/lib/notification-store"; +import { buildNoStoreJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: Request, context: RouteContext) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { id } = await context.params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return buildNoStoreJson( + { ok: false, error: "Request body must be valid JSON." }, + 400, + rateLimitHeaders, + ); + } + + const userId = String( + (body as Record | null)?.userId ?? "", + ).trim(); + + if (!userId) { + return buildNoStoreJson( + { ok: false, error: "userId is required." }, + 400, + rateLimitHeaders, + ); + } + + const notification = markAsRead(userId, id); + + if (!notification) { + return buildNoStoreJson( + { ok: false, error: "Notification not found." }, + 404, + rateLimitHeaders, + ); + } + + return buildNoStoreJson({ ok: true, notification }, 200, rateLimitHeaders); +} diff --git a/frontend/src/app/api/notifications/read-all/route.ts b/frontend/src/app/api/notifications/read-all/route.ts new file mode 100644 index 0000000..ab4f3a5 --- /dev/null +++ b/frontend/src/app/api/notifications/read-all/route.ts @@ -0,0 +1,41 @@ +import { markAllAsRead } from "@/lib/notification-store"; +import { buildNoStoreJson } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return buildNoStoreJson( + { ok: false, error: "Request body must be valid JSON." }, + 400, + rateLimitHeaders, + ); + } + + const userId = String( + (body as Record | null)?.userId ?? "", + ).trim(); + + if (!userId) { + return buildNoStoreJson( + { ok: false, error: "userId is required." }, + 400, + rateLimitHeaders, + ); + } + + const updatedCount = markAllAsRead(userId); + + return buildNoStoreJson({ ok: true, updatedCount }, 200, rateLimitHeaders); +} diff --git a/frontend/src/app/api/notifications/route.ts b/frontend/src/app/api/notifications/route.ts new file mode 100644 index 0000000..e99ce28 --- /dev/null +++ b/frontend/src/app/api/notifications/route.ts @@ -0,0 +1,44 @@ +import { + getUnreadCount, + listNotifications, +} from "@/lib/notification-store"; +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) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { searchParams } = new URL(request.url); + const userId = (searchParams.get("userId") ?? "").trim(); + + if (!userId) { + return buildNoStoreJson( + { + ok: false, + error: "userId query parameter is required.", + }, + 400, + rateLimitHeaders, + ); + } + + const notifications = listNotifications(userId); + const unreadCount = getUnreadCount(userId); + + return buildNoStoreJson( + { + ok: true, + notifications, + unreadCount, + }, + 200, + rateLimitHeaders, + ); +} diff --git a/frontend/src/app/api/notifications/stream/route.ts b/frontend/src/app/api/notifications/stream/route.ts new file mode 100644 index 0000000..48bb9c1 --- /dev/null +++ b/frontend/src/app/api/notifications/stream/route.ts @@ -0,0 +1,89 @@ +import { + getUnreadCount, + listNotifications, + subscribe, +} from "@/lib/notification-store"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const HEARTBEAT_INTERVAL_MS = 25_000; + +function sseMessage(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +/** + * Server-Sent Events stream of live notifications for a user. + * Emits an initial `snapshot` with the current list + unread count, then a + * `notification` event per newly created record, plus periodic `ping` + * heartbeats to keep intermediary proxies from closing the connection. + */ +export async function GET(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { searchParams } = new URL(request.url); + const userId = (searchParams.get("userId") ?? "").trim(); + + if (!userId) { + return new Response( + JSON.stringify({ ok: false, error: "userId query parameter is required." }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const encoder = new TextEncoder(); + let unsubscribe: () => void = () => {}; + let heartbeat: ReturnType | undefined; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + sseMessage("snapshot", { + notifications: listNotifications(userId), + unreadCount: getUnreadCount(userId), + }), + ), + ); + + unsubscribe = subscribe(userId, (record) => { + controller.enqueue( + encoder.encode( + sseMessage("notification", { + notification: record, + unreadCount: getUnreadCount(userId), + }), + ), + ); + }); + + heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(": ping\n\n")); + } catch { + // Controller already closed; the interval is cleared on cancel(). + } + }, HEARTBEAT_INTERVAL_MS); + }, + cancel() { + unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + }, + }); + + return new Response(stream, { + headers: { + ...rateLimitHeaders, + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/frontend/src/app/api/tasks/[taskId]/comments/route.ts b/frontend/src/app/api/tasks/[taskId]/comments/route.ts new file mode 100644 index 0000000..a059eec --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/comments/route.ts @@ -0,0 +1,53 @@ +import { addComment } 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"; + +type RouteContext = { + params: Promise<{ taskId: string }>; +}; + +export async function POST(request: Request, context: RouteContext) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { taskId } = await context.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 submissionId = payload.submissionId + ? String(payload.submissionId) + : undefined; + + const result = addComment({ + taskId, + submissionId, + author: String(payload.author ?? ""), + message: String(payload.message ?? ""), + }); + + if (!result.ok) { + return buildNoStoreJson( + { ok: false, error: result.error, details: result.details }, + result.status, + rateLimitHeaders, + ); + } + + return buildNoStoreJson({ ok: true, comment: result.comment }, 201, rateLimitHeaders); +} diff --git a/frontend/src/app/api/tasks/[taskId]/submissions/[submissionId]/approve/route.ts b/frontend/src/app/api/tasks/[taskId]/submissions/[submissionId]/approve/route.ts new file mode 100644 index 0000000..4f0f485 --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/submissions/[submissionId]/approve/route.ts @@ -0,0 +1,57 @@ +import { approveSubmission } 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"; + +type RouteContext = { + params: Promise<{ taskId: string; submissionId: string }>; +}; + +export async function POST(request: Request, context: RouteContext) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { taskId, submissionId } = await context.params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return buildNoStoreJson( + { ok: false, error: "Request body must be valid JSON." }, + 400, + rateLimitHeaders, + ); + } + + const actor = String((body as Record | null)?.actor ?? "").trim(); + + if (!actor) { + return buildNoStoreJson( + { ok: false, error: "actor is required." }, + 400, + rateLimitHeaders, + ); + } + + const result = approveSubmission(taskId, submissionId, actor); + + if (!result.ok) { + return buildNoStoreJson( + { ok: false, error: result.error, details: result.details }, + result.status, + rateLimitHeaders, + ); + } + + return buildNoStoreJson( + { ok: true, task: result.task, submission: result.submission }, + 200, + rateLimitHeaders, + ); +} diff --git a/frontend/src/app/api/tasks/[taskId]/submissions/[submissionId]/reject/route.ts b/frontend/src/app/api/tasks/[taskId]/submissions/[submissionId]/reject/route.ts new file mode 100644 index 0000000..78cfa52 --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/submissions/[submissionId]/reject/route.ts @@ -0,0 +1,57 @@ +import { rejectSubmission } 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"; + +type RouteContext = { + params: Promise<{ taskId: string; submissionId: string }>; +}; + +export async function POST(request: Request, context: RouteContext) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { taskId, submissionId } = await context.params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return buildNoStoreJson( + { ok: false, error: "Request body must be valid JSON." }, + 400, + rateLimitHeaders, + ); + } + + const actor = String((body as Record | null)?.actor ?? "").trim(); + + if (!actor) { + return buildNoStoreJson( + { ok: false, error: "actor is required." }, + 400, + rateLimitHeaders, + ); + } + + const result = rejectSubmission(taskId, submissionId, actor); + + if (!result.ok) { + return buildNoStoreJson( + { ok: false, error: result.error, details: result.details }, + result.status, + rateLimitHeaders, + ); + } + + return buildNoStoreJson( + { ok: true, task: result.task, submission: result.submission }, + 200, + rateLimitHeaders, + ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 5ad023b..09eec28 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -3,7 +3,9 @@ import { useState, useEffect } from "react"; import { Menu, X } from "lucide-react"; import ConnectWalletButton from "./ConnectWalletButton"; +import NotificationBell from "./NotificationBell"; import Image from "next/image"; +import { getPublicKey } from "@/hooks/stellar-wallets-kit"; const NAV_ITEMS = [ { name: "Overview", href: "/user/overview" }, @@ -16,6 +18,27 @@ const NAV_ITEMS = [ export function Navbar() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [walletAddress, setWalletAddress] = useState(null); + + // Poll the connected wallet address so the notification bell can subscribe + // to the right user's stream once a wallet connects (or disconnects). + 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); + }; + }, []); // Close menu when window is resized to desktop useEffect(() => { @@ -69,12 +92,14 @@ export function Navbar() { {/* Connect Button (Desktop) */} -
+
+
{/* Mobile menu button */}
+ + + {open && ( +
+
+ Notifications + +
+ + {notifications.length === 0 ? ( +

+ No notifications yet. +

+ ) : ( +
    + {notifications.map((notification) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useNotificationPreferences.test.ts b/frontend/src/hooks/useNotificationPreferences.test.ts index e1006c1..4c1585d 100644 --- a/frontend/src/hooks/useNotificationPreferences.test.ts +++ b/frontend/src/hooks/useNotificationPreferences.test.ts @@ -15,7 +15,6 @@ import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS, NOTIFICATION_PREFS_KEY, - type NotificationCategory, type NotificationPreferences, } from "./useNotificationPreferences"; @@ -105,17 +104,10 @@ describe("NOTIFICATION_CATEGORY_LABELS", () => { // Storage round-trip (import helpers under a controlled window stub) // --------------------------------------------------------------------------- -async function getHelpers() { - vi.resetModules(); - const mod = await import("./useNotificationPreferences"); - return mod; -} - describe("localStorage persistence helpers", () => { - it("loadFromStorage returns null when no key is stored", async () => { - const { loadFromStorage } = await import("./useNotificationPreferences") as any; - // loadFromStorage is not exported — tested indirectly via saveToStorage below - // We verify round-trip via save + item presence + it("loadFromStorage returns null when no key is stored", () => { + // loadFromStorage is internal; its contract is verified indirectly via + // the storage round-trip below. expect(localStorageMock.getItem(NOTIFICATION_PREFS_KEY)).toBeNull(); }); @@ -208,12 +200,6 @@ describe("preference mutation helpers", () => { }); it("reset restores defaults", () => { - const modified: NotificationPreferences = { - ...DEFAULT_NOTIFICATION_PREFERENCES, - payments: false, - disputes: false, - }; - // After reset we should get defaults back const afterReset: NotificationPreferences = { ...DEFAULT_NOTIFICATION_PREFERENCES }; diff --git a/frontend/src/hooks/useNotificationPreferences.ts b/frontend/src/hooks/useNotificationPreferences.ts index 9672563..184238b 100644 --- a/frontend/src/hooks/useNotificationPreferences.ts +++ b/frontend/src/hooks/useNotificationPreferences.ts @@ -177,18 +177,20 @@ function removeFromStorage(): void { * ``` */ export function useNotificationPreferences(): UseNotificationPreferencesReturn { + // Lazy initializer reads localStorage on the client's first render, so no + // setState call is needed inside an effect to sync the loaded value in. const [preferences, setPreferences] = useState( - DEFAULT_NOTIFICATION_PREFERENCES, + () => loadFromStorage() ?? DEFAULT_NOTIFICATION_PREFERENCES, ); const [isLoading, setIsLoading] = useState(true); - // Load persisted preferences on mount (client-side only). + // Preferences are already loaded via the lazy initializer above; this + // effect only flips the loading flag once the client has mounted so the + // server-rendered skeleton (loading=true) matches the initial client render. + // The flip is deferred to a microtask (rather than called synchronously in + // the effect body) to avoid cascading renders within the same commit. useEffect(() => { - const stored = loadFromStorage(); - if (stored) { - setPreferences(stored); - } - setIsLoading(false); + queueMicrotask(() => setIsLoading(false)); }, []); const toggle = useCallback((category: NotificationCategory) => { diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..6f12dcb --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -0,0 +1,108 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { NotificationRecord } from "@/types/notification"; + +export interface UseNotificationsReturn { + notifications: NotificationRecord[]; + unreadCount: number; + isConnected: boolean; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; +} + +interface SnapshotEvent { + notifications: NotificationRecord[]; + unreadCount: number; +} + +interface NotificationEvent { + notification: NotificationRecord; + unreadCount: number; +} + +/** + * Subscribes to the live notification stream for `userId` over + * Server-Sent Events, falling back to an inert empty state until a userId + * (e.g. a connected wallet address) is available. + */ +export function useNotifications(userId: string | null): UseNotificationsReturn { + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isConnected, setIsConnected] = useState(false); + const sourceRef = useRef(null); + + useEffect(() => { + if (!userId || typeof window === "undefined") { + return; + } + + const source = new EventSource( + `/api/notifications/stream?userId=${encodeURIComponent(userId)}`, + ); + sourceRef.current = source; + + source.addEventListener("open", () => setIsConnected(true)); + source.addEventListener("error", () => setIsConnected(false)); + + source.addEventListener("snapshot", (event) => { + const data = JSON.parse((event as MessageEvent).data) as SnapshotEvent; + setNotifications(data.notifications); + setUnreadCount(data.unreadCount); + }); + + source.addEventListener("notification", (event) => { + const data = JSON.parse((event as MessageEvent).data) as NotificationEvent; + setNotifications((prev) => [data.notification, ...prev]); + setUnreadCount(data.unreadCount); + }); + + // Runs on userId change and on unmount — clears stale state from the + // previous subscription rather than setting state synchronously in the + // effect body. + return () => { + source.close(); + sourceRef.current = null; + setNotifications([]); + setUnreadCount(0); + setIsConnected(false); + }; + }, [userId]); + + const markAsRead = useCallback( + async (id: string) => { + if (!userId) return; + + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)), + ); + setUnreadCount((prev) => Math.max(0, prev - 1)); + + await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }).catch(() => { + // Best-effort — the SSE snapshot will reconcile state on reconnect. + }); + }, + [userId], + ); + + const markAllAsRead = useCallback(async () => { + if (!userId) return; + + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + setUnreadCount(0); + + await fetch("/api/notifications/read-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }).catch(() => { + // Best-effort — the SSE snapshot will reconcile state on reconnect. + }); + }, [userId]); + + return { notifications, unreadCount, isConnected, markAsRead, markAllAsRead }; +} diff --git a/frontend/src/lib/notification-store.test.ts b/frontend/src/lib/notification-store.test.ts new file mode 100644 index 0000000..5d3d900 --- /dev/null +++ b/frontend/src/lib/notification-store.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + BROADCAST_USER_ID, + createNotification, + getUnreadCount, + listNotifications, + markAllAsRead, + markAsRead, + resetNotificationStore, + subscribe, +} from "@/lib/notification-store"; + +describe("notification-store", () => { + afterEach(() => { + resetNotificationStore(); + }); + + it("creates a notification with an incrementing id and unread by default", () => { + const a = createNotification({ + userId: "alice", + type: "bounty_created", + title: "New bounty", + message: "hello", + }); + const b = createNotification({ + userId: "alice", + type: "bounty_created", + title: "New bounty 2", + message: "hello again", + }); + + expect(a.id).not.toBe(b.id); + expect(a.read).toBe(false); + expect(b.read).toBe(false); + }); + + it("lists notifications for a user, newest first", () => { + createNotification( + { userId: "alice", type: "bounty_created", title: "First", message: "m" }, + new Date("2026-01-01T00:00:00.000Z"), + ); + createNotification( + { userId: "alice", type: "bounty_created", title: "Second", message: "m" }, + new Date("2026-01-02T00:00:00.000Z"), + ); + + const list = listNotifications("alice"); + expect(list).toHaveLength(2); + expect(list[0].title).toBe("Second"); + expect(list[1].title).toBe("First"); + }); + + it("excludes notifications belonging to other users", () => { + createNotification({ userId: "alice", type: "bounty_created", title: "A", message: "m" }); + createNotification({ userId: "bob", type: "bounty_created", title: "B", message: "m" }); + + expect(listNotifications("alice")).toHaveLength(1); + expect(listNotifications("bob")).toHaveLength(1); + }); + + it("delivers broadcast notifications to every user", () => { + createNotification({ + userId: BROADCAST_USER_ID, + type: "bounty_created", + title: "New bounty", + message: "everyone sees this", + }); + + expect(listNotifications("alice")).toHaveLength(1); + expect(listNotifications("bob")).toHaveLength(1); + }); + + it("tracks unread count and marks individual notifications as read", () => { + const n1 = createNotification({ userId: "alice", type: "bounty_created", title: "A", message: "m" }); + createNotification({ userId: "alice", type: "bounty_created", title: "B", message: "m" }); + + expect(getUnreadCount("alice")).toBe(2); + + const updated = markAsRead("alice", n1.id); + expect(updated?.read).toBe(true); + expect(getUnreadCount("alice")).toBe(1); + }); + + it("does not let one user mark another user's notification as read", () => { + const n1 = createNotification({ userId: "alice", type: "bounty_created", title: "A", message: "m" }); + + const result = markAsRead("bob", n1.id); + expect(result).toBeNull(); + expect(getUnreadCount("alice")).toBe(1); + }); + + it("marks all notifications as read for a user and returns the count updated", () => { + createNotification({ userId: "alice", type: "bounty_created", title: "A", message: "m" }); + createNotification({ userId: "alice", type: "bounty_created", title: "B", message: "m" }); + createNotification({ userId: "bob", type: "bounty_created", title: "C", message: "m" }); + + const updatedCount = markAllAsRead("alice"); + + expect(updatedCount).toBe(2); + expect(getUnreadCount("alice")).toBe(0); + expect(getUnreadCount("bob")).toBe(1); + }); + + it("notifies subscribers instantly when a matching notification is created", () => { + const listener = vi.fn(); + const unsubscribe = subscribe("alice", listener); + + createNotification({ userId: "alice", type: "submission_received", title: "A", message: "m" }); + createNotification({ userId: "bob", type: "submission_received", title: "B", message: "m" }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toMatchObject({ userId: "alice", title: "A" }); + + unsubscribe(); + createNotification({ userId: "alice", type: "submission_received", title: "C", message: "m" }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("delivers broadcast notifications to subscribers regardless of userId", () => { + const listener = vi.fn(); + subscribe("alice", listener); + + createNotification({ + userId: BROADCAST_USER_ID, + type: "bounty_created", + title: "Broadcast", + message: "m", + }); + + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/notification-store.ts b/frontend/src/lib/notification-store.ts new file mode 100644 index 0000000..b3172ed --- /dev/null +++ b/frontend/src/lib/notification-store.ts @@ -0,0 +1,119 @@ +import { EventEmitter } from "node:events"; +import type { + CreateNotificationInput, + NotificationRecord, +} from "@/types/notification"; + +/** Recipient value meaning "deliver to every connected user". */ +export const BROADCAST_USER_ID = "*"; + +const notifications = new Map(); +let nextId = 1; + +// EventEmitter powers both the SSE stream and any in-process subscribers. +// Raised to avoid MaxListenersExceededWarning when many clients connect. +const emitter = new EventEmitter(); +emitter.setMaxListeners(0); + +const NOTIFICATION_EVENT = "notification"; + +function isForUser(record: NotificationRecord, userId: string): boolean { + return record.userId === userId || record.userId === BROADCAST_USER_ID; +} + +/** + * Create a notification and publish it to any live subscribers. + * `userId` may be a specific wallet address or `BROADCAST_USER_ID` ("*") + * to notify every connected client (e.g. "new bounty created"). + */ +export function createNotification( + input: CreateNotificationInput, + now: Date = new Date(), +): NotificationRecord { + const record: NotificationRecord = { + id: String(nextId++), + userId: input.userId, + type: input.type, + title: input.title, + message: input.message, + taskId: input.taskId, + submissionId: input.submissionId, + read: false, + createdAt: now.toISOString(), + }; + + notifications.set(record.id, record); + emitter.emit(NOTIFICATION_EVENT, record); + + return record; +} + +/** List notifications for a user (specific + broadcast), newest first. */ +export function listNotifications(userId: string): NotificationRecord[] { + return Array.from(notifications.values()) + .filter((record) => isForUser(record, userId)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export function getUnreadCount(userId: string): number { + let count = 0; + for (const record of notifications.values()) { + if (isForUser(record, userId) && !record.read) { + count += 1; + } + } + return count; +} + +export function markAsRead( + userId: string, + notificationId: string, +): NotificationRecord | null { + const record = notifications.get(notificationId); + if (!record || !isForUser(record, userId)) { + return null; + } + + if (!record.read) { + const updated: NotificationRecord = { ...record, read: true }; + notifications.set(notificationId, updated); + return updated; + } + + return record; +} + +export function markAllAsRead(userId: string): number { + let updatedCount = 0; + for (const [id, record] of notifications.entries()) { + if (isForUser(record, userId) && !record.read) { + notifications.set(id, { ...record, read: true }); + updatedCount += 1; + } + } + return updatedCount; +} + +/** + * Subscribe to newly created notifications matching `userId` (or a + * broadcast). Returns an unsubscribe function. + */ +export function subscribe( + userId: string, + listener: (record: NotificationRecord) => void, +): () => void { + const handler = (record: NotificationRecord) => { + if (isForUser(record, userId)) { + listener(record); + } + }; + + emitter.on(NOTIFICATION_EVENT, handler); + return () => emitter.off(NOTIFICATION_EVENT, handler); +} + +export function resetNotificationStore() { + notifications.clear(); + nextId = 1; + emitter.removeAllListeners(NOTIFICATION_EVENT); +} diff --git a/frontend/src/lib/notifications-api.test.ts b/frontend/src/lib/notifications-api.test.ts new file mode 100644 index 0000000..8f2814f --- /dev/null +++ b/frontend/src/lib/notifications-api.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { GET as listNotificationsRoute } from "@/app/api/notifications/route"; +import { POST as markReadRoute } from "@/app/api/notifications/[id]/read/route"; +import { POST as markAllReadRoute } from "@/app/api/notifications/read-all/route"; +import { createNotification, resetNotificationStore } from "@/lib/notification-store"; + +function readRouteContext(id: string) { + return { params: Promise.resolve({ id }) }; +} + +describe("GET /api/notifications", () => { + afterEach(() => { + resetNotificationStore(); + }); + + it("requires a userId query parameter", async () => { + const response = await listNotificationsRoute( + new Request("http://localhost/api/notifications"), + ); + expect(response.status).toBe(400); + }); + + it("returns notifications and unread count for the given user", async () => { + createNotification({ userId: "alice", type: "bounty_created", title: "A", message: "m" }); + createNotification({ userId: "bob", type: "bounty_created", title: "B", message: "m" }); + + const response = await listNotificationsRoute( + new Request("http://localhost/api/notifications?userId=alice"), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.notifications).toHaveLength(1); + expect(body.unreadCount).toBe(1); + }); + + it("disables caching so unread counts are always live", async () => { + const response = await listNotificationsRoute( + new Request("http://localhost/api/notifications?userId=alice"), + ); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); +}); + +describe("POST /api/notifications/[id]/read", () => { + afterEach(() => { + resetNotificationStore(); + }); + + it("marks a notification as read for its owner", async () => { + const notification = createNotification({ + userId: "alice", + type: "bounty_created", + title: "A", + message: "m", + }); + + const response = await markReadRoute( + new Request(`http://localhost/api/notifications/${notification.id}/read`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: "alice" }), + }), + readRouteContext(notification.id), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.notification.read).toBe(true); + }); + + it("returns 404 when the notification does not belong to the requesting user", async () => { + const notification = createNotification({ + userId: "alice", + type: "bounty_created", + title: "A", + message: "m", + }); + + const response = await markReadRoute( + new Request(`http://localhost/api/notifications/${notification.id}/read`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: "bob" }), + }), + readRouteContext(notification.id), + ); + + expect(response.status).toBe(404); + }); +}); + +describe("POST /api/notifications/read-all", () => { + afterEach(() => { + resetNotificationStore(); + }); + + it("marks every unread notification for the user as read", async () => { + createNotification({ userId: "alice", type: "bounty_created", title: "A", message: "m" }); + createNotification({ userId: "alice", type: "bounty_created", title: "B", message: "m" }); + + const response = await markAllReadRoute( + new Request("http://localhost/api/notifications/read-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: "alice" }), + }), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.updatedCount).toBe(2); + }); +}); diff --git a/frontend/src/lib/rate-limit.test.ts b/frontend/src/lib/rate-limit.test.ts index eb52eb0..6513d92 100644 --- a/frontend/src/lib/rate-limit.test.ts +++ b/frontend/src/lib/rate-limit.test.ts @@ -200,8 +200,13 @@ describe("endpoint rate-limit integration", () => { process.env.API_RATE_LIMIT_WINDOW_MS = "60000"; }); + type RouteHandler = ( + request: Request, + context: { params: Promise<{ taskId: string }> }, + ) => Promise; + async function checkEndpointEnforces( - importFn: () => Promise<{ GET?: Function; POST?: Function }>, + importFn: () => Promise<{ GET?: RouteHandler; POST?: RouteHandler }>, method: "GET" | "POST", buildRequest: () => Request, ) { diff --git a/frontend/src/lib/task-workflow-notifications.test.ts b/frontend/src/lib/task-workflow-notifications.test.ts new file mode 100644 index 0000000..2fee4a3 --- /dev/null +++ b/frontend/src/lib/task-workflow-notifications.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + addComment, + approveSubmission, + createTask, + rejectSubmission, + resetTaskWorkflowStore, + submitTaskWork, +} from "@/lib/task-workflow"; +import { listNotifications, resetNotificationStore } from "@/lib/notification-store"; + +const POSTER = "GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const CONTRIBUTOR = "GCONTRIB1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +function futureDeadline(offsetSeconds = 86_400) { + return Math.floor(Date.now() / 1000) + offsetSeconds; +} + +function createSampleTask() { + const result = createTask({ + poster: POSTER, + title: "Design a logo", + description: "Deliver an SVG logo", + reward: 5_000_000, + deadline: futureDeadline(), + maxSubmissions: 2, + }); + if (!result.ok) throw new Error("setup: createTask failed"); + return result.task; +} + +function submitSampleWork(taskId: string) { + const result = submitTaskWork( + { + taskId, + contributor: CONTRIBUTOR, + description: "Here is the logo", + workUrl: "ipfs://logo", + }, + [], + ); + if (!result.ok) throw new Error("setup: submitTaskWork failed"); + return result.submission; +} + +describe("task-workflow notification triggers", () => { + afterEach(() => { + resetTaskWorkflowStore(); + resetNotificationStore(); + }); + + it("broadcasts a bounty_created notification when a task is created", () => { + const task = createSampleTask(); + + const everyoneSees = listNotifications("anyone-at-all"); + expect(everyoneSees).toHaveLength(1); + expect(everyoneSees[0]).toMatchObject({ + type: "bounty_created", + taskId: task.id, + }); + }); + + it("notifies the poster when a submission is received", () => { + const task = createSampleTask(); + const submission = submitSampleWork(task.id); + + const posterNotifications = listNotifications(POSTER).filter( + (n) => n.type === "submission_received", + ); + expect(posterNotifications).toHaveLength(1); + expect(posterNotifications[0]).toMatchObject({ + taskId: task.id, + submissionId: submission.id, + }); + }); + + it("notifies the contributor of approval and reward payment", () => { + const task = createSampleTask(); + const submission = submitSampleWork(task.id); + + const result = approveSubmission(task.id, submission.id, POSTER); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.submission.status).toBe("approved"); + expect(result.task.status).toBe("completed"); + + const contributorNotifications = listNotifications(CONTRIBUTOR).map((n) => n.type); + expect(contributorNotifications).toContain("submission_approved"); + expect(contributorNotifications).toContain("reward_paid"); + }); + + it("notifies the contributor when a submission is rejected", () => { + const task = createSampleTask(); + const submission = submitSampleWork(task.id); + + const result = rejectSubmission(task.id, submission.id, POSTER); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.submission.status).toBe("rejected"); + + const contributorNotifications = listNotifications(CONTRIBUTOR).filter( + (n) => n.type === "submission_rejected", + ); + expect(contributorNotifications).toHaveLength(1); + }); + + it("rejects approval attempts from a non-poster actor", () => { + const task = createSampleTask(); + const submission = submitSampleWork(task.id); + + const result = approveSubmission(task.id, submission.id, "GSOMEONE-ELSE"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(409); + }); + + it("prevents reviewing a submission twice", () => { + const task = createSampleTask(); + const submission = submitSampleWork(task.id); + + approveSubmission(task.id, submission.id, POSTER); + const second = approveSubmission(task.id, submission.id, POSTER); + + expect(second.ok).toBe(false); + }); + + it("notifies the poster when a contributor comments, and vice versa", () => { + const task = createSampleTask(); + const submission = submitSampleWork(task.id); + + const fromContributor = addComment({ + taskId: task.id, + submissionId: submission.id, + author: CONTRIBUTOR, + message: "Any feedback?", + }); + expect(fromContributor.ok).toBe(true); + + const posterComments = listNotifications(POSTER).filter( + (n) => n.type === "comment_added", + ); + expect(posterComments).toHaveLength(1); + + const fromPoster = addComment({ + taskId: task.id, + submissionId: submission.id, + author: POSTER, + message: "Looks great!", + }); + expect(fromPoster.ok).toBe(true); + + const contributorComments = listNotifications(CONTRIBUTOR).filter( + (n) => n.type === "comment_added", + ); + expect(contributorComments).toHaveLength(1); + }); + + it("rejects comments with an empty message", () => { + const task = createSampleTask(); + + const result = addComment({ taskId: task.id, author: POSTER, message: " " }); + expect(result.ok).toBe(false); + }); +}); diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts index 4d29708..4d95115 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -1,4 +1,6 @@ import type { + AddCommentInput, + CommentRecord, CreateTaskInput, SubmissionRecord, SubmitTaskInput, @@ -6,6 +8,10 @@ import type { TaskStatus, } from "@/types/task-workflow"; import type { ValidatedTaskSubmissionFile } from "@/lib/task-submission-files"; +import { + BROADCAST_USER_ID, + createNotification, +} from "@/lib/notification-store"; export const MIN_TASK_REWARD = 1_000_000; export const MAX_TASK_DEADLINE_OFFSET_SECONDS = 365 * 24 * 60 * 60; @@ -25,9 +31,11 @@ const tasks = new Map(); const submissions = new Map(); const taskSubmissions = new Map(); const contributorSubmissions = new Map>(); +const comments = new Map(); let nextTaskId = 1; let nextSubmissionId = 1; +let nextCommentId = 1; function validateCreateTaskInput(input: CreateTaskInput, nowSeconds: number): string[] { const errors: string[] = []; @@ -97,6 +105,17 @@ export function createTask( taskSubmissions.set(id, []); contributorSubmissions.set(id, new Set()); + createNotification( + { + userId: BROADCAST_USER_ID, + type: "bounty_created", + title: "New bounty created", + message: `${task.title} is now open for submissions.`, + taskId: task.id, + }, + now, + ); + return { ok: true, task }; } @@ -214,6 +233,18 @@ export function submitTaskWork( tasks.set(task.id, updatedTask); + createNotification( + { + userId: task.poster, + type: "submission_received", + title: "New submission received", + message: `${contributor} submitted work for "${task.title}".`, + taskId: task.id, + submissionId: submission.id, + }, + now, + ); + return { ok: true, task: { ...updatedTask }, @@ -221,11 +252,196 @@ export function submitTaskWork( }; } +export function approveSubmission( + taskId: string, + submissionId: string, + actor: string, + now: Date = new Date(), +): WorkflowResult<{ task: TaskRecord; submission: SubmissionRecord }> { + const taskResult = getTask(taskId); + if (!taskResult.ok) { + return taskResult; + } + + const task = tasks.get(taskId)!; + const submission = submissions.get(submissionId); + + if (!submission || submission.taskId !== taskId) { + return { ok: false, status: 404, error: "Submission not found." }; + } + + if (task.poster !== actor.trim()) { + return { + ok: false, + status: 409, + error: "Only the task poster can approve submissions.", + }; + } + + if (submission.status !== "pending") { + return { + ok: false, + status: 409, + error: "Submission has already been reviewed.", + details: [`Current status: ${submission.status}`], + }; + } + + const updatedSubmission: SubmissionRecord = { ...submission, status: "approved" }; + submissions.set(submissionId, updatedSubmission); + + const updatedTask: TaskRecord = { ...task, status: "completed" }; + tasks.set(taskId, updatedTask); + + createNotification( + { + userId: submission.contributor, + type: "submission_approved", + title: "Submission approved", + message: `Your submission for "${task.title}" was approved.`, + taskId: task.id, + submissionId: submission.id, + }, + now, + ); + + createNotification( + { + userId: submission.contributor, + type: "reward_paid", + title: "Reward paid", + message: `You received the reward for "${task.title}".`, + taskId: task.id, + submissionId: submission.id, + }, + now, + ); + + return { ok: true, task: updatedTask, submission: updatedSubmission }; +} + +export function rejectSubmission( + taskId: string, + submissionId: string, + actor: string, + now: Date = new Date(), +): WorkflowResult<{ task: TaskRecord; submission: SubmissionRecord }> { + const taskResult = getTask(taskId); + if (!taskResult.ok) { + return taskResult; + } + + const task = tasks.get(taskId)!; + const submission = submissions.get(submissionId); + + if (!submission || submission.taskId !== taskId) { + return { ok: false, status: 404, error: "Submission not found." }; + } + + if (task.poster !== actor.trim()) { + return { + ok: false, + status: 409, + error: "Only the task poster can reject submissions.", + }; + } + + if (submission.status !== "pending") { + return { + ok: false, + status: 409, + error: "Submission has already been reviewed.", + details: [`Current status: ${submission.status}`], + }; + } + + const updatedSubmission: SubmissionRecord = { ...submission, status: "rejected" }; + submissions.set(submissionId, updatedSubmission); + + createNotification( + { + userId: submission.contributor, + type: "submission_rejected", + title: "Submission rejected", + message: `Your submission for "${task.title}" was rejected.`, + taskId: task.id, + submissionId: submission.id, + }, + now, + ); + + return { ok: true, task: { ...task }, submission: updatedSubmission }; +} + +export function addComment( + input: AddCommentInput, + now: Date = new Date(), +): WorkflowResult<{ comment: CommentRecord }> { + const taskResult = getTask(input.taskId); + if (!taskResult.ok) { + return taskResult; + } + + const task = tasks.get(input.taskId)!; + const author = input.author.trim(); + + if (!author) { + return { ok: false, status: 400, error: "Comment author is required." }; + } + + if (!input.message.trim()) { + return { ok: false, status: 400, error: "Comment message is required." }; + } + + let submission: SubmissionRecord | undefined; + if (input.submissionId) { + submission = submissions.get(input.submissionId); + if (!submission || submission.taskId !== input.taskId) { + return { ok: false, status: 404, error: "Submission not found." }; + } + } + + const comment: CommentRecord = { + id: String(nextCommentId++), + taskId: input.taskId, + submissionId: input.submissionId, + author, + message: input.message.trim(), + createdAt: now.toISOString(), + }; + + comments.set(comment.id, comment); + + const recipient = submission + ? author === submission.contributor + ? task.poster + : submission.contributor + : task.poster; + + if (recipient !== author) { + createNotification( + { + userId: recipient, + type: "comment_added", + title: "New comment", + message: `${author} commented on "${task.title}".`, + taskId: task.id, + submissionId: input.submissionId, + }, + now, + ); + } + + return { ok: true, comment }; +} + export function resetTaskWorkflowStore() { tasks.clear(); submissions.clear(); taskSubmissions.clear(); contributorSubmissions.clear(); + comments.clear(); nextTaskId = 1; nextSubmissionId = 1; + nextCommentId = 1; } diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts new file mode 100644 index 0000000..0540a7f --- /dev/null +++ b/frontend/src/types/notification.ts @@ -0,0 +1,30 @@ +export type NotificationType = + | "bounty_created" + | "submission_received" + | "submission_approved" + | "submission_rejected" + | "reward_paid" + | "comment_added"; + +export interface NotificationRecord { + id: string; + /** Recipient wallet address, or "*" for a platform-wide broadcast. */ + userId: string; + type: NotificationType; + title: string; + message: string; + /** Related task/submission ids, if any, so the UI can deep-link. */ + taskId?: string; + submissionId?: string; + read: boolean; + createdAt: string; +} + +export interface CreateNotificationInput { + userId: string; + type: NotificationType; + title: string; + message: string; + taskId?: string; + submissionId?: string; +} diff --git a/frontend/src/types/task-workflow.ts b/frontend/src/types/task-workflow.ts index 39edb1b..e7f76c8 100644 --- a/frontend/src/types/task-workflow.ts +++ b/frontend/src/types/task-workflow.ts @@ -47,3 +47,19 @@ export interface SubmitTaskInput { description: string; workUrl?: string; } + +export interface CommentRecord { + id: string; + taskId: string; + submissionId?: string; + author: string; + message: string; + createdAt: string; +} + +export interface AddCommentInput { + taskId: string; + submissionId?: string; + author: string; + message: string; +}