Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
387e343
Add audit logging functionality
devkiran Jun 28, 2025
24cd28e
Merge branch 'main' into partner-audit-logs
devkiran Jul 4, 2025
07d7d5a
audit log discount actions
devkiran Jul 4, 2025
e6ff6ce
audit log reward events
devkiran Jul 4, 2025
ce6238e
Update approve-partners-bulk.ts
devkiran Jul 4, 2025
44a0d7b
Update schemas.ts
devkiran Jul 4, 2025
9fd8810
Update approve-partner-enrollment.ts
devkiran Jul 4, 2025
0b7a416
partner applications events
devkiran Jul 4, 2025
680be14
audit log ban, archive, approve events
devkiran Jul 4, 2025
326ae7f
partner invitation events
devkiran Jul 4, 2025
5822a86
log commission & clawback events
devkiran Jul 4, 2025
9d85718
record commission status changes
devkiran Jul 4, 2025
4f0366b
record payout events
devkiran Jul 4, 2025
af6e6e0
move to new components
devkiran Jul 4, 2025
85a724f
Add audit log export functionality with CSV support
devkiran Jul 4, 2025
a613950
Update record-audit-log.ts
devkiran Jul 4, 2025
0dbaf64
commission.updated
devkiran Jul 4, 2025
d591c7f
Update audit-log.tsx
devkiran Jul 4, 2025
45428c2
Update schemas.ts
devkiran Jul 4, 2025
9169ffe
Update schemas.ts
devkiran Jul 4, 2025
d5857cc
Merge branch 'main' into partner-audit-logs
steven-tey Jul 4, 2025
5e91044
Update audit-log.tsx
steven-tey Jul 4, 2025
4f6d9b8
small updates
steven-tey Jul 4, 2025
ac0f681
rename location -> ip_address + simplify waitUntil(recordAuditLog)
steven-tey Jul 4, 2025
cb76410
rename pipe
steven-tey Jul 4, 2025
6e92406
Update audit-logs.tsx
steven-tey Jul 4, 2025
441231e
prefixWorkspaceId
steven-tey Jul 4, 2025
ffaf2b5
Update schemas.ts
devkiran Jul 4, 2025
c362a05
use ?? instead of ||
steven-tey Jul 4, 2025
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
60 changes: 60 additions & 0 deletions apps/web/app/(ee)/api/audit-logs/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { convertToCSV } from "@/lib/analytics/utils";
import { getAuditLogs } from "@/lib/api/audit-logs/get-audit-logs";
import { DubApiError } from "@/lib/api/errors";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { getPlanCapabilities } from "@/lib/plan-capabilities";
import { z } from "zod";

const auditLogExportQuerySchema = z.object({
start: z.string(),
end: z.string(),
});

// POST /api/audit-logs/export – export audit logs to CSV
export const POST = withWorkspace(
async ({ req, workspace }) => {
const { start, end } = auditLogExportQuerySchema.parse(
await parseRequestBody(req),
);

if (!start || !end) {
throw new DubApiError({
code: "bad_request",
message: "Must provide start and end dates.",
});
}

const { canExportAuditLogs } = getPlanCapabilities(workspace.plan);

if (!canExportAuditLogs) {
throw new DubApiError({
code: "forbidden",
message: "You are not authorized to export audit logs.",
});
}

const programId = getDefaultProgramIdOrThrow(workspace);

const auditLogs = await getAuditLogs({
workspaceId: workspace.id,
programId,
start: new Date(start),
end: new Date(end),
});

const csvData = convertToCSV(auditLogs);

return new Response(csvData, {
headers: {
"Content-Type": "application/csv",
"Content-Disposition": `attachment;`,
},
});
},
{
requiredPermissions: ["workspaces.write"],
requiredPlan: ["enterprise"],
},
);
263 changes: 142 additions & 121 deletions apps/web/app/(ee)/api/commissions/[commissionId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { convertCurrency } from "@/lib/analytics/convert-currency";
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { DubApiError } from "@/lib/api/errors";
import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
Expand All @@ -15,148 +16,168 @@ import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

// PATCH /api/commissions/:commissionId - update a commission
export const PATCH = withWorkspace(async ({ workspace, params, req }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { commissionId } = params;

const commission = await prisma.commission.findUnique({
where: {
id: commissionId,
programId,
},
include: {
partner: true,
},
});

if (!commission) {
throw new DubApiError({
code: "not_found",
message: `Commission ${commissionId} not found.`,
});
}

if (commission.status === "paid") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update amount: Commission ${commissionId} has already been paid.`,
});
}
export const PATCH = withWorkspace(
async ({ workspace, params, req, session }) => {
const programId = getDefaultProgramIdOrThrow(workspace);

const { partner, amount: originalAmount } = commission;
const { commissionId } = params;

let { amount, modifyAmount, currency, status } = updateCommissionSchema.parse(
await parseRequestBody(req),
);
const commission = await prisma.commission.findUnique({
where: {
id: commissionId,
programId,
},
include: {
partner: true,
},
});

let finalAmount: number | undefined;
let finalEarnings: number | undefined;
if (!commission) {
throw new DubApiError({
code: "not_found",
message: `Commission ${commissionId} not found.`,
});
}

if (amount || modifyAmount) {
if (commission.type !== "sale") {
if (commission.status === "paid") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update amount: Commission ${commissionId} is not a sale commission.`,
message: `Cannot update amount: Commission ${commissionId} has already been paid.`,
});
}

