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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions app/api/bills/cron/reminders/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
44 changes: 44 additions & 0 deletions app/api/bills/due-soon/route.ts
Original file line number Diff line number Diff line change
@@ -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);
51 changes: 10 additions & 41 deletions app/api/bills/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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();
Expand Down
80 changes: 80 additions & 0 deletions app/api/notifications/reminders/route.ts
Original file line number Diff line number Diff line change
@@ -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);
77 changes: 77 additions & 0 deletions docs/BILL_REMINDERS.md
Original file line number Diff line number Diff line change
@@ -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 <CRON_SECRET>` 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.
Loading