Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
29 changes: 29 additions & 0 deletions prisma/migrations/20260821010000_add_admin_log/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('ADMIN', 'MERCHANT', 'ANONYMOUS', 'SYSTEM');

-- CreateTable
CREATE TABLE "AdminLog" (
"id" TEXT NOT NULL,
"action" TEXT NOT NULL,
"actorType" "ActorType" NOT NULL,
"actorId" TEXT,
"actorLabel" TEXT NOT NULL,
"targetType" TEXT,
"targetId" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "AdminLog_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "AdminLog_actorType_actorId_idx" ON "AdminLog"("actorType", "actorId");

-- CreateIndex
CREATE INDEX "AdminLog_targetType_targetId_idx" ON "AdminLog"("targetType", "targetId");

-- CreateIndex
CREATE INDEX "AdminLog_action_idx" ON "AdminLog"("action");

-- CreateIndex
CREATE INDEX "AdminLog_createdAt_idx" ON "AdminLog"("createdAt");
29 changes: 29 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ model AdminRefreshToken {
createdAt DateTime @default(now())
}

enum ActorType {
ADMIN
MERCHANT
ANONYMOUS
SYSTEM
}

// Cross-cutting audit trail for every state-changing action, on-chain or off-chain.
// actorId is a loose reference (no FK) to Admin.id or Merchant.id — it must be able
// to represent an anonymous on-chain address or a system action without forcing a
// fake Admin/Merchant row into existence. recordAuditLog in audit-log.services.ts
// is the only place that should write to this model.
model AdminLog {
id String @id @default(uuid())
action String
actorType ActorType
actorId String?
actorLabel String
targetType String?
targetId String?
metadata Json?
createdAt DateTime @default(now())

@@index([actorType, actorId])
@@index([targetType, targetId])
@@index([action])
@@index([createdAt])
}

model Merchant {
id String @id @default(uuid())
merchantId Int @unique
Expand Down
20 changes: 20 additions & 0 deletions src/controllers/admin-log.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Request, Response } from 'express';
import { listAuditLogs } from '../services/audit-log.services.js';
import { parseAuditLogListQuery } from '../utils/audit-log.validation.js';

export const listAuditLogsController = async (req: Request, res: Response): Promise<void> => {
const { filters, pagination, errors } = parseAuditLogListQuery(
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 listAuditLogs(filters, pagination);
res.status(200).json(result);
} catch {
res.status(500).json({ error: 'Internal Server Error' });
}
};
31 changes: 31 additions & 0 deletions src/controllers/admin-merchant.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Request, Response } from 'express';
import { blockMerchant } from '../services/merchant.services.js';
import { recordAuditLog, ActorType } from '../services/audit-log.services.js';
import { AppError } from '../utils/errors.js';

export const blockMerchantController = async (req: Request, res: Response): Promise<void> => {
const admin = req.admin;
if (!admin) {
res.status(401).json({ error: 'Unauthorized' });
return;
}

try {
const merchant = await blockMerchant(req.params.id as string);
await recordAuditLog({
action: 'merchant.blocked',
actorType: ActorType.ADMIN,
actorId: admin.id,
actorLabel: admin.address,
targetType: 'Merchant',
targetId: merchant.id,
});
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' });
}
};
17 changes: 17 additions & 0 deletions src/controllers/auth.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createNonce, authenticateWallet } from '../services/auth.services.js';
import { resendEmailOtp, verifyEmailOtp } from '../services/otp.services.js';
import { sanitizeMerchant } from '../services/merchant.services.js';
import { AppError } from '../utils/errors.js';
import { recordAuditLog, ActorType } from '../services/audit-log.services.js';