// if currency is not USD, convert it to USD based on the current FX rate
// TODO: allow custom "defaultCurrency" on workspace table in the future
if (currency !== "usd") {
const valueToConvert = modifyAmount || amount;
if (valueToConvert) {
const { currency: convertedCurrency, amount: convertedAmount } =
await convertCurrency({ currency, amount: valueToConvert });

if (modifyAmount) {
modifyAmount = convertedAmount;
} else {
amount = convertedAmount;
const { partner, amount: originalAmount } = commission;

let { amount, modifyAmount, currency, status } =
updateCommissionSchema.parse(await parseRequestBody(req));

let finalAmount: number | undefined;
let finalEarnings: number | undefined;

if (amount || modifyAmount) {
if (commission.type !== "sale") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update amount: Commission ${commissionId} is not a sale commission.`,
});
}

// if currency is not USD, convert it to USD based on the current FX rate
// TODO: allow custom "defaultCurrency" on workspace table in the future
if (currency !== "usd") {
const valueToConvert = modifyAmount || amount;
if (valueToConvert) {
const { currency: convertedCurrency, amount: convertedAmount } =
await convertCurrency({ currency, amount: valueToConvert });

if (modifyAmount) {
modifyAmount = convertedAmount;
} else {
amount = convertedAmount;
}
currency = convertedCurrency;
}
currency = convertedCurrency;
}
}

finalAmount = Math.max(
modifyAmount ? originalAmount + modifyAmount : amount ?? originalAmount,
0, // Ensure the amount is not negative
);
finalAmount = Math.max(
modifyAmount ? originalAmount + modifyAmount : amount ?? originalAmount,
0, // Ensure the amount is not negative
);

const reward = await determinePartnerReward({
event: "sale",
partnerId: partner.id,
programId,
});
const reward = await determinePartnerReward({
event: "sale",
partnerId: partner.id,
programId,
});

if (!reward) {
throw new DubApiError({
code: "not_found",
message: `No reward found for partner ${partner.id} in program ${programId}.`,
if (!reward) {
throw new DubApiError({
code: "not_found",
message: `No reward found for partner ${partner.id} in program ${programId}.`,
});
}

// Recalculate the earnings based on the new amount
finalEarnings = calculateSaleEarnings({
reward,
sale: {
amount: finalAmount,
quantity: commission.quantity,
},
});
}

// Recalculate the earnings based on the new amount
finalEarnings = calculateSaleEarnings({
reward,
sale: {
const updatedCommission = await prisma.commission.update({
where: {
id: commission.id,
},
data: {
amount: finalAmount,
quantity: commission.quantity,
earnings: finalEarnings,
status,
// need to update payoutId to null if the commission has no earnings
// or is being updated to refunded, duplicate, canceled, or fraudulent
...(finalEarnings === 0 || status ? { payoutId: null } : {}),
},
});
}

const updatedCommission = await prisma.commission.update({
where: {
id: commission.id,
},
data: {
amount: finalAmount,
earnings: finalEarnings,
status,
// need to update payoutId to null if the commission has no earnings
// or is being updated to refunded, duplicate, canceled, or fraudulent
...(finalEarnings === 0 || status ? { payoutId: null } : {}),
},
});

// If the commission has already been added to a payout, we need to update the payout amount
if (commission.status === "processed" && commission.payoutId) {
waitUntil(
prisma.$transaction(async (tx) => {
const commissionAggregate = await tx.commission.aggregate({
where: {
payoutId: commission.payoutId,
},
_sum: {
earnings: true,
},
});

const newPayoutAmount = commissionAggregate._sum.earnings ?? 0;

if (newPayoutAmount === 0) {
console.log(`Deleting payout ${commission.payoutId}`);
await tx.payout.delete({ where: { id: commission.payoutId! } });
} else {
console.log(
`Updating payout ${commission.payoutId} to ${newPayoutAmount}`,
);
await tx.payout.update({
where: { id: commission.payoutId! },
data: { amount: newPayoutAmount },
// If the commission has already been added to a payout, we need to update the payout amount
if (commission.status === "processed" && commission.payoutId) {
waitUntil(
prisma.$transaction(async (tx) => {
const commissionAggregate = await tx.commission.aggregate({
where: {
payoutId: commission.payoutId,
},
_sum: {
earnings: true,
},
});
}
}),
);
}

waitUntil(
syncTotalCommissions({
partnerId: commission.partnerId,
programId: commission.programId,
}),
);
const newPayoutAmount = commissionAggregate._sum.earnings ?? 0;

if (newPayoutAmount === 0) {
console.log(`Deleting payout ${commission.payoutId}`);
await tx.payout.delete({ where: { id: commission.payoutId! } });
} else {
console.log(
`Updating payout ${commission.payoutId} to ${newPayoutAmount}`,
);
await tx.payout.update({
where: { id: commission.payoutId! },
data: { amount: newPayoutAmount },
});
}
}),
);
}

waitUntil(
(async () => {
await Promise.allSettled([
syncTotalCommissions({
partnerId: commission.partnerId,
programId: commission.programId,
}),

recordAuditLog({
workspaceId: workspace.id,
programId,
action: "commission.updated",
description: `Commission ${commissionId} updated`,
actor: session.user,
targets: [
{
type: "commission",
id: commission.id,
metadata: updatedCommission,
},
],
}),
]);
})(),
);

return NextResponse.json(CommissionSchema.parse(updatedCommission));
});
return NextResponse.json(CommissionSchema.parse(updatedCommission));
},
);
Loading