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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions backend/SUBSCRIPTION_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,74 @@ Authorization: Bearer <token>
**Query Parameters:**
- `status` (optional): Filter by status (`active`, `cancelled`, `paused`, `trial`)
- `category` (optional): Filter by category
- `limit` (optional): Number of results (default: all)
- `offset` (optional): Pagination offset
- `limit` (optional): Number of results per page (default: 20, max: 100)
- `cursor` (optional): Pagination cursor for fetching next page

#### Cursor-Based Pagination

The subscriptions endpoint supports cursor-based pagination, which is more efficient for large datasets and provides consistent results when data changes between requests.

**How it works:**

1. **First Request**: Fetch the first page without a cursor
```http
GET /api/subscriptions?limit=10
```

2. **Response includes `nextCursor`**: If more results exist, the response includes a `nextCursor` value
```json
{
"success": true,
"data": [...],
"pagination": {
"total": 100,
"limit": 10,
"hasMore": true,
"nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMlQwMDowMDo1MC4wMFoifQ=="
}
}
```

3. **Next Page Request**: Use the `nextCursor` value to fetch the next page
```http
GET /api/subscriptions?limit=10&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMlQwMDowMDo1MC4wMFoifQ==
```

**Cursor Semantics:**

- **Format**: Base64-encoded JSON containing a `created_at` timestamp
- **Expiration**: Cursors do not expire but may become invalid if the underlying data is deleted
- **Ordering**: Results are ordered by `created_at` descending (newest first)
- **Limit Range**: Must be between 1 and 100 (inclusive)

**Error Responses:**

- **Invalid Cursor (400)**:
```json
{
"success": false,
"error": "Invalid pagination cursor",
"code": "INVALID_CURSOR"
}
```

- **Malformed Cursor (400)**:
```json
{
"success": false,
"error": "Invalid cursor: missing created_at field",
"code": "MALFORMED_CURSOR"
}
```

- **Invalid Limit (400)**:
```json
{
"success": false,
"error": "Limit must be between 1 and 100",
"code": "INVALID_LIMIT"
}
```

