From e8be5364c08f5dd7867066420e74bb69ed4ba67a Mon Sep 17 00:00:00 2001 From: Damola09 Date: Fri, 28 Aug 2026 22:38:08 +0100 Subject: [PATCH 1/7] feat(admin): add read and moderation endpoints over merchants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the admin dashboard's merchant surface. No schema change is required — every field served here already exists on Merchant, Invoice, MerchantAnalytics and Subscription. GET /admin/merchants lists merchants with limit/offset pagination reusing the DEFAULT_LIMIT/MAX_LIMIT convention from invoice.validation.ts, filters on active, verified, category and a case-insensitive search across businessName, email and address, and sorts by createdAt, merchantId or businessName in either direction, defaulting to createdAt desc. Booleans are parsed strictly: a query string carries no real boolean, so only the literals "true" and "false" are accepted rather than coercing anything truthy and silently filtering on the wrong value. GET /admin/merchants/:id serves the merchant detail through sanitizeMerchant, which already withholds the OTP columns an admin has no reason to see. GET /admin/merchants/:id/invoices delegates to the existing listInvoices(merchantId, filters, pagination) and parses its query with parseInvoiceListQuery, so the admin-scoped response shape and accepted filters cannot drift from the merchant-facing route. The merchant is resolved first so an unknown id is a 404 rather than an empty page. GET /admin/merchants/:id/analytics adds getMerchantAdminAnalytics: per-token volume, fees and transaction counts from MerchantAnalytics, plus live status-grouped invoice and subscription counts. Subscription.merchantId is a direct scalar, so the subscription grouping needs no join through SubscriptionPlan. BigInt counters are serialized as strings, matching how analytics.services.ts already reports them. POST /admin/merchants/:id/block replaces the previous PATCH route and is now gated by requireSuperAdmin, so a non-superadmin admin gets a 403. It sets Merchant.active = false and records exactly one merchant.blocked AdminLog entry, carrying an optional { reason } in the metadata. This is off-chain only: the contract's set_merchant_status(admin, merchant_id, status) requires the on-chain admin's signature, which this backend cannot produce, so reconciling the on-chain status is deferred to separate work rather than silently skipped — the same off-chain-first pattern used for invoice amendment. Unblocking is deliberately not implemented. Only blocking was in scope; a test asserts no unblock route answers, so its absence is explicit rather than an oversight. --- src/controllers/admin-merchant.controllers.ts | 113 +++++- src/routes/admin/merchant.routes.ts | 20 +- src/services/merchant.services.ts | 156 ++++++++ src/utils/merchant.validation.ts | 150 ++++++++ .../integration/admin.merchant.routes.test.ts | 346 +++++++++++++++++- tests/unit/merchant.validation.test.ts | 100 +++++ 6 files changed, 863 insertions(+), 22 deletions(-) create mode 100644 src/utils/merchant.validation.ts create mode 100644 tests/unit/merchant.validation.test.ts diff --git a/src/controllers/admin-merchant.controllers.ts b/src/controllers/admin-merchant.controllers.ts index 58caf4d..430b658 100644 --- a/src/controllers/admin-merchant.controllers.ts +++ b/src/controllers/admin-merchant.controllers.ts @@ -1,8 +1,106 @@ import { Request, Response } from 'express'; -import { blockMerchant } from '../services/merchant.services.js'; +import { + blockMerchant, + getMerchantAdminAnalytics, + getMerchantForAdmin, + listMerchantsForAdmin, +} from '../services/merchant.services.js'; +import { listInvoices } from '../services/invoice.services.js'; import { recordAuditLog, ActorType } from '../services/audit-log.services.js'; +import { + parseAdminMerchantListQuery, + validateBlockMerchant, +} from '../utils/merchant.validation.js'; +import { parseInvoiceListQuery } from '../utils/invoice.validation.js'; import { AppError } from '../utils/errors.js'; +const handleError = (error: unknown, req: Request, res: Response, action: string): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error(`Failed to ${action}`, { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export const listMerchantsController = async (req: Request, res: Response): Promise => { + const { filters, pagination, sortBy, sortDir, errors } = parseAdminMerchantListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listMerchantsForAdmin(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'list merchants'); + } +}; + +export const getMerchantController = async (req: Request, res: Response): Promise => { + try { + const merchant = await getMerchantForAdmin(req.params.id as string); + res.status(200).json(merchant); + } catch (error) { + handleError(error, req, res, 'load the merchant'); + } +}; + +/** + * Admin-scoped view of one merchant's invoices. Delegates to the same + * listInvoices the merchant-facing route uses, so the response shape and the + * accepted filters cannot drift between the two. + */ +export const listMerchantInvoicesController = async ( + req: Request, + res: Response, +): Promise => { + const { filters, pagination, errors } = parseInvoiceListQuery( + req.query as Record, + ); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + // 404s an unknown merchant rather than returning an empty page for an id + // that never existed. + await getMerchantForAdmin(req.params.id as string); + const result = await listInvoices(req.params.id as string, filters, pagination); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'list the merchant invoices'); + } +}; + +export const getMerchantAnalyticsController = async ( + req: Request, + res: Response, +): Promise => { + try { + const result = await getMerchantAdminAnalytics(req.params.id as string); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res, 'load the merchant analytics'); + } +}; + +/** + * Blocks a merchant off-chain. The on-chain `set_merchant_status` call is + * deliberately not made here — it requires the on-chain admin's signature, + * which this backend does not hold; that reconciliation is deferred. + * + * Unblocking is out of scope for this endpoint and is not implemented. + */ export const blockMerchantController = async (req: Request, res: Response): Promise => { const admin = req.admin; if (!admin) { @@ -10,6 +108,12 @@ export const blockMerchantController = async (req: Request, res: Response): Prom return; } + const { input, errors } = validateBlockMerchant(req.body); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + try { const merchant = await blockMerchant(req.params.id as string); await recordAuditLog({ @@ -19,13 +123,10 @@ export const blockMerchantController = async (req: Request, res: Response): Prom actorLabel: admin.address, targetType: 'Merchant', targetId: merchant.id, + ...(input.reason !== undefined ? { metadata: { reason: input.reason } } : {}), }); res.status(200).json(merchant); } catch (error) { - if (error instanceof AppError) { - res.status(error.statusCode).json({ error: error.message }); - return; - } - res.status(500).json({ error: 'Internal Server Error' }); + handleError(error, req, res, 'block the merchant'); } }; diff --git a/src/routes/admin/merchant.routes.ts b/src/routes/admin/merchant.routes.ts index 7466868..3cff0f8 100644 --- a/src/routes/admin/merchant.routes.ts +++ b/src/routes/admin/merchant.routes.ts @@ -1,8 +1,24 @@ import { Router } from 'express'; -import { blockMerchantController } from '../../controllers/admin-merchant.controllers.js'; +import { + blockMerchantController, + getMerchantAnalyticsController, + getMerchantController, + listMerchantInvoicesController, + listMerchantsController, +} from '../../controllers/admin-merchant.controllers.js'; +import { requireSuperAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); -router.patch('/:id/block', blockMerchantController); +// Read-only dashboard data: any authenticated admin, no superadmin requirement. +// authenticateAdmin is applied where this router is mounted (admin/index.ts). +router.get('/', listMerchantsController); +router.get('/:id', getMerchantController); +router.get('/:id/invoices', listMerchantInvoicesController); +router.get('/:id/analytics', getMerchantAnalyticsController); + +// Moderation: superadmin only. Unblocking is deliberately not exposed here — +// only blocking was in scope; see blockMerchant in merchant.services.ts. +router.post('/:id/block', requireSuperAdmin, blockMerchantController); export default router; diff --git a/src/services/merchant.services.ts b/src/services/merchant.services.ts index 042a098..40b5752 100644 --- a/src/services/merchant.services.ts +++ b/src/services/merchant.services.ts @@ -2,6 +2,12 @@ import { Merchant, Prisma } from '@prisma/client'; import prisma from '../config/prisma.js'; import { AppError } from '../utils/errors.js'; import { RegisterMerchantInput, UpdateMerchantInput } from '../utils/validation.js'; +import type { + AdminMerchantListFilters, + AdminMerchantListPagination, + MerchantListSortBy, + MerchantListSortDir, +} from '../utils/merchant.validation.js'; import { generateOtp, hashOtp } from './otp.services.js'; import { sendOtp } from './email.service.js'; import { Keypair } from '@stellar/stellar-sdk'; @@ -187,6 +193,14 @@ export const generateMerchantSigningKey = async (id: string) => { /** * Deactivates a merchant (admin action). Sets Merchant.active = false only; * this does not currently gate login, invoice creation, or any other flow. + * + * Off-chain only. The contract's own `set_merchant_status(admin, merchant_id, + * status)` requires the on-chain admin's signature, which this backend cannot + * produce, so the on-chain merchant status is deliberately left untouched here. + * Reconciling the two is deferred to separate future work, following the same + * off-chain-first pattern already established for invoice amendment. + * + * Unblocking is intentionally not implemented — only blocking was in scope. */ export const blockMerchant = async (id: string) => { const merchant = await prisma.merchant.findUnique({ where: { id } }); @@ -203,6 +217,148 @@ export const blockMerchant = async (id: string) => { return sanitizeMerchant(updated); }; +// ── Admin read side ───────────────────────────────────────────────────────── + +/** + * Paginated merchant list for the admin dashboard. + * + * `search` is a case-insensitive contains across businessName, email and + * address; the boolean and category filters are exact. Rows go through + * sanitizeMerchant like every other merchant response, which already withholds + * the OTP columns an admin has no reason to see. + */ +export const listMerchantsForAdmin = async ( + filters: AdminMerchantListFilters, + pagination: AdminMerchantListPagination, + sortBy: MerchantListSortBy, + sortDir: MerchantListSortDir, +) => { + const where: Prisma.MerchantWhereInput = {}; + + if (filters.active !== undefined) { + where.active = filters.active; + } + + if (filters.verified !== undefined) { + where.verified = filters.verified; + } + + if (filters.category) { + where.category = filters.category; + } + + if (filters.search) { + where.OR = [ + { businessName: { contains: filters.search, mode: 'insensitive' } }, + { email: { contains: filters.search, mode: 'insensitive' } }, + { address: { contains: filters.search, mode: 'insensitive' } }, + ]; + } + + const [merchants, total] = await Promise.all([ + prisma.merchant.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy: { [sortBy]: sortDir }, + }), + prisma.merchant.count({ where }), + ]); + + return { + data: merchants.map(sanitizeMerchant), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +/** + * Full merchant detail for an admin, keyed by Merchant.id (uuid). + */ +export const getMerchantForAdmin = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + return sanitizeMerchant(merchant); +}; + +/** + * Per-merchant analytics for the admin dashboard: the merchant's own per-token + * counters plus live status-grouped invoice and subscription counts. + * + * BigInt counters are serialized as strings, matching how analytics.services.ts + * already reports them, since JSON has no BigInt. + */ +export const getMerchantAdminAnalytics = async (id: string) => { + const merchant = await prisma.merchant.findUnique({ where: { id } }); + + if (!merchant) { + throw new AppError(404, 'Merchant not found'); + } + + const [tokenRows, invoicesByStatus, subscriptionsByStatus] = await Promise.all([ + prisma.merchantAnalytics.findMany({ + where: { merchantId: id }, + orderBy: { totalVolume: 'desc' }, + }), + prisma.invoice.groupBy({ + by: ['status'], + where: { merchantId: id }, + _count: { _all: true }, + }), + // Subscription.merchantId is a direct scalar (see the composite FK comment + // on the model), so this needs no join through SubscriptionPlan. + prisma.subscription.groupBy({ + by: ['status'], + where: { merchantId: id }, + _count: { _all: true }, + }), + ]); + + const countByStatus = (groups: { status: string; _count: { _all: number } }[]) => { + const counts: Record = {}; + for (const group of groups) { + counts[group.status] = group._count._all; + } + return counts; + }; + + const invoiceCounts = countByStatus( + invoicesByStatus as { status: string; _count: { _all: number } }[], + ); + const subscriptionCounts = countByStatus( + subscriptionsByStatus as { status: string; _count: { _all: number } }[], + ); + + const sumCounts = (counts: Record) => + Object.values(counts).reduce((total, count) => total + count, 0); + + return { + merchantId: merchant.id, + tokens: tokenRows.map(row => ({ + token: row.token, + totalVolume: row.totalVolume.toString(), + totalFees: row.totalFees.toString(), + transactionCount: row.transactionCount.toString(), + lastUpdated: row.lastUpdated.toISOString(), + })), + invoices: { + total: sumCounts(invoiceCounts), + byStatus: invoiceCounts, + }, + subscriptions: { + total: sumCounts(subscriptionCounts), + byStatus: subscriptionCounts, + }, + }; +}; + /** * Partially updates the authenticated merchant's editable profile fields. * diff --git a/src/utils/merchant.validation.ts b/src/utils/merchant.validation.ts new file mode 100644 index 0000000..1fc0841 --- /dev/null +++ b/src/utils/merchant.validation.ts @@ -0,0 +1,150 @@ +export const DEFAULT_LIMIT = 20; +export const MAX_LIMIT = 100; + +const MERCHANT_SORT_FIELDS = ['createdAt', 'merchantId', 'businessName'] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export interface AdminMerchantListFilters { + active?: boolean; + verified?: boolean; + category?: string; + search?: string; +} + +export interface AdminMerchantListPagination { + limit: number; + offset: number; +} + +export type MerchantListSortBy = (typeof MERCHANT_SORT_FIELDS)[number]; +export type MerchantListSortDir = (typeof SORT_DIRECTIONS)[number]; + +export type ValidationErrors = Record; + +export interface ParsedAdminMerchantListQuery { + filters: AdminMerchantListFilters; + pagination: AdminMerchantListPagination; + sortBy: MerchantListSortBy; + sortDir: MerchantListSortDir; + errors: ValidationErrors; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** + * Parses a boolean query parameter. Query strings never carry a real boolean, + * so only the literals "true"/"false" are accepted; anything else is a 400 + * rather than a silent coercion that would filter on the wrong value. + */ +const parseBoolean = ( + value: unknown, + field: string, + errors: ValidationErrors, +): boolean | undefined => { + const raw = String(value).toLowerCase(); + if (raw === 'true') return true; + if (raw === 'false') return false; + + errors[field] = `${field} must be either true or false`; + return undefined; +}; + +/** + * Parses admin merchant list query parameters into typed filters, sort and + * pagination, clamping the page size to [1, MAX_LIMIT] and defaulting to + * DEFAULT_LIMIT. Mirrors parseAdminSubscriptionListQuery in + * subscription.validation.ts. + */ +export const parseAdminMerchantListQuery = ( + query: Record, +): ParsedAdminMerchantListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminMerchantListFilters = {}; + + if (query.active !== undefined) { + const active = parseBoolean(query.active, 'active', errors); + if (active !== undefined) filters.active = active; + } + + if (query.verified !== undefined) { + const verified = parseBoolean(query.verified, 'verified', errors); + if (verified !== undefined) filters.verified = verified; + } + + if (isNonEmptyString(query.category)) { + filters.category = query.category.trim(); + } + + if (isNonEmptyString(query.search)) { + filters.search = query.search.trim(); + } + + let sortBy: MerchantListSortBy = 'createdAt'; + if (query.sortBy !== undefined) { + const value = String(query.sortBy); + if ((MERCHANT_SORT_FIELDS as readonly string[]).includes(value)) { + sortBy = value as MerchantListSortBy; + } else { + errors.sortBy = `sortBy must be one of ${MERCHANT_SORT_FIELDS.join(', ')}`; + } + } + + let sortDir: MerchantListSortDir = 'desc'; + if (query.sortDir !== undefined) { + const value = String(query.sortDir).toLowerCase(); + if ((SORT_DIRECTIONS as readonly string[]).includes(value)) { + sortDir = value as MerchantListSortDir; + } else { + errors.sortDir = `sortDir must be one of ${SORT_DIRECTIONS.join(', ')}`; + } + } + + let limit = DEFAULT_LIMIT; + if (query.limit !== undefined) { + const parsed = Number(query.limit); + if (!Number.isFinite(parsed) || parsed < 1) { + errors.limit = 'limit must be a positive number'; + } else { + limit = Math.min(Math.floor(parsed), MAX_LIMIT); + } + } + + let offset = 0; + if (query.offset !== undefined) { + const parsed = Number(query.offset); + if (!Number.isFinite(parsed) || parsed < 0) { + errors.offset = 'offset must be a non-negative number'; + } else { + offset = Math.floor(parsed); + } + } + + return { filters, pagination: { limit, offset }, sortBy, sortDir, errors }; +}; + +export interface BlockMerchantInput { + reason?: string; +} + +/** + * Validates the optional `reason` carried on a block request. The reason is + * recorded in the audit log's metadata; it is never persisted on Merchant. + */ +export const validateBlockMerchant = ( + body: unknown, +): { input: BlockMerchantInput; errors: ValidationErrors } => { + const errors: ValidationErrors = {}; + const payload = (body ?? {}) as Record; + const input: BlockMerchantInput = {}; + + if (payload.reason !== undefined && payload.reason !== null) { + if (typeof payload.reason !== 'string' || payload.reason.trim().length === 0) { + errors.reason = 'reason must be a non-empty string'; + } else { + input.reason = payload.reason.trim(); + } + } + + return { input, errors }; +}; diff --git a/tests/integration/admin.merchant.routes.test.ts b/tests/integration/admin.merchant.routes.test.ts index 78d7a55..bb0f591 100644 --- a/tests/integration/admin.merchant.routes.test.ts +++ b/tests/integration/admin.merchant.routes.test.ts @@ -16,6 +16,8 @@ const admin = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; +const superAdmin = { ...admin, id: 'superadmin-uuid', isSuperAdmin: true }; + const merchant = { id: 'merchant-1', merchantId: 1, @@ -40,32 +42,310 @@ const merchant = { updatedAt: new Date('2026-06-27T12:00:00.000Z'), }; -const adminToken = jwt.sign( - { sub: admin.id, address: admin.address, type: 'admin' }, - environment.jwtSecret, - { expiresIn: '15m' }, -); +const signToken = (subject: string) => + jwt.sign({ sub: subject, address: admin.address, type: 'admin' }, environment.jwtSecret, { + expiresIn: '15m', + }); + +const adminToken = signToken(admin.id); +const superAdminToken = signToken(superAdmin.id); -describe('PATCH /api/v1/admin/merchants/:id/block', () => { +describe('GET /api/v1/admin/merchants', () => { beforeEach(() => { mockReset(prismaMock); prismaMock.admin.findUnique.mockResolvedValue(admin); }); test('returns 401 when unauthenticated', async () => { - const response = await request(app).patch('/api/v1/admin/merchants/merchant-1/block'); + const response = await request(app).get('/api/v1/admin/merchants'); + + expect(response.status).toBe(401); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('defaults to createdAt desc with the default page size', async () => { + prismaMock.merchant.findMany.mockResolvedValue([merchant]); + prismaMock.merchant.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].id).toBe('merchant-1'); + // sanitizeMerchant keeps the OTP columns out of an admin response too. + expect(response.body.data[0]).not.toHaveProperty('emailOtp'); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('applies the active, verified, category and search filters', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ active: 'true', verified: 'false', category: 'software', search: 'eng' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: { + active: true, + verified: false, + category: 'software', + OR: [ + { businessName: { contains: 'eng', mode: 'insensitive' } }, + { email: { contains: 'eng', mode: 'insensitive' } }, + { address: { contains: 'eng', mode: 'insensitive' } }, + ], + }, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('honours sortBy, sortDir and pagination, clamping limit to MAX_LIMIT', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'businessName', sortDir: 'asc', limit: '500', offset: '40' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({ + where: {}, + take: 100, + skip: 40, + orderBy: { businessName: 'asc' }, + }); + }); + + test('sorts by merchantId when asked', async () => { + prismaMock.merchant.findMany.mockResolvedValue([]); + prismaMock.merchant.count.mockResolvedValue(0); + + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'merchantId', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { merchantId: 'asc' } }), + ); + }); + + test('returns 400 for an unsupported sortBy', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ sortBy: 'email' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('sortBy'); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); + + test('returns 400 for a non-boolean active filter', async () => { + const response = await request(app) + .get('/api/v1/admin/merchants') + .query({ active: 'yes' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('active'); + expect(prismaMock.merchant.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the merchant detail', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.id).toBe('merchant-1'); + expect(response.body.businessName).toBe('Engines'); + expect(response.body).not.toHaveProperty('emailOtp'); + }); + + test('returns 404 for an unknown id', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/invoices', () => { + const invoice = { + id: 'invoice-1', + invoiceId: 1, + merchantId: 'merchant-1', + description: 'work', + amount: 1000n, + amountPaid: 0n, + amountRefunded: 0n, + token: 'USDC', + status: 'PENDING', + payerEmail: null, + expiresAt: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), + }; + + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('scopes listInvoices to the merchant and passes its filters through', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.invoice.findMany.mockResolvedValue([invoice]); + prismaMock.invoice.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/invoices') + .query({ status: 'pending', token: 'USDC' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(response.body.data).toHaveLength(1); + expect(prismaMock.invoice.findMany).toHaveBeenCalledWith({ + where: { merchantId: 'merchant-1', status: 'PENDING', token: 'USDC' }, + take: 20, + skip: 0, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('returns 404 for an unknown merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing/invoices') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.invoice.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/v1/admin/merchants/:id/analytics', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns per-token totals and status-grouped invoice/subscription counts', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.merchantAnalytics.findMany.mockResolvedValue([ + { + id: 'analytics-1', + merchantId: 'merchant-1', + token: 'USDC', + totalVolume: 5000n, + totalFees: 50n, + transactionCount: 3n, + lastUpdated: new Date('2026-06-27T12:00:00.000Z'), + }, + ]); + prismaMock.invoice.groupBy.mockResolvedValue([ + { status: 'PAID', _count: { _all: 2 } }, + { status: 'PENDING', _count: { _all: 1 } }, + ]); + prismaMock.subscription.groupBy.mockResolvedValue([{ status: 'ACTIVE', _count: { _all: 4 } }]); + + const response = await request(app) + .get('/api/v1/admin/merchants/merchant-1/analytics') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.tokens).toEqual([ + { + token: 'USDC', + totalVolume: '5000', + totalFees: '50', + transactionCount: '3', + lastUpdated: '2026-06-27T12:00:00.000Z', + }, + ]); + expect(response.body.invoices).toEqual({ total: 3, byStatus: { PAID: 2, PENDING: 1 } }); + expect(response.body.subscriptions).toEqual({ total: 4, byStatus: { ACTIVE: 4 } }); + // Subscription.merchantId is a direct scalar, so no join through the plan. + expect(prismaMock.subscription.groupBy).toHaveBeenCalledWith({ + by: ['status'], + where: { merchantId: 'merchant-1' }, + _count: { _all: true }, + }); + }); + + test('returns 404 for an unknown merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/merchants/missing/analytics') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.merchantAnalytics.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/v1/admin/merchants/:id/block', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(superAdmin); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).post('/api/v1/admin/merchants/merchant-1/block'); expect(response.status).toBe(401); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); }); - test('sets active to false, returns the merchant, and logs the action', async () => { + test('returns 403 for an authenticated admin that is not a superadmin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(admin); + + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(403); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('sets active to false, returns the merchant, and logs the action once', async () => { prismaMock.merchant.findUnique.mockResolvedValue(merchant); prismaMock.merchant.update.mockResolvedValue({ ...merchant, active: false }); const response = await request(app) - .patch('/api/v1/admin/merchants/merchant-1/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(200); expect(response.body.active).toBe(false); @@ -73,27 +353,65 @@ describe('PATCH /api/v1/admin/merchants/:id/block', () => { where: { id: 'merchant-1' }, data: { active: false }, }); + expect(prismaMock.adminLog.create).toHaveBeenCalledTimes(1); expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'merchant.blocked', actorType: 'ADMIN', - actorId: admin.id, - actorLabel: admin.address, + actorId: superAdmin.id, + actorLabel: superAdmin.address, targetType: 'Merchant', targetId: 'merchant-1', }), }); }); + test('records an optional reason in the audit log metadata', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(merchant); + prismaMock.merchant.update.mockResolvedValue({ ...merchant, active: false }); + + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ reason: 'chargeback fraud' }); + + expect(response.status).toBe(200); + expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ metadata: { reason: 'chargeback fraud' } }), + }); + }); + + test('returns 400 for a non-string reason', async () => { + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/block') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ reason: 42 }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('reason'); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); + test('returns 404 when the merchant does not exist', async () => { prismaMock.merchant.findUnique.mockResolvedValue(null); const response = await request(app) - .patch('/api/v1/admin/merchants/missing/block') - .set('Authorization', `Bearer ${adminToken}`); + .post('/api/v1/admin/merchants/missing/block') + .set('Authorization', `Bearer ${superAdminToken}`); expect(response.status).toBe(404); expect(prismaMock.merchant.update).not.toHaveBeenCalled(); expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); }); + + // Unblocking is deliberately out of scope for this issue; no unblock route + // exists, so the router must not answer one. + test('exposes no unblock route', async () => { + const response = await request(app) + .post('/api/v1/admin/merchants/merchant-1/unblock') + .set('Authorization', `Bearer ${superAdminToken}`); + + expect(response.status).toBe(404); + expect(prismaMock.merchant.update).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/merchant.validation.test.ts b/tests/unit/merchant.validation.test.ts new file mode 100644 index 0000000..af2774d --- /dev/null +++ b/tests/unit/merchant.validation.test.ts @@ -0,0 +1,100 @@ +const { parseAdminMerchantListQuery, validateBlockMerchant, DEFAULT_LIMIT, MAX_LIMIT } = + await import('../../src/utils/merchant.validation.js'); + +describe('parseAdminMerchantListQuery', () => { + test('defaults to createdAt desc with DEFAULT_LIMIT and no filters', () => { + const result = parseAdminMerchantListQuery({}); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: DEFAULT_LIMIT, offset: 0 }); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + }); + + test('parses the boolean, category and search filters', () => { + const result = parseAdminMerchantListQuery({ + active: 'false', + verified: 'true', + category: ' software ', + search: ' eng ', + }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({ + active: false, + verified: true, + category: 'software', + search: 'eng', + }); + }); + + test('rejects a boolean filter that is not true or false', () => { + const result = parseAdminMerchantListQuery({ active: '1', verified: 'nope' }); + + expect(result.errors.active).toBeDefined(); + expect(result.errors.verified).toBeDefined(); + expect(result.filters.active).toBeUndefined(); + expect(result.filters.verified).toBeUndefined(); + }); + + test('ignores a blank category or search rather than filtering on an empty string', () => { + const result = parseAdminMerchantListQuery({ category: ' ', search: '' }); + + expect(result.errors).toEqual({}); + expect(result.filters).toEqual({}); + }); + + test.each(['createdAt', 'merchantId', 'businessName'])('accepts sortBy=%s', field => { + const result = parseAdminMerchantListQuery({ sortBy: field }); + + expect(result.errors).toEqual({}); + expect(result.sortBy).toBe(field); + }); + + test('rejects an unsupported sortBy and sortDir', () => { + const result = parseAdminMerchantListQuery({ sortBy: 'email', sortDir: 'sideways' }); + + expect(result.errors.sortBy).toBeDefined(); + expect(result.errors.sortDir).toBeDefined(); + }); + + test('accepts sortDir case-insensitively', () => { + const result = parseAdminMerchantListQuery({ sortDir: 'ASC' }); + + expect(result.errors).toEqual({}); + expect(result.sortDir).toBe('asc'); + }); + + test('clamps limit to MAX_LIMIT and floors fractional pagination', () => { + const result = parseAdminMerchantListQuery({ limit: '1000', offset: '10.9' }); + + expect(result.errors).toEqual({}); + expect(result.pagination).toEqual({ limit: MAX_LIMIT, offset: 10 }); + }); + + test('rejects a non-positive limit and a negative offset', () => { + const result = parseAdminMerchantListQuery({ limit: '0', offset: '-1' }); + + expect(result.errors.limit).toBeDefined(); + expect(result.errors.offset).toBeDefined(); + }); +}); + +describe('validateBlockMerchant', () => { + test('accepts a missing body', () => { + expect(validateBlockMerchant(undefined)).toEqual({ input: {}, errors: {} }); + }); + + test('trims a supplied reason', () => { + const result = validateBlockMerchant({ reason: ' fraud ' }); + + expect(result.errors).toEqual({}); + expect(result.input.reason).toBe('fraud'); + }); + + test('rejects a non-string or blank reason', () => { + expect(validateBlockMerchant({ reason: 42 }).errors.reason).toBeDefined(); + expect(validateBlockMerchant({ reason: ' ' }).errors.reason).toBeDefined(); + }); +}); From 14b2f55e9f1de33505c47032de9ae3a63280bdf0 Mon Sep 17 00:00:00 2001 From: dslegacy Date: Fri, 28 Aug 2026 22:58:37 +0100 Subject: [PATCH 2/7] feat(admin): add superadmin-only create-admin endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only way to get an Admin row was scripts/create-superadmin.ts, which by design can only bootstrap the first one. This adds the ongoing path: an existing superadmin adding another admin through the API. POST /admin/admins takes { address, name, isSuperAdmin? } behind requireSuperAdmin, so a non-superadmin admin gets a 403. The address is validated with StrKey.isValidEd25519PublicKey, and an address that already has an Admin row is a 409 — the same non-overwrite discipline the bootstrap script enforces, never an update and never a silently swallowed no-op. isSuperAdmin defaults to false: a superadmin adding another admin does not implicitly grant superadmin, though it can be requested explicitly. Only a real boolean is accepted rather than any truthy value, since coercing the string "false" would silently escalate the new admin's privileges. The created row records createdBy as the acting superadmin's id, and exactly one admin.created AdminLog entry is written per successful call. The response goes through a new sanitizeAdmin allow-list, mirroring sanitizeMerchant, so a sensitive field added to the model later is not exposed by default. No keypair or secret exists anywhere in this flow — admins authenticate with their own existing Stellar wallet. No smart contract call is made. Admin membership here is deliberately a backend concept, decoupled from the contract's own Admin/Manager/Operator role system, which is a separate on-chain authorization concern this backend does not drive. Also updates the "off-chain actions with no endpoint yet" note in indexer/handlers/not-yet-implemented.ts, which named admin.created as a known gap that this endpoint closes. --- src/controllers/admin-auth.controllers.ts | 58 ++++- src/indexer/handlers/not-yet-implemented.ts | 6 +- src/routes/admin/admins.routes.ts | 11 + src/routes/admin/index.ts | 2 + src/services/admin-auth.services.ts | 49 +++++ src/utils/admin.validation.ts | 53 +++++ tests/integration/admin.admins.routes.test.ts | 204 ++++++++++++++++++ tests/unit/admin.validation.test.ts | 72 +++++++ 8 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 src/routes/admin/admins.routes.ts create mode 100644 src/utils/admin.validation.ts create mode 100644 tests/integration/admin.admins.routes.test.ts create mode 100644 tests/unit/admin.validation.test.ts diff --git a/src/controllers/admin-auth.controllers.ts b/src/controllers/admin-auth.controllers.ts index 4081608..f693645 100644 --- a/src/controllers/admin-auth.controllers.ts +++ b/src/controllers/admin-auth.controllers.ts @@ -1,7 +1,14 @@ import { Request, Response } from 'express'; import { StrKey } from '@stellar/stellar-sdk'; import { createNonce } from '../services/auth.services.js'; -import { authenticateAdminWallet } from '../services/admin-auth.services.js'; +import { + authenticateAdminWallet, + createAdmin, + sanitizeAdmin, +} from '../services/admin-auth.services.js'; +import { recordAuditLog, ActorType } from '../services/audit-log.services.js'; +import { validateCreateAdmin } from '../utils/admin.validation.js'; +import { AppError } from '../utils/errors.js'; export const createAdminChallengeController = async (req: Request, res: Response) => { try { @@ -57,3 +64,52 @@ export const verifyAdminSignatureController = async (req: Request, res: Response res.status(500).json({ error: 'Internal Server Error' }); } }; + +/** + * Creates another admin. Superadmin-only; the route applies requireSuperAdmin. + * + * Deliberately makes no smart contract call: admin membership here is a + * backend concept, decoupled from the contract's own Admin/Manager/Operator + * role system. + */ +export const createAdminController = async (req: Request, res: Response): Promise => { + const actingAdmin = req.admin; + if (!actingAdmin) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const { input, errors } = validateCreateAdmin(req.body); + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const admin = await createAdmin(actingAdmin.id, input); + + await recordAuditLog({ + action: 'admin.created', + actorType: ActorType.ADMIN, + actorId: actingAdmin.id, + actorLabel: actingAdmin.address, + targetType: 'Admin', + targetId: admin.id, + metadata: { address: admin.address, isSuperAdmin: admin.isSuperAdmin }, + }); + + res.status(201).json(sanitizeAdmin(admin)); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error('Failed to create admin', { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/indexer/handlers/not-yet-implemented.ts b/src/indexer/handlers/not-yet-implemented.ts index 64960f9..8ab03f8 100644 --- a/src/indexer/handlers/not-yet-implemented.ts +++ b/src/indexer/handlers/not-yet-implemented.ts @@ -69,10 +69,8 @@ * AccountRestricted event, listed once) * * ---- Off-chain actions with no endpoint yet ---- - * admin.created <- no admin-management endpoint exists yet (only admin login, - * from a prior issue). Deferred: see the issue discussion — - * building admin-management is out of scope for the - * audit-log issue that added this file. + * (admin.created is implemented — POST /admin/admins records it; see + * createAdminController in ../../controllers/admin-auth.controllers.ts) * * ---- Explicitly excluded, not gaps (per the issue) ---- * NonceInvalidatedEvent - not a state change worth auditing diff --git a/src/routes/admin/admins.routes.ts b/src/routes/admin/admins.routes.ts new file mode 100644 index 0000000..8d97867 --- /dev/null +++ b/src/routes/admin/admins.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { createAdminController } from '../../controllers/admin-auth.controllers.js'; +import { requireSuperAdmin } from '../../middlewares/admin.middleware.js'; + +const router = Router(); + +// Admin management is superadmin-only. authenticateAdmin is applied where this +// router is mounted (admin/index.ts); requireSuperAdmin chains after it. +router.post('/', requireSuperAdmin, createAdminController); + +export default router; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 1776288..3fda7bf 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import authRoutes from './auth.routes.js'; +import adminsRoutes from './admins.routes.js'; import analyticsRoutes from './analytics.routes.js'; import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; @@ -16,6 +17,7 @@ router.use('/analytics', analyticsRoutes); // Sibling routers added by later issues (merchant.routes.ts, invoice.routes.ts, ...) // are mounted here behind authenticateAdmin. +router.use('/admins', authenticateAdmin, adminsRoutes); router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); router.use('/subscriptions', authenticateAdmin, subscriptionsRoutes); diff --git a/src/services/admin-auth.services.ts b/src/services/admin-auth.services.ts index 262d090..1a4d22c 100644 --- a/src/services/admin-auth.services.ts +++ b/src/services/admin-auth.services.ts @@ -1,8 +1,11 @@ import crypto from 'node:crypto'; +import type { Admin } from '@prisma/client'; import jwt from 'jsonwebtoken'; import prisma from '../config/prisma.js'; import { environment } from '../config/environment.js'; import { verifySignature } from './auth.services.js'; +import { AppError } from '../utils/errors.js'; +import type { CreateAdminInput } from '../utils/admin.validation.js'; import { recordAuditLog, ActorType } from './audit-log.services.js'; const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; @@ -79,3 +82,49 @@ export async function authenticateAdminWallet(address: string, nonce: string, si }, } as const; } + +/** + * Public view of an Admin row. Built as an allow-list, matching sanitizeMerchant, + * so any sensitive field added to the model later is not exposed by default. + */ +export const sanitizeAdmin = (admin: Admin) => ({ + id: admin.id, + address: admin.address, + name: admin.name, + active: admin.active, + isSuperAdmin: admin.isSuperAdmin, + createdBy: admin.createdBy, + createdAt: admin.createdAt, + updatedAt: admin.updatedAt, +}); + +/** + * Creates an Admin row on behalf of an acting superadmin. + * + * This is the ongoing counterpart to scripts/create-superadmin.ts, which can + * only bootstrap the very first admin. It keeps that script's non-overwrite + * discipline: an address that already has an Admin row is a 409, never an + * update and never a silent no-op. + * + * Backend-only by design. The contract's own Admin/Manager/Operator roles are a + * separate on-chain authorization concern this backend does not drive, so no + * contract call is made anywhere in this flow. No keypair or secret is involved + * either — admins authenticate with their own existing Stellar wallet. + */ +export const createAdmin = async (actingAdminId: string, input: CreateAdminInput) => { + const existing = await prisma.admin.findUnique({ where: { address: input.address } }); + + if (existing) { + throw new AppError(409, 'An admin already exists for this address'); + } + + return prisma.admin.create({ + data: { + address: input.address, + name: input.name, + isSuperAdmin: input.isSuperAdmin, + active: true, + createdBy: actingAdminId, + }, + }); +}; diff --git a/src/utils/admin.validation.ts b/src/utils/admin.validation.ts new file mode 100644 index 0000000..b0cd89a --- /dev/null +++ b/src/utils/admin.validation.ts @@ -0,0 +1,53 @@ +import { StrKey } from '@stellar/stellar-sdk'; + +export interface CreateAdminInput { + address: string; + name: string; + isSuperAdmin: boolean; +} + +export type ValidationErrors = Record; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** + * Validates the body of a create-admin request. + * + * `isSuperAdmin` is optional and defaults to false: a superadmin adding another + * admin does not implicitly grant superadmin, though it may be requested + * explicitly. Only a real boolean is accepted — coercing a truthy string here + * would silently escalate the new admin's privileges. + */ +export const validateCreateAdmin = ( + body: unknown, +): { input: CreateAdminInput; errors: ValidationErrors } => { + const errors: ValidationErrors = {}; + const payload = (body ?? {}) as Record; + + if (!isNonEmptyString(payload.address)) { + errors.address = 'address is required'; + } else if (!StrKey.isValidEd25519PublicKey(payload.address.trim())) { + errors.address = 'address must be a valid Stellar public key'; + } + + if (!isNonEmptyString(payload.name)) { + errors.name = 'name is required'; + } + + if ( + payload.isSuperAdmin !== undefined && + payload.isSuperAdmin !== null && + typeof payload.isSuperAdmin !== 'boolean' + ) { + errors.isSuperAdmin = 'isSuperAdmin must be a boolean'; + } + + const input: CreateAdminInput = { + address: isNonEmptyString(payload.address) ? payload.address.trim() : '', + name: isNonEmptyString(payload.name) ? payload.name.trim() : '', + isSuperAdmin: payload.isSuperAdmin === true, + }; + + return { input, errors }; +}; diff --git a/tests/integration/admin.admins.routes.test.ts b/tests/integration/admin.admins.routes.test.ts new file mode 100644 index 0000000..1c52b03 --- /dev/null +++ b/tests/integration/admin.admins.routes.test.ts @@ -0,0 +1,204 @@ +import { beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMINADDRESS', + name: 'Plain Admin', + active: true, + isSuperAdmin: false, + createdBy: null, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const superAdmin = { + ...admin, + id: 'superadmin-uuid', + address: 'GSUPERADMINADDRESS', + name: 'Super Admin', + isSuperAdmin: true, +}; + +// A real, structurally valid Ed25519 public key — StrKey verifies the checksum. +const NEW_ADMIN_ADDRESS = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + +const createdAdmin = { + id: 'new-admin-uuid', + address: NEW_ADMIN_ADDRESS, + name: 'Jane Doe', + active: true, + isSuperAdmin: false, + createdBy: superAdmin.id, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const signToken = (subject: string, address: string) => + jwt.sign({ sub: subject, address, type: 'admin' }, environment.jwtSecret, { expiresIn: '15m' }); + +const adminToken = signToken(admin.id, admin.address); +const superAdminToken = signToken(superAdmin.id, superAdmin.address); + +describe('POST /api/v1/admin/admins', () => { + beforeEach(() => { + mockReset(prismaMock); + // authenticateAdmin resolves the acting admin by the token's sub. + prismaMock.admin.findUnique.mockImplementation(({ where }: any) => + Promise.resolve(where.id === superAdmin.id ? superAdmin : null), + ); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(401); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 403 for an authenticated admin that is not a superadmin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(admin); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${adminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(403); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('creates the admin, defaults isSuperAdmin to false, and logs the action once', async () => { + prismaMock.admin.create.mockResolvedValue(createdAdmin); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(201); + expect(response.body.id).toBe('new-admin-uuid'); + expect(response.body.isSuperAdmin).toBe(false); + // createdBy is the acting superadmin, not the new row itself. + expect(response.body.createdBy).toBe(superAdmin.id); + expect(prismaMock.admin.create).toHaveBeenCalledWith({ + data: { + address: NEW_ADMIN_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: false, + active: true, + createdBy: superAdmin.id, + }, + }); + expect(prismaMock.adminLog.create).toHaveBeenCalledTimes(1); + expect(prismaMock.adminLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin.created', + actorType: 'ADMIN', + actorId: superAdmin.id, + actorLabel: superAdmin.address, + targetType: 'Admin', + targetId: createdAdmin.id, + }), + }); + }); + + test('grants superadmin when explicitly requested', async () => { + prismaMock.admin.create.mockResolvedValue({ ...createdAdmin, isSuperAdmin: true }); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe', isSuperAdmin: true }); + + expect(response.status).toBe(201); + expect(response.body.isSuperAdmin).toBe(true); + expect(prismaMock.admin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ isSuperAdmin: true }), + }); + }); + + test('returns 409 for an address that already has an admin row, creating nothing', async () => { + prismaMock.admin.findUnique.mockImplementation(({ where }: any) => { + if (where.id === superAdmin.id) return Promise.resolve(superAdmin); + if (where.address === NEW_ADMIN_ADDRESS) return Promise.resolve(createdAdmin); + return Promise.resolve(null); + }); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(409); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + expect(prismaMock.admin.update).not.toHaveBeenCalled(); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('returns 400 for an invalid Stellar address', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: 'not-a-stellar-key', name: 'Jane Doe' }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('address'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 400 for a missing name', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('name'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 400 for a non-boolean isSuperAdmin rather than escalating', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe', isSuperAdmin: 'yes' }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('isSuperAdmin'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('never exposes a secret or keypair in the response', async () => { + prismaMock.admin.create.mockResolvedValue(createdAdmin); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(201); + const body = JSON.stringify(response.body); + expect(body).not.toMatch(/secret/i); + expect(body).not.toMatch(/privateKey/i); + expect(response.body).toEqual({ + id: createdAdmin.id, + address: createdAdmin.address, + name: createdAdmin.name, + active: createdAdmin.active, + isSuperAdmin: createdAdmin.isSuperAdmin, + createdBy: superAdmin.id, + createdAt: createdAdmin.createdAt.toISOString(), + updatedAt: createdAdmin.updatedAt.toISOString(), + }); + }); +}); diff --git a/tests/unit/admin.validation.test.ts b/tests/unit/admin.validation.test.ts new file mode 100644 index 0000000..66ce305 --- /dev/null +++ b/tests/unit/admin.validation.test.ts @@ -0,0 +1,72 @@ +const { validateCreateAdmin } = await import('../../src/utils/admin.validation.js'); + +// A real, structurally valid Ed25519 public key; StrKey checks the checksum, so +// an arbitrary G-prefixed string will not do. +const VALID_ADDRESS = 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH'; + +describe('validateCreateAdmin', () => { + test('accepts an address and name, defaulting isSuperAdmin to false', () => { + const result = validateCreateAdmin({ address: VALID_ADDRESS, name: 'Jane Doe' }); + + expect(result.errors).toEqual({}); + expect(result.input).toEqual({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: false, + }); + }); + + test('trims the address and name', () => { + const result = validateCreateAdmin({ + address: ` ${VALID_ADDRESS} `, + name: ' Jane Doe ', + }); + + expect(result.errors).toEqual({}); + expect(result.input.address).toBe(VALID_ADDRESS); + expect(result.input.name).toBe('Jane Doe'); + }); + + test('grants superadmin only when explicitly requested', () => { + const granted = validateCreateAdmin({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: true, + }); + + expect(granted.errors).toEqual({}); + expect(granted.input.isSuperAdmin).toBe(true); + }); + + test('rejects a non-boolean isSuperAdmin rather than coercing it', () => { + const result = validateCreateAdmin({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: 'true', + }); + + expect(result.errors.isSuperAdmin).toBeDefined(); + // A truthy string must never escalate the new admin's privileges. + expect(result.input.isSuperAdmin).toBe(false); + }); + + test('rejects an invalid Stellar address', () => { + const result = validateCreateAdmin({ address: 'not-a-key', name: 'Jane Doe' }); + + expect(result.errors.address).toBeDefined(); + }); + + test('rejects a missing or blank address and name', () => { + expect(validateCreateAdmin({}).errors.address).toBeDefined(); + expect(validateCreateAdmin({}).errors.name).toBeDefined(); + expect(validateCreateAdmin({ address: ' ', name: ' ' }).errors.address).toBeDefined(); + expect(validateCreateAdmin({ address: VALID_ADDRESS, name: ' ' }).errors.name).toBeDefined(); + }); + + test('accepts a missing body without throwing', () => { + const result = validateCreateAdmin(undefined); + + expect(result.errors.address).toBeDefined(); + expect(result.errors.name).toBeDefined(); + }); +}); From f0305e6445ce4ffbbb3b68701b91cc701b682282 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Fri, 28 Aug 2026 23:32:47 +0100 Subject: [PATCH 3/7] feat: add admin subscription plans endpoints --- .../admin-subscription-plan.controllers.ts | 51 ++++ src/routes/admin/index.ts | 2 + src/routes/admin/subscription-plans.routes.ts | 12 + .../admin-subscription-plan.services.ts | 98 ++++++++ .../admin-subscription-plan.validation.ts | 96 ++++++++ .../admin.subscription-plan.routes.test.ts | 221 ++++++++++++++++++ .../admin.subscription-plan.services.test.ts | 75 ++++++ 7 files changed, 555 insertions(+) create mode 100644 src/controllers/admin-subscription-plan.controllers.ts create mode 100644 src/routes/admin/subscription-plans.routes.ts create mode 100644 src/services/admin-subscription-plan.services.ts create mode 100644 src/utils/admin-subscription-plan.validation.ts create mode 100644 tests/integration/admin.subscription-plan.routes.test.ts create mode 100644 tests/unit/admin.subscription-plan.services.test.ts diff --git a/src/controllers/admin-subscription-plan.controllers.ts b/src/controllers/admin-subscription-plan.controllers.ts new file mode 100644 index 0000000..7bb6e37 --- /dev/null +++ b/src/controllers/admin-subscription-plan.controllers.ts @@ -0,0 +1,51 @@ +import { Request, Response } from 'express'; +import { + listSubscriptionPlans, + getSubscriptionPlan, +} from '../services/admin-subscription-plan.services.js'; +import { parseAdminSubscriptionPlanListQuery } from '../utils/admin-subscription-plan.validation.js'; +import { AppError } from '../utils/errors.js'; + +export const listSubscriptionPlansController = async ( + req: Request, + res: Response, +): Promise => { + const { filters, pagination, sortBy, sortDir, errors } = parseAdminSubscriptionPlanListQuery( + req.query as Record, + ); + + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listSubscriptionPlans(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getSubscriptionPlanController = async (req: Request, res: Response): Promise => { + try { + const plan = await getSubscriptionPlan(req.params.id as string); + res.status(200).json(plan); + } catch (error) { + handleError(error, req, res); + } +}; + +const handleError = (error: unknown, req: Request, res: Response): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error('Failed to process admin subscription plans request', { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 1776288..22cd49a 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -4,6 +4,7 @@ import analyticsRoutes from './analytics.routes.js'; import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; import subscriptionsRoutes from './subscriptions.routes.js'; +import subscriptionPlansRoutes from './subscription-plans.routes.js'; import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); @@ -19,6 +20,7 @@ router.use('/analytics', analyticsRoutes); router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); router.use('/subscriptions', authenticateAdmin, subscriptionsRoutes); +router.use('/subscription-plans', authenticateAdmin, subscriptionPlansRoutes); // Sibling routers added by later issues (invoice.routes.ts, ...) are mounted // here behind authenticateAdmin. diff --git a/src/routes/admin/subscription-plans.routes.ts b/src/routes/admin/subscription-plans.routes.ts new file mode 100644 index 0000000..101f9f6 --- /dev/null +++ b/src/routes/admin/subscription-plans.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { + listSubscriptionPlansController, + getSubscriptionPlanController, +} from '../../controllers/admin-subscription-plan.controllers.js'; + +const router = Router(); + +router.get('/', listSubscriptionPlansController); +router.get('/:id', getSubscriptionPlanController); + +export default router; diff --git a/src/services/admin-subscription-plan.services.ts b/src/services/admin-subscription-plan.services.ts new file mode 100644 index 0000000..5439a98 --- /dev/null +++ b/src/services/admin-subscription-plan.services.ts @@ -0,0 +1,98 @@ +import type { Prisma, SubscriptionPlan } from '@prisma/client'; +import prisma from '../config/prisma.js'; +import { AppError } from '../utils/errors.js'; +import type { + AdminSubscriptionPlanListFilters, + AdminSubscriptionPlanListPagination, + PlanListSortBy, + PlanListSortDir, +} from '../utils/admin-subscription-plan.validation.js'; + +export const sanitizeSubscriptionPlan = (plan: SubscriptionPlan) => ({ + id: plan.id, + planId: plan.planId, + merchantId: plan.merchantId, + description: plan.description, + token: plan.token, + amount: plan.amount.toString(), + interval: plan.interval, + active: plan.active, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, +}); + +export const sanitizeSubscriptionPlanWithCount = ( + plan: SubscriptionPlan & { _count: { subscriptions: number } }, +) => ({ + ...sanitizeSubscriptionPlan(plan), + subscriberCount: plan._count.subscriptions, +}); + +export const listSubscriptionPlans = async ( + filters: AdminSubscriptionPlanListFilters, + pagination: AdminSubscriptionPlanListPagination, + sortBy: PlanListSortBy, + sortDir: PlanListSortDir, +) => { + const where: Prisma.SubscriptionPlanWhereInput = {}; + + if (filters.token) { + where.token = filters.token; + } + + if (filters.active !== undefined) { + where.active = filters.active; + } + + if (filters.merchantAddress) { + const merchant = await prisma.merchant.findUnique({ + where: { address: filters.merchantAddress }, + select: { id: true }, + }); + if (!merchant) { + return { data: [], pagination: { ...pagination, total: 0 } }; + } + where.merchantId = merchant.id; + } + + const orderBy: Prisma.SubscriptionPlanOrderByWithRelationInput[] = [ + { [sortBy]: sortDir }, + { id: 'desc' }, + ]; + + const [plans, total] = await Promise.all([ + prisma.subscriptionPlan.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy, + }), + prisma.subscriptionPlan.count({ where }), + ]); + + return { + data: plans.map(sanitizeSubscriptionPlan), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +export const getSubscriptionPlan = async (id: string) => { + const plan = await prisma.subscriptionPlan.findUnique({ + where: { id }, + include: { + _count: { + select: { subscriptions: { where: { status: 'ACTIVE' } } }, + }, + }, + }); + + if (!plan) { + throw new AppError(404, 'Subscription plan not found'); + } + + return sanitizeSubscriptionPlanWithCount(plan); +}; diff --git a/src/utils/admin-subscription-plan.validation.ts b/src/utils/admin-subscription-plan.validation.ts new file mode 100644 index 0000000..2950203 --- /dev/null +++ b/src/utils/admin-subscription-plan.validation.ts @@ -0,0 +1,96 @@ +import { DEFAULT_LIMIT, MAX_LIMIT, ValidationErrors } from './subscription.validation.js'; + +const PLAN_SORT_FIELDS = ['createdAt', 'amount', 'interval'] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export interface AdminSubscriptionPlanListFilters { + merchantAddress?: string; + token?: string; + active?: boolean; +} + +export interface AdminSubscriptionPlanListPagination { + limit: number; + offset: number; +} + +export type PlanListSortBy = (typeof PLAN_SORT_FIELDS)[number]; +export type PlanListSortDir = (typeof SORT_DIRECTIONS)[number]; + +interface ParsedSubscriptionPlanListQuery { + filters: AdminSubscriptionPlanListFilters; + pagination: AdminSubscriptionPlanListPagination; + sortBy: PlanListSortBy; + sortDir: PlanListSortDir; + errors: ValidationErrors; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +export const parseAdminSubscriptionPlanListQuery = ( + query: Record, +): ParsedSubscriptionPlanListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminSubscriptionPlanListFilters = {}; + + if (isNonEmptyString(query.merchantAddress)) { + filters.merchantAddress = query.merchantAddress.trim(); + } + + if (isNonEmptyString(query.token)) { + filters.token = query.token.trim(); + } + + if (query.active !== undefined) { + if (query.active === 'true' || query.active === true) { + filters.active = true; + } else if (query.active === 'false' || query.active === false) { + filters.active = false; + } else { + errors.active = 'active must be a boolean'; + } + } + + let sortBy: PlanListSortBy = 'createdAt'; + if (query.sortBy !== undefined) { + const value = String(query.sortBy); + if ((PLAN_SORT_FIELDS as readonly string[]).includes(value)) { + sortBy = value as PlanListSortBy; + } else { + errors.sortBy = `sortBy must be one of ${PLAN_SORT_FIELDS.join(', ')}`; + } + } + + let sortDir: PlanListSortDir = 'desc'; + if (query.sortDir !== undefined) { + const value = String(query.sortDir).toLowerCase(); + if ((SORT_DIRECTIONS as readonly string[]).includes(value)) { + sortDir = value as PlanListSortDir; + } else { + errors.sortDir = `sortDir must be one of ${SORT_DIRECTIONS.join(', ')}`; + } + } + + let limit = DEFAULT_LIMIT; + if (query.limit !== undefined) { + const parsed = Number(query.limit); + if (!Number.isFinite(parsed) || parsed < 1) { + errors.limit = 'limit must be a positive number'; + } else { + limit = Math.min(Math.floor(parsed), MAX_LIMIT); + } + } + + let offset = 0; + if (query.offset !== undefined) { + const parsed = Number(query.offset); + if (!Number.isFinite(parsed) || parsed < 0) { + errors.offset = 'offset must be a non-negative number'; + } else { + offset = Math.floor(parsed); + } + } + + return { filters, pagination: { limit, offset }, sortBy, sortDir, errors }; +}; diff --git a/tests/integration/admin.subscription-plan.routes.test.ts b/tests/integration/admin.subscription-plan.routes.test.ts new file mode 100644 index 0000000..3dddeb0 --- /dev/null +++ b/tests/integration/admin.subscription-plan.routes.test.ts @@ -0,0 +1,221 @@ + +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMINADDRESS', + active: true, + isSuperAdmin: false, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const adminToken = jwt.sign( + { sub: admin.id, address: admin.address, type: 'admin' }, + environment.jwtSecret, + { expiresIn: '15m' }, +); + +const mockDate = new Date('2026-06-24T10:00:00.000Z'); + +const basePlan = { + id: 'plan-uuid', + planId: 101, + merchantId: 'merchant-uuid', + description: 'Monthly Pro Plan', + token: 'CABC...TOKEN', + amount: BigInt(10_000_000), + interval: 2_592_000, + active: true, + createdAt: mockDate, + updatedAt: mockDate, +}; + +describe('GET /api/v1/admin/subscription-plans', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get('/api/v1/admin/subscription-plans'); + + expect(response.status).toBe(401); + }); + + test('lists subscription plans newest-first by default with default pagination', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('applies token, active filters and pagination', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ token: 'CABC...TOKEN', active: 'true', limit: 5, offset: 10 }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: { + token: 'CABC...TOKEN', + active: true, + }, + take: 5, + skip: 10, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('resolves merchantAddress to a merchant id and filters by merchantId', async () => { + prismaMock.merchant.findUnique.mockResolvedValue({ id: 'merchant-uuid' }); + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ merchantAddress: 'GMERCHANT123' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ + where: { address: 'GMERCHANT123' }, + select: { id: true }, + }); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: { merchantId: 'merchant-uuid' }, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('returns an empty page when merchantAddress has no matching merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ merchantAddress: 'GUNKNOWN123' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [], + pagination: { limit: 20, offset: 0, total: 0 }, + }); + expect(prismaMock.subscriptionPlan.findMany).not.toHaveBeenCalled(); + }); + + test('applies sortBy and sortDir', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ sortBy: 'amount', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ amount: 'asc' }, { id: 'desc' }], + }); + }); + + test('serializes plan amount as a string', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data[0].amount).toBe('10000000'); + }); + + test('returns 400 for invalid active filter', async () => { + const response = await request(app) + .get('/api/v1/admin/subscription-plans?active=invalid') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('active'); + }); + + test('returns 400 for invalid sortBy and sortDir', async () => { + const response = await request(app) + .get('/api/v1/admin/subscription-plans?sortBy=invalid&sortDir=sideways') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('sortBy'); + expect(response.body.errors).toHaveProperty('sortDir'); + }); +}); + +describe('GET /api/v1/admin/subscription-plans/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the subscription plan with subscriberCount', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue({ + ...basePlan, + _count: { subscriptions: 5 }, + }); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans/plan-uuid') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findUnique).toHaveBeenCalledWith({ + where: { id: 'plan-uuid' }, + include: { + _count: { + select: { subscriptions: { where: { status: 'ACTIVE' } } }, + }, + }, + }); + expect(response.body.id).toBe('plan-uuid'); + expect(response.body.amount).toBe('10000000'); + expect(response.body.subscriberCount).toBe(5); + }); + + test('returns 404 for an unknown id', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans/nope') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Subscription plan not found'); + }); +}); diff --git a/tests/unit/admin.subscription-plan.services.test.ts b/tests/unit/admin.subscription-plan.services.test.ts new file mode 100644 index 0000000..e0f75cb --- /dev/null +++ b/tests/unit/admin.subscription-plan.services.test.ts @@ -0,0 +1,75 @@ +import { parseAdminSubscriptionPlanListQuery } from '../../src/utils/admin-subscription-plan.validation.js'; + +describe('parseAdminSubscriptionPlanListQuery', () => { + test('returns default values for an empty query', () => { + const result = parseAdminSubscriptionPlanListQuery({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + expect(result.errors).toEqual({}); + }); + + test('parses merchantAddress filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ merchantAddress: ' GMERCHANT ' }); + expect(result.filters.merchantAddress).toBe('GMERCHANT'); + expect(result.errors).toEqual({}); + }); + + test('parses token filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ token: ' TOKEN ' }); + expect(result.filters.token).toBe('TOKEN'); + expect(result.errors).toEqual({}); + }); + + test('parses active filter', () => { + let result = parseAdminSubscriptionPlanListQuery({ active: 'true' }); + expect(result.filters.active).toBe(true); + expect(result.errors).toEqual({}); + + result = parseAdminSubscriptionPlanListQuery({ active: true }); + expect(result.filters.active).toBe(true); + + result = parseAdminSubscriptionPlanListQuery({ active: 'false' }); + expect(result.filters.active).toBe(false); + + result = parseAdminSubscriptionPlanListQuery({ active: false }); + expect(result.filters.active).toBe(false); + }); + + test('records error for invalid active filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ active: 'yes' }); + expect(result.filters.active).toBeUndefined(); + expect(result.errors).toHaveProperty('active'); + }); + + test('parses sortBy and sortDir', () => { + const result = parseAdminSubscriptionPlanListQuery({ sortBy: 'amount', sortDir: 'asc' }); + expect(result.sortBy).toBe('amount'); + expect(result.sortDir).toBe('asc'); + expect(result.errors).toEqual({}); + }); + + test('records errors for invalid sortBy and sortDir', () => { + const result = parseAdminSubscriptionPlanListQuery({ sortBy: 'invalid', sortDir: 'sideways' }); + expect(result.sortBy).toBe('createdAt'); // Defaults applied + expect(result.sortDir).toBe('desc'); + expect(result.errors).toHaveProperty('sortBy'); + expect(result.errors).toHaveProperty('sortDir'); + }); + + test('parses pagination with clamping', () => { + let result = parseAdminSubscriptionPlanListQuery({ limit: '15', offset: '5' }); + expect(result.pagination).toEqual({ limit: 15, offset: 5 }); + + result = parseAdminSubscriptionPlanListQuery({ limit: '500' }); + expect(result.pagination.limit).toBe(100); + }); + + test('records errors for invalid pagination', () => { + const result = parseAdminSubscriptionPlanListQuery({ limit: '-1', offset: 'abc' }); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); // Defaults applied + expect(result.errors).toHaveProperty('limit'); + expect(result.errors).toHaveProperty('offset'); + }); +}); From bb839ce253dab7540b9faa623b18e76f6cbb5e81 Mon Sep 17 00:00:00 2001 From: dslegacy Date: Sat, 29 Aug 2026 23:17:22 +0100 Subject: [PATCH 4/7] fix(admin): make create-admin atomic and tighten validation Address CodeRabbit review on PR #57. Admin creation and its audit row are now written in one Prisma transaction. recordAuditLog swallows its own database errors by design, so the previous flow could return 201 with no admin.created row, leaving a privileged account with no audit trail. createAdmin now writes the adminLog row directly inside the transaction so a failure there propagates and rolls back the admin row. Every other recordAuditLog caller keeps the swallowing behaviour it wants. The findUnique pre-check is not a lock, so two concurrent requests for the same address can both pass it. The unique constraint on Admin.address now surfaces as the same 409 the sequential path returns, rather than a 500. The P2002 check is duck-typed to match auth.services.ts, since the generated client is mocked in tests. Validation treated an explicit null isSuperAdmin as omission, silently creating a non-superadmin record for a payload that violates the boolean contract. Only undefined counts as omitted now. Tests cover the concurrent duplicate, the audit write failure, and the null isSuperAdmin case at both the unit and integration level. --- src/controllers/admin-auth.controllers.ts | 15 +---- src/services/admin-auth.services.ts | 59 ++++++++++++++++--- src/utils/admin.validation.ts | 11 ++-- tests/integration/admin.admins.routes.test.ts | 43 ++++++++++++++ tests/unit/admin.validation.test.ts | 11 ++++ 5 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/controllers/admin-auth.controllers.ts b/src/controllers/admin-auth.controllers.ts index f693645..48db347 100644 --- a/src/controllers/admin-auth.controllers.ts +++ b/src/controllers/admin-auth.controllers.ts @@ -6,7 +6,6 @@ import { createAdmin, sanitizeAdmin, } from '../services/admin-auth.services.js'; -import { recordAuditLog, ActorType } from '../services/audit-log.services.js'; import { validateCreateAdmin } from '../utils/admin.validation.js'; import { AppError } from '../utils/errors.js'; @@ -86,17 +85,9 @@ export const createAdminController = async (req: Request, res: Response): Promis } try { - const admin = await createAdmin(actingAdmin.id, input); - - await recordAuditLog({ - action: 'admin.created', - actorType: ActorType.ADMIN, - actorId: actingAdmin.id, - actorLabel: actingAdmin.address, - targetType: 'Admin', - targetId: admin.id, - metadata: { address: admin.address, isSuperAdmin: admin.isSuperAdmin }, - }); + // createAdmin writes the row and its admin.created log in one transaction, + // so a 201 here always means both committed. + const admin = await createAdmin({ id: actingAdmin.id, address: actingAdmin.address }, input); res.status(201).json(sanitizeAdmin(admin)); } catch (error) { diff --git a/src/services/admin-auth.services.ts b/src/services/admin-auth.services.ts index 1a4d22c..a169ba8 100644 --- a/src/services/admin-auth.services.ts +++ b/src/services/admin-auth.services.ts @@ -98,6 +98,12 @@ export const sanitizeAdmin = (admin: Admin) => ({ updatedAt: admin.updatedAt, }); +// Duck-typed rather than an instanceof check against +// PrismaClientKnownRequestError: the generated client is mocked in tests, so +// the same convention as auth.services.ts applies here. +const isUniqueConstraintError = (error: unknown): boolean => + (error as { code?: string })?.code === 'P2002'; + /** * Creates an Admin row on behalf of an acting superadmin. * @@ -111,20 +117,55 @@ export const sanitizeAdmin = (admin: Admin) => ({ * contract call is made anywhere in this flow. No keypair or secret is involved * either — admins authenticate with their own existing Stellar wallet. */ -export const createAdmin = async (actingAdminId: string, input: CreateAdminInput) => { +export const createAdmin = async ( + actingAdmin: { id: string; address: string }, + input: CreateAdminInput, +) => { const existing = await prisma.admin.findUnique({ where: { address: input.address } }); if (existing) { throw new AppError(409, 'An admin already exists for this address'); } - return prisma.admin.create({ - data: { - address: input.address, - name: input.name, - isSuperAdmin: input.isSuperAdmin, - active: true, - createdBy: actingAdminId, - }, + // The row and its `admin.created` log share one transaction so a privileged + // account can never exist without an audit trail. recordAuditLog is + // deliberately not used here: it swallows its own failures, which would break + // that invariant. Every other caller still wants that swallowing behaviour. + return prisma.$transaction(async (tx: any) => { + let admin: Admin; + + try { + admin = await tx.admin.create({ + data: { + address: input.address, + name: input.name, + isSuperAdmin: input.isSuperAdmin, + active: true, + createdBy: actingAdmin.id, + }, + }); + } catch (error) { + // The findUnique above is not a lock, so two concurrent requests for the + // same address can both pass it. Admin.address is unique, so the loser + // gets the same 409 it would have got sequentially. + if (isUniqueConstraintError(error)) { + throw new AppError(409, 'An admin already exists for this address'); + } + throw error; + } + + await tx.adminLog.create({ + data: { + action: 'admin.created', + actorType: ActorType.ADMIN, + actorId: actingAdmin.id, + actorLabel: actingAdmin.address, + targetType: 'Admin', + targetId: admin.id, + metadata: { address: admin.address, isSuperAdmin: admin.isSuperAdmin }, + }, + }); + + return admin; }); }; diff --git a/src/utils/admin.validation.ts b/src/utils/admin.validation.ts index b0cd89a..d266c80 100644 --- a/src/utils/admin.validation.ts +++ b/src/utils/admin.validation.ts @@ -17,7 +17,8 @@ const isNonEmptyString = (value: unknown): value is string => * `isSuperAdmin` is optional and defaults to false: a superadmin adding another * admin does not implicitly grant superadmin, though it may be requested * explicitly. Only a real boolean is accepted — coercing a truthy string here - * would silently escalate the new admin's privileges. + * would silently escalate the new admin's privileges, and an explicit null is + * rejected rather than treated as omission. */ export const validateCreateAdmin = ( body: unknown, @@ -35,11 +36,9 @@ export const validateCreateAdmin = ( errors.name = 'name is required'; } - if ( - payload.isSuperAdmin !== undefined && - payload.isSuperAdmin !== null && - typeof payload.isSuperAdmin !== 'boolean' - ) { + // Only `undefined` counts as omitted. An explicit null is a supplied value + // that is not a boolean, so it is rejected rather than silently defaulted. + if (payload.isSuperAdmin !== undefined && typeof payload.isSuperAdmin !== 'boolean') { errors.isSuperAdmin = 'isSuperAdmin must be a boolean'; } diff --git a/tests/integration/admin.admins.routes.test.ts b/tests/integration/admin.admins.routes.test.ts index 1c52b03..eed1fc8 100644 --- a/tests/integration/admin.admins.routes.test.ts +++ b/tests/integration/admin.admins.routes.test.ts @@ -53,6 +53,9 @@ describe('POST /api/v1/admin/admins', () => { prismaMock.admin.findUnique.mockImplementation(({ where }: any) => Promise.resolve(where.id === superAdmin.id ? superAdmin : null), ); + // The admin row and its audit row are written in one transaction, so the + // interactive callback has to run against the same mock client. + prismaMock.$transaction.mockImplementation(async (callback: any) => callback(prismaMock)); }); test('returns 401 when unauthenticated', async () => { @@ -145,6 +148,46 @@ describe('POST /api/v1/admin/admins', () => { expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); }); + test('returns 409 when a concurrent request wins the unique address', async () => { + // Both requests pass the findUnique pre-check; only one create survives the + // unique constraint on Admin.address. + prismaMock.admin.create.mockRejectedValue({ code: 'P2002', meta: { target: ['address'] } }); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + expect(response.status).toBe(409); + expect(response.body.error).toBe('An admin already exists for this address'); + expect(prismaMock.adminLog.create).not.toHaveBeenCalled(); + }); + + test('fails the request when the audit row cannot be written', async () => { + prismaMock.admin.create.mockResolvedValue(createdAdmin); + prismaMock.adminLog.create.mockRejectedValue(new Error('audit write failed')); + + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe' }); + + // A privileged account must never be created without an audit trail, so the + // transaction rolls back and the caller sees a 500 rather than a 201. + expect(response.status).toBe(500); + }); + + test('returns 400 for a null isSuperAdmin', async () => { + const response = await request(app) + .post('/api/v1/admin/admins') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ address: NEW_ADMIN_ADDRESS, name: 'Jane Doe', isSuperAdmin: null }); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('isSuperAdmin'); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + test('returns 400 for an invalid Stellar address', async () => { const response = await request(app) .post('/api/v1/admin/admins') diff --git a/tests/unit/admin.validation.test.ts b/tests/unit/admin.validation.test.ts index 66ce305..838bee7 100644 --- a/tests/unit/admin.validation.test.ts +++ b/tests/unit/admin.validation.test.ts @@ -50,6 +50,17 @@ describe('validateCreateAdmin', () => { expect(result.input.isSuperAdmin).toBe(false); }); + test('rejects a null isSuperAdmin rather than treating it as omitted', () => { + const result = validateCreateAdmin({ + address: VALID_ADDRESS, + name: 'Jane Doe', + isSuperAdmin: null, + }); + + expect(result.errors.isSuperAdmin).toBeDefined(); + expect(result.input.isSuperAdmin).toBe(false); + }); + test('rejects an invalid Stellar address', () => { const result = validateCreateAdmin({ address: 'not-a-key', name: 'Jane Doe' }); From 61614a9a069b243335d77343c98b76e24f3e3ac5 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Fri, 28 Aug 2026 23:32:47 +0100 Subject: [PATCH 5/7] feat: add admin subscription plans endpoints --- .../admin-subscription-plan.controllers.ts | 51 ++++ src/routes/admin/index.ts | 2 + src/routes/admin/subscription-plans.routes.ts | 12 + .../admin-subscription-plan.services.ts | 98 ++++++++ .../admin-subscription-plan.validation.ts | 96 ++++++++ .../admin.subscription-plan.routes.test.ts | 221 ++++++++++++++++++ .../admin.subscription-plan.services.test.ts | 75 ++++++ 7 files changed, 555 insertions(+) create mode 100644 src/controllers/admin-subscription-plan.controllers.ts create mode 100644 src/routes/admin/subscription-plans.routes.ts create mode 100644 src/services/admin-subscription-plan.services.ts create mode 100644 src/utils/admin-subscription-plan.validation.ts create mode 100644 tests/integration/admin.subscription-plan.routes.test.ts create mode 100644 tests/unit/admin.subscription-plan.services.test.ts diff --git a/src/controllers/admin-subscription-plan.controllers.ts b/src/controllers/admin-subscription-plan.controllers.ts new file mode 100644 index 0000000..7bb6e37 --- /dev/null +++ b/src/controllers/admin-subscription-plan.controllers.ts @@ -0,0 +1,51 @@ +import { Request, Response } from 'express'; +import { + listSubscriptionPlans, + getSubscriptionPlan, +} from '../services/admin-subscription-plan.services.js'; +import { parseAdminSubscriptionPlanListQuery } from '../utils/admin-subscription-plan.validation.js'; +import { AppError } from '../utils/errors.js'; + +export const listSubscriptionPlansController = async ( + req: Request, + res: Response, +): Promise => { + const { filters, pagination, sortBy, sortDir, errors } = parseAdminSubscriptionPlanListQuery( + req.query as Record, + ); + + if (Object.keys(errors).length > 0) { + res.status(400).json({ error: 'Validation failed', errors }); + return; + } + + try { + const result = await listSubscriptionPlans(filters, pagination, sortBy, sortDir); + res.status(200).json(result); + } catch (error) { + handleError(error, req, res); + } +}; + +export const getSubscriptionPlanController = async (req: Request, res: Response): Promise => { + try { + const plan = await getSubscriptionPlan(req.params.id as string); + res.status(200).json(plan); + } catch (error) { + handleError(error, req, res); + } +}; + +const handleError = (error: unknown, req: Request, res: Response): void => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.message }); + return; + } + + console.error('Failed to process admin subscription plans request', { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); + res.status(500).json({ error: 'Internal Server Error' }); +}; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index b8087dd..ec3dc54 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -6,6 +6,7 @@ import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; import subscriptionsRoutes from './subscriptions.routes.js'; import invoiceRoutes from './invoice.routes.js'; +import subscriptionPlansRoutes from './subscription-plans.routes.js'; import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; const router = Router(); @@ -23,5 +24,6 @@ router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); router.use('/subscriptions', authenticateAdmin, subscriptionsRoutes); router.use('/invoices', authenticateAdmin, invoiceRoutes); +router.use('/subscription-plans', authenticateAdmin, subscriptionPlansRoutes); export default router; diff --git a/src/routes/admin/subscription-plans.routes.ts b/src/routes/admin/subscription-plans.routes.ts new file mode 100644 index 0000000..101f9f6 --- /dev/null +++ b/src/routes/admin/subscription-plans.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { + listSubscriptionPlansController, + getSubscriptionPlanController, +} from '../../controllers/admin-subscription-plan.controllers.js'; + +const router = Router(); + +router.get('/', listSubscriptionPlansController); +router.get('/:id', getSubscriptionPlanController); + +export default router; diff --git a/src/services/admin-subscription-plan.services.ts b/src/services/admin-subscription-plan.services.ts new file mode 100644 index 0000000..5439a98 --- /dev/null +++ b/src/services/admin-subscription-plan.services.ts @@ -0,0 +1,98 @@ +import type { Prisma, SubscriptionPlan } from '@prisma/client'; +import prisma from '../config/prisma.js'; +import { AppError } from '../utils/errors.js'; +import type { + AdminSubscriptionPlanListFilters, + AdminSubscriptionPlanListPagination, + PlanListSortBy, + PlanListSortDir, +} from '../utils/admin-subscription-plan.validation.js'; + +export const sanitizeSubscriptionPlan = (plan: SubscriptionPlan) => ({ + id: plan.id, + planId: plan.planId, + merchantId: plan.merchantId, + description: plan.description, + token: plan.token, + amount: plan.amount.toString(), + interval: plan.interval, + active: plan.active, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, +}); + +export const sanitizeSubscriptionPlanWithCount = ( + plan: SubscriptionPlan & { _count: { subscriptions: number } }, +) => ({ + ...sanitizeSubscriptionPlan(plan), + subscriberCount: plan._count.subscriptions, +}); + +export const listSubscriptionPlans = async ( + filters: AdminSubscriptionPlanListFilters, + pagination: AdminSubscriptionPlanListPagination, + sortBy: PlanListSortBy, + sortDir: PlanListSortDir, +) => { + const where: Prisma.SubscriptionPlanWhereInput = {}; + + if (filters.token) { + where.token = filters.token; + } + + if (filters.active !== undefined) { + where.active = filters.active; + } + + if (filters.merchantAddress) { + const merchant = await prisma.merchant.findUnique({ + where: { address: filters.merchantAddress }, + select: { id: true }, + }); + if (!merchant) { + return { data: [], pagination: { ...pagination, total: 0 } }; + } + where.merchantId = merchant.id; + } + + const orderBy: Prisma.SubscriptionPlanOrderByWithRelationInput[] = [ + { [sortBy]: sortDir }, + { id: 'desc' }, + ]; + + const [plans, total] = await Promise.all([ + prisma.subscriptionPlan.findMany({ + where, + take: pagination.limit, + skip: pagination.offset, + orderBy, + }), + prisma.subscriptionPlan.count({ where }), + ]); + + return { + data: plans.map(sanitizeSubscriptionPlan), + pagination: { + limit: pagination.limit, + offset: pagination.offset, + total, + }, + }; +}; + +export const getSubscriptionPlan = async (id: string) => { + const plan = await prisma.subscriptionPlan.findUnique({ + where: { id }, + include: { + _count: { + select: { subscriptions: { where: { status: 'ACTIVE' } } }, + }, + }, + }); + + if (!plan) { + throw new AppError(404, 'Subscription plan not found'); + } + + return sanitizeSubscriptionPlanWithCount(plan); +}; diff --git a/src/utils/admin-subscription-plan.validation.ts b/src/utils/admin-subscription-plan.validation.ts new file mode 100644 index 0000000..2950203 --- /dev/null +++ b/src/utils/admin-subscription-plan.validation.ts @@ -0,0 +1,96 @@ +import { DEFAULT_LIMIT, MAX_LIMIT, ValidationErrors } from './subscription.validation.js'; + +const PLAN_SORT_FIELDS = ['createdAt', 'amount', 'interval'] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +export interface AdminSubscriptionPlanListFilters { + merchantAddress?: string; + token?: string; + active?: boolean; +} + +export interface AdminSubscriptionPlanListPagination { + limit: number; + offset: number; +} + +export type PlanListSortBy = (typeof PLAN_SORT_FIELDS)[number]; +export type PlanListSortDir = (typeof SORT_DIRECTIONS)[number]; + +interface ParsedSubscriptionPlanListQuery { + filters: AdminSubscriptionPlanListFilters; + pagination: AdminSubscriptionPlanListPagination; + sortBy: PlanListSortBy; + sortDir: PlanListSortDir; + errors: ValidationErrors; +} + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +export const parseAdminSubscriptionPlanListQuery = ( + query: Record, +): ParsedSubscriptionPlanListQuery => { + const errors: ValidationErrors = {}; + const filters: AdminSubscriptionPlanListFilters = {}; + + if (isNonEmptyString(query.merchantAddress)) { + filters.merchantAddress = query.merchantAddress.trim(); + } + + if (isNonEmptyString(query.token)) { + filters.token = query.token.trim(); + } + + if (query.active !== undefined) { + if (query.active === 'true' || query.active === true) { + filters.active = true; + } else if (query.active === 'false' || query.active === false) { + filters.active = false; + } else { + errors.active = 'active must be a boolean'; + } + } + + let sortBy: PlanListSortBy = 'createdAt'; + if (query.sortBy !== undefined) { + const value = String(query.sortBy); + if ((PLAN_SORT_FIELDS as readonly string[]).includes(value)) { + sortBy = value as PlanListSortBy; + } else { + errors.sortBy = `sortBy must be one of ${PLAN_SORT_FIELDS.join(', ')}`; + } + } + + let sortDir: PlanListSortDir = 'desc'; + if (query.sortDir !== undefined) { + const value = String(query.sortDir).toLowerCase(); + if ((SORT_DIRECTIONS as readonly string[]).includes(value)) { + sortDir = value as PlanListSortDir; + } else { + errors.sortDir = `sortDir must be one of ${SORT_DIRECTIONS.join(', ')}`; + } + } + + let limit = DEFAULT_LIMIT; + if (query.limit !== undefined) { + const parsed = Number(query.limit); + if (!Number.isFinite(parsed) || parsed < 1) { + errors.limit = 'limit must be a positive number'; + } else { + limit = Math.min(Math.floor(parsed), MAX_LIMIT); + } + } + + let offset = 0; + if (query.offset !== undefined) { + const parsed = Number(query.offset); + if (!Number.isFinite(parsed) || parsed < 0) { + errors.offset = 'offset must be a non-negative number'; + } else { + offset = Math.floor(parsed); + } + } + + return { filters, pagination: { limit, offset }, sortBy, sortDir, errors }; +}; diff --git a/tests/integration/admin.subscription-plan.routes.test.ts b/tests/integration/admin.subscription-plan.routes.test.ts new file mode 100644 index 0000000..3dddeb0 --- /dev/null +++ b/tests/integration/admin.subscription-plan.routes.test.ts @@ -0,0 +1,221 @@ + +import { mockReset } from 'jest-mock-extended'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { default: app } = await import('../../src/app.js'); + +const admin = { + id: 'admin-uuid', + address: 'GADMINADDRESS', + active: true, + isSuperAdmin: false, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const adminToken = jwt.sign( + { sub: admin.id, address: admin.address, type: 'admin' }, + environment.jwtSecret, + { expiresIn: '15m' }, +); + +const mockDate = new Date('2026-06-24T10:00:00.000Z'); + +const basePlan = { + id: 'plan-uuid', + planId: 101, + merchantId: 'merchant-uuid', + description: 'Monthly Pro Plan', + token: 'CABC...TOKEN', + amount: BigInt(10_000_000), + interval: 2_592_000, + active: true, + createdAt: mockDate, + updatedAt: mockDate, +}; + +describe('GET /api/v1/admin/subscription-plans', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns 401 when unauthenticated', async () => { + const response = await request(app).get('/api/v1/admin/subscription-plans'); + + expect(response.status).toBe(401); + }); + + test('lists subscription plans newest-first by default with default pagination', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 }); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('applies token, active filters and pagination', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ token: 'CABC...TOKEN', active: 'true', limit: 5, offset: 10 }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: { + token: 'CABC...TOKEN', + active: true, + }, + take: 5, + skip: 10, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('resolves merchantAddress to a merchant id and filters by merchantId', async () => { + prismaMock.merchant.findUnique.mockResolvedValue({ id: 'merchant-uuid' }); + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ merchantAddress: 'GMERCHANT123' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.merchant.findUnique).toHaveBeenCalledWith({ + where: { address: 'GMERCHANT123' }, + select: { id: true }, + }); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: { merchantId: 'merchant-uuid' }, + take: 20, + skip: 0, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + test('returns an empty page when merchantAddress has no matching merchant', async () => { + prismaMock.merchant.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ merchantAddress: 'GUNKNOWN123' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [], + pagination: { limit: 20, offset: 0, total: 0 }, + }); + expect(prismaMock.subscriptionPlan.findMany).not.toHaveBeenCalled(); + }); + + test('applies sortBy and sortDir', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .query({ sortBy: 'amount', sortDir: 'asc' }) + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + skip: 0, + orderBy: [{ amount: 'asc' }, { id: 'desc' }], + }); + }); + + test('serializes plan amount as a string', async () => { + prismaMock.subscriptionPlan.findMany.mockResolvedValue([basePlan]); + prismaMock.subscriptionPlan.count.mockResolvedValue(1); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(response.body.data[0].amount).toBe('10000000'); + }); + + test('returns 400 for invalid active filter', async () => { + const response = await request(app) + .get('/api/v1/admin/subscription-plans?active=invalid') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('active'); + }); + + test('returns 400 for invalid sortBy and sortDir', async () => { + const response = await request(app) + .get('/api/v1/admin/subscription-plans?sortBy=invalid&sortDir=sideways') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(400); + expect(response.body.errors).toHaveProperty('sortBy'); + expect(response.body.errors).toHaveProperty('sortDir'); + }); +}); + +describe('GET /api/v1/admin/subscription-plans/:id', () => { + beforeEach(() => { + mockReset(prismaMock); + prismaMock.admin.findUnique.mockResolvedValue(admin); + }); + + test('returns the subscription plan with subscriberCount', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue({ + ...basePlan, + _count: { subscriptions: 5 }, + }); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans/plan-uuid') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(200); + expect(prismaMock.subscriptionPlan.findUnique).toHaveBeenCalledWith({ + where: { id: 'plan-uuid' }, + include: { + _count: { + select: { subscriptions: { where: { status: 'ACTIVE' } } }, + }, + }, + }); + expect(response.body.id).toBe('plan-uuid'); + expect(response.body.amount).toBe('10000000'); + expect(response.body.subscriberCount).toBe(5); + }); + + test('returns 404 for an unknown id', async () => { + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(null); + + const response = await request(app) + .get('/api/v1/admin/subscription-plans/nope') + .set('Authorization', `Bearer ${adminToken}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Subscription plan not found'); + }); +}); diff --git a/tests/unit/admin.subscription-plan.services.test.ts b/tests/unit/admin.subscription-plan.services.test.ts new file mode 100644 index 0000000..e0f75cb --- /dev/null +++ b/tests/unit/admin.subscription-plan.services.test.ts @@ -0,0 +1,75 @@ +import { parseAdminSubscriptionPlanListQuery } from '../../src/utils/admin-subscription-plan.validation.js'; + +describe('parseAdminSubscriptionPlanListQuery', () => { + test('returns default values for an empty query', () => { + const result = parseAdminSubscriptionPlanListQuery({}); + expect(result.filters).toEqual({}); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); + expect(result.sortBy).toBe('createdAt'); + expect(result.sortDir).toBe('desc'); + expect(result.errors).toEqual({}); + }); + + test('parses merchantAddress filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ merchantAddress: ' GMERCHANT ' }); + expect(result.filters.merchantAddress).toBe('GMERCHANT'); + expect(result.errors).toEqual({}); + }); + + test('parses token filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ token: ' TOKEN ' }); + expect(result.filters.token).toBe('TOKEN'); + expect(result.errors).toEqual({}); + }); + + test('parses active filter', () => { + let result = parseAdminSubscriptionPlanListQuery({ active: 'true' }); + expect(result.filters.active).toBe(true); + expect(result.errors).toEqual({}); + + result = parseAdminSubscriptionPlanListQuery({ active: true }); + expect(result.filters.active).toBe(true); + + result = parseAdminSubscriptionPlanListQuery({ active: 'false' }); + expect(result.filters.active).toBe(false); + + result = parseAdminSubscriptionPlanListQuery({ active: false }); + expect(result.filters.active).toBe(false); + }); + + test('records error for invalid active filter', () => { + const result = parseAdminSubscriptionPlanListQuery({ active: 'yes' }); + expect(result.filters.active).toBeUndefined(); + expect(result.errors).toHaveProperty('active'); + }); + + test('parses sortBy and sortDir', () => { + const result = parseAdminSubscriptionPlanListQuery({ sortBy: 'amount', sortDir: 'asc' }); + expect(result.sortBy).toBe('amount'); + expect(result.sortDir).toBe('asc'); + expect(result.errors).toEqual({}); + }); + + test('records errors for invalid sortBy and sortDir', () => { + const result = parseAdminSubscriptionPlanListQuery({ sortBy: 'invalid', sortDir: 'sideways' }); + expect(result.sortBy).toBe('createdAt'); // Defaults applied + expect(result.sortDir).toBe('desc'); + expect(result.errors).toHaveProperty('sortBy'); + expect(result.errors).toHaveProperty('sortDir'); + }); + + test('parses pagination with clamping', () => { + let result = parseAdminSubscriptionPlanListQuery({ limit: '15', offset: '5' }); + expect(result.pagination).toEqual({ limit: 15, offset: 5 }); + + result = parseAdminSubscriptionPlanListQuery({ limit: '500' }); + expect(result.pagination.limit).toBe(100); + }); + + test('records errors for invalid pagination', () => { + const result = parseAdminSubscriptionPlanListQuery({ limit: '-1', offset: 'abc' }); + expect(result.pagination).toEqual({ limit: 20, offset: 0 }); // Defaults applied + expect(result.errors).toHaveProperty('limit'); + expect(result.errors).toHaveProperty('offset'); + }); +}); From b11d131b22a8da6b44a88c895abca9201ca82d5a Mon Sep 17 00:00:00 2001 From: Lewechi Date: Sun, 30 Aug 2026 11:31:20 +0100 Subject: [PATCH 6/7] fix: fixes --- src/routes/admin/index.ts | 3 --- src/services/admin-subscription-plan.services.ts | 16 ++++++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 02a6198..1d8f859 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -5,7 +5,6 @@ import analyticsRoutes from './analytics.routes.js'; import merchantRoutes from './merchant.routes.js'; import logsRoutes from './logs.routes.js'; import subscriptionsRoutes from './subscriptions.routes.js'; -import subscriptionPlansRoutes from './subscription-plans.routes.js'; import invoiceRoutes from './invoice.routes.js'; import subscriptionPlansRoutes from './subscription-plans.routes.js'; import { authenticateAdmin } from '../../middlewares/admin.middleware.js'; @@ -24,8 +23,6 @@ router.use('/admins', authenticateAdmin, adminsRoutes); router.use('/merchants', authenticateAdmin, merchantRoutes); router.use('/logs', authenticateAdmin, logsRoutes); router.use('/subscriptions', authenticateAdmin, subscriptionsRoutes); -router.use('/subscription-plans', authenticateAdmin, subscriptionPlansRoutes); - // Sibling routers added by later issues (invoice.routes.ts, ...) are mounted // here behind authenticateAdmin. router.use('/invoices', authenticateAdmin, invoiceRoutes); diff --git a/src/services/admin-subscription-plan.services.ts b/src/services/admin-subscription-plan.services.ts index 5439a98..c5e0b36 100644 --- a/src/services/admin-subscription-plan.services.ts +++ b/src/services/admin-subscription-plan.services.ts @@ -22,10 +22,11 @@ export const sanitizeSubscriptionPlan = (plan: SubscriptionPlan) => ({ }); export const sanitizeSubscriptionPlanWithCount = ( - plan: SubscriptionPlan & { _count: { subscriptions: number } }, + plan: SubscriptionPlan, + subscriberCount: number, ) => ({ ...sanitizeSubscriptionPlan(plan), - subscriberCount: plan._count.subscriptions, + subscriberCount, }); export const listSubscriptionPlans = async ( @@ -83,16 +84,15 @@ export const listSubscriptionPlans = async ( export const getSubscriptionPlan = async (id: string) => { const plan = await prisma.subscriptionPlan.findUnique({ where: { id }, - include: { - _count: { - select: { subscriptions: { where: { status: 'ACTIVE' } } }, - }, - }, }); if (!plan) { throw new AppError(404, 'Subscription plan not found'); } - return sanitizeSubscriptionPlanWithCount(plan); + const subscriberCount = await prisma.subscription.count({ + where: { planId: id, status: 'ACTIVE' }, + }); + + return sanitizeSubscriptionPlanWithCount(plan, subscriberCount); }; From 2bde458e5a742e3fddd95593d0067cccb8a4d217 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Sun, 30 Aug 2026 11:35:36 +0100 Subject: [PATCH 7/7] done: done --- .../admin.subscription-plan.routes.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/integration/admin.subscription-plan.routes.test.ts b/tests/integration/admin.subscription-plan.routes.test.ts index 3dddeb0..1b5d574 100644 --- a/tests/integration/admin.subscription-plan.routes.test.ts +++ b/tests/integration/admin.subscription-plan.routes.test.ts @@ -185,10 +185,8 @@ describe('GET /api/v1/admin/subscription-plans/:id', () => { }); test('returns the subscription plan with subscriberCount', async () => { - prismaMock.subscriptionPlan.findUnique.mockResolvedValue({ - ...basePlan, - _count: { subscriptions: 5 }, - }); + prismaMock.subscriptionPlan.findUnique.mockResolvedValue(basePlan); + prismaMock.subscription.count.mockResolvedValue(5); const response = await request(app) .get('/api/v1/admin/subscription-plans/plan-uuid') @@ -197,11 +195,9 @@ describe('GET /api/v1/admin/subscription-plans/:id', () => { expect(response.status).toBe(200); expect(prismaMock.subscriptionPlan.findUnique).toHaveBeenCalledWith({ where: { id: 'plan-uuid' }, - include: { - _count: { - select: { subscriptions: { where: { status: 'ACTIVE' } } }, - }, - }, + }); + expect(prismaMock.subscription.count).toHaveBeenCalledWith({ + where: { planId: 'plan-uuid', status: 'ACTIVE' }, }); expect(response.body.id).toBe('plan-uuid'); expect(response.body.amount).toBe('10000000');