diff --git a/app/api/routes-f/block-user/__tests__/route.test.ts b/app/api/routes-f/block-user/__tests__/route.test.ts new file mode 100644 index 00000000..2870d560 --- /dev/null +++ b/app/api/routes-f/block-user/__tests__/route.test.ts @@ -0,0 +1,101 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, GET, DELETE, _resetStore } from "../route"; +import { POST as checkPOST } from "../check/route"; + +function makeReq(method: string, url: string, body?: unknown) { + return new NextRequest(`http://localhost${url}`, { + method, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + headers: { "Content-Type": "application/json" }, + }); +} + +const BASE = "/api/routes-f/block-user"; + +describe("Block User API", () => { + beforeEach(() => _resetStore()); + + // ── POST block ──────────────────────────────────────────────────────────── + + it("blocks a user (POST)", async () => { + const res = await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2", reason: "spam" })); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.blocked_at).toBeDefined(); + }); + + it("400 when blocker_id is missing", async () => { + const res = await POST(makeReq("POST", BASE, { blocked_id: "u2" })); + expect(res.status).toBe(400); + }); + + it("400 when user tries to block themselves", async () => { + const res = await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u1" })); + expect(res.status).toBe(400); + }); + + // ── GET list ────────────────────────────────────────────────────────────── + + it("lists blocked users (GET)", async () => { + await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" })); + await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u3" })); + const res = await GET(makeReq("GET", `${BASE}?blocker_id=u1`)); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.blocked).toHaveLength(2); + }); + + it("400 GET without blocker_id", async () => { + const res = await GET(makeReq("GET", BASE)); + expect(res.status).toBe(400); + }); + + // ── DELETE unblock ──────────────────────────────────────────────────────── + + it("unblocks a user (DELETE)", async () => { + await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" })); + const res = await DELETE(makeReq("DELETE", `${BASE}?blocker_id=u1&blocked_id=u2`)); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + }); + + it("404 DELETE on non-existent block", async () => { + const res = await DELETE(makeReq("DELETE", `${BASE}?blocker_id=x&blocked_id=y`)); + expect(res.status).toBe(404); + }); + + // ── POST /check ─────────────────────────────────────────────────────────── + + it("check returns none when no block exists", async () => { + const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" })); + const data = await res.json(); + expect(data.blocked).toBe(false); + expect(data.direction).toBe("none"); + }); + + it("check detects a_blocks_b direction", async () => { + await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" })); + const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" })); + const data = await res.json(); + expect(data.blocked).toBe(true); + expect(data.direction).toBe("a_blocks_b"); + }); + + it("check detects b_blocks_a direction", async () => { + await POST(makeReq("POST", BASE, { blocker_id: "u2", blocked_id: "u1" })); + const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" })); + const data = await res.json(); + expect(data.direction).toBe("b_blocks_a"); + }); + + it("check detects both directions", async () => { + await POST(makeReq("POST", BASE, { blocker_id: "u1", blocked_id: "u2" })); + await POST(makeReq("POST", BASE, { blocker_id: "u2", blocked_id: "u1" })); + const res = await checkPOST(makeReq("POST", `${BASE}/check`, { a: "u1", b: "u2" })); + const data = await res.json(); + expect(data.direction).toBe("both"); + }); +}); diff --git a/app/api/routes-f/block-user/check/route.ts b/app/api/routes-f/block-user/check/route.ts new file mode 100644 index 00000000..44da8fcd --- /dev/null +++ b/app/api/routes-f/block-user/check/route.ts @@ -0,0 +1,30 @@ +/** + * POST /api/routes-f/block-user/check + * { a, b } -> { blocked: boolean, direction: "a_blocks_b" | "b_blocks_a" | "both" | "none" } + */ +import { NextRequest, NextResponse } from "next/server"; +import { blockStore, blockKey } from "../store"; + +export async function POST(req: NextRequest) { + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { a, b } = body as { a?: unknown; b?: unknown }; + if (!a || typeof a !== "string") return NextResponse.json({ error: "a is required" }, { status: 400 }); + if (!b || typeof b !== "string") return NextResponse.json({ error: "b is required" }, { status: 400 }); + + const aBlocksB = blockStore.has(blockKey(a, b)); + const bBlocksA = blockStore.has(blockKey(b, a)); + + let direction: "a_blocks_b" | "b_blocks_a" | "both" | "none"; + if (aBlocksB && bBlocksA) direction = "both"; + else if (aBlocksB) direction = "a_blocks_b"; + else if (bBlocksA) direction = "b_blocks_a"; + else direction = "none"; + + return NextResponse.json({ blocked: aBlocksB || bBlocksA, direction }, { status: 200 }); +} diff --git a/app/api/routes-f/block-user/route.ts b/app/api/routes-f/block-user/route.ts new file mode 100644 index 00000000..fd7b5e8c --- /dev/null +++ b/app/api/routes-f/block-user/route.ts @@ -0,0 +1,81 @@ +/** + * Block a user — issue #992 + * + * POST { blocker_id, blocked_id, reason? } -> { blocked_at } + * DELETE ?blocker_id&blocked_id -> { success: true } + * GET ?blocker_id -> { blocked: BlockRecord[] } + */ +import { NextRequest, NextResponse } from "next/server"; +import { blockStore, blockKey, _resetStore } from "./store"; +import { BlockRecord } from "./types"; + +export { _resetStore }; + +function bad(msg: string) { + return NextResponse.json({ error: msg }, { status: 400 }); +} + +// ── POST ───────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + // Route /check is handled by the check sub-route; here we handle plain block + let body: Record; + try { + body = await req.json(); + } catch { + return bad("Invalid JSON body"); + } + + const { blocker_id, blocked_id, reason } = body as { + blocker_id?: unknown; + blocked_id?: unknown; + reason?: unknown; + }; + + if (!blocker_id || typeof blocker_id !== "string") return bad("blocker_id is required"); + if (!blocked_id || typeof blocked_id !== "string") return bad("blocked_id is required"); + if (blocker_id === blocked_id) return bad("A user cannot block themselves"); + + const record: BlockRecord = { + blocker_id, + blocked_id, + blocked_at: new Date().toISOString(), + ...(reason && typeof reason === "string" ? { reason } : {}), + }; + + blockStore.set(blockKey(blocker_id, blocked_id), record); + return NextResponse.json({ blocked_at: record.blocked_at }, { status: 201 }); +} + +// ── DELETE ─────────────────────────────────────────────────────────────────── + +export async function DELETE(req: NextRequest) { + const params = new URL(req.url).searchParams; + const blocker_id = params.get("blocker_id"); + const blocked_id = params.get("blocked_id"); + + if (!blocker_id) return bad("blocker_id is required"); + if (!blocked_id) return bad("blocked_id is required"); + + const key = blockKey(blocker_id, blocked_id); + if (!blockStore.has(key)) { + return NextResponse.json({ error: "Block not found" }, { status: 404 }); + } + + blockStore.delete(key); + return NextResponse.json({ success: true }, { status: 200 }); +} + +// ── GET ────────────────────────────────────────────────────────────────────── + +export async function GET(req: NextRequest) { + const blocker_id = new URL(req.url).searchParams.get("blocker_id"); + if (!blocker_id) return bad("blocker_id is required"); + + const blocked: BlockRecord[] = []; + for (const record of blockStore.values()) { + if (record.blocker_id === blocker_id) blocked.push(record); + } + + return NextResponse.json({ blocked }, { status: 200 }); +} diff --git a/app/api/routes-f/block-user/store.ts b/app/api/routes-f/block-user/store.ts new file mode 100644 index 00000000..6221cc9b --- /dev/null +++ b/app/api/routes-f/block-user/store.ts @@ -0,0 +1,12 @@ +import { BlockRecord } from "./types"; + +// Key: `${blocker_id}:${blocked_id}` +export const blockStore = new Map(); + +export function blockKey(blocker_id: string, blocked_id: string) { + return `${blocker_id}:${blocked_id}`; +} + +export function _resetStore() { + blockStore.clear(); +} diff --git a/app/api/routes-f/block-user/types.ts b/app/api/routes-f/block-user/types.ts new file mode 100644 index 00000000..b12053c5 --- /dev/null +++ b/app/api/routes-f/block-user/types.ts @@ -0,0 +1,6 @@ +export interface BlockRecord { + blocker_id: string; + blocked_id: string; + reason?: string; + blocked_at: string; +} diff --git a/app/api/routes-f/followed-feed/__tests__/route.test.ts b/app/api/routes-f/followed-feed/__tests__/route.test.ts new file mode 100644 index 00000000..44f32e09 --- /dev/null +++ b/app/api/routes-f/followed-feed/__tests__/route.test.ts @@ -0,0 +1,59 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET } from "../route"; + +function makeGet(viewer_id?: string) { + const url = viewer_id + ? `http://localhost/api/routes-f/followed-feed?viewer_id=${viewer_id}` + : "http://localhost/api/routes-f/followed-feed"; + return new NextRequest(url, { method: "GET" }); +} + +describe("GET /api/routes-f/followed-feed", () => { + it("400 when viewer_id is missing", async () => { + const res = await GET(makeGet()); + expect(res.status).toBe(400); + }); + + it("returns empty feed for viewer with no follows", async () => { + const res = await GET(makeGet("unknown-viewer")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.live).toHaveLength(0); + expect(data.offline_recently).toHaveLength(0); + }); + + it("returns all-live when followed creators are live (viewer-2 follows alpha+delta, alpha is live)", async () => { + const res = await GET(makeGet("viewer-2")); + expect(res.status).toBe(200); + const data = await res.json(); + // creator-alpha is live; creator-delta is offline_recently + expect(data.live.some((s: { creator_id: string }) => s.creator_id === "creator-alpha")).toBe(true); + expect(data.offline_recently.some((s: { creator_id: string }) => s.creator_id === "creator-delta")).toBe(true); + }); + + it("returns all-offline when followed creator went offline recently (viewer-3 follows gamma)", async () => { + const res = await GET(makeGet("viewer-3")); + const data = await res.json(); + expect(data.live).toHaveLength(0); + expect(data.offline_recently).toHaveLength(1); + expect(data.offline_recently[0].creator_id).toBe("creator-gamma"); + }); + + it("returns mixed live and offline_recently (viewer-1 follows all)", async () => { + const res = await GET(makeGet("viewer-1")); + const data = await res.json(); + // alpha + beta are live, gamma + delta are offline_recently + expect(data.live.length).toBeGreaterThanOrEqual(1); + expect(data.offline_recently.length).toBeGreaterThanOrEqual(1); + }); + + it("returns empty for viewer following creator with no stream data (viewer-4)", async () => { + const res = await GET(makeGet("viewer-4")); + const data = await res.json(); + expect(data.live).toHaveLength(0); + expect(data.offline_recently).toHaveLength(0); + }); +}); diff --git a/app/api/routes-f/followed-feed/route.ts b/app/api/routes-f/followed-feed/route.ts new file mode 100644 index 00000000..63e88aef --- /dev/null +++ b/app/api/routes-f/followed-feed/route.ts @@ -0,0 +1,41 @@ +/** + * Followed creators feed — issue #995 + * + * GET ?viewer_id -> { live: [...], offline_recently: [...] } + * offline_recently = creators followed by viewer who went offline in last 24h + */ +import { NextRequest, NextResponse } from "next/server"; +import { followsData, liveStreams, recentlyOfflineStreams } from "./seed"; +import { FollowedFeedResponse } from "./types"; + +const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; + +export async function GET(req: NextRequest): Promise { + const viewer_id = new URL(req.url).searchParams.get("viewer_id"); + + if (!viewer_id) { + return NextResponse.json({ error: "viewer_id is required" }, { status: 400 }); + } + + const followed = followsData[viewer_id] ?? []; + + if (followed.length === 0) { + return NextResponse.json( + { live: [], offline_recently: [] }, + { status: 200 } + ); + } + + const followedSet = new Set(followed); + const now = Date.now(); + + const live = liveStreams.filter((s) => followedSet.has(s.creator_id)); + + const offline_recently = recentlyOfflineStreams.filter((s) => { + if (!followedSet.has(s.creator_id)) return false; + if (!s.ended_at) return false; + return now - new Date(s.ended_at).getTime() <= TWENTY_FOUR_HOURS_MS; + }); + + return NextResponse.json({ live, offline_recently }, { status: 200 }); +} diff --git a/app/api/routes-f/followed-feed/seed.ts b/app/api/routes-f/followed-feed/seed.ts new file mode 100644 index 00000000..4fea4b2c --- /dev/null +++ b/app/api/routes-f/followed-feed/seed.ts @@ -0,0 +1,58 @@ +import { CreatorStream } from "./types"; + +const now = new Date(); + +// Helpers +function hoursAgo(h: number) { + return new Date(now.getTime() - h * 60 * 60 * 1000).toISOString(); +} + +// viewer_id -> creator_ids they follow +export const followsData: Record = { + "viewer-1": ["creator-alpha", "creator-beta", "creator-gamma", "creator-delta"], + "viewer-2": ["creator-alpha", "creator-delta"], + "viewer-3": ["creator-gamma"], + "viewer-4": ["creator-epsilon"], // follows someone with no recent stream +}; + +// Currently live streams +export const liveStreams: CreatorStream[] = [ + { + creator_id: "creator-alpha", + username: "alpha_streams", + title: "Grinding ranked — XLM tipping enabled", + category: "Gaming", + viewer_count: 312, + started_at: hoursAgo(2), + }, + { + creator_id: "creator-beta", + username: "beta_live", + title: "Stellar blockchain Q&A", + category: "Technology", + viewer_count: 87, + started_at: hoursAgo(1), + }, +]; + +// Streams that ended within the last 24 hours +export const recentlyOfflineStreams: CreatorStream[] = [ + { + creator_id: "creator-gamma", + username: "gamma_cast", + title: "Chill lo-fi session", + category: "Music", + viewer_count: 45, + started_at: hoursAgo(10), + ended_at: hoursAgo(8), + }, + { + creator_id: "creator-delta", + username: "delta_play", + title: "Mux stream test", + category: "Just Chatting", + viewer_count: 150, + started_at: hoursAgo(5), + ended_at: hoursAgo(3), + }, +]; diff --git a/app/api/routes-f/followed-feed/types.ts b/app/api/routes-f/followed-feed/types.ts new file mode 100644 index 00000000..9af1e741 --- /dev/null +++ b/app/api/routes-f/followed-feed/types.ts @@ -0,0 +1,14 @@ +export interface CreatorStream { + creator_id: string; + username: string; + title: string; + category: string; + viewer_count: number; + started_at: string; + ended_at?: string; // present when offline +} + +export interface FollowedFeedResponse { + live: CreatorStream[]; + offline_recently: CreatorStream[]; +} diff --git a/app/api/routes-f/tip-goal-manage/__tests__/route.test.ts b/app/api/routes-f/tip-goal-manage/__tests__/route.test.ts new file mode 100644 index 00000000..d8ace9ca --- /dev/null +++ b/app/api/routes-f/tip-goal-manage/__tests__/route.test.ts @@ -0,0 +1,102 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { POST, PATCH, DELETE, _resetStore } from "../route"; + +function makeReq(method: string, url: string, body?: unknown) { + return new NextRequest(`http://localhost${url}`, { + method, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + headers: { "Content-Type": "application/json" }, + }); +} + +const BASE = "/api/routes-f/tip-goal-manage"; +const FUTURE = "2099-12-31T00:00:00.000Z"; + +describe("Tip Goal Manage", () => { + beforeEach(() => _resetStore()); + + // ── POST ──────────────────────────────────────────────────────────────── + + it("creates a goal (POST)", async () => { + const res = await POST(makeReq("POST", BASE, { + creator_id: "creator-1", + goal_usdc: 100, + title: "New PC", + ends_at: FUTURE, + })); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.goal_id).toBeDefined(); + expect(data.created_at).toBeDefined(); + }); + + it("400 when goal_usdc <= 0", async () => { + const res = await POST(makeReq("POST", BASE, { creator_id: "c1", goal_usdc: 0 })); + expect(res.status).toBe(400); + }); + + it("400 when ends_at is in the past", async () => { + const res = await POST(makeReq("POST", BASE, { + creator_id: "c1", + goal_usdc: 50, + ends_at: "2000-01-01T00:00:00.000Z", + })); + expect(res.status).toBe(400); + }); + + it("400 when creator_id is missing", async () => { + const res = await POST(makeReq("POST", BASE, { goal_usdc: 50 })); + expect(res.status).toBe(400); + }); + + // ── PATCH ─────────────────────────────────────────────────────────────── + + it("updates title and goal_usdc (PATCH)", async () => { + await POST(makeReq("POST", BASE, { creator_id: "creator-2", goal_usdc: 50 })); + const res = await PATCH(makeReq("PATCH", BASE, { + creator_id: "creator-2", + goal_usdc: 200, + title: "Upgraded goal", + })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.goal_usdc).toBe(200); + expect(data.title).toBe("Upgraded goal"); + }); + + it("404 PATCH on non-existent goal", async () => { + const res = await PATCH(makeReq("PATCH", BASE, { creator_id: "nobody", goal_usdc: 10 })); + expect(res.status).toBe(404); + }); + + // ── DELETE ────────────────────────────────────────────────────────────── + + it("deletes a goal (DELETE)", async () => { + await POST(makeReq("POST", BASE, { creator_id: "creator-3", goal_usdc: 75 })); + const res = await DELETE(makeReq("DELETE", `${BASE}?creator_id=creator-3`)); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it("404 DELETE on non-existent goal", async () => { + const res = await DELETE(makeReq("DELETE", `${BASE}?creator_id=ghost`)); + expect(res.status).toBe(404); + }); + + // ── lifecycle ─────────────────────────────────────────────────────────── + + it("full lifecycle: create -> patch -> delete", async () => { + await POST(makeReq("POST", BASE, { creator_id: "lc", goal_usdc: 25 })); + const patchRes = await PATCH(makeReq("PATCH", BASE, { creator_id: "lc", title: "Updated" })); + expect((await patchRes.json()).title).toBe("Updated"); + const delRes = await DELETE(makeReq("DELETE", `${BASE}?creator_id=lc`)); + expect(delRes.status).toBe(200); + // second delete should 404 + const del2 = await DELETE(makeReq("DELETE", `${BASE}?creator_id=lc`)); + expect(del2.status).toBe(404); + }); +}); diff --git a/app/api/routes-f/tip-goal-manage/route.ts b/app/api/routes-f/tip-goal-manage/route.ts new file mode 100644 index 00000000..327bfaeb --- /dev/null +++ b/app/api/routes-f/tip-goal-manage/route.ts @@ -0,0 +1,122 @@ +/** + * Tip Goal Manage — issue #978 + * + * POST { creator_id, goal_usdc, title?, ends_at? } -> { goal_id, created_at } + * PATCH { creator_id, goal_usdc?, title?, ends_at? } -> updated TipGoal + * DELETE ?creator_id -> { success: true } + */ +import { NextRequest, NextResponse } from "next/server"; +import { goalStore, generateId, _resetStore } from "./store"; +import { TipGoal } from "./types"; + +export { _resetStore }; + +function badRequest(msg: string) { + return NextResponse.json({ error: msg }, { status: 400 }); +} + +// ── POST ───────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + let body: Record; + try { + body = await req.json(); + } catch { + return badRequest("Invalid JSON body"); + } + + const { creator_id, goal_usdc, title, ends_at } = body as { + creator_id?: unknown; + goal_usdc?: unknown; + title?: unknown; + ends_at?: unknown; + }; + + if (!creator_id || typeof creator_id !== "string") { + return badRequest("creator_id is required"); + } + if (typeof goal_usdc !== "number" || goal_usdc <= 0) { + return badRequest("goal_usdc must be a positive number"); + } + if (ends_at !== undefined) { + if (typeof ends_at !== "string" || new Date(ends_at) <= new Date()) { + return badRequest("ends_at must be a future ISO date string"); + } + } + + const now = new Date().toISOString(); + const goal: TipGoal = { + goal_id: generateId(), + creator_id, + goal_usdc, + ...(title && typeof title === "string" ? { title } : {}), + ...(ends_at ? { ends_at: ends_at as string } : {}), + created_at: now, + updated_at: now, + }; + + goalStore.set(creator_id, goal); + return NextResponse.json({ goal_id: goal.goal_id, created_at: goal.created_at }, { status: 201 }); +} + +// ── PATCH ──────────────────────────────────────────────────────────────────── + +export async function PATCH(req: NextRequest) { + let body: Record; + try { + body = await req.json(); + } catch { + return badRequest("Invalid JSON body"); + } + + const { creator_id, goal_usdc, title, ends_at } = body as { + creator_id?: unknown; + goal_usdc?: unknown; + title?: unknown; + ends_at?: unknown; + }; + + if (!creator_id || typeof creator_id !== "string") { + return badRequest("creator_id is required"); + } + + const existing = goalStore.get(creator_id); + if (!existing) { + return NextResponse.json({ error: "Goal not found" }, { status: 404 }); + } + + if (goal_usdc !== undefined) { + if (typeof goal_usdc !== "number" || goal_usdc <= 0) { + return badRequest("goal_usdc must be a positive number"); + } + existing.goal_usdc = goal_usdc; + } + if (title !== undefined) { + if (typeof title !== "string") return badRequest("title must be a string"); + existing.title = title; + } + if (ends_at !== undefined) { + if (typeof ends_at !== "string" || new Date(ends_at) <= new Date()) { + return badRequest("ends_at must be a future ISO date string"); + } + existing.ends_at = ends_at; + } + + existing.updated_at = new Date().toISOString(); + goalStore.set(creator_id, existing); + return NextResponse.json(existing, { status: 200 }); +} + +// ── DELETE ─────────────────────────────────────────────────────────────────── + +export async function DELETE(req: NextRequest) { + const creator_id = new URL(req.url).searchParams.get("creator_id"); + if (!creator_id) return badRequest("creator_id is required"); + + if (!goalStore.has(creator_id)) { + return NextResponse.json({ error: "Goal not found" }, { status: 404 }); + } + + goalStore.delete(creator_id); + return NextResponse.json({ success: true }, { status: 200 }); +} diff --git a/app/api/routes-f/tip-goal-manage/store.ts b/app/api/routes-f/tip-goal-manage/store.ts new file mode 100644 index 00000000..c255b663 --- /dev/null +++ b/app/api/routes-f/tip-goal-manage/store.ts @@ -0,0 +1,14 @@ +import { TipGoal } from "./types"; + +// In-memory store: creator_id -> TipGoal +export const goalStore = new Map(); + +/** Generates a simple deterministic-ish ID for practice use */ +export function generateId(): string { + return `goal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** Reset store — used in tests */ +export function _resetStore() { + goalStore.clear(); +} diff --git a/app/api/routes-f/tip-goal-manage/types.ts b/app/api/routes-f/tip-goal-manage/types.ts new file mode 100644 index 00000000..ba9e0f61 --- /dev/null +++ b/app/api/routes-f/tip-goal-manage/types.ts @@ -0,0 +1,9 @@ +export interface TipGoal { + goal_id: string; + creator_id: string; + goal_usdc: number; + title?: string; + ends_at?: string; // ISO string + created_at: string; + updated_at: string; +} diff --git a/app/api/routesF/currency-denomination/denominations.ts b/app/api/routesF/currency-denomination/denominations.ts new file mode 100644 index 00000000..fa864586 --- /dev/null +++ b/app/api/routesF/currency-denomination/denominations.ts @@ -0,0 +1,53 @@ +// Denomination tables for supported currencies (values in smallest unit: cents/kobo) +// Stored in descending order so the greedy algorithm works correctly + +export type DenominationEntry = { + denomination: number; // face value in the currency's base unit (e.g. 100.00 for $100 bill) + label: string; +}; + +export const DENOMINATION_TABLES: Record = { + USD: [ + { denomination: 100, label: "100 dollar bill" }, + { denomination: 50, label: "50 dollar bill" }, + { denomination: 20, label: "20 dollar bill" }, + { denomination: 10, label: "10 dollar bill" }, + { denomination: 5, label: "5 dollar bill" }, + { denomination: 1, label: "1 dollar bill" }, + { denomination: 0.25, label: "quarter" }, + { denomination: 0.10, label: "dime" }, + { denomination: 0.05, label: "nickel" }, + { denomination: 0.01, label: "penny" }, + ], + EUR: [ + { denomination: 500, label: "500 euro note" }, + { denomination: 200, label: "200 euro note" }, + { denomination: 100, label: "100 euro note" }, + { denomination: 50, label: "50 euro note" }, + { denomination: 20, label: "20 euro note" }, + { denomination: 10, label: "10 euro note" }, + { denomination: 5, label: "5 euro note" }, + { denomination: 2, label: "2 euro coin" }, + { denomination: 1, label: "1 euro coin" }, + { denomination: 0.50, label: "50 cent coin" }, + { denomination: 0.20, label: "20 cent coin" }, + { denomination: 0.10, label: "10 cent coin" }, + { denomination: 0.05, label: "5 cent coin" }, + { denomination: 0.02, label: "2 cent coin" }, + { denomination: 0.01, label: "1 cent coin" }, + ], + NGN: [ + { denomination: 1000, label: "1000 naira note" }, + { denomination: 500, label: "500 naira note" }, + { denomination: 200, label: "200 naira note" }, + { denomination: 100, label: "100 naira note" }, + { denomination: 50, label: "50 naira note" }, + { denomination: 20, label: "20 naira note" }, + { denomination: 10, label: "10 naira note" }, + { denomination: 5, label: "5 naira coin" }, + { denomination: 1, label: "1 naira coin" }, + { denomination: 0.50, label: "50 kobo coin" }, + ], +}; + +export const SUPPORTED_CURRENCIES = Object.keys(DENOMINATION_TABLES); diff --git a/app/api/routesF/currency-denomination/helpers.ts b/app/api/routesF/currency-denomination/helpers.ts new file mode 100644 index 00000000..d6c9601f --- /dev/null +++ b/app/api/routesF/currency-denomination/helpers.ts @@ -0,0 +1,37 @@ +import { DENOMINATION_TABLES, DenominationEntry } from "./denominations"; + +export type BreakdownItem = { + denomination: number; + label: string; + count: number; +}; + +export type BreakdownResult = { + breakdown: BreakdownItem[]; + total_pieces: number; +}; + +/** + * Breaks an amount into the fewest bills/coins using a greedy algorithm. + * Uses cent-precision (rounds to 2 decimal places to avoid floating-point drift). + */ +export function breakdownAmount(amount: number, currency: string): BreakdownResult { + const table: DenominationEntry[] = DENOMINATION_TABLES[currency]; + const breakdown: BreakdownItem[] = []; + + // Work in integer cents to avoid floating-point issues + let remaining = Math.round(amount * 100); + + for (const entry of table) { + const denomCents = Math.round(entry.denomination * 100); + if (remaining <= 0) break; + if (denomCents > remaining) continue; + + const count = Math.floor(remaining / denomCents); + remaining -= count * denomCents; + breakdown.push({ denomination: entry.denomination, label: entry.label, count }); + } + + const total_pieces = breakdown.reduce((sum, item) => sum + item.count, 0); + return { breakdown, total_pieces }; +} diff --git a/app/api/routesF/currency-denomination/route.test.ts b/app/api/routesF/currency-denomination/route.test.ts new file mode 100644 index 00000000..7fc0878e --- /dev/null +++ b/app/api/routesF/currency-denomination/route.test.ts @@ -0,0 +1,107 @@ +import { NextRequest } from "next/server"; +import { POST } from "./route"; + +function makePost(body: unknown) { + return new NextRequest("http://localhost/api/routesF/currency-denomination", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("POST /api/routesF/currency-denomination", () => { + // ── validation ──────────────────────────────────────────────────────────── + + it("returns 400 when amount is missing", async () => { + const res = await POST(makePost({ currency: "USD" })); + expect(res.status).toBe(400); + }); + + it("returns 400 when amount is negative", async () => { + const res = await POST(makePost({ amount: -5 })); + expect(res.status).toBe(400); + }); + + it("returns 400 for unsupported currency", async () => { + const res = await POST(makePost({ amount: 10, currency: "GBP" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/routesF/currency-denomination", { + method: "POST", + body: "not-json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + // ── USD minimal-piece breakdown ─────────────────────────────────────────── + + it("breaks $1.00 into 1 dollar bill (fewest pieces)", async () => { + const res = await POST(makePost({ amount: 1.0, currency: "USD" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.total_pieces).toBe(1); + expect(data.breakdown[0]).toMatchObject({ denomination: 1, count: 1 }); + }); + + it("breaks $0.41 into fewest USD coins", async () => { + const res = await POST(makePost({ amount: 0.41, currency: "USD" })); + const data = await res.json(); + // quarter(1) + dime(1) + nickel(1) + penny(1) = 4 pieces + expect(data.total_pieces).toBe(4); + }); + + it("breaks $126.37 correctly with USD", async () => { + const res = await POST(makePost({ amount: 126.37, currency: "USD" })); + const data = await res.json(); + expect(data.breakdown).toEqual( + expect.arrayContaining([ + expect.objectContaining({ denomination: 100, count: 1 }), + expect.objectContaining({ denomination: 20, count: 1 }), + expect.objectContaining({ denomination: 5, count: 1 }), + expect.objectContaining({ denomination: 1, count: 1 }), + expect.objectContaining({ denomination: 0.25, count: 1 }), + expect.objectContaining({ denomination: 0.10, count: 1 }), + expect.objectContaining({ denomination: 0.01, count: 2 }), + ]) + ); + }); + + // ── EUR ─────────────────────────────────────────────────────────────────── + + it("breaks EUR amount into fewest pieces", async () => { + const res = await POST(makePost({ amount: 7.50, currency: "EUR" })); + const data = await res.json(); + // 5 euro note + 2 euro coin + 50 cent coin = 3 pieces + expect(data.total_pieces).toBe(3); + }); + + // ── NGN ─────────────────────────────────────────────────────────────────── + + it("breaks NGN amount into fewest pieces", async () => { + const res = await POST(makePost({ amount: 1750, currency: "NGN" })); + const data = await res.json(); + // 1000 + 500 + 200 + 50 = 4 pieces + expect(data.total_pieces).toBe(4); + }); + + // ── zero ────────────────────────────────────────────────────────────────── + + it("returns empty breakdown for zero amount", async () => { + const res = await POST(makePost({ amount: 0, currency: "USD" })); + const data = await res.json(); + expect(data.total_pieces).toBe(0); + expect(data.breakdown).toHaveLength(0); + }); + + // ── defaults to USD ─────────────────────────────────────────────────────── + + it("defaults to USD when currency is omitted", async () => { + const res = await POST(makePost({ amount: 1.0 })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.breakdown[0]).toMatchObject({ denomination: 1 }); + }); +}); diff --git a/app/api/routesF/currency-denomination/route.ts b/app/api/routesF/currency-denomination/route.ts new file mode 100644 index 00000000..c3d653f5 --- /dev/null +++ b/app/api/routesF/currency-denomination/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { SUPPORTED_CURRENCIES } from "./denominations"; +import { breakdownAmount } from "./helpers"; + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { amount, currency = "USD" } = body as { amount?: unknown; currency?: unknown }; + + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + return NextResponse.json( + { error: "amount must be a non-negative finite number" }, + { status: 400 } + ); + } + + if (typeof currency !== "string" || !SUPPORTED_CURRENCIES.includes(currency.toUpperCase())) { + return NextResponse.json( + { error: `currency must be one of: ${SUPPORTED_CURRENCIES.join(", ")}` }, + { status: 400 } + ); + } + + // Round to cent precision + const roundedAmount = Math.round(amount * 100) / 100; + const result = breakdownAmount(roundedAmount, currency.toUpperCase()); + + return NextResponse.json(result, { status: 200 }); +}