**Response:**
```json
Expand All @@ -48,9 +114,10 @@ Authorization: Bearer <token>
}
],
"pagination": {
"total": 10,
"total": 100,
"limit": 20,
"offset": 0
"hasMore": true,
"nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMlQwMDowMDo1MC4wMFoifQ=="
}
}
```
Expand Down Expand Up @@ -230,9 +297,22 @@ Content-Type: application/json
}
```

### Pagination Error Response

```json
{
"success": false,
"error": "Invalid limit: must be between 1 and 100",
"code": "INVALID_LIMIT"
}
```

### Status Codes

- `400`: Bad Request (validation error)
- `400`: Bad Request (validation error, including pagination errors)
- `INVALID_CURSOR`: The provided cursor is malformed or invalid
- `MALFORMED_CURSOR`: The cursor is not properly formatted
- `INVALID_LIMIT`: The limit parameter is out of range or not a valid integer
- `401`: Unauthorized (missing/invalid token)
- `403`: Forbidden (ownership validation failed)
- `404`: Not Found
Expand Down
15 changes: 12 additions & 3 deletions backend/src/routes/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { requireRole } from '../middleware/rbac';
import { validate } from '../middleware/validate';
import logger from '../config/logger';
import { auditBatchSchema, auditQuerySchema } from '../schemas/audit';
import { PaginationError } from '../utils/pagination';

const router: Router = Router();

Expand Down Expand Up @@ -98,11 +99,19 @@ router.get(
hasMore: offset + limit < total,
},
});
} catch (error) {
} catch (error: any) {
if (error.name === 'PaginationError') {
res.status(400).json({
success: false,
error: error.message,
code: error.code,
});
return;
}
logger.error('Error in GET /api/admin/audit:', error);
res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
},
Expand Down
14 changes: 11 additions & 3 deletions backend/src/routes/gift-card-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import { giftCardLedgerService } from '../services/gift-card-ledger-service';
import { validateRequest } from '../utils/validation';
import { BadRequestError } from '../errors';
import { validateLimit } from '../utils/pagination';

const router = Router();
router.use(authenticate);
Expand All @@ -27,9 +28,16 @@ router.get('/balance', async (req: AuthenticatedRequest, res: Response) => {

/** GET /api/gift-card-ledger/history */
router.get('/history', async (req: AuthenticatedRequest, res: Response) => {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const history = await giftCardLedgerService.getHistory(req.user!.id, limit);
res.json({ success: true, data: history });
try {
const limit = validateLimit(req.query.limit, 100, 50);
const history = await giftCardLedgerService.getHistory(req.user!.id, limit);
res.json({ success: true, data: history });
} catch (error: any) {
if (error.name === 'PaginationError') {
throw new BadRequestError(error.message);
}
throw error;
}
});

/** POST /api/gift-card-ledger/top-up */
Expand Down
12 changes: 11 additions & 1 deletion backend/src/routes/merchants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { validate } from '../middleware/validate';
import logger from '../config/logger';
import { adminAuth } from '../middleware/admin';
import { createMerchantSchema, updateMerchantSchema, merchantQuerySchema } from '../schemas/merchant';
import { validateRequest } from '../utils/validation';
import { PaginationError } from '../utils/pagination';

const router: Router = Router();

Expand All @@ -29,7 +31,15 @@ router.get(
data: result.merchants,
pagination: { total: result.total, limit, offset },
});
} catch (error) {
} catch (error: any) {
if (error.name === 'PaginationError') {
res.status(400).json({
success: false,
error: error.message,
code: error.code,
});
return;
}
logger.error('List merchants error:', error);
res.status(500).json({
success: false,
Expand Down
51 changes: 29 additions & 22 deletions backend/src/routes/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SUPPORTED_CURRENCIES } from '../constants/currencies';
import logger from '../config/logger';
import { BadRequestError } from '../errors';
import { validateRequest } from '../utils/validation';
import { cursorPaginationSchema } from '../schemas/common';

const router = Router();

Expand Down Expand Up @@ -109,30 +110,36 @@ router.use(authenticate);
* List user's subscriptions
*/
router.get('/', async (req: AuthenticatedRequest, res: Response) => {
const { status, category, limit, cursor } = req.query;

const limitNum = limit ? parseInt(limit as string, 10) : 20;
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
throw new BadRequestError('Limit must be a number between 1 and 100');
}
try {
const { status, category, cursor } = req.query;
const pagination = validateRequest(cursorPaginationSchema, {
limit: req.query.limit,
cursor: req.query.cursor,
});

const result = await subscriptionService.listSubscriptions(req.user!.id, {
status: status as any,
category: category as string,
limit: limitNum,
cursor: cursor as string,
});
const result = await subscriptionService.listSubscriptions(req.user!.id, {
status: status as any,
category: category as string,
limit: pagination.limit,
cursor: pagination.cursor,
});

res.json({
success: true,
data: result.subscriptions,
pagination: {
total: result.total,
limit: limitNum,
hasMore: result.hasMore,
nextCursor: result.nextCursor ?? null,
},
});
res.json({
success: true,
data: result.subscriptions,
pagination: {
total: result.total,
limit: pagination.limit,
hasMore: result.hasMore,
nextCursor: result.nextCursor ?? null,
},
});
} catch (error: any) {
if (error.name === 'PaginationError') {
throw new BadRequestError(error.message);
}
throw error;
}
});

/**
Expand Down
6 changes: 6 additions & 0 deletions backend/src/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ export const paginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});

/** Reusable cursor-based pagination schema. */
export const cursorPaginationSchema = z.object({
limit: z.coerce.number().int().min(1, 'Limit must be at least 1').max(100, 'Limit must not exceed 100').default(20),
cursor: z.string().optional(),
});
25 changes: 7 additions & 18 deletions backend/src/services/subscription-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { referralService } from "./referral-service";
import logger from "../config/logger";
import { DatabaseTransaction } from "../utils/transaction";
import SERVICE_CATEGORIES from "../../services/service-categories";
import { validateCursor, encodeCursor } from "../utils/pagination";
import type {
Subscription,
SubscriptionCreateInput,
Expand Down Expand Up @@ -592,6 +593,8 @@ export class SubscriptionService {
): Promise<ListSubscriptionsResult> {
const limit = Math.min(options.limit ?? 20, 100);

const validatedCursor = validateCursor(options.cursor);

let query = supabase
.from("subscriptions")
.select("*", { count: "exact" })
Expand All @@ -607,23 +610,13 @@ export class SubscriptionService {
query = query.eq("category", options.category);
}

if (options.cursor) {
try {
const decoded = JSON.parse(
Buffer.from(options.cursor, "base64").toString("utf-8"),
);
if (!decoded.created_at) {
throw new Error("Invalid cursor: missing created_at");
}
query = query.lt("created_at", decoded.created_at);
} catch {
throw new Error("Invalid pagination cursor");
}
if (validatedCursor) {
query = query.lt("created_at", validatedCursor.createdAt);
}

const { data: rows, error, count } = await query;

if (error) {
if (error) {
throw new Error(`Failed to fetch subscriptions: ${error.message}`);
}

Expand All @@ -633,11 +626,7 @@ export class SubscriptionService {
// Build next cursor from the last item in the page
const nextCursor =
hasMore && subscriptions.length > 0
? Buffer.from(
JSON.stringify({
created_at: subscriptions[subscriptions.length - 1].created_at,
}),
).toString("base64")
? encodeCursor({ createdAt: subscriptions[subscriptions.length - 1].created_at })
: null;

return {
Expand Down
Loading
Loading