export const createNonceController = async (req: Request, res: Response) => {
try {
Expand Down Expand Up @@ -85,6 +86,14 @@ export const verifyEmailController = async (req: Request, res: Response): Promis

try {
const updatedMerchant = await verifyEmailOtp(merchant.id, code.trim());
await recordAuditLog({
action: 'merchant.email_verified',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Merchant',
targetId: merchant.id,
});
res.status(200).json(sanitizeMerchant(updatedMerchant));
} catch (error) {
if (error instanceof AppError) {
Expand All @@ -105,6 +114,14 @@ export const resendOtpController = async (req: Request, res: Response): Promise<

try {
await resendEmailOtp(merchant.id);
await recordAuditLog({
action: 'merchant.otp_resent',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Merchant',
targetId: merchant.id,
});
res.status(200).json({ message: 'Verification code sent' });
} catch (error) {
if (error instanceof AppError) {
Expand Down
33 changes: 33 additions & 0 deletions src/controllers/invoice.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { parseInvoiceListQuery, validateCreateInvoice } from '../utils/invoice.v
import { AppError } from '../utils/errors.js';
import { generateInvoicePdf } from '../services/invoice-pdf.services.js';
import { sendInvoiceEmail } from '../services/email.service.js';
import { recordAuditLog, ActorType } from '../services/audit-log.services.js';

export const createInvoiceController = async (req: Request, res: Response): Promise<void> => {
const merchant = req.merchant;
Expand All @@ -27,6 +28,14 @@ export const createInvoiceController = async (req: Request, res: Response): Prom

try {
const invoice = await createInvoice(merchant.id, req.body);
await recordAuditLog({
action: 'invoice.created',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Invoice',
targetId: invoice.id,
});
res.status(201).json(invoice);
} catch (error) {
if (error instanceof AppError) {
Expand Down Expand Up @@ -88,6 +97,14 @@ export const amendInvoiceController = async (req: Request, res: Response): Promi

try {
const invoice = await amendInvoice(merchant.id, req.params.id, req.body);
await recordAuditLog({
action: 'invoice.amended',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Invoice',
targetId: invoice.id,
});
res.status(200).json(invoice);
} catch (error) {
if (error instanceof AppError) {
Expand All @@ -107,6 +124,14 @@ export const voidInvoiceController = async (req: Request, res: Response): Promis

try {
const invoice = await voidInvoice(merchant.id, req.params.id as string);
await recordAuditLog({
action: 'invoice.voided',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Invoice',
targetId: invoice.id,
});
res.status(200).json(invoice);
} catch (error) {
if (error instanceof AppError) {
Expand Down Expand Up @@ -159,6 +184,14 @@ export const sendInvoiceController = async (req: Request, res: Response): Promis
}

await sendInvoiceEmail(invoice, invoice.merchant);
await recordAuditLog({
action: 'invoice.email_sent',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Invoice',
targetId: invoice.id,
});
res.status(200).json({ message: 'Invoice email sent' });
} catch (error) {
if (error instanceof AppError) {
Expand Down
26 changes: 26 additions & 0 deletions src/controllers/merchant.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../services/merchant.services.js';
import { validateRegisterMerchant, validateUpdateMerchant } from '../utils/validation.js';
import { AppError } from '../utils/errors.js';
import { recordAuditLog, ActorType } from '../services/audit-log.services.js';

export const createMerchantController = async (req: Request, res: Response) => {
try {
Expand Down Expand Up @@ -54,6 +55,14 @@ export const registerMerchantController = async (req: Request, res: Response): P

try {
const profile = await registerMerchant(merchant.id, req.body);
await recordAuditLog({
action: 'merchant.profile_registered',
actorType: ActorType.MERCHANT,
actorId: profile.id,
actorLabel: profile.businessName ?? profile.address,
targetType: 'Merchant',
targetId: profile.id,
});
res.status(200).json(profile);
} catch (error) {
if (error instanceof AppError) {
Expand Down Expand Up @@ -94,6 +103,15 @@ export const generateSigningKeyController = async (req: Request, res: Response):

try {
const keys = await generateMerchantSigningKey(merchant.id);
await recordAuditLog({
action: 'merchant.signing_key_generated',
actorType: ActorType.MERCHANT,
actorId: merchant.id,
actorLabel: merchant.businessName ?? merchant.address,
targetType: 'Merchant',
targetId: merchant.id,
metadata: { publicKey: keys.publicKey },
});
res.status(201).json(keys);
} catch (error) {
if (error instanceof AppError) {
Expand All @@ -120,6 +138,14 @@ export const updateMyProfileController = async (req: Request, res: Response): Pr

try {
const profile = await updateMyProfile(merchant.id, req.body);
await recordAuditLog({
action: 'merchant.profile_updated',
actorType: ActorType.MERCHANT,
actorId: profile.id,
actorLabel: profile.businessName ?? profile.address,
targetType: 'Merchant',
targetId: profile.id,
});
res.status(200).json(profile);
} catch (error) {
if (error instanceof AppError) {
Expand Down
81 changes: 81 additions & 0 deletions src/indexer/handlers/not-yet-implemented.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Audit-log catalog rows with no working call site yet.
*
* This file intentionally registers nothing with `registerEventHandler` — it
* exists so the gap between the full audit-log Action Catalog and what this
* backend actually implements is discoverable in review/grep, rather than
* silently absent. `dispatch()` in ../registry.ts already logs "No handler
* registered for topic X, skipping" for any of the on-chain topics below, so
* there is no behavior change here, only documentation.
*
* None of these topic strings have been observed against a live deployment —
* they are inferred from the one confirmed convention in this codebase
* (`#[contractevent] InvoicePaidEvent` -> topic "InvoicePaid", see
* ../handlers/invoicePaid.ts). Do not build a decoder from this file alone;
* verify the actual event payload shape against the deployed contract first.
*
* When wiring one of these for real: add a decoder to ../types.ts, a handler
* to ../handlers/ (see invoicePaid.ts for the pattern), register it in
* ../handlers/index.ts, and call recordAuditLog (../../services/audit-log.services.ts)
* at the point of DB mutation — then delete that line from this file.
*
* ---- On-chain events: Governance & Config (no service/handler exists) ----
* governance.admin_transfer_proposed / _accepted <- AdminTransferProposed / AdminTransferAccepted
* governance.token_added / token_removed <- TokenAdded / TokenRemoved
* governance.fee_proposed / fee_set <- FeeProposed / FeeSet
* governance.platform_account_set <- PlatformAccountSet
* governance.token_oracle_set <- TokenOracleSet
* governance.account_wasm_hash_set <- AccountWasmHashSet
* governance.contract_paused / _unpaused <- ContractPaused / ContractUnpaused
* governance.contract_upgraded <- ContractUpgraded
* governance.role_granted / role_revoked <- RoleGranted / RoleRevoked
*
* ---- On-chain events: Merchant Lifecycle (beyond InvoicePaid) ----
* merchant.registered (on-chain) <- MerchantRegistered
* merchant.account_deployed <- MerchantAccountDeployed
* merchant.status_changed <- MerchantStatusChanged
* merchant.verified <- MerchantVerified
* merchant.webhook_set (on-chain) <- MerchantWebhookSet (the off-chain webhook
* field set via PATCH /merchants/me is
* already covered by merchant.profile_updated)
* merchant.key_set (on-chain) <- MerchantKeySet
* merchant.tokens_set / token_removed <- MerchantTokensSet / MerchantTokenRemoved
* merchant.account_restricted <- AccountRestricted
*
* ---- On-chain events: Invoice Lifecycle (beyond InvoicePaid) ----
* invoice.created (on-chain) <- InvoiceCreated
* invoice.payment_split_routed <- PaymentSplitRouted
* invoice.refunded / partially_refunded <- InvoiceRefunded / InvoicePartiallyRefunded
* invoice.cancelled (on-chain) <- InvoiceCancelled
* invoice.amended (on-chain) <- InvoiceAmended (the off-chain PATCH
* .../amend route is already covered by
* the real invoice.amended call site)
* invoice.fiat_priced <- FiatInvoicePriced
*
* ---- On-chain events: Subscription Lifecycle (no service exists at all) ----
* subscription_plan.created <- SubscriptionPlanCreated
* subscription_plan.deactivated <- PlanDeactivated
* subscription.created <- Subscribed
* subscription.charged <- SubscriptionCharged
* subscription.cancelled <- SubscriptionCancelled
*
* ---- On-chain events: Account Contract / Withdrawals ----
* account.initialized / verified <- AccountInitialized / AccountVerified
* account.withdrawal <- WithdrawalTo
* account.refund_processed <- RefundProcessed
* (account.restricted duplicates merchant.account_restricted above — same
* 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.
*
* ---- Explicitly excluded, not gaps (per the issue) ----
* NonceInvalidatedEvent - not a state change worth auditing
* InitializedEvent - one-time contract deploy, not an admin action
* FeeDiscountAppliedEvent - computed side-effect of invoice.paid/subscription.charged
* BridgePlaceholderEvent - placeholder, no real state change yet
*/
export {};
10 changes: 8 additions & 2 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { Router } from 'express';
import authRoutes from './auth.routes.js';
import merchantRoutes from './merchant.routes.js';
import logsRoutes from './logs.routes.js';
import { authenticateAdmin } from '../../middlewares/admin.middleware.js';

const router = Router();

// Public: issues the wallet challenge/verify pair, no admin session yet.
router.use('/auth', authRoutes);

// Sibling routers added by later issues (merchant.routes.ts, invoice.routes.ts, ...)
// are mounted here behind authenticateAdmin.
router.use('/merchants', authenticateAdmin, merchantRoutes);
router.use('/logs', authenticateAdmin, logsRoutes);

// Sibling routers added by later issues (invoice.routes.ts, ...) are mounted
// here behind authenticateAdmin.

export default router;
8 changes: 8 additions & 0 deletions src/routes/admin/logs.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Router } from 'express';
import { listAuditLogsController } from '../../controllers/admin-log.controllers.js';

const router = Router();

router.get('/', listAuditLogsController);

export default router;
8 changes: 8 additions & 0 deletions src/routes/admin/merchant.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Router } from 'express';
import { blockMerchantController } from '../../controllers/admin-merchant.controllers.js';

const router = Router();

router.patch('/:id/block', blockMerchantController);

export default router;
Loading
Loading