Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d448339
feat(#29): add AuditLog model and AuditAction enum to Prisma schema
SweetBoy-eth Jul 11, 2026
5979081
feat(#29): add audit logging service with request context capture
SweetBoy-eth Jul 11, 2026
7c05966
feat(#29): add audit log controller with admin and user endpoints
SweetBoy-eth Jul 11, 2026
64e1a4d
feat(#29): add audit log routes with filtering and pagination
SweetBoy-eth Jul 11, 2026
2bc9d37
feat(#28): add Stellar wallet verification challenge service
SweetBoy-eth Jul 11, 2026
fb9ca91
feat(#28): add wallet verification validation schemas
SweetBoy-eth Jul 11, 2026
feaed62
feat(#28): add wallet verification controller with challenge-response…
SweetBoy-eth Jul 11, 2026
f191574
feat(#28): add wallet verification routes
SweetBoy-eth Jul 11, 2026
366ebc7
feat(#27): enhance AppError classes with error codes and new error types
SweetBoy-eth Jul 11, 2026
a29545a
feat(#27): enhance error handler with structured JSON logging
SweetBoy-eth Jul 11, 2026
49630c3
feat(#29): add audit logging to escrow operations
SweetBoy-eth Jul 11, 2026
47ec36b
feat(#29): add audit logging to payment operations
SweetBoy-eth Jul 11, 2026
cafb5ea
feat(#29): add audit logging to invoice operations
SweetBoy-eth Jul 11, 2026
6f77b66
feat(#29): add audit logging to account modifications
SweetBoy-eth Jul 11, 2026
952d503
feat(#29): add audit logging to auth operations (register, login)
SweetBoy-eth Jul 11, 2026
93837d5
feat: register wallet and audit log routes in main app
SweetBoy-eth Jul 11, 2026
a4a6394
fix: override @types/http-errors to 2.0.4 to fix Linux CI platform co…
SweetBoy-eth Jul 11, 2026
f993e0b
fix: resolve type errors in audit logging metadata and strict type co…
SweetBoy-eth Jul 11, 2026
fc58724
fix: remove unused imports from invoice routes
SweetBoy-eth Jul 11, 2026
086334e
fix: add missing sharp dependency for upload service typecheck
SweetBoy-eth Jul 11, 2026
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
2,210 changes: 1,439 additions & 771 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"multer": "~2.0.1",
"pg": "~8.20.0",
"sanitize-html": "~2.17.6",
"sharp": "~0.35.3",
"socket.io": "~4.8.3",
"stellar-sdk": "~13.3.0",
"swagger-jsdoc": "~6.3.0",
Expand All @@ -63,5 +64,8 @@
"typescript": "~6.0.3",
"typescript-eslint": "^8.63.0",
"vitest": "~3.2.0"
},
"overrides": {
"@types/http-errors": "2.0.4"
}
}
47 changes: 47 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ model User {

payments Payment[]
notifications Notification[]
auditLogs AuditLog[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -189,3 +190,49 @@ model Notification {
@@index([isRead])
@@index([createdAt])
}

enum AuditAction {
PAYMENT_CREATED
PAYMENT_VERIFIED
PAYMENT_FAILED
ESCROW_CREATED
ESCROW_FUNDED
ESCROW_RELEASED
ESCROW_REFUNDED
INVOICE_CREATED
INVOICE_UPDATED
INVOICE_DELETED
INVOICE_STATUS_CHANGED
ACCOUNT_UPDATED
ACCOUNT_DELETED
WALLET_LINKED
WALLET_UNLINKED
WALLET_VERIFIED
USER_REGISTERED
USER_LOGIN
USER_LOGOUT
}

model AuditLog {
id String @id @default(cuid())

userId String?
action AuditAction
resource String?
resourceId String?
description String

ipAddress String?
userAgent String?

metadata Json?

user User? @relation(fields: [userId], references: [id])

createdAt DateTime @default(now())

@@index([userId])
@@index([action])
@@index([resource])
@@index([createdAt])
}
121 changes: 121 additions & 0 deletions src/controllers/audit.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Request, Response } from "express";
import { prisma } from "../config/prisma.js";
import { NotFoundError } from "../utils/AppError.js";
import type { AuthRequest } from "../middleware/auth.js";
import { getAuditLogs } from "../services/audit.service.js";
import { getPaginationMeta } from "../utils/pagination.js";
import type { AuditAction } from "@prisma/client";

export async function getAuditLogById(req: Request, res: Response): Promise<void> {
const authReq = req as AuthRequest;
const id = String(req.params["id"]);

const log = await prisma.auditLog.findUnique({
where: { id },
include: {
user: {
select: { id: true, fullname: true, email: true },
},
},
});

if (!log) {
throw new NotFoundError("Audit log");
}

if (authReq.user.role !== "ADMIN" && log.userId !== authReq.user.userId) {
res.status(403).json({
success: false,
error: { message: "You don't have access to this audit log" },
});
return;
}

res.json({
success: true,
data: { log },
});
}

export async function getUserAuditLogs(req: Request, res: Response): Promise<void> {
const authReq = req as AuthRequest;
const { action, resource, startDate, endDate, page: rawPage, limit: rawLimit } = req.query;

const page = Math.max(1, parseInt(String(rawPage ?? "1"), 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(String(rawLimit ?? "50"), 10) || 50));

const params: {
userId: string;
action?: AuditAction;
resource?: string;
startDate?: Date;
endDate?: Date;
page: number;
limit: number;
} = {
userId: authReq.user.userId,
page,
limit,
};

if (action) params.action = action as AuditAction;
if (resource) params.resource = String(resource);
if (startDate) params.startDate = new Date(String(startDate));
if (endDate) params.endDate = new Date(String(endDate));

const { logs, total } = await getAuditLogs(params);

res.json({
success: true,
data: {
logs,
pagination: getPaginationMeta(page, limit, total),
},
});
}

export async function getAllAuditLogs(req: Request, res: Response): Promise<void> {
const authReq = req as AuthRequest;

if (authReq.user.role !== "ADMIN") {
res.status(403).json({
success: false,
error: { message: "Only admins can access all audit logs" },
});
return;
}

const { userId, action, resource, startDate, endDate, page: rawPage, limit: rawLimit } = req.query;

const page = Math.max(1, parseInt(String(rawPage ?? "1"), 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(String(rawLimit ?? "50"), 10) || 50));

const params: {
userId?: string;
action?: AuditAction;
resource?: string;
startDate?: Date;
endDate?: Date;
page: number;
limit: number;
} = {
page,
limit,
};

if (userId) params.userId = String(userId);
if (action) params.action = action as AuditAction;
if (resource) params.resource = String(resource);
if (startDate) params.startDate = new Date(String(startDate));
if (endDate) params.endDate = new Date(String(endDate));

const { logs, total } = await getAuditLogs(params);

res.json({
success: true,
data: {
logs,
pagination: getPaginationMeta(page, limit, total),
},
});
}
25 changes: 22 additions & 3 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { prisma } from "../config/prisma.js";
import { config } from "../config/index.js";
import { ConflictError, UnauthorizedError, NotFoundError } from "../utils/errors.js";
import { ConflictError, UnauthorizedError, NotFoundError } from "../utils/AppError.js";
import type { AuthRequest } from "../middleware/auth.js";
import { logAuditFromRequest } from "../services/audit.service.js";

function generateTokens(payload: { userId: string; email: string; role: string }) {
const accessToken = jwt.sign(payload, config.jwt.secret, {
Expand Down Expand Up @@ -72,6 +73,15 @@ export async function register(req: Request, res: Response): Promise<void> {

setRefreshTokenCookie(res, tokens.refreshToken);

await logAuditFromRequest(req, {
userId: user.id,
action: "USER_REGISTERED",
resource: "User",
resourceId: user.id,
description: `New user registered: ${user.email} (${user.role})`,
metadata: { email: user.email, role: user.role },
});

res.status(201).json({
success: true,
data: {
Expand Down Expand Up @@ -103,6 +113,15 @@ export async function login(req: Request, res: Response): Promise<void> {

setRefreshTokenCookie(res, tokens.refreshToken);

await logAuditFromRequest(req, {
userId: user.id,
action: "USER_LOGIN",
resource: "User",
resourceId: user.id,
description: `User logged in: ${user.email}`,
metadata: { email: user.email },
});

res.json({
success: true,
data: {
Expand Down Expand Up @@ -151,7 +170,7 @@ export async function refreshToken(req: Request, res: Response): Promise<void> {

const user = await prisma.user.findUnique({ where: { id: decoded.userId } });
if (!user) {
throw new NotFoundError("User not found");
throw new NotFoundError("User");
}

const tokens = generateTokens({
Expand Down Expand Up @@ -190,7 +209,7 @@ export async function getMe(req: Request, res: Response): Promise<void> {
});

if (!user) {
throw new NotFoundError("User not found");
throw new NotFoundError("User");
}

res.json({
Expand Down
37 changes: 37 additions & 0 deletions src/controllers/escrow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NotFoundError, ForbiddenError, BadRequestError } from "../utils/AppErro
import type { AuthRequest } from "../middleware/auth.js";
import * as stellarService from "../services/stellar.service.js";
import { broadcastToUser, broadcastToEscrow } from "../services/socket.service.js";
import { logAuditFromRequest } from "../services/audit.service.js";

const VALID_ESCROW_TRANSITIONS: Record<string, string[]> = {
PENDING: ["FUNDED", "REFUNDED"],
Expand Down Expand Up @@ -78,6 +79,15 @@ export async function createEscrow(req: Request, res: Response): Promise<void> {
data: { status: "IN_ESCROW", contractId: escrow.contractId },
});

await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "ESCROW_CREATED",
resource: "Escrow",
resourceId: escrow.id,
description: `Escrow created for invoice ${invoiceId} with amount ${invoice.amount} ${invoice.currency}`,
metadata: { invoiceId, amount: invoice.amount.toString(), currency: invoice.currency },
});

broadcastToUser(invoice.creatorId, "escrow:created", {
escrowId: escrow.id,
invoiceId,
Expand Down Expand Up @@ -158,6 +168,15 @@ export async function fundEscrow(req: Request, res: Response): Promise<void> {
data: { status: "FUNDED", txHash },
});

await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "ESCROW_FUNDED",
resource: "Escrow",
resourceId: id,
description: `Escrow funded with ${escrow.amount} ${escrow.currency}, txHash: ${txHash}`,
metadata: { amount: escrow.amount.toString(), currency: escrow.currency, txHash },
});

broadcastToEscrow(id, "escrow:stateChange", {
escrowId: id,
status: "FUNDED",
Expand Down Expand Up @@ -240,6 +259,15 @@ export async function releaseEscrow(req: Request, res: Response): Promise<void>
data: { status: "COMPLETED", paidAt: new Date(), txHash },
});

await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "ESCROW_RELEASED",
resource: "Escrow",
resourceId: id,
description: `Escrow funds released: ${escrow.amount} ${escrow.currency} to freelancer, txHash: ${txHash}`,
metadata: { amount: escrow.amount.toString(), currency: escrow.currency, txHash, freelancerId: escrow.freelancerId },
});

broadcastToEscrow(id, "escrow:stateChange", {
escrowId: id,
status: "RELEASED",
Expand Down Expand Up @@ -320,6 +348,15 @@ export async function refundEscrow(req: Request, res: Response): Promise<void> {
data: { status: "CANCELLED", txHash },
});

await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "ESCROW_REFUNDED",
resource: "Escrow",
resourceId: id,
description: `Escrow refunded: ${escrow.amount} ${escrow.currency} to client, txHash: ${txHash}`,
metadata: { amount: escrow.amount.toString(), currency: escrow.currency, txHash, clientId: escrow.clientId },
});

broadcastToEscrow(id, "escrow:stateChange", {
escrowId: id,
status: "REFUNDED",
Expand Down
37 changes: 37 additions & 0 deletions src/controllers/invoice.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NotFoundError, ForbiddenError, BadRequestError } from "../utils/AppErro
import type { AuthRequest } from "../middleware/auth.js";
import { getPaginationParams, getPaginationMeta, getSkipTake } from "../utils/pagination.js";
import { broadcastToUser } from "../services/socket.service.js";
import { logAuditFromRequest } from "../services/audit.service.js";

const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
DRAFT: ["PENDING", "CANCELLED"],
Expand Down Expand Up @@ -53,6 +54,15 @@ export async function createInvoice(req: Request, res: Response): Promise<void>
},
});

await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "INVOICE_CREATED",
resource: "Invoice",
resourceId: invoice.id,
description: `Invoice created: ${title} for ${amount} ${currency ?? "USDC"}`,
metadata: { title, amount, currency, recipientId },
});

broadcastToUser(recipientId, "invoice:sent", {
invoiceId: invoice.id,
title: invoice.title,
Expand Down Expand Up @@ -200,11 +210,29 @@ export async function updateInvoice(req: Request, res: Response): Promise<void>
});

if (status && status !== invoice.status) {
await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "INVOICE_STATUS_CHANGED",
resource: "Invoice",
resourceId: id,
description: `Invoice status changed: ${invoice.status} -> ${status}`,
metadata: { oldStatus: invoice.status, newStatus: status },
});

broadcastToUser(invoice.recipientId, "invoice:statusUpdate", {
invoiceId: id,
oldStatus: invoice.status,
newStatus: status,
});
} else {
await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "INVOICE_UPDATED",
resource: "Invoice",
resourceId: id,
description: `Invoice updated`,
metadata: { fields: Object.keys(req.body).filter((k) => req.body[k] !== undefined).join(",") },
});
}

res.json({
Expand All @@ -230,6 +258,15 @@ export async function deleteInvoice(req: Request, res: Response): Promise<void>
throw new BadRequestError("Only draft invoices can be deleted");
}

await logAuditFromRequest(req, {
userId: authReq.user.userId,
action: "INVOICE_DELETED",
resource: "Invoice",
resourceId: id,
description: `Invoice deleted: ${invoice.title}`,
metadata: { title: invoice.title, amount: invoice.amount.toString() },
});

await prisma.invoice.delete({ where: { id } });

res.json({
Expand Down
Loading
Loading