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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion src/controllers/admin-auth.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
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 { validateCreateAdmin } from '../utils/admin.validation.js';
import { AppError } from '../utils/errors.js';

export const createAdminChallengeController = async (req: Request, res: Response) => {
try {
Expand Down Expand Up @@ -57,3 +63,44 @@ 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<void> => {
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 {
// 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) {
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' });
}
};
113 changes: 107 additions & 6 deletions src/controllers/admin-merchant.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,119 @@
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<void> => {
const { filters, pagination, sortBy, sortDir, errors } = parseAdminMerchantListQuery(
req.query as Record<string, unknown>,
);
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<void> => {
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<void> => {
const { filters, pagination, errors } = parseInvoiceListQuery(
req.query as Record<string, unknown>,
);
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<void> => {
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<void> => {
const admin = req.admin;
if (!admin) {
res.status(401).json({ error: 'Unauthorized' });
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({
Expand All @@ -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');
}
};
51 changes: 51 additions & 0 deletions src/controllers/admin-subscription-plan.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
const { filters, pagination, sortBy, sortDir, errors } = parseAdminSubscriptionPlanListQuery(
req.query as Record<string, unknown>,
);

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<void> => {
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' });
};
6 changes: 2 additions & 4 deletions src/indexer/handlers/not-yet-implemented.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/routes/admin/admins.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 9 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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';
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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { authenticateAdmin } from '../../middlewares/admin.middleware.js';

const router = Router();
Expand All @@ -17,9 +20,15 @@ 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);
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);
router.use('/subscription-plans', authenticateAdmin, subscriptionPlansRoutes);

export default router;
20 changes: 18 additions & 2 deletions src/routes/admin/merchant.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions src/routes/admin/subscription-plans.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading