diff --git a/app/api/bills/cron/reminders/route.ts b/app/api/bills/cron/reminders/route.ts new file mode 100644 index 0000000..aa6be34 --- /dev/null +++ b/app/api/bills/cron/reminders/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/lib/db"; +import { getUnpaidBills } from "@/lib/contracts/bill-payments"; + +const DUE_SOON_DAYS = 7; +const CRON_SECRET = process.env.CRON_SECRET; + +/** + * GET /api/bills/cron/reminders + * Trigger logic to scan bills and populate reminders DB. + * Designed to be called by a cron scheduler (e.g. Vercel Cron). + */ +export async function GET(request: NextRequest) { + // 1. Auth check for Cron + const authHeader = request.headers.get("authorization"); + if (CRON_SECRET && authHeader !== `Bearer ${CRON_SECRET}`) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + // 2. Fetch all users who have notifications enabled + const users = await prisma.user.findMany({ + where: { + preferences: { + notifications_enabled: true, + }, + }, + include: { + preferences: true, + }, + }); + + const now = Date.now(); + const sevenDaysFromNow = now + DUE_SOON_DAYS * 24 * 60 * 60 * 1000; + let totalRemindersCreated = 0; + + for (const user of users) { + try { + // 3. Fetch user's unpaid bills from the contract/mock + const unpaidBills = await getUnpaidBills(user.stellar_address); + + // 4. Identify bills due soon + const dueSoonBills = unpaidBills.filter((b) => { + const dueDate = new Date(b.dueDate).getTime(); + return dueDate <= sevenDaysFromNow; + }); + + // 5. Create reminders if they don't already exist for this bill + user + timeframe + // (Avoiding duplicate reminders for the same bill if already notified recently) + for (const bill of dueSoonBills) { + const existingReminder = await prisma.billReminder.findFirst({ + where: { + userId: user.stellar_address, + billId: bill.id, + // If we already created a reminder in the last 24h, skip + createdAt: { + gt: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }, + }); + + if (!existingReminder) { + await prisma.billReminder.create({ + data: { + userId: user.stellar_address, + billId: bill.id, + name: bill.name, + amount: bill.amount, + dueDate: new Date(bill.dueDate), + }, + }); + totalRemindersCreated++; + } + } + } catch (userErr) { + console.error(`Failed to process reminders for user ${user.stellar_address}:`, userErr); + } + } + + return NextResponse.json({ + success: true, + processedUsers: users.length, + remindersCreated: totalRemindersCreated, + }); + } catch (err) { + console.error("[CRON /api/bills/cron/reminders]", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/app/api/bills/due-soon/route.ts b/app/api/bills/due-soon/route.ts new file mode 100644 index 0000000..1a3f9b1 --- /dev/null +++ b/app/api/bills/due-soon/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; +import { withAuth } from "@/lib/auth"; +import { getUnpaidBills } from "@/lib/contracts/bill-payments"; +import { jsonSuccess, jsonError } from "@/lib/api/types"; + +const DUE_SOON_DAYS = 7; + +async function getDueSoonBillsHandler(request: NextRequest, session: string) { + try { + const { searchParams } = new URL(request.url); + const owner = searchParams.get("owner") ?? session; + + // Fetch all unpaid bills + const unpaidBills = await getUnpaidBills(owner); + + const now = Date.now(); + const sevenDaysFromNow = now + DUE_SOON_DAYS * 24 * 60 * 60 * 1000; + + // Filter bills that are due within the next 7 days or overdue + const dueSoonBills = unpaidBills + .filter((b) => { + const dueDate = new Date(b.dueDate).getTime(); + return dueDate <= sevenDaysFromNow; + }) + .map((b) => ({ + billId: b.id, + name: b.name, + amount: b.amount, + dueDate: b.dueDate, + status: b.status, + })); + + return jsonSuccess({ + message: "Due-soon bills retrieved successfully", + count: dueSoonBills.length, + bills: dueSoonBills + }); + } catch (err) { + console.error("[GET /api/bills/due-soon]", err); + return jsonError("INTERNAL_ERROR", "Failed to fetch due-soon bills"); + } +} + +export const GET = withAuth(getDueSoonBillsHandler); diff --git a/app/api/bills/route.ts b/app/api/bills/route.ts index ba6a249..7ec39d4 100644 --- a/app/api/bills/route.ts +++ b/app/api/bills/route.ts @@ -1,49 +1,13 @@ - -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { compose, validatedRoute, withAuth } from "@/lib/auth/middleware"; - -const billSchema = z.object({ - name: z.string().min(4, "Name is too short"), - amount: z.coerce.number().positive().gt(0), - dueDate: z.coerce.date(), - recurring: z.preprocess((val) => val === "on" || val === true, z.boolean()), -}); - -const addBillHandler = validatedRoute(billSchema, "body", async (req, data) => { - // data is fully typed as { name: string, amount: number, dueDate: Date, recurring: boolean } -// console.log(data, 'data in handler'); - - // your DB logic here - - return NextResponse.json({ - success: "Bill added successfully", - name: data.name, - amount: data.amount, - }); -}); - -// if auth is needed on a route -// compose auth + validation — order matters: auth runs first -// export const POST = compose(withAuth)(addBillHandler); - -// if you don't need auth on a route, just export directly: -// export const POST = addBillHandler; -// import { withAuth } from '@/lib/auth'; - -async function getHandler(request: NextRequest) { - // TODO: Fetch bills from Soroban bill_payments contract - return NextResponse.json({ bills: [] }); -} - - -export const GET = compose(withAuth)(getHandler); -export const POST = compose(withAuth)(addBillHandler); import { NextRequest, NextResponse } from 'next/server'; import { withAuth } from '@/lib/auth'; import { getAllBills, getBill } from '@/lib/contracts/bill-payments'; import { jsonSuccess, jsonError } from '@/lib/api/types'; +/** + * GET /api/bills + * Fetches bills for the authenticated user. + * Supports filtering by id and unpaid status. + */ async function getHandler(request: NextRequest, session: string) { try { const { searchParams } = new URL(request.url); @@ -74,6 +38,11 @@ async function getHandler(request: NextRequest, session: string) { } } +/** + * POST /api/bills + * Validates bill creation data and returns success message. + * Actual XDR building should happen on the frontend using contract libs. + */ async function postHandler(request: NextRequest, session: string) { try { const body = await request.json(); diff --git a/app/api/notifications/reminders/route.ts b/app/api/notifications/reminders/route.ts new file mode 100644 index 0000000..56081ef --- /dev/null +++ b/app/api/notifications/reminders/route.ts @@ -0,0 +1,80 @@ +import { NextRequest } from "next/server"; +import { withAuth } from "@/lib/auth"; +import prisma from "@/lib/db"; +import { jsonSuccess, jsonError } from "@/lib/api/types"; + +/** + * GET /api/notifications/reminders + * Fetches stored bill reminders for the authenticated user. + */ +async function getRemindersHandler(request: NextRequest, session: string) { + try { + const { searchParams } = new URL(request.url); + const owner = searchParams.get("owner") ?? session; + const unreadOnly = searchParams.get("unread") === "true"; + + const reminders = await prisma.billReminder.findMany({ + where: { + userId: owner, + ...(unreadOnly ? { isRead: false } : {}), + }, + orderBy: { + dueDate: "asc", + }, + select: { + id: true, + billId: true, + name: true, + amount: true, + dueDate: true, + isRead: true, + createdAt: true, + } + }); + + return jsonSuccess({ + message: "Reminders retrieved successfully", + count: reminders.length, + reminders, + }); + } catch (err) { + console.error("[GET /api/notifications/reminders]", err); + return jsonError("INTERNAL_ERROR", "Failed to fetch reminders"); + } +} + +/** + * PATCH /api/notifications/reminders + * Marks specific reminder as read. + */ +async function markAsReadHandler(request: NextRequest, session: string) { + try { + const body = await request.json(); + const { reminderId, all = false } = body; + + if (all) { + await prisma.billReminder.updateMany({ + where: { userId: session, isRead: false }, + data: { isRead: true } + }); + return jsonSuccess({ message: "All reminders marked as read" }); + } + + if (!reminderId) { + return jsonError("VALIDATION_ERROR", "reminderId is required unless 'all' is true"); + } + + await prisma.billReminder.update({ + where: { id: reminderId, userId: session }, + data: { isRead: true } + }); + + return jsonSuccess({ message: "Reminder marked as read" }); + } catch (err) { + console.error("[PATCH /api/notifications/reminders]", err); + return jsonError("INTERNAL_ERROR", "Failed to update reminder"); + } +} + +export const GET = withAuth(getRemindersHandler); +export const PATCH = withAuth(markAsReadHandler); diff --git a/docs/BILL_REMINDERS.md b/docs/BILL_REMINDERS.md new file mode 100644 index 0000000..bd98769 --- /dev/null +++ b/docs/BILL_REMINDERS.md @@ -0,0 +1,77 @@ +# Bill Reminders and Due-Soon Notifications + +This document outlines the implementation of bill reminders and due-soon notifications in the Remitwise Frontend. + +## Overview + +The system provides two mechanisms for handling bill reminders: +1. **Real-time Polling (Option A)**: Frontend can fetch bills due soon directly from the API, which filters data from the Soroban contract. +2. **Scheduled Reminders (Option B)**: A cron job runs periodically, scans all users' bills, and populates a database table for persistent notifications. + +## API Endpoints + +### 1. Due-Soon Bills (Polling) +- **Endpoint**: `GET /api/bills/due-soon` +- **Auth**: Required +- **Description**: Fetches bills due within the next 7 days or already overdue. +- **Query Params**: + - `owner` (optional): User's Stellar address. Defaults to current session. +- **Response**: + ```json + { + "success": true, + "message": "Due-soon bills retrieved successfully", + "count": 2, + "bills": [ + { + "billId": "2", + "name": "Rent Payment", + "amount": 800, + "dueDate": "2026-02-01T00:00:00.000Z", + "status": "urgent" + } + ] + } + ``` + +### 2. Notifications (Stored Reminders) +- **Endpoint**: `GET /api/notifications/reminders` +- **Auth**: Required +- **Description**: Returns stored notifications for the user. +- **Query Params**: + - `unread` (optional): Filter specifically for unread reminders (`true`). + +- **Endpoint**: `PATCH /api/notifications/reminders` +- **Auth**: Required +- **Description**: Mark a reminder as read. +- **Body**: `{ "reminderId": "...", "all": false }` + +### 3. Cron Job (Populator) +- **Endpoint**: `GET /api/bills/cron/reminders` +- **Auth**: `Bearer ` header. +- **Description**: Designed for Vercel Cron. Scans all active users and generates `BillReminder` records for any bill due within 7 days. +- **Logic**: + - Scans users with `notifications_enabled: true`. + - Filters bills due <= 7 days from now. + - Prevents duplicate notifications for the same bill within a 24h window. + +## Database Schema + +The `BillReminder` model in Prisma tracks these notifications: +```prisma +model BillReminder { + id String @id @default(cuid()) + billId String + name String + amount Float + dueDate DateTime + userId String + isRead Boolean @default(false) + createdAt DateTime @default(now()) +} +``` + +## Implementation Strategy + +- **Defined "Due Soon"**: 7 days (604,800,000 ms). +- **Polling vs Cron**: The frontend should primarily use `GET /api/notifications/reminders` to show a notification dot/list, and can use `GET /api/bills/due-soon` for specific "Due Soon" dashboard widgets. diff --git a/package.json b/package.json index 88d510a..17e60b9 100644 --- a/package.json +++ b/package.json @@ -1,55 +1,58 @@ { - "name": "remitwise-frontend", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint .", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "test:watch": "vitest", - "test:ui": "vitest --ui", - "test:unit": "vitest run tests/unit", - "test:property": "vitest run tests/property", - "test:integration": "vitest run tests/integration", - "test:e2e": "playwright test" - }, - "dependencies": { - "@prisma/client": "^5.22.0", - "@radix-ui/react-icons": "^1.3.2", - "@stellar/stellar-sdk": "^11.2.2", - "clsx": "^2.1.1", - "iron-session": "^8.0.4", - "lru-cache": "^11.2.6", - "lucide-react": "^0.575.0", - "next": "^16.1.6", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "recharts": "^3.7.0", - "stellar-wallet-kit": "^2.0.7", - "swagger-ui-react": "^5.31.2", - "tailwind-merge": "^3.4.0", - "yaml": "^2.8.2", - "zod": "^4.3.6" - }, - "devDependencies": { - "@playwright/test": "^1.58.2", - "@types/node": "^20.19.33", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitest/coverage-v8": "^4.0.18", - "@vitest/ui": "^4.0.18", - "autoprefixer": "^10.4.0", - "eslint": "^8.57.1", - "eslint-config-next": "^14.2.35", - "jest": "^30.2.0", - "postcss": "^8.4.0", - "prisma": "^5.22.0", - "tailwindcss": "^3.3.0", - "tsx": "^4.21.0", - "typescript": "^5.0.0", - "vitest": "^4.0.18" - } + "name": "remitwise-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:unit": "vitest run tests/unit", + "test:property": "vitest run tests/property", + "test:integration": "vitest run tests/integration", + "test:e2e": "playwright test", + "db:push": "prisma db push", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^5.22.0", + "@radix-ui/react-icons": "^1.3.2", + "@stellar/stellar-sdk": "^11.2.2", + "clsx": "^2.1.1", + "iron-session": "^8.0.4", + "lru-cache": "^11.2.6", + "lucide-react": "^0.575.0", + "next": "^16.1.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^3.7.0", + "stellar-wallet-kit": "^2.0.7", + "swagger-ui-react": "^5.31.2", + "tailwind-merge": "^3.4.0", + "uuid": "^13.0.0", + "yaml": "^2.8.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^20.19.33", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "autoprefixer": "^10.4.0", + "eslint": "^8.57.1", + "eslint-config-next": "^14.2.35", + "jest": "^30.2.0", + "postcss": "^8.4.0", + "prisma": "^5.22.0", + "tailwindcss": "^3.3.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^4.0.18" + } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 26b5cf0..4585f18 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt preferences UserPreference? + reminders BillReminder[] } model UserPreference { @@ -23,3 +24,17 @@ model UserPreference { language String @default("en") notifications_enabled Boolean @default(true) } + +model BillReminder { + id String @id @default(cuid()) + billId String + name String + amount Float + dueDate DateTime + userId String + user User @relation(fields: [userId], references: [stellar_address], onDelete: Cascade) + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([userId]) +}