From 387e34314d4d25810de31b92ef98e6a9069df8e2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sat, 28 Jun 2025 16:35:27 +0530 Subject: [PATCH 01/27] Add audit logging functionality - Introduced new `record-audit-log.ts` to handle recording of audit logs. - Created `schemas.ts` for defining audit log schemas using Zod. - Added `dub_audit_logs.datasource` for Tinybird integration. - Updated `create-id.ts` to include "audit_" prefix for generated IDs. --- .../lib/api/audit-logs/record-audit-log.ts | 48 ++++++++++++ apps/web/lib/api/audit-logs/schemas.ts | 77 +++++++++++++++++++ apps/web/lib/api/create-id.ts | 1 + .../datasources/dub_audit_logs.datasource | 19 +++++ 4 files changed, 145 insertions(+) create mode 100644 apps/web/lib/api/audit-logs/record-audit-log.ts create mode 100644 apps/web/lib/api/audit-logs/schemas.ts create mode 100644 packages/tinybird/datasources/dub_audit_logs.datasource diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts new file mode 100644 index 00000000000..482ff52213f --- /dev/null +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -0,0 +1,48 @@ +import { tb } from "@/lib/tinybird"; +import { ipAddress } from "@vercel/functions"; +import { headers } from "next/headers"; +import { z } from "zod"; +import { createId } from "../create-id"; +import { getIP } from "../utils"; +import { auditLogSchemaTB, recordAuditLogInputSchema } from "./schemas"; + +export const recordAuditLogTB = tb.buildIngestEndpoint({ + datasource: "dub_audit_logs", + event: auditLogSchemaTB, + wait: true, +}); + +// TODO: +// Support array of logs +export const recordAuditLog = async ( + data: z.infer, +) => { + const headersList = headers(); + const location = data.req ? ipAddress(data.req) : getIP(); + const userAgent = headersList.get("user-agent"); + + const auditLog: z.infer = { + id: createId({ prefix: "audit_" }), + timestamp: new Date().toISOString(), + workspace_id: data.workspaceId, + program_id: data.programId, + action: data.action, + actor_id: data.actor.id, + actor_type: data.actor.type || "user", + actor_name: data.actor.name || "", + description: data.description || "", + location: location || "", + user_agent: userAgent || "", + targets: data.targets ? JSON.stringify(data.targets) : "", + metadata: data.metadata ? JSON.stringify(data.metadata) : "", + }; + + if (process.env.NODE_ENV === "development") { + console.info("Inserting audit log", auditLog); + return; + } + + await recordAuditLogTB(auditLog).catch((error) => { + console.error("Failed to record audit log", error); + }); +}; diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts new file mode 100644 index 00000000000..54d3fa92ad8 --- /dev/null +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -0,0 +1,77 @@ +import { DiscountSchema } from "@/lib/zod/schemas/discount"; +import { RewardSchema } from "@/lib/zod/schemas/rewards"; +import { z } from "zod"; + +// Schema that represents the audit log schema in Tinybird +export const auditLogSchemaTB = z.object({ + id: z.string(), + timestamp: z.string(), + workspace_id: z.string(), + program_id: z.string(), + action: z.string(), + actor_id: z.string(), + actor_type: z.string(), + actor_name: z.string(), + description: z.string(), + targets: z.string().nullable(), + location: z.string().nullable(), + user_agent: z.string().nullable(), + metadata: z.string().nullable(), +}); + +// Schema that represents the audit log in the CSV file +export const AuditLogSchema = z.object({ + id: z.string(), + timestamp: z.string(), + workspaceId: z.string(), + programId: z.string(), + action: z.string(), + actorId: z.string(), + actorType: z.string(), + actorName: z.string(), + description: z.string(), + targets: z.array(z.record(z.string(), z.any())).nullable(), + location: z.string().nullable(), + userAgent: z.string().nullable(), + metadata: z.record(z.string(), z.any()).nullable(), +}); + +const RewardEvent = z.object({ + type: z.literal("reward"), + id: z.string(), + metadata: RewardSchema.pick({ + event: true, + type: true, + amount: true, + maxDuration: true, + }), +}); + +const DiscountEvent = z.object({ + type: z.literal("discount"), + id: z.string(), + metadata: DiscountSchema.pick({ + type: true, + amount: true, + maxDuration: true, + }), +}); + +export const AuditLogEvent = z.union([RewardEvent, DiscountEvent]); + +export const recordAuditLogInputSchema = z.object({ + workspaceId: z.string(), + programId: z.string(), + action: z.string(), + actor: z.object({ + id: z.string(), + name: z.string().nullable(), + type: z.string().optional(), + }), + description: z.string().optional(), + location: z.string().optional(), + userAgent: z.string().optional(), + targets: z.array(AuditLogEvent).nullable(), + metadata: z.record(z.string(), z.any()).nullable(), + req: z.instanceof(Request).optional(), +}); diff --git a/apps/web/lib/api/create-id.ts b/apps/web/lib/api/create-id.ts index 5808806ad95..5820a8ea22b 100644 --- a/apps/web/lib/api/create-id.ts +++ b/apps/web/lib/api/create-id.ts @@ -26,6 +26,7 @@ const prefixes = [ "rw_", "disc_", "dub_embed_", + "audit_", ] as const; // ULID uses base32 encoding diff --git a/packages/tinybird/datasources/dub_audit_logs.datasource b/packages/tinybird/datasources/dub_audit_logs.datasource new file mode 100644 index 00000000000..9633ab0580e --- /dev/null +++ b/packages/tinybird/datasources/dub_audit_logs.datasource @@ -0,0 +1,19 @@ +SCHEMA > + `id` String `json:$.id`, + `timestamp` DateTime64(3) `json:$.timestamp`, + `workspace_id` String `json:$.workspace_id`, + `program_id` String `json:$.program_id`, + `action` LowCardinality(String) `json:$.action`, + `actor_id` String `json:$.actor_id`, + `actor_type` LowCardinality(String) `json:$.actor_type`, + `actor_name` String `json:$.actor_name`, + `targets` String `json:$.targets`, + `description` String `json:$.description`, + `location` String `json:$.location`, + `user_agent` String `json:$.user_agent`, + `metadata` String `json:$.metadata` + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" +ENGINE_SORTING_KEY "workspace_id, program_id, timestamp" +ENGINE_TTL "timestamp + INTERVAL 1 YEAR" \ No newline at end of file From 07d7d5a23fc32808e2b662f1e1f65e833f618774 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 15:18:44 +0530 Subject: [PATCH 02/27] audit log discount actions --- .../lib/actions/partners/create-discount.ts | 40 ++++++++++++---- .../lib/actions/partners/delete-discount.ts | 42 ++++++++++++----- .../lib/actions/partners/update-discount.ts | 40 +++++++++++----- .../lib/api/audit-logs/record-audit-log.ts | 47 ++++++++++++------- apps/web/lib/api/audit-logs/schemas.ts | 39 ++++++++++----- .../datasources/dub_audit_logs.datasource | 2 +- 6 files changed, 146 insertions(+), 64 deletions(-) diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 578de9b305b..30d50103683 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; @@ -13,7 +14,7 @@ import { authActionClient } from "../safe-action"; export const createDiscountAction = authActionClient .schema(createDiscountSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerIds, amount, type, maxDuration, couponId, couponTestId } = parsedInput; @@ -97,14 +98,33 @@ export const createDiscountAction = authActionClient } waitUntil( - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, - body: { - programId, - discountId: discount.id, - isDefault, - action: "discount-created", - }, - }), + (async () => { + await Promise.allSettled([ + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, + body: { + programId, + discountId: discount.id, + isDefault, + action: "discount-created", + }, + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.created", + description: `Discount ${discount.id} created`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: discount, + }, + ], + }), + ]); + })(), ); }); diff --git a/apps/web/lib/actions/partners/delete-discount.ts b/apps/web/lib/actions/partners/delete-discount.ts index 83b60d2a68d..90acd3804f7 100644 --- a/apps/web/lib/actions/partners/delete-discount.ts +++ b/apps/web/lib/actions/partners/delete-discount.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; @@ -19,7 +20,7 @@ const deleteDiscountSchema = z.object({ export const deleteDiscountAction = authActionClient .schema(deleteDiscountSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { discountId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -29,7 +30,7 @@ export const deleteDiscountAction = authActionClient programId, }); - await getDiscountOrThrow({ + const discount = await getDiscountOrThrow({ programId, discountId, }); @@ -98,15 +99,34 @@ export const deleteDiscountAction = authActionClient if (deletedDiscountId) { waitUntil( - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, - body: { - programId, - discountId, - isDefault, - action: "discount-deleted", - }, - }), + (async () => { + await Promise.allSettled([ + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, + body: { + programId, + discountId, + isDefault, + action: "discount-deleted", + }, + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "discount.deleted", + description: `Discount ${discountId} deleted`, + actor: user, + targets: [ + { + type: "discount", + id: discountId, + metadata: discount, + }, + ], + }), + ]); + })(), ); } }); diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index fadb8c4b08a..c1056a67275 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; @@ -13,7 +14,7 @@ import { authActionClient } from "../safe-action"; export const updateDiscountAction = authActionClient .schema(updateDiscountSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { discountId, partnerIds, @@ -119,19 +120,34 @@ export const updateDiscountAction = authActionClient }, ); - if (!shouldExpireCache) { - return; - } + await Promise.allSettled([ + shouldExpireCache + ? qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, + body: { + programId, + discountId, + isDefault, + action: "discount-updated", + }, + }) + : Promise.resolve(), - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, - body: { + recordAuditLog({ + workspaceId: workspace.id, programId, - discountId, - isDefault, - action: "discount-updated", - }, - }); + action: "discount.updated", + description: `Discount ${discount.id} updated`, + actor: user, + targets: [ + { + type: "discount", + id: discount.id, + metadata: updatedDiscount, + }, + ], + }), + ]); })(), ); }); diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts index 482ff52213f..f2be898e128 100644 --- a/apps/web/lib/api/audit-logs/record-audit-log.ts +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -6,11 +6,7 @@ import { createId } from "../create-id"; import { getIP } from "../utils"; import { auditLogSchemaTB, recordAuditLogInputSchema } from "./schemas"; -export const recordAuditLogTB = tb.buildIngestEndpoint({ - datasource: "dub_audit_logs", - event: auditLogSchemaTB, - wait: true, -}); +const ENABLE_AUDIT_LOGS = true; // TODO: // Support array of logs @@ -21,28 +17,45 @@ export const recordAuditLog = async ( const location = data.req ? ipAddress(data.req) : getIP(); const userAgent = headersList.get("user-agent"); + const auditLogInput = recordAuditLogInputSchema.parse({ + ...data, + location, + userAgent, + }); + const auditLog: z.infer = { id: createId({ prefix: "audit_" }), timestamp: new Date().toISOString(), - workspace_id: data.workspaceId, - program_id: data.programId, - action: data.action, - actor_id: data.actor.id, - actor_type: data.actor.type || "user", - actor_name: data.actor.name || "", - description: data.description || "", + workspace_id: auditLogInput.workspaceId, + program_id: auditLogInput.programId, + action: auditLogInput.action, + actor_id: auditLogInput.actor.id, + actor_type: auditLogInput.actor.type || "user", + actor_name: auditLogInput.actor.name || "", + description: auditLogInput.description || "", + targets: auditLogInput.targets ? JSON.stringify(auditLogInput.targets) : "", + metadata: auditLogInput.metadata + ? JSON.stringify(auditLogInput.metadata) + : "", location: location || "", user_agent: userAgent || "", - targets: data.targets ? JSON.stringify(data.targets) : "", - metadata: data.metadata ? JSON.stringify(data.metadata) : "", }; - if (process.env.NODE_ENV === "development") { - console.info("Inserting audit log", auditLog); + if (!ENABLE_AUDIT_LOGS) { + console.info(auditLog); return; } await recordAuditLogTB(auditLog).catch((error) => { - console.error("Failed to record audit log", error); + console.error("Failed to record audit log", error, auditLog); + + // TODO: + // Send a Slack notification }); }; + +const recordAuditLogTB = tb.buildIngestEndpoint({ + datasource: "dub_audit_logs", + event: auditLogSchemaTB, + wait: true, +}); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 54d3fa92ad8..3c1f08cb88a 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -20,7 +20,7 @@ export const auditLogSchemaTB = z.object({ }); // Schema that represents the audit log in the CSV file -export const AuditLogSchema = z.object({ +export const auditLogSchema = z.object({ id: z.string(), timestamp: z.string(), workspaceId: z.string(), @@ -36,7 +36,19 @@ export const AuditLogSchema = z.object({ metadata: z.record(z.string(), z.any()).nullable(), }); -const RewardEvent = z.object({ +const actionSchema = z.enum([ + // Rewards + "reward.created", + "reward.updated", + "reward.deleted", + + // Discounts + "discount.created", + "discount.updated", + "discount.deleted", +]); + +const rewardEvent = z.object({ type: z.literal("reward"), id: z.string(), metadata: RewardSchema.pick({ @@ -47,31 +59,32 @@ const RewardEvent = z.object({ }), }); -const DiscountEvent = z.object({ +const discountEvent = z.object({ type: z.literal("discount"), id: z.string(), metadata: DiscountSchema.pick({ type: true, amount: true, maxDuration: true, + couponId: true, }), }); -export const AuditLogEvent = z.union([RewardEvent, DiscountEvent]); +export const auditLogEvent = z.union([rewardEvent, discountEvent]); export const recordAuditLogInputSchema = z.object({ workspaceId: z.string(), programId: z.string(), - action: z.string(), + action: actionSchema, actor: z.object({ id: z.string(), - name: z.string().nullable(), - type: z.string().optional(), + name: z.string().nullish(), + type: z.string().nullish(), }), - description: z.string().optional(), - location: z.string().optional(), - userAgent: z.string().optional(), - targets: z.array(AuditLogEvent).nullable(), - metadata: z.record(z.string(), z.any()).nullable(), - req: z.instanceof(Request).optional(), + description: z.string().nullish(), + location: z.string().nullish(), + userAgent: z.string().nullish(), + targets: z.array(auditLogEvent).nullish(), + metadata: z.record(z.string(), z.any()).nullish(), + req: z.instanceof(Request).nullish(), }); diff --git a/packages/tinybird/datasources/dub_audit_logs.datasource b/packages/tinybird/datasources/dub_audit_logs.datasource index 9633ab0580e..2b32c46ecf0 100644 --- a/packages/tinybird/datasources/dub_audit_logs.datasource +++ b/packages/tinybird/datasources/dub_audit_logs.datasource @@ -16,4 +16,4 @@ SCHEMA > ENGINE "MergeTree" ENGINE_PARTITION_KEY "toYYYYMM(timestamp)" ENGINE_SORTING_KEY "workspace_id, program_id, timestamp" -ENGINE_TTL "timestamp + INTERVAL 1 YEAR" \ No newline at end of file +ENGINE_TTL "toDateTime(timestamp) + INTERVAL 1 YEAR" \ No newline at end of file From e6ff6ce006814ddfc2d76353ad48aa873c53528f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 15:24:24 +0530 Subject: [PATCH 03/27] audit log reward events --- .../web/lib/actions/partners/create-reward.ts | 23 ++++++++++++++++++- .../web/lib/actions/partners/delete-reward.ts | 23 ++++++++++++++++++- .../web/lib/actions/partners/update-reward.ts | 23 ++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/actions/partners/create-reward.ts b/apps/web/lib/actions/partners/create-reward.ts index cbd27fb9f7e..5f1d249ff01 100644 --- a/apps/web/lib/actions/partners/create-reward.ts +++ b/apps/web/lib/actions/partners/create-reward.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { @@ -7,12 +8,13 @@ import { REWARD_EVENT_COLUMN_MAPPING, } from "@/lib/zod/schemas/rewards"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; export const createRewardAction = authActionClient .schema(createRewardSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; let { event, amount, @@ -108,4 +110,23 @@ export const createRewardAction = authActionClient [rewardIdColumn]: reward.id, }, }); + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "reward.created", + description: `Reward ${reward.id} created`, + actor: user, + targets: [ + { + type: "reward", + id: reward.id, + metadata: reward, + }, + ], + }); + })(), + ); }); diff --git a/apps/web/lib/actions/partners/delete-reward.ts b/apps/web/lib/actions/partners/delete-reward.ts index 6c36bffac2f..86c15747aeb 100644 --- a/apps/web/lib/actions/partners/delete-reward.ts +++ b/apps/web/lib/actions/partners/delete-reward.ts @@ -1,9 +1,11 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getRewardOrThrow } from "@/lib/api/partners/get-reward-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { REWARD_EVENT_COLUMN_MAPPING } from "@/lib/zod/schemas/rewards"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { z } from "zod"; import { authActionClient } from "../safe-action"; @@ -15,7 +17,7 @@ const deleteRewardSchema = z.object({ export const deleteRewardAction = authActionClient .schema(deleteRewardSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { rewardId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -60,4 +62,23 @@ export const deleteRewardAction = authActionClient }, }); }); + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "reward.deleted", + description: `Reward ${rewardId} deleted`, + actor: user, + targets: [ + { + type: "reward", + id: rewardId, + metadata: reward, + }, + ], + }); + })(), + ); }); diff --git a/apps/web/lib/actions/partners/update-reward.ts b/apps/web/lib/actions/partners/update-reward.ts index fd3112cf8da..f55e26895e3 100644 --- a/apps/web/lib/actions/partners/update-reward.ts +++ b/apps/web/lib/actions/partners/update-reward.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getRewardOrThrow } from "@/lib/api/partners/get-reward-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { @@ -8,12 +9,13 @@ import { } from "@/lib/zod/schemas/rewards"; import { prisma } from "@dub/prisma"; import { Reward } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; export const updateRewardAction = authActionClient .schema(updateRewardSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; let { rewardId, amount, @@ -79,6 +81,25 @@ export const updateRewardAction = authActionClient partnerIds: includedPartnerIds, }); } + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "reward.updated", + description: `Reward ${rewardId} updated`, + actor: user, + targets: [ + { + type: "reward", + id: rewardId, + metadata: updatedReward, + }, + ], + }); + })(), + ); }); // Update default reward From ce6238e73a01aea2f4d6e8319c159f0ddc9b1bc5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 15:39:16 +0530 Subject: [PATCH 04/27] Update approve-partners-bulk.ts --- apps/web/lib/actions/partners/approve-partners-bulk.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/actions/partners/approve-partners-bulk.ts b/apps/web/lib/actions/partners/approve-partners-bulk.ts index a833427458d..a586040af15 100644 --- a/apps/web/lib/actions/partners/approve-partners-bulk.ts +++ b/apps/web/lib/actions/partners/approve-partners-bulk.ts @@ -27,6 +27,7 @@ export const approvePartnersBulkAction = authActionClient includeDefaultRewards: true, }, ), + prisma.programEnrollment.findMany({ where: { programId: programId, From 44a0d7b32ecd9e9d148f695ba33a825571aaf5eb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 15:39:23 +0530 Subject: [PATCH 05/27] Update schemas.ts --- apps/web/lib/api/audit-logs/schemas.ts | 53 ++++++++++++++++---------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 3c1f08cb88a..459926ffea4 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -1,4 +1,5 @@ import { DiscountSchema } from "@/lib/zod/schemas/discount"; +import { PartnerSchema } from "@/lib/zod/schemas/partners"; import { RewardSchema } from "@/lib/zod/schemas/rewards"; import { z } from "zod"; @@ -46,31 +47,43 @@ const actionSchema = z.enum([ "discount.created", "discount.updated", "discount.deleted", + + // Partner applications + "partner_application.approved", ]); -const rewardEvent = z.object({ - type: z.literal("reward"), - id: z.string(), - metadata: RewardSchema.pick({ - event: true, - type: true, - amount: true, - maxDuration: true, +export const auditLogTarget = z.union([ + z.object({ + type: z.literal("reward"), + id: z.string(), + metadata: RewardSchema.pick({ + event: true, + type: true, + amount: true, + maxDuration: true, + }), }), -}); -const discountEvent = z.object({ - type: z.literal("discount"), - id: z.string(), - metadata: DiscountSchema.pick({ - type: true, - amount: true, - maxDuration: true, - couponId: true, + z.object({ + type: z.literal("discount"), + id: z.string(), + metadata: DiscountSchema.pick({ + type: true, + amount: true, + maxDuration: true, + couponId: true, + }), }), -}); -export const auditLogEvent = z.union([rewardEvent, discountEvent]); + z.object({ + type: z.literal("partner"), + id: z.string(), + metadata: PartnerSchema.pick({ + name: true, + email: true, + }), + }), +]); export const recordAuditLogInputSchema = z.object({ workspaceId: z.string(), @@ -84,7 +97,7 @@ export const recordAuditLogInputSchema = z.object({ description: z.string().nullish(), location: z.string().nullish(), userAgent: z.string().nullish(), - targets: z.array(auditLogEvent).nullish(), + targets: z.array(auditLogTarget).nullish(), metadata: z.record(z.string(), z.any()).nullish(), req: z.instanceof(Request).nullish(), }); From 9fd8810152f7ca9928312948479d1529d80261a9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 15:39:31 +0530 Subject: [PATCH 06/27] Update approve-partner-enrollment.ts --- .../partners/approve-partner-enrollment.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/web/lib/partners/approve-partner-enrollment.ts b/apps/web/lib/partners/approve-partner-enrollment.ts index 4691a399b3c..a3d738545dc 100644 --- a/apps/web/lib/partners/approve-partner-enrollment.ts +++ b/apps/web/lib/partners/approve-partner-enrollment.ts @@ -3,6 +3,7 @@ import { sendEmail } from "@dub/email"; import PartnerApplicationApproved from "@dub/email/templates/partner-application-approved"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; +import { recordAuditLog } from "../api/audit-logs/record-audit-log"; import { getLinkOrThrow } from "../api/links/get-link-or-throw"; import { createPartnerLink } from "../api/partners/create-partner-link"; import { recordLink } from "../tinybird/record-link"; @@ -118,6 +119,16 @@ export async function approvePartnerEnrollment({ waitUntil( (async () => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + }, + }); + const enrolledPartner = EnrolledPartnerSchema.parse({ ...partner, ...enrollment, @@ -154,6 +165,21 @@ export async function approvePartnerEnrollment({ trigger: "partner.enrolled", data: enrolledPartner, }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner_application.approved", + description: `Partner application approved for ${partner.id}`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + }), ]); })(), ); From 0b7a4165e950029e4b25846783693c805e98d01a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 15:55:20 +0530 Subject: [PATCH 07/27] partner applications events --- .../actions/partners/approve-partners-bulk.ts | 2 +- .../lib/actions/partners/reject-partner.ts | 26 ++++++++++++++++++- .../actions/partners/reject-partners-bulk.ts | 26 ++++++++++++++++++- .../lib/api/audit-logs/record-audit-log.ts | 22 +++++++++------- apps/web/lib/api/audit-logs/schemas.ts | 1 + .../web/lib/partners/bulk-approve-partners.ts | 25 +++++++++++++++--- 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/apps/web/lib/actions/partners/approve-partners-bulk.ts b/apps/web/lib/actions/partners/approve-partners-bulk.ts index a586040af15..2eea3e2e488 100644 --- a/apps/web/lib/actions/partners/approve-partners-bulk.ts +++ b/apps/web/lib/actions/partners/approve-partners-bulk.ts @@ -46,6 +46,6 @@ export const approvePartnersBulkAction = authActionClient workspace, program, programEnrollments, - userId: user.id, + user, }); }); diff --git a/apps/web/lib/actions/partners/reject-partner.ts b/apps/web/lib/actions/partners/reject-partner.ts index 9061bcefcd7..e6b7fe0623c 100644 --- a/apps/web/lib/actions/partners/reject-partner.ts +++ b/apps/web/lib/actions/partners/reject-partner.ts @@ -1,15 +1,17 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { rejectPartnerSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; // Reject a pending partner export const rejectPartnerAction = authActionClient .schema(rejectPartnerSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -21,6 +23,9 @@ export const rejectPartnerAction = authActionClient programId, }, }, + include: { + partner: true, + }, }); if (programEnrollment.status !== "pending") { @@ -35,4 +40,23 @@ export const rejectPartnerAction = authActionClient status: "rejected", }, }); + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner_application.rejected", + description: `Partner application rejected for ${partnerId}`, + actor: user, + targets: [ + { + type: "partner", + id: partnerId, + metadata: programEnrollment.partner, + }, + ], + }); + })(), + ); }); diff --git a/apps/web/lib/actions/partners/reject-partners-bulk.ts b/apps/web/lib/actions/partners/reject-partners-bulk.ts index 58c8c7121a1..d38adb10e71 100644 --- a/apps/web/lib/actions/partners/reject-partners-bulk.ts +++ b/apps/web/lib/actions/partners/reject-partners-bulk.ts @@ -1,16 +1,18 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { rejectPartnersBulkSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { ProgramEnrollmentStatus } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; // Reject a list of pending partners export const rejectPartnersBulkAction = authActionClient .schema(rejectPartnersBulkSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerIds } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -25,6 +27,7 @@ export const rejectPartnersBulkAction = authActionClient }, select: { id: true, + partner: true, }, }); @@ -42,4 +45,25 @@ export const rejectPartnersBulkAction = authActionClient status: ProgramEnrollmentStatus.rejected, }, }); + + waitUntil( + (async () => { + await recordAuditLog( + programEnrollments.map(({ partner }) => ({ + workspaceId: workspace.id, + programId, + action: "partner_application.rejected", + description: `Partner application rejected for ${partner.id}`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + })), + ); + })(), + ); }); diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts index f2be898e128..f5e82eca68b 100644 --- a/apps/web/lib/api/audit-logs/record-audit-log.ts +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -8,11 +8,9 @@ import { auditLogSchemaTB, recordAuditLogInputSchema } from "./schemas"; const ENABLE_AUDIT_LOGS = true; -// TODO: -// Support array of logs -export const recordAuditLog = async ( - data: z.infer, -) => { +type AuditLogInput = z.infer; + +const transformAuditLogTB = (data: AuditLogInput) => { const headersList = headers(); const location = data.req ? ipAddress(data.req) : getIP(); const userAgent = headersList.get("user-agent"); @@ -23,7 +21,7 @@ export const recordAuditLog = async ( userAgent, }); - const auditLog: z.infer = { + return { id: createId({ prefix: "audit_" }), timestamp: new Date().toISOString(), workspace_id: auditLogInput.workspaceId, @@ -40,14 +38,20 @@ export const recordAuditLog = async ( location: location || "", user_agent: userAgent || "", }; +}; + +export const recordAuditLog = async (data: AuditLogInput | AuditLogInput[]) => { + const auditLogs = Array.isArray(data) + ? data.map(transformAuditLogTB) + : [transformAuditLogTB(data)]; if (!ENABLE_AUDIT_LOGS) { - console.info(auditLog); + console.info(auditLogs); return; } - await recordAuditLogTB(auditLog).catch((error) => { - console.error("Failed to record audit log", error, auditLog); + await recordAuditLogTB(auditLogs).catch((error) => { + console.error("Failed to record audit log", error, auditLogs); // TODO: // Send a Slack notification diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 459926ffea4..62682cc9112 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -50,6 +50,7 @@ const actionSchema = z.enum([ // Partner applications "partner_application.approved", + "partner_application.rejected", ]); export const auditLogTarget = z.union([ diff --git a/apps/web/lib/partners/bulk-approve-partners.ts b/apps/web/lib/partners/bulk-approve-partners.ts index 2b282f5ba26..f825e167c73 100644 --- a/apps/web/lib/partners/bulk-approve-partners.ts +++ b/apps/web/lib/partners/bulk-approve-partners.ts @@ -6,8 +6,10 @@ import { prisma } from "@dub/prisma"; import { chunk, isFulfilled } from "@dub/utils"; import { Partner, ProgramEnrollment } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; +import { recordAuditLog } from "../api/audit-logs/record-audit-log"; import { bulkCreateLinks } from "../api/links"; import { generatePartnerLink } from "../api/partners/create-partner-link"; +import { Session } from "../auth/utils"; import { ProgramProps, WorkspaceProps } from "../types"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { EnrolledPartnerSchema } from "../zod/schemas/partners"; @@ -17,12 +19,12 @@ export async function bulkApprovePartners({ workspace, program, programEnrollments, - userId, + user, }: { workspace: Pick; program: ProgramProps; programEnrollments: (ProgramEnrollment & { partner: Partner })[]; - userId: string; + user: Session["user"]; }) { await prisma.programEnrollment.updateMany({ where: { @@ -61,7 +63,7 @@ export async function bulkApprovePartners({ name: partner.name, email: partner.email!, }, - userId, + userId: user.id, partnerId: partner.id, }), ), @@ -115,6 +117,23 @@ export async function bulkApprovePartners({ }), }), ), + + recordAuditLog( + programEnrollments.map(({ partner }) => ({ + workspaceId: workspace.id, + programId: program.id, + action: "partner_application.approved", + description: `Partner application approved for ${partner.id}`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + })), + ), ]); })(), ); From 680be14f5e545b65dc7c765968258da5431f1402 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 16:18:59 +0530 Subject: [PATCH 08/27] audit log ban, archive, approve events --- .../lib/actions/partners/archive-partner.ts | 29 +++++++- apps/web/lib/actions/partners/ban-partner.ts | 70 +++++++++++-------- .../web/lib/actions/partners/unban-partner.ts | 25 ++++++- .../partners/update-auto-approve-partners.ts | 24 ++++++- apps/web/lib/api/audit-logs/schemas.ts | 22 ++++++ .../get-program-enrollment-or-throw.ts | 5 ++ 6 files changed, 137 insertions(+), 38 deletions(-) diff --git a/apps/web/lib/actions/partners/archive-partner.ts b/apps/web/lib/actions/partners/archive-partner.ts index 3027ccf43fd..10612f5bbbd 100644 --- a/apps/web/lib/actions/partners/archive-partner.ts +++ b/apps/web/lib/actions/partners/archive-partner.ts @@ -1,16 +1,18 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { archivePartnerSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { authActionClient } from "../safe-action"; // Archive a partner export const archivePartnerAction = authActionClient .schema(archivePartnerSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -20,7 +22,7 @@ export const archivePartnerAction = authActionClient programId, }); - await prisma.programEnrollment.update({ + const { status, partner } = await prisma.programEnrollment.update({ where: { partnerId_programId: { programId, @@ -31,5 +33,28 @@ export const archivePartnerAction = authActionClient status: programEnrollment.status === "archived" ? "approved" : "archived", }, + include: { + partner: true, + }, }); + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: + status === "archived" ? "partner.archived" : "partner.approved", + description: `Partner ${partnerId} ${status}`, + actor: user, + targets: [ + { + type: "partner", + id: partnerId, + metadata: partner, + }, + ], + }); + })(), + ); }); diff --git a/apps/web/lib/actions/partners/ban-partner.ts b/apps/web/lib/actions/partners/ban-partner.ts index 2dd1398c0d9..7163b21fe9e 100644 --- a/apps/web/lib/actions/partners/ban-partner.ts +++ b/apps/web/lib/actions/partners/ban-partner.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { linkCache } from "@/lib/api/links/cache"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; @@ -18,7 +19,7 @@ import { authActionClient } from "../safe-action"; export const banPartnerAction = authActionClient .schema(banPartnerSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -26,6 +27,7 @@ export const banPartnerAction = authActionClient const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, + includePartner: true, }); if (programEnrollment.status === "banned") { @@ -79,44 +81,15 @@ export const banPartnerAction = authActionClient // sync total commissions await syncTotalCommissions({ partnerId, programId }); - // Send email to partner - const partner = await prisma.partner.findUniqueOrThrow({ - where: { - id: partnerId, - }, - select: { - email: true, - name: true, - }, - }); + const { program, partner } = programEnrollment; if (!partner.email) { console.error("Partner has no email address."); return; } - const { program } = programEnrollment; - const supportEmail = program.supportEmail || "support@dub.co"; - await sendEmail({ - subject: `You've been banned from the ${program.name} Partner Program`, - email: partner.email, - replyTo: supportEmail, - react: PartnerBanned({ - partner: { - name: partner.name, - email: partner.email, - }, - program: { - name: program.name, - supportEmail, - }, - bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], - }), - variant: "notifications", - }); - // Delete links from cache const links = await prisma.link.findMany({ where, @@ -127,6 +100,41 @@ export const banPartnerAction = authActionClient }); await linkCache.deleteMany(links); + + await Promise.allSettled([ + sendEmail({ + subject: `You've been banned from the ${program.name} Partner Program`, + email: partner.email, + replyTo: supportEmail, + react: PartnerBanned({ + partner: { + name: partner.name, + email: partner.email, + }, + program: { + name: program.name, + supportEmail, + }, + bannedReason: BAN_PARTNER_REASONS[parsedInput.reason], + }), + variant: "notifications", + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner.banned", + description: `Partner ${partnerId} banned`, + actor: user, + targets: [ + { + type: "partner", + id: partnerId, + metadata: partner, + }, + ], + }), + ]); })(), ); }); diff --git a/apps/web/lib/actions/partners/unban-partner.ts b/apps/web/lib/actions/partners/unban-partner.ts index 8cd7124ea38..3b5f4690985 100644 --- a/apps/web/lib/actions/partners/unban-partner.ts +++ b/apps/web/lib/actions/partners/unban-partner.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { linkCache } from "@/lib/api/links/cache"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; @@ -16,7 +17,7 @@ const unbanPartnerSchema = banPartnerSchema.omit({ export const unbanPartnerAction = authActionClient .schema(unbanPartnerSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -24,6 +25,7 @@ export const unbanPartnerAction = authActionClient const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, + includePartner: true, }); if (programEnrollment.status !== "banned") { @@ -77,7 +79,6 @@ export const unbanPartnerAction = authActionClient waitUntil( (async () => { - // Delete links from cache const links = await prisma.link.findMany({ where, select: { @@ -86,7 +87,25 @@ export const unbanPartnerAction = authActionClient }, }); - await linkCache.deleteMany(links); + await Promise.allSettled([ + // Delete links from cache + linkCache.deleteMany(links), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner.unbanned", + description: `Partner ${partnerId} unbanned`, + actor: user, + targets: [ + { + type: "partner", + id: partnerId, + metadata: programEnrollment.partner, + }, + ], + }), + ]); // TODO // Send email to partner about being unbanned diff --git a/apps/web/lib/actions/partners/update-auto-approve-partners.ts b/apps/web/lib/actions/partners/update-auto-approve-partners.ts index f6d7110bd27..f12403f36cf 100644 --- a/apps/web/lib/actions/partners/update-auto-approve-partners.ts +++ b/apps/web/lib/actions/partners/update-auto-approve-partners.ts @@ -1,7 +1,9 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { z } from "zod"; import { authActionClient } from "../safe-action"; @@ -13,12 +15,12 @@ const schema = z.object({ export const updateAutoApprovePartnersAction = authActionClient .schema(schema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { autoApprovePartners } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); - return await prisma.program.update({ + const program = await prisma.program.update({ where: { id: programId, }, @@ -26,4 +28,22 @@ export const updateAutoApprovePartnersAction = authActionClient autoApprovePartnersEnabledAt: autoApprovePartners ? new Date() : null, }, }); + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: autoApprovePartners + ? "auto_approve_partner.enabled" + : "auto_approve_partner.disabled", + description: autoApprovePartners + ? "Auto approve partners enabled" + : "Auto approve partners disabled", + actor: user, + }); + })(), + ); + + return program; }); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 62682cc9112..ed0fcad9756 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -1,5 +1,6 @@ import { DiscountSchema } from "@/lib/zod/schemas/discount"; import { PartnerSchema } from "@/lib/zod/schemas/partners"; +import { ProgramSchema } from "@/lib/zod/schemas/programs"; import { RewardSchema } from "@/lib/zod/schemas/rewards"; import { z } from "zod"; @@ -51,6 +52,17 @@ const actionSchema = z.enum([ // Partner applications "partner_application.approved", "partner_application.rejected", + + // Partner enrollments + "partner.archived", + "partner.banned", + "partner.unbanned", + "partner.invited", + "partner.approved", + + // Auto approve partners + "auto_approve_partner.enabled", + "auto_approve_partner.disabled", ]); export const auditLogTarget = z.union([ @@ -84,6 +96,16 @@ export const auditLogTarget = z.union([ email: true, }), }), + + z.object({ + type: z.literal("program"), + id: z.string(), + metadata: ProgramSchema.pick({ + name: true, + supportEmail: true, + autoApprovePartnersEnabledAt: true, + }).optional(), + }), ]); export const recordAuditLogInputSchema = z.object({ diff --git a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts index c48c6b28252..0813e9845fe 100644 --- a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts +++ b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts @@ -7,10 +7,12 @@ export async function getProgramEnrollmentOrThrow({ partnerId, programId, includeRewards = false, + includePartner = false, }: { partnerId: string; programId: string; includeRewards?: boolean; + includePartner?: boolean; }) { const include: Prisma.ProgramEnrollmentInclude = { program: true, @@ -24,6 +26,9 @@ export async function getProgramEnrollmentOrThrow({ leadReward: true, saleReward: true, }), + ...(includePartner && { + partner: true, + }), }; const programEnrollment = programId.startsWith("prog_") From 326ae7f3280d5ae63d2884330c40ee39ae6e6601 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 16:42:41 +0530 Subject: [PATCH 09/27] partner invitation events --- .../actions/partners/delete-program-invite.ts | 20 +++++++- .../lib/actions/partners/invite-partner.ts | 46 +++++++++++++------ .../actions/partners/resend-program-invite.ts | 20 +++++++- apps/web/lib/api/audit-logs/schemas.ts | 3 ++ 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/apps/web/lib/actions/partners/delete-program-invite.ts b/apps/web/lib/actions/partners/delete-program-invite.ts index 0502c8f98f4..6cea83dc062 100644 --- a/apps/web/lib/actions/partners/delete-program-invite.ts +++ b/apps/web/lib/actions/partners/delete-program-invite.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { prisma } from "@dub/prisma"; @@ -15,7 +16,7 @@ export const deleteProgramInviteAction = authActionClient .schema(deleteProgramInviteSchema) .action(async ({ parsedInput, ctx }) => { const { partnerId } = parsedInput; - const { workspace } = ctx; + const { workspace, user } = ctx; const programId = getDefaultProgramIdOrThrow(workspace); @@ -49,9 +50,26 @@ export const deleteProgramInviteAction = authActionClient id: programEnrollment.id, }, }), + prisma.link.deleteMany({ where: { id: { in: linksToDelete.map((link) => link.id) } }, }), + bulkDeleteLinks(linksToDelete), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner.invite_deleted", + description: `Partner ${partner.id} invite deleted`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + }), ]); }); diff --git a/apps/web/lib/actions/partners/invite-partner.ts b/apps/web/lib/actions/partners/invite-partner.ts index 29fc6be00a5..27a5013d945 100644 --- a/apps/web/lib/actions/partners/invite-partner.ts +++ b/apps/web/lib/actions/partners/invite-partner.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createAndEnrollPartner } from "@/lib/api/partners/create-and-enroll-partner"; import { createPartnerLink } from "@/lib/api/partners/create-partner-link"; import { getDiscountOrThrow } from "@/lib/api/partners/get-discount-or-throw"; @@ -92,7 +93,7 @@ export const invitePartnerAction = authActionClient }); } - await createAndEnrollPartner({ + const enrolledPartner = await createAndEnrollPartner({ program, link, workspace, @@ -107,17 +108,36 @@ export const invitePartnerAction = authActionClient }); waitUntil( - sendEmail({ - subject: `${program.name} invited you to join Dub Partners`, - email, - react: PartnerInvite({ - email, - program: { - name: program.name, - slug: program.slug, - logo: program.logo, - }, - }), - }), + (async () => { + await Promise.allSettled([ + sendEmail({ + subject: `${program.name} invited you to join Dub Partners`, + email, + react: PartnerInvite({ + email, + program: { + name: program.name, + slug: program.slug, + logo: program.logo, + }, + }), + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner.invited", + description: `Partner ${enrolledPartner.id} invited`, + actor: user, + targets: [ + { + type: "partner", + id: enrolledPartner.id, + metadata: enrolledPartner, + }, + ], + }), + ]); + })(), ); }); diff --git a/apps/web/lib/actions/partners/resend-program-invite.ts b/apps/web/lib/actions/partners/resend-program-invite.ts index a582f4e31ff..dc65f94a233 100644 --- a/apps/web/lib/actions/partners/resend-program-invite.ts +++ b/apps/web/lib/actions/partners/resend-program-invite.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { sendEmail } from "@dub/email"; import PartnerInvite from "@dub/email/templates/partner-invite"; @@ -16,7 +17,7 @@ export const resendProgramInviteAction = authActionClient .schema(resendProgramInviteSchema) .action(async ({ parsedInput, ctx }) => { const { partnerId } = parsedInput; - const { workspace } = ctx; + const { workspace, user } = ctx; const programId = getDefaultProgramIdOrThrow(workspace); @@ -48,7 +49,7 @@ export const resendProgramInviteAction = authActionClient ); } - await Promise.all([ + await Promise.allSettled([ sendEmail({ subject: `${program.name} invited you to join Dub Partners`, email: partner.email!, @@ -70,5 +71,20 @@ export const resendProgramInviteAction = authActionClient createdAt: new Date(), }, }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner.invite_resent", + description: `Partner ${partner.id} invite resent`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + }), ]); }); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index ed0fcad9756..e9630fc4c5b 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -59,6 +59,9 @@ const actionSchema = z.enum([ "partner.unbanned", "partner.invited", "partner.approved", + "partner.invited", + "partner.invite_deleted", + "partner.invite_resent", // Auto approve partners "auto_approve_partner.enabled", From 5822a86befca346931a0045ec91e1757c8db301a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 17:02:12 +0530 Subject: [PATCH 10/27] log commission & clawback events --- .../lib/actions/partners/create-clawback.ts | 4 +- .../lib/actions/partners/create-commission.ts | 8 +++- .../lib/api/audit-logs/record-audit-log.ts | 5 +-- apps/web/lib/api/audit-logs/schemas.ts | 16 +++++++ .../lib/partners/create-partner-commission.ts | 42 ++++++++++++++++--- 5 files changed, 64 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/actions/partners/create-clawback.ts b/apps/web/lib/actions/partners/create-clawback.ts index 9aa77eba4b5..99834a06c64 100644 --- a/apps/web/lib/actions/partners/create-clawback.ts +++ b/apps/web/lib/actions/partners/create-clawback.ts @@ -9,7 +9,7 @@ import { authActionClient } from "../safe-action"; export const createClawbackAction = authActionClient .schema(createClawbackSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, amount, description } = parsedInput; @@ -26,5 +26,7 @@ export const createClawbackAction = authActionClient description, amount: -amount, quantity: 1, + user, + workspaceId: workspace.id, }); }); diff --git a/apps/web/lib/actions/partners/create-commission.ts b/apps/web/lib/actions/partners/create-commission.ts index 0d128d35df6..7df2cec5140 100644 --- a/apps/web/lib/actions/partners/create-commission.ts +++ b/apps/web/lib/actions/partners/create-commission.ts @@ -19,7 +19,7 @@ import { authActionClient } from "../safe-action"; export const createCommissionAction = authActionClient .schema(createCommissionSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { partnerId, @@ -60,6 +60,8 @@ export const createCommissionAction = authActionClient amount: amount ?? 0, quantity: 1, createdAt: date ?? new Date(), + user, + workspaceId: workspace.id, }); return; @@ -179,6 +181,8 @@ export const createCommissionAction = authActionClient amount: 0, quantity: 1, createdAt: finalLeadEventDate, + user, + workspaceId: workspace.id, }), ]); } @@ -213,6 +217,8 @@ export const createCommissionAction = authActionClient invoiceId, currency: "usd", createdAt: saleEventDate, + user, + workspaceId: workspace.id, }), ]); } diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts index f5e82eca68b..a2acbe5f1dc 100644 --- a/apps/web/lib/api/audit-logs/record-audit-log.ts +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -45,10 +45,7 @@ export const recordAuditLog = async (data: AuditLogInput | AuditLogInput[]) => { ? data.map(transformAuditLogTB) : [transformAuditLogTB(data)]; - if (!ENABLE_AUDIT_LOGS) { - console.info(auditLogs); - return; - } + console.log(auditLogs); await recordAuditLogTB(auditLogs).catch((error) => { console.error("Failed to record audit log", error, auditLogs); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index e9630fc4c5b..a37e8a5a2ab 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -1,3 +1,4 @@ +import { CommissionSchema } from "@/lib/zod/schemas/commissions"; import { DiscountSchema } from "@/lib/zod/schemas/discount"; import { PartnerSchema } from "@/lib/zod/schemas/partners"; import { ProgramSchema } from "@/lib/zod/schemas/programs"; @@ -66,6 +67,10 @@ const actionSchema = z.enum([ // Auto approve partners "auto_approve_partner.enabled", "auto_approve_partner.disabled", + + // Commissions & clawbacks + "commission.created", + "clawback.created", ]); export const auditLogTarget = z.union([ @@ -109,6 +114,17 @@ export const auditLogTarget = z.union([ autoApprovePartnersEnabledAt: true, }).optional(), }), + + z.object({ + type: z.union([z.literal("commission"), z.literal("clawback")]), + id: z.string(), + metadata: CommissionSchema.pick({ + type: true, + amount: true, + earnings: true, + currency: true, + }), + }), ]); export const recordAuditLogInputSchema = z.object({ diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index 95325072b41..438daade72a 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -7,17 +7,20 @@ import { import { log } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { differenceInMonths } from "date-fns"; +import { recordAuditLog } from "../api/audit-logs/record-audit-log"; import { createId } from "../api/create-id"; import { syncTotalCommissions } from "../api/partners/sync-total-commissions"; import { calculateSaleEarnings } from "../api/sales/calculate-sale-earnings"; +import { Session } from "../auth"; import { RewardProps } from "../types"; import { determinePartnerReward } from "./determine-partner-reward"; export const createPartnerCommission = async ({ reward, event, - programId, partnerId, + programId, + workspaceId, linkId, customerId, eventId, @@ -27,6 +30,7 @@ export const createPartnerCommission = async ({ currency, description, createdAt, + user, }: { // we optionally let the caller pass in a reward to avoid a db call // (e.g. in aggregate-clicks route) @@ -34,6 +38,7 @@ export const createPartnerCommission = async ({ event: CommissionType; partnerId: string; programId: string; + workspaceId?: string; linkId?: string; customerId?: string; eventId?: string; @@ -43,6 +48,7 @@ export const createPartnerCommission = async ({ currency?: string; description?: string; createdAt?: Date; + user?: Session["user"]; // user who created the commission }) => { let earnings = 0; let status: CommissionStatus = "pending"; @@ -183,10 +189,36 @@ export const createPartnerCommission = async ({ }); waitUntil( - syncTotalCommissions({ - partnerId, - programId, - }), + (async () => { + const shouldCaptureAuditLog = user && workspaceId; + const isClawback = earnings < 0; + + await Promise.allSettled([ + syncTotalCommissions({ + partnerId, + programId, + }), + + shouldCaptureAuditLog + ? recordAuditLog({ + workspaceId, + programId, + action: isClawback ? "clawback.created" : "commission.created", + description: isClawback + ? `Clawback created for ${partnerId}` + : `Commission created for ${partnerId}`, + actor: user, + targets: [ + { + type: isClawback ? "clawback" : "commission", + id: commission.id, + metadata: commission, + }, + ], + }) + : Promise.resolve(), + ]); + })(), ); return commission; From 9d85718245a0181a3e947524e806f958f6b1be7b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 17:19:20 +0530 Subject: [PATCH 11/27] record commission status changes --- .../partners/mark-commission-duplicate.ts | 30 ++++++++++++++--- .../mark-commission-fraud-or-canceled.ts | 33 ++++++++++++++++--- apps/web/lib/api/audit-logs/schemas.ts | 6 ++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/apps/web/lib/actions/partners/mark-commission-duplicate.ts b/apps/web/lib/actions/partners/mark-commission-duplicate.ts index a8d1cf53bf1..1c59792efa2 100644 --- a/apps/web/lib/actions/partners/mark-commission-duplicate.ts +++ b/apps/web/lib/actions/partners/mark-commission-duplicate.ts @@ -1,5 +1,6 @@ "use server"; +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"; @@ -17,7 +18,7 @@ const markCommissionDuplicateSchema = z.object({ export const markCommissionDuplicateAction = authActionClient .schema(markCommissionDuplicateSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { commissionId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -73,10 +74,29 @@ export const markCommissionDuplicateAction = authActionClient }); waitUntil( - syncTotalCommissions({ - partnerId: commission.partnerId, - programId, - }), + (async () => { + await Promise.allSettled([ + syncTotalCommissions({ + partnerId: commission.partnerId, + programId, + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "commission.marked_duplicate", + description: `Commission ${commissionId} marked as duplicate`, + actor: user, + targets: [ + { + type: "commission", + id: commissionId, + metadata: commission, + }, + ], + }), + ]); + })(), ); // TODO: We might want to store the history of the sale status changes diff --git a/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts b/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts index 8cafee735e3..15ba2b0af9f 100644 --- a/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts +++ b/apps/web/lib/actions/partners/mark-commission-fraud-or-canceled.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { prisma } from "@dub/prisma"; @@ -17,7 +18,7 @@ const markCommissionFraudOrCanceledSchema = z.object({ export const markCommissionFraudOrCanceledAction = authActionClient .schema(markCommissionFraudOrCanceledSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { commissionId, status } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -112,9 +113,31 @@ export const markCommissionFraudOrCanceledAction = authActionClient }); waitUntil( - syncTotalCommissions({ - partnerId: commission.partnerId, - programId, - }), + (async () => { + await Promise.allSettled([ + syncTotalCommissions({ + partnerId: commission.partnerId, + programId, + }), + + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: + status === "fraud" + ? "commission.marked_fraud" + : "commission.canceled", + description: `Commission ${commissionId} marked as ${status}`, + actor: user, + targets: [ + { + type: "commission", + id: commissionId, + metadata: commission, + }, + ], + }), + ]); + })(), ); }); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index a37e8a5a2ab..31f19d92868 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -71,6 +71,12 @@ const actionSchema = z.enum([ // Commissions & clawbacks "commission.created", "clawback.created", + + // TODO: + // Finalize the action names + "commission.canceled", + "commission.marked_fraud", + "commission.marked_duplicate", ]); export const auditLogTarget = z.union([ From 4f0366b19fed38771d79e917b8ef30c25f0b5256 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 17:51:26 +0530 Subject: [PATCH 12/27] record payout events --- .../cron/payouts/confirm/confirm-payouts.ts | 38 +++++++++++++++++++ .../lib/actions/partners/create-program.ts | 16 ++++++++ .../lib/actions/partners/mark-payout-paid.ts | 24 +++++++++++- .../lib/actions/partners/update-program.ts | 20 +++++++++- .../lib/api/audit-logs/record-audit-log.ts | 6 ++- apps/web/lib/api/audit-logs/schemas.ts | 32 ++++++++++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/payouts/confirm/confirm-payouts.ts b/apps/web/app/(ee)/api/cron/payouts/confirm/confirm-payouts.ts index 457a1d26c18..8c86f24e4df 100644 --- a/apps/web/app/(ee)/api/cron/payouts/confirm/confirm-payouts.ts +++ b/apps/web/app/(ee)/api/cron/payouts/confirm/confirm-payouts.ts @@ -1,3 +1,4 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { exceededLimitError } from "@/lib/api/errors"; import { @@ -18,6 +19,7 @@ import PartnerPayoutConfirmed from "@dub/email/templates/partner-payout-confirme import { prisma } from "@dub/prisma"; import { chunk, currencyFormatter, log } from "@dub/utils"; import { Program, Project } from "@prisma/client"; +import { waitUntil } from "@vercel/functions"; const paymentMethodToCurrency = { sepa_debit: "eur", @@ -267,4 +269,40 @@ export async function confirmPayouts({ ); } } + + waitUntil( + (async () => { + // refetching to confirm the payouts are in the processing state + const updatedPayouts = await prisma.payout.findMany({ + where: { + id: { + in: payouts.map((p) => p.id), + }, + status: "processing", + }, + select: { + id: true, + status: true, + user: true, + }, + }); + + await recordAuditLog( + updatedPayouts.map((payout) => ({ + workspaceId: workspace.id, + programId: program.id, + action: "payout.confirmed", + description: `Payout ${payout.id} confirmed`, + actor: payout.user!, + targets: [ + { + type: "payout", + id: payout.id, + metadata: payout, + }, + ], + })), + ); + })(), + ); } diff --git a/apps/web/lib/actions/partners/create-program.ts b/apps/web/lib/actions/partners/create-program.ts index 4eaaccb1b48..0c95637520d 100644 --- a/apps/web/lib/actions/partners/create-program.ts +++ b/apps/web/lib/actions/partners/create-program.ts @@ -1,3 +1,4 @@ +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw"; import { createAndEnrollPartner } from "@/lib/api/partners/create-and-enroll-partner"; @@ -195,6 +196,21 @@ export const createProgram = async ({ }, }), }), + + recordAuditLog({ + workspaceId: workspace.id, + programId: program.id, + action: "program.created", + description: `Program ${program.name} created`, + actor: user, + targets: [ + { + type: "program", + id: program.id, + metadata: program, + }, + ], + }), ]), ); diff --git a/apps/web/lib/actions/partners/mark-payout-paid.ts b/apps/web/lib/actions/partners/mark-payout-paid.ts index 27d4be3e06e..6bd483f5f40 100644 --- a/apps/web/lib/actions/partners/mark-payout-paid.ts +++ b/apps/web/lib/actions/partners/mark-payout-paid.ts @@ -1,8 +1,10 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getPayoutOrThrow } from "@/lib/api/partners/get-payout-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { z } from "zod"; import { authActionClient } from "../safe-action"; @@ -14,7 +16,7 @@ const markPayoutPaidSchema = z.object({ export const markPayoutPaidAction = authActionClient .schema(markPayoutPaidSchema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { payoutId } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); @@ -34,6 +36,7 @@ export const markPayoutPaidAction = authActionClient paidAt: new Date(), }, }), + prisma.commission.updateMany({ where: { payoutId: payout.id, @@ -43,4 +46,23 @@ export const markPayoutPaidAction = authActionClient }, }), ]); + + waitUntil( + (async () => { + await recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "payout.marked_paid", + description: `Payout ${payout.id} marked as paid`, + actor: user, + targets: [ + { + type: "payout", + id: payout.id, + metadata: payout, + }, + ], + }); + })(), + ); }); diff --git a/apps/web/lib/actions/partners/update-program.ts b/apps/web/lib/actions/partners/update-program.ts index 311e9e32ecc..970072494de 100644 --- a/apps/web/lib/actions/partners/update-program.ts +++ b/apps/web/lib/actions/partners/update-program.ts @@ -1,5 +1,6 @@ "use server"; +import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getFolderOrThrow } from "@/lib/folder/get-folder-or-throw"; import { DUB_MIN_PAYOUT_AMOUNT_CENTS } from "@/lib/partners/constants"; @@ -26,7 +27,7 @@ const schema = updateProgramSchema.partial().extend({ export const updateProgramAction = authActionClient .schema(schema) .action(async ({ parsedInput, ctx }) => { - const { workspace } = ctx; + const { workspace, user } = ctx; const { name, logo, @@ -72,7 +73,7 @@ export const updateProgramAction = authActionClient : null, ]); - await prisma.program.update({ + const updatedProgram = await prisma.program.update({ where: { id: programId, }, @@ -129,6 +130,21 @@ export const updateProgramAction = authActionClient revalidatePath(`/partners.dub.co/${program.slug}/apply/success`), ] : []), + + recordAuditLog({ + workspaceId: workspace.id, + programId: program.id, + action: "program.updated", + description: `Program ${program.name} updated`, + actor: user, + targets: [ + { + type: "program", + id: program.id, + metadata: updatedProgram, + }, + ], + }), ]), ); }); diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts index a2acbe5f1dc..c08940c4c73 100644 --- a/apps/web/lib/api/audit-logs/record-audit-log.ts +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -6,7 +6,7 @@ import { createId } from "../create-id"; import { getIP } from "../utils"; import { auditLogSchemaTB, recordAuditLogInputSchema } from "./schemas"; -const ENABLE_AUDIT_LOGS = true; +const DEBUG_AUDIT_LOGS = false; type AuditLogInput = z.infer; @@ -45,7 +45,9 @@ export const recordAuditLog = async (data: AuditLogInput | AuditLogInput[]) => { ? data.map(transformAuditLogTB) : [transformAuditLogTB(data)]; - console.log(auditLogs); + if (DEBUG_AUDIT_LOGS) { + console.log(auditLogs); + } await recordAuditLogTB(auditLogs).catch((error) => { console.error("Failed to record audit log", error, auditLogs); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 31f19d92868..968fe9bda3e 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -1,6 +1,7 @@ import { CommissionSchema } from "@/lib/zod/schemas/commissions"; import { DiscountSchema } from "@/lib/zod/schemas/discount"; import { PartnerSchema } from "@/lib/zod/schemas/partners"; +import { PayoutSchema } from "@/lib/zod/schemas/payouts"; import { ProgramSchema } from "@/lib/zod/schemas/programs"; import { RewardSchema } from "@/lib/zod/schemas/rewards"; import { z } from "zod"; @@ -40,6 +41,10 @@ export const auditLogSchema = z.object({ }); const actionSchema = z.enum([ + // Program + "program.created", + "program.updated", + // Rewards "reward.created", "reward.updated", @@ -77,9 +82,28 @@ const actionSchema = z.enum([ "commission.canceled", "commission.marked_fraud", "commission.marked_duplicate", + + // Payouts + "payout.confirmed", + "payout.marked_paid", ]); export const auditLogTarget = z.union([ + z.object({ + type: z.literal("program"), + id: z.string(), + metadata: ProgramSchema.pick({ + domain: true, + url: true, + linkStructure: true, + supportEmail: true, + helpUrl: true, + termsUrl: true, + holdingPeriodDays: true, + minPayoutAmount: true, + }).optional(), + }), + z.object({ type: z.literal("reward"), id: z.string(), @@ -131,6 +155,14 @@ export const auditLogTarget = z.union([ currency: true, }), }), + + z.object({ + type: z.literal("payout"), + id: z.string(), + metadata: PayoutSchema.pick({ + status: true, + }), + }), ]); export const recordAuditLogInputSchema = z.object({ From af6e6e095961df692f02b0e6cd9357850c637ca9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 18:01:43 +0530 Subject: [PATCH 13/27] move to new components --- .../(ee)/settings/security/page-client.tsx | 325 +----------------- .../[slug]/(ee)/settings/security/saml.tsx | 160 +++++++++ .../[slug]/(ee)/settings/security/scim.tsx | 167 +++++++++ 3 files changed, 333 insertions(+), 319 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/saml.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx index 3a0c18a2775..0c6a208c593 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx @@ -1,328 +1,15 @@ "use client"; -import useSAML from "@/lib/swr/use-saml"; -import useSCIM from "@/lib/swr/use-scim"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { useRemoveSAMLModal } from "@/ui/modals/remove-saml-modal"; -import { useRemoveSCIMModal } from "@/ui/modals/remove-scim-modal"; -import { useSAMLModal } from "@/ui/modals/saml-modal"; -import { useSCIMModal } from "@/ui/modals/scim-modal"; -import { ThreeDots } from "@/ui/shared/icons"; -import { Button, IconMenu, Popover, TooltipContent } from "@dub/ui"; -import { SAML_PROVIDERS } from "@dub/utils"; -import { FolderSync, Lock, ShieldOff } from "lucide-react"; -import { useMemo, useState } from "react"; +import { AuditLog } from "./audit-log"; +import { SAML } from "./saml"; +import { SCIM } from "./scim"; export default function WorkspaceSecurityClient() { return ( <> - - + + + ); } - -const SAMLSection = () => { - const { plan } = useWorkspace(); - const { SAMLModal, setShowSAMLModal } = useSAMLModal(); - const { RemoveSAMLModal, setShowRemoveSAMLModal } = useRemoveSAMLModal(); - const { provider, configured, loading } = useSAML(); - - const currentProvider = useMemo( - () => provider && SAML_PROVIDERS.find((p) => p.name.startsWith(provider)), - [provider], - ); - - const data = useMemo(() => { - if (loading) { - return { - logo: null, - title: null, - description: null, - }; - } else if (currentProvider) { - return { - logo: ( - {currentProvider.name} - ), - title: `${currentProvider.name} SAML`, - description: "SAML SSO is configured for your workspace.", - }; - } else { - return { - status: "unconfigured", - logo: ( -
- -
- ), - title: "SAML", - description: "Choose an identity provider to get started.", - }; - } - }, [provider, configured, loading]); - - const [openPopover, setOpenPopover] = useState(false); - - return ( - <> - {configured ? : } -
-
-
-

SAML Single Sign-On

-

- Set up SAML Single Sign-On (SSO) to allow your team to sign in to{" "} - {process.env.NEXT_PUBLIC_APP_NAME} with your identity provider. -

-
- -
-
- {data.logo || ( -
- )} -
- {data.title ? ( -

{data.title}

- ) : ( -
- )} - {data.description ? ( -

{data.description}

- ) : ( -
- )} -
-
-
- {loading ? ( -
- ) : configured ? ( - - -
- } - align="end" - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - - - ) : ( -
-
-
- - -
- - ); -}; - -const SCIMSection = () => { - const { plan } = useWorkspace(); - const { SCIMModal, setShowSCIMModal } = useSCIMModal(); - const { RemoveSCIMModal, setShowRemoveSCIMModal } = useRemoveSCIMModal(); - - const { provider, configured, loading } = useSCIM(); - - const data = useMemo(() => { - if (loading) { - return { - logo: null, - title: null, - description: null, - }; - } else if (configured) { - return { - logo: ( - p.scim === provider)!.logo} - alt={`${provider} logo`} - className="h-8 w-8" - /> - ), - title: `${SAML_PROVIDERS.find((p) => p.scim === provider)!.name} SCIM`, - description: "SCIM directory sync is configured for your workspace.", - }; - } else - return { - logo: ( -
- -
- ), - title: "SCIM", - description: "Choose an identity provider to get started.", - }; - }, [provider, configured, loading]); - - const [openPopover, setOpenPopover] = useState(false); - - return ( - <> - - {configured && } -
-
-
-

Directory Sync

-

- Automatically provision and deprovision users from your identity - provider. -

-
- -
-
- {data.logo || ( -
- )} -
- {data.title ? ( -

{data.title}

- ) : ( -
- )} - {data.description ? ( -

{data.description}

- ) : ( -
- )} -
-
-
- {loading ? ( -
- ) : configured ? ( - - - -
- } - align="end" - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - - - ) : ( -
-
-
- - -
- - ); -}; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/saml.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/saml.tsx new file mode 100644 index 00000000000..e6a94b50c6c --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/saml.tsx @@ -0,0 +1,160 @@ +"use client"; + +import useSAML from "@/lib/swr/use-saml"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { useRemoveSAMLModal } from "@/ui/modals/remove-saml-modal"; +import { useSAMLModal } from "@/ui/modals/saml-modal"; +import { ThreeDots } from "@/ui/shared/icons"; +import { Button, IconMenu, Popover, TooltipContent } from "@dub/ui"; +import { SAML_PROVIDERS } from "@dub/utils"; +import { Lock, ShieldOff } from "lucide-react"; +import { useMemo, useState } from "react"; + +export function SAML() { + const { plan } = useWorkspace(); + const { SAMLModal, setShowSAMLModal } = useSAMLModal(); + const { RemoveSAMLModal, setShowRemoveSAMLModal } = useRemoveSAMLModal(); + const { provider, configured, loading } = useSAML(); + + const currentProvider = useMemo( + () => provider && SAML_PROVIDERS.find((p) => p.name.startsWith(provider)), + [provider], + ); + + const data = useMemo(() => { + if (loading) { + return { + logo: null, + title: null, + description: null, + }; + } else if (currentProvider) { + return { + logo: ( + {currentProvider.name} + ), + title: `${currentProvider.name} SAML`, + description: "SAML SSO is configured for your workspace.", + }; + } else { + return { + status: "unconfigured", + logo: ( +
+ +
+ ), + title: "SAML", + description: "Choose an identity provider to get started.", + }; + } + }, [provider, configured, loading]); + + const [openPopover, setOpenPopover] = useState(false); + + return ( + <> + {configured ? : } +
+
+
+

SAML Single Sign-On

+

+ Set up SAML Single Sign-On (SSO) to allow your team to sign in to{" "} + {process.env.NEXT_PUBLIC_APP_NAME} with your identity provider. +

+
+ +
+
+ {data.logo || ( +
+ )} +
+ {data.title ? ( +

{data.title}

+ ) : ( +
+ )} + {data.description ? ( +

{data.description}

+ ) : ( +
+ )} +
+
+
+ {loading ? ( +
+ ) : configured ? ( + + +
+ } + align="end" + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + + + ) : ( +
+
+
+ + +
+ + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx new file mode 100644 index 00000000000..e0f0c402c00 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx @@ -0,0 +1,167 @@ +"use client"; + +import useSCIM from "@/lib/swr/use-scim"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { useRemoveSCIMModal } from "@/ui/modals/remove-scim-modal"; +import { useSCIMModal } from "@/ui/modals/scim-modal"; +import { ThreeDots } from "@/ui/shared/icons"; +import { Button, IconMenu, Popover, TooltipContent } from "@dub/ui"; +import { SAML_PROVIDERS } from "@dub/utils"; +import { FolderSync, ShieldOff } from "lucide-react"; +import { useMemo, useState } from "react"; + +export function SCIM() { + const { plan } = useWorkspace(); + const { SCIMModal, setShowSCIMModal } = useSCIMModal(); + const { RemoveSCIMModal, setShowRemoveSCIMModal } = useRemoveSCIMModal(); + + const { provider, configured, loading } = useSCIM(); + + const data = useMemo(() => { + if (loading) { + return { + logo: null, + title: null, + description: null, + }; + } else if (configured) { + return { + logo: ( + p.scim === provider)!.logo} + alt={`${provider} logo`} + className="h-8 w-8" + /> + ), + title: `${SAML_PROVIDERS.find((p) => p.scim === provider)!.name} SCIM`, + description: "SCIM directory sync is configured for your workspace.", + }; + } else + return { + logo: ( +
+ +
+ ), + title: "SCIM", + description: "Choose an identity provider to get started.", + }; + }, [provider, configured, loading]); + + const [openPopover, setOpenPopover] = useState(false); + + return ( + <> + + {configured && } +
+
+
+

Directory Sync

+

+ Automatically provision and deprovision users from your identity + provider. +

+
+ +
+
+ {data.logo || ( +
+ )} +
+ {data.title ? ( +

{data.title}

+ ) : ( +
+ )} + {data.description ? ( +

{data.description}

+ ) : ( +
+ )} +
+
+
+ {loading ? ( +
+ ) : configured ? ( + + + +
+ } + align="end" + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + + + ) : ( +
+
+
+ + +
+ + ); +} From 85a724fec451acd0b14c61c59952a5f29bc922c8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 18:47:35 +0530 Subject: [PATCH 14/27] Add audit log export functionality with CSV support --- .../app/(ee)/api/audit-logs/export/route.ts | 60 ++++++++ .../(ee)/settings/security/audit-log.tsx | 131 ++++++++++++++++++ apps/web/lib/api/audit-logs/get-audit-logs.ts | 50 +++++++ apps/web/lib/plan-capabilities.ts | 1 + .../ui/shared/simple-date-range-picker.tsx | 3 + packages/tinybird/pipes/audit_logs.pipe | 26 ++++ 6 files changed, 271 insertions(+) create mode 100644 apps/web/app/(ee)/api/audit-logs/export/route.ts create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx create mode 100644 apps/web/lib/api/audit-logs/get-audit-logs.ts create mode 100644 packages/tinybird/pipes/audit_logs.pipe diff --git a/apps/web/app/(ee)/api/audit-logs/export/route.ts b/apps/web/app/(ee)/api/audit-logs/export/route.ts new file mode 100644 index 00000000000..fd364320ab9 --- /dev/null +++ b/apps/web/app/(ee)/api/audit-logs/export/route.ts @@ -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"], + }, +); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx new file mode 100644 index 00000000000..4f3d97ba48f --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx @@ -0,0 +1,131 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; +import { Button, TooltipContent } from "@dub/ui"; +import { subMonths } from "date-fns"; +import { useState } from "react"; +import { toast } from "sonner"; + +const defaultDateRange = { + from: subMonths(new Date(), 12), + to: new Date(), +} as const; + +export function AuditLog() { + const [loading, setLoading] = useState(false); + const { plan, slug, id: workspaceId } = useWorkspace(); + const [dateRange, setDateRange] = useState(defaultDateRange); + + const isEnterprise = plan === "enterprise"; + + const exportAuditLogs = async () => { + if (!workspaceId) { + return; + } + + setLoading(true); + + const lid = toast.loading("Exporting audit logs..."); + + try { + const response = await fetch( + `/api/audit-logs/export?workspaceId=${workspaceId}`, + { + method: "POST", + body: JSON.stringify({ + start: dateRange.from.toISOString(), + end: dateRange.to.toISOString(), + }), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + const { error } = await response.json(); + throw new Error(error.message); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + + a.href = url; + a.download = `Dub Audit Logs Export - ${new Date().toISOString()}.csv`; + a.click(); + + toast.success("Exported successfully"); + } catch (error) { + toast.error(error); + } finally { + setLoading(false); + toast.dismiss(lid); + } + }; + + return ( +
+
+
+

Audit Log

+

+ Workspace partner and payout history +

+
+
+ + +
+
+ + {!isEnterprise && ( +
+ + Audit logs are available on the{" "} + + Enterprise Plan + + +
+ )} +
+ ); +} diff --git a/apps/web/lib/api/audit-logs/get-audit-logs.ts b/apps/web/lib/api/audit-logs/get-audit-logs.ts new file mode 100644 index 00000000000..69b9b809bd4 --- /dev/null +++ b/apps/web/lib/api/audit-logs/get-audit-logs.ts @@ -0,0 +1,50 @@ +import { tb } from "@/lib/tinybird"; +import { z } from "zod"; + +export const auditLogFilterSchemaTB = z.object({ + workspaceId: z.string(), + programId: z.string(), + start: z.string(), + end: z.string(), +}); + +export const auditLogResponseSchemaTB = z.object({ + id: z.string(), + timestamp: z.string(), + action: z.string(), + actor_id: z.string(), + actor_type: z.string(), + actor_name: z.string(), + description: z.string(), + location: z.string(), + user_agent: z.string(), + targets: z.string(), + metadata: z.string(), +}); + +export const getAuditLogs = async ({ + workspaceId, + programId, + start, + end, +}: { + start: Date; + end: Date; + workspaceId: string; + programId: string; +}) => { + const pipe = tb.buildPipe({ + pipe: "get_audit_logs", + parameters: auditLogFilterSchemaTB, + data: auditLogResponseSchemaTB, + }); + + const events = await pipe({ + workspaceId, + programId, + start: start.toISOString().replace("T", " ").replace("Z", ""), + end: end.toISOString().replace("T", " ").replace("Z", ""), + }); + + return events.data; +}; diff --git a/apps/web/lib/plan-capabilities.ts b/apps/web/lib/plan-capabilities.ts index 94cca519755..fb6402087ff 100644 --- a/apps/web/lib/plan-capabilities.ts +++ b/apps/web/lib/plan-capabilities.ts @@ -10,5 +10,6 @@ export const getPlanCapabilities = ( canManageCustomers: !!plan && !["free", "pro"].includes(plan), canManageProgram: !!plan && !["free", "pro"].includes(plan), canTrackConversions: !!plan && !["free", "pro"].includes(plan), + canExportAuditLogs: !!plan && ["enterprise"].includes(plan), }; }; diff --git a/apps/web/ui/shared/simple-date-range-picker.tsx b/apps/web/ui/shared/simple-date-range-picker.tsx index bfda903cb57..f41790e0138 100644 --- a/apps/web/ui/shared/simple-date-range-picker.tsx +++ b/apps/web/ui/shared/simple-date-range-picker.tsx @@ -5,10 +5,12 @@ export default function SimpleDateRangePicker({ className, align = "center", defaultInterval = "30d", + disabled, }: { className?: string; align?: "start" | "center" | "end"; defaultInterval?: string; + disabled?: boolean; }) { const { queryParams, searchParamsObj } = useRouterStuff(); const { start, end, interval } = searchParamsObj as { @@ -69,6 +71,7 @@ export default function SimpleDateRangePicker({ shortcut, }; })} + disabled={disabled} /> ); } diff --git a/packages/tinybird/pipes/audit_logs.pipe b/packages/tinybird/pipes/audit_logs.pipe new file mode 100644 index 00000000000..c9af353ae62 --- /dev/null +++ b/packages/tinybird/pipes/audit_logs.pipe @@ -0,0 +1,26 @@ +NODE endpoint +SQL > + + % + SELECT + id, + timestamp, + action, + actor_id, + actor_type, + actor_name, + targets, + description, + location, + user_agent, + metadata + FROM dub_audit_logs + WHERE + true + {% if defined(start) and defined(end) %} + AND timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }} + AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }} + {% end %} + {% if defined(workspaceId) %} AND workspace_id = {{ String(workspaceId) }} {% end %} + {% if defined(programId) %} AND program_id = {{ String(programId) }} {% end %} + ORDER BY timestamp DESC From a613950051c892dc2a272a434b69086aa74388bd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 21:43:08 +0530 Subject: [PATCH 15/27] Update record-audit-log.ts --- .../lib/api/audit-logs/record-audit-log.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts index c08940c4c73..44545f28163 100644 --- a/apps/web/lib/api/audit-logs/record-audit-log.ts +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -1,4 +1,5 @@ import { tb } from "@/lib/tinybird"; +import { log } from "@dub/utils"; import { ipAddress } from "@vercel/functions"; import { headers } from "next/headers"; import { z } from "zod"; @@ -6,8 +7,6 @@ import { createId } from "../create-id"; import { getIP } from "../utils"; import { auditLogSchemaTB, recordAuditLogInputSchema } from "./schemas"; -const DEBUG_AUDIT_LOGS = false; - type AuditLogInput = z.infer; const transformAuditLogTB = (data: AuditLogInput) => { @@ -45,16 +44,21 @@ export const recordAuditLog = async (data: AuditLogInput | AuditLogInput[]) => { ? data.map(transformAuditLogTB) : [transformAuditLogTB(data)]; - if (DEBUG_AUDIT_LOGS) { - console.log(auditLogs); - } - - await recordAuditLogTB(auditLogs).catch((error) => { - console.error("Failed to record audit log", error, auditLogs); + try { + await recordAuditLogTB(auditLogs); + } catch (error) { + console.error( + "Failed to record audit log", + error, + JSON.stringify(auditLogs), + ); - // TODO: - // Send a Slack notification - }); + await log({ + message: "Failed to record audit log. See logs for more details.", + type: "errors", + mention: true, + }); + } }; const recordAuditLogTB = tb.buildIngestEndpoint({ From 0dbaf647e8a8f7fb626c1872615f7ef1245628e0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 21:57:27 +0530 Subject: [PATCH 16/27] commission.updated --- .../api/commissions/[commissionId]/route.ts | 263 ++++++++++-------- apps/web/lib/api/audit-logs/schemas.ts | 2 + 2 files changed, 144 insertions(+), 121 deletions(-) diff --git a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts index 01d677e0e80..8ed3e207f5c 100644 --- a/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts +++ b/apps/web/app/(ee)/api/commissions/[commissionId]/route.ts @@ -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"; @@ -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)); + }, +); diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 968fe9bda3e..082ae248de9 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -60,6 +60,7 @@ const actionSchema = z.enum([ "partner_application.rejected", // Partner enrollments + "partner.created", "partner.archived", "partner.banned", "partner.unbanned", @@ -75,6 +76,7 @@ const actionSchema = z.enum([ // Commissions & clawbacks "commission.created", + "commission.updated", "clawback.created", // TODO: From d591c7f418984cade8ebbefc807870ff20601996 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 22:23:30 +0530 Subject: [PATCH 17/27] Update audit-log.tsx --- .../[slug]/(ee)/settings/security/audit-log.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx index 4f3d97ba48f..6034e322c74 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx @@ -1,5 +1,6 @@ "use client"; +import { getPlanCapabilities } from "@/lib/plan-capabilities"; import useWorkspace from "@/lib/swr/use-workspace"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; import { Button, TooltipContent } from "@dub/ui"; @@ -17,7 +18,7 @@ export function AuditLog() { const { plan, slug, id: workspaceId } = useWorkspace(); const [dateRange, setDateRange] = useState(defaultDateRange); - const isEnterprise = plan === "enterprise"; + const { canExportAuditLogs } = getPlanCapabilities(plan); const exportAuditLogs = async () => { if (!workspaceId) { @@ -78,7 +79,8 @@ export function AuditLog() {
- {!isEnterprise && ( + {!canExportAuditLogs && (
Audit logs are available on the{" "} From 45428c24e62e78e98bee1539685fb888a61b4efe Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 22:25:11 +0530 Subject: [PATCH 18/27] Update schemas.ts --- apps/web/lib/api/audit-logs/schemas.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 082ae248de9..32c04c46dac 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -103,6 +103,7 @@ export const auditLogTarget = z.union([ termsUrl: true, holdingPeriodDays: true, minPayoutAmount: true, + autoApprovePartnersEnabledAt: true, }).optional(), }), @@ -137,16 +138,6 @@ export const auditLogTarget = z.union([ }), }), - z.object({ - type: z.literal("program"), - id: z.string(), - metadata: ProgramSchema.pick({ - name: true, - supportEmail: true, - autoApprovePartnersEnabledAt: true, - }).optional(), - }), - z.object({ type: z.union([z.literal("commission"), z.literal("clawback")]), id: z.string(), From 9169ffea3bf683615627cf5acf89beeca31f047f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 4 Jul 2025 22:25:39 +0530 Subject: [PATCH 19/27] Update schemas.ts --- apps/web/lib/api/audit-logs/schemas.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 32c04c46dac..8f54f6f7344 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -66,7 +66,6 @@ const actionSchema = z.enum([ "partner.unbanned", "partner.invited", "partner.approved", - "partner.invited", "partner.invite_deleted", "partner.invite_resent", From 5e91044f536b80994b6de9903694ab12bbb14d8a Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 4 Jul 2025 10:29:41 -0700 Subject: [PATCH 20/27] Update audit-log.tsx --- .../(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx index 6034e322c74..7b542e23c6a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx @@ -70,7 +70,7 @@ export function AuditLog() {
-

Audit Log

+

Audit Logs

Workspace partner and payout history

From 4f6d9b8a9ba46a693d64657aba91a3c0057b40a9 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 4 Jul 2025 10:30:47 -0700 Subject: [PATCH 21/27] small updates --- .../security/{audit-log.tsx => audit-logs.tsx} | 13 +++---------- .../[slug]/(ee)/settings/security/page-client.tsx | 4 ++-- 2 files changed, 5 insertions(+), 12 deletions(-) rename apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/{audit-log.tsx => audit-logs.tsx} (92%) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx similarity index 92% rename from apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx rename to apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx index 7b542e23c6a..82787eeda3d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-log.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx @@ -8,16 +8,9 @@ import { subMonths } from "date-fns"; import { useState } from "react"; import { toast } from "sonner"; -const defaultDateRange = { - from: subMonths(new Date(), 12), - to: new Date(), -} as const; - -export function AuditLog() { +export function AuditLogs() { const [loading, setLoading] = useState(false); const { plan, slug, id: workspaceId } = useWorkspace(); - const [dateRange, setDateRange] = useState(defaultDateRange); - const { canExportAuditLogs } = getPlanCapabilities(plan); const exportAuditLogs = async () => { @@ -35,8 +28,8 @@ export function AuditLog() { { method: "POST", body: JSON.stringify({ - start: dateRange.from.toISOString(), - end: dateRange.to.toISOString(), + start: subMonths(new Date(), 12).toISOString(), + end: new Date().toISOString(), }), headers: { "Content-Type": "application/json", diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx index 0c6a208c593..e0f652eb154 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { AuditLog } from "./audit-log"; +import { AuditLogs } from "./audit-logs"; import { SAML } from "./saml"; import { SCIM } from "./scim"; @@ -9,7 +9,7 @@ export default function WorkspaceSecurityClient() { <> - + ); } From ac0f681a0704999bb5e13a50d2a9df9fc3fbb30b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 4 Jul 2025 10:40:56 -0700 Subject: [PATCH 22/27] rename location -> ip_address + simplify waitUntil(recordAuditLog) --- .../lib/actions/partners/archive-partner.ts | 31 ++++++++--------- .../web/lib/actions/partners/create-reward.ts | 30 ++++++++-------- .../web/lib/actions/partners/delete-reward.ts | 30 ++++++++-------- .../lib/actions/partners/mark-payout-paid.ts | 30 ++++++++-------- .../lib/actions/partners/reject-partner.ts | 30 ++++++++-------- .../actions/partners/reject-partners-bulk.ts | 34 +++++++++---------- .../partners/update-auto-approve-partners.ts | 24 ++++++------- .../web/lib/actions/partners/update-reward.ts | 30 ++++++++-------- apps/web/lib/api/audit-logs/get-audit-logs.ts | 2 +- .../lib/api/audit-logs/record-audit-log.ts | 8 ++--- apps/web/lib/api/audit-logs/schemas.ts | 9 ++--- .../datasources/dub_audit_logs.datasource | 2 +- 12 files changed, 120 insertions(+), 140 deletions(-) diff --git a/apps/web/lib/actions/partners/archive-partner.ts b/apps/web/lib/actions/partners/archive-partner.ts index 10612f5bbbd..3b63de46c57 100644 --- a/apps/web/lib/actions/partners/archive-partner.ts +++ b/apps/web/lib/actions/partners/archive-partner.ts @@ -39,22 +39,19 @@ export const archivePartnerAction = authActionClient }); waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: - status === "archived" ? "partner.archived" : "partner.approved", - description: `Partner ${partnerId} ${status}`, - actor: user, - targets: [ - { - type: "partner", - id: partnerId, - metadata: partner, - }, - ], - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: status === "archived" ? "partner.archived" : "partner.approved", + description: `Partner ${partnerId} ${status}`, + actor: user, + targets: [ + { + type: "partner", + id: partnerId, + metadata: partner, + }, + ], + }), ); }); diff --git a/apps/web/lib/actions/partners/create-reward.ts b/apps/web/lib/actions/partners/create-reward.ts index 5f1d249ff01..5e792963be6 100644 --- a/apps/web/lib/actions/partners/create-reward.ts +++ b/apps/web/lib/actions/partners/create-reward.ts @@ -112,21 +112,19 @@ export const createRewardAction = authActionClient }); waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "reward.created", - description: `Reward ${reward.id} created`, - actor: user, - targets: [ - { - type: "reward", - id: reward.id, - metadata: reward, - }, - ], - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "reward.created", + description: `Reward ${reward.id} created`, + actor: user, + targets: [ + { + type: "reward", + id: reward.id, + metadata: reward, + }, + ], + }), ); }); diff --git a/apps/web/lib/actions/partners/delete-reward.ts b/apps/web/lib/actions/partners/delete-reward.ts index 86c15747aeb..5bb64679571 100644 --- a/apps/web/lib/actions/partners/delete-reward.ts +++ b/apps/web/lib/actions/partners/delete-reward.ts @@ -64,21 +64,19 @@ export const deleteRewardAction = authActionClient }); waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "reward.deleted", - description: `Reward ${rewardId} deleted`, - actor: user, - targets: [ - { - type: "reward", - id: rewardId, - metadata: reward, - }, - ], - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "reward.deleted", + description: `Reward ${rewardId} deleted`, + actor: user, + targets: [ + { + type: "reward", + id: rewardId, + metadata: reward, + }, + ], + }), ); }); diff --git a/apps/web/lib/actions/partners/mark-payout-paid.ts b/apps/web/lib/actions/partners/mark-payout-paid.ts index 6bd483f5f40..407fb367d47 100644 --- a/apps/web/lib/actions/partners/mark-payout-paid.ts +++ b/apps/web/lib/actions/partners/mark-payout-paid.ts @@ -48,21 +48,19 @@ export const markPayoutPaidAction = authActionClient ]); waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "payout.marked_paid", - description: `Payout ${payout.id} marked as paid`, - actor: user, - targets: [ - { - type: "payout", - id: payout.id, - metadata: payout, - }, - ], - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "payout.marked_paid", + description: `Payout ${payout.id} marked as paid`, + actor: user, + targets: [ + { + type: "payout", + id: payout.id, + metadata: payout, + }, + ], + }), ); }); diff --git a/apps/web/lib/actions/partners/reject-partner.ts b/apps/web/lib/actions/partners/reject-partner.ts index e6b7fe0623c..ce086226077 100644 --- a/apps/web/lib/actions/partners/reject-partner.ts +++ b/apps/web/lib/actions/partners/reject-partner.ts @@ -42,21 +42,19 @@ export const rejectPartnerAction = authActionClient }); waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "partner_application.rejected", - description: `Partner application rejected for ${partnerId}`, - actor: user, - targets: [ - { - type: "partner", - id: partnerId, - metadata: programEnrollment.partner, - }, - ], - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "partner_application.rejected", + description: `Partner application rejected for ${partnerId}`, + actor: user, + targets: [ + { + type: "partner", + id: partnerId, + metadata: programEnrollment.partner, + }, + ], + }), ); }); diff --git a/apps/web/lib/actions/partners/reject-partners-bulk.ts b/apps/web/lib/actions/partners/reject-partners-bulk.ts index d38adb10e71..f6e5b3e9f28 100644 --- a/apps/web/lib/actions/partners/reject-partners-bulk.ts +++ b/apps/web/lib/actions/partners/reject-partners-bulk.ts @@ -47,23 +47,21 @@ export const rejectPartnersBulkAction = authActionClient }); waitUntil( - (async () => { - await recordAuditLog( - programEnrollments.map(({ partner }) => ({ - workspaceId: workspace.id, - programId, - action: "partner_application.rejected", - description: `Partner application rejected for ${partner.id}`, - actor: user, - targets: [ - { - type: "partner", - id: partner.id, - metadata: partner, - }, - ], - })), - ); - })(), + recordAuditLog( + programEnrollments.map(({ partner }) => ({ + workspaceId: workspace.id, + programId, + action: "partner_application.rejected", + description: `Partner application rejected for ${partner.id}`, + actor: user, + targets: [ + { + type: "partner", + id: partner.id, + metadata: partner, + }, + ], + })), + ), ); }); diff --git a/apps/web/lib/actions/partners/update-auto-approve-partners.ts b/apps/web/lib/actions/partners/update-auto-approve-partners.ts index f12403f36cf..e93b845cb0b 100644 --- a/apps/web/lib/actions/partners/update-auto-approve-partners.ts +++ b/apps/web/lib/actions/partners/update-auto-approve-partners.ts @@ -30,19 +30,17 @@ export const updateAutoApprovePartnersAction = authActionClient }); waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: autoApprovePartners - ? "auto_approve_partner.enabled" - : "auto_approve_partner.disabled", - description: autoApprovePartners - ? "Auto approve partners enabled" - : "Auto approve partners disabled", - actor: user, - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: autoApprovePartners + ? "auto_approve_partner.enabled" + : "auto_approve_partner.disabled", + description: autoApprovePartners + ? "Auto approve partners enabled" + : "Auto approve partners disabled", + actor: user, + }), ); return program; diff --git a/apps/web/lib/actions/partners/update-reward.ts b/apps/web/lib/actions/partners/update-reward.ts index f55e26895e3..3abf9436193 100644 --- a/apps/web/lib/actions/partners/update-reward.ts +++ b/apps/web/lib/actions/partners/update-reward.ts @@ -83,22 +83,20 @@ export const updateRewardAction = authActionClient } waitUntil( - (async () => { - await recordAuditLog({ - workspaceId: workspace.id, - programId, - action: "reward.updated", - description: `Reward ${rewardId} updated`, - actor: user, - targets: [ - { - type: "reward", - id: rewardId, - metadata: updatedReward, - }, - ], - }); - })(), + recordAuditLog({ + workspaceId: workspace.id, + programId, + action: "reward.updated", + description: `Reward ${rewardId} updated`, + actor: user, + targets: [ + { + type: "reward", + id: rewardId, + metadata: updatedReward, + }, + ], + }), ); }); diff --git a/apps/web/lib/api/audit-logs/get-audit-logs.ts b/apps/web/lib/api/audit-logs/get-audit-logs.ts index 69b9b809bd4..e1f2ee3af87 100644 --- a/apps/web/lib/api/audit-logs/get-audit-logs.ts +++ b/apps/web/lib/api/audit-logs/get-audit-logs.ts @@ -16,7 +16,7 @@ export const auditLogResponseSchemaTB = z.object({ actor_type: z.string(), actor_name: z.string(), description: z.string(), - location: z.string(), + ip_address: z.string(), user_agent: z.string(), targets: z.string(), metadata: z.string(), diff --git a/apps/web/lib/api/audit-logs/record-audit-log.ts b/apps/web/lib/api/audit-logs/record-audit-log.ts index 44545f28163..73624d1a265 100644 --- a/apps/web/lib/api/audit-logs/record-audit-log.ts +++ b/apps/web/lib/api/audit-logs/record-audit-log.ts @@ -1,6 +1,6 @@ import { tb } from "@/lib/tinybird"; import { log } from "@dub/utils"; -import { ipAddress } from "@vercel/functions"; +import { ipAddress as getIPAddress } from "@vercel/functions"; import { headers } from "next/headers"; import { z } from "zod"; import { createId } from "../create-id"; @@ -11,12 +11,12 @@ type AuditLogInput = z.infer; const transformAuditLogTB = (data: AuditLogInput) => { const headersList = headers(); - const location = data.req ? ipAddress(data.req) : getIP(); + const ipAddress = data.req ? getIPAddress(data.req) : getIP(); const userAgent = headersList.get("user-agent"); const auditLogInput = recordAuditLogInputSchema.parse({ ...data, - location, + ipAddress, userAgent, }); @@ -34,7 +34,7 @@ const transformAuditLogTB = (data: AuditLogInput) => { metadata: auditLogInput.metadata ? JSON.stringify(auditLogInput.metadata) : "", - location: location || "", + ip_address: ipAddress || "", user_agent: userAgent || "", }; }; diff --git a/apps/web/lib/api/audit-logs/schemas.ts b/apps/web/lib/api/audit-logs/schemas.ts index 8f54f6f7344..68ac65adde2 100644 --- a/apps/web/lib/api/audit-logs/schemas.ts +++ b/apps/web/lib/api/audit-logs/schemas.ts @@ -18,7 +18,7 @@ export const auditLogSchemaTB = z.object({ actor_name: z.string(), description: z.string(), targets: z.string().nullable(), - location: z.string().nullable(), + ip_address: z.string().nullable(), user_agent: z.string().nullable(), metadata: z.string().nullable(), }); @@ -35,7 +35,7 @@ export const auditLogSchema = z.object({ actorName: z.string(), description: z.string(), targets: z.array(z.record(z.string(), z.any())).nullable(), - location: z.string().nullable(), + ipAddress: z.string().nullable(), userAgent: z.string().nullable(), metadata: z.record(z.string(), z.any()).nullable(), }); @@ -77,9 +77,6 @@ const actionSchema = z.enum([ "commission.created", "commission.updated", "clawback.created", - - // TODO: - // Finalize the action names "commission.canceled", "commission.marked_fraud", "commission.marked_duplicate", @@ -167,7 +164,7 @@ export const recordAuditLogInputSchema = z.object({ type: z.string().nullish(), }), description: z.string().nullish(), - location: z.string().nullish(), + ipAddress: z.string().nullish(), userAgent: z.string().nullish(), targets: z.array(auditLogTarget).nullish(), metadata: z.record(z.string(), z.any()).nullish(), diff --git a/packages/tinybird/datasources/dub_audit_logs.datasource b/packages/tinybird/datasources/dub_audit_logs.datasource index 2b32c46ecf0..8612f95dd77 100644 --- a/packages/tinybird/datasources/dub_audit_logs.datasource +++ b/packages/tinybird/datasources/dub_audit_logs.datasource @@ -9,7 +9,7 @@ SCHEMA > `actor_name` String `json:$.actor_name`, `targets` String `json:$.targets`, `description` String `json:$.description`, - `location` String `json:$.location`, + `ip_address` String `json:$.ip_address`, `user_agent` String `json:$.user_agent`, `metadata` String `json:$.metadata` From cb76410f5f6baa8eb9f0f867888d51a704edc191 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 4 Jul 2025 10:47:42 -0700 Subject: [PATCH 23/27] rename pipe --- .../tinybird/pipes/{audit_logs.pipe => get_audit_logs.pipe} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/tinybird/pipes/{audit_logs.pipe => get_audit_logs.pipe} (97%) diff --git a/packages/tinybird/pipes/audit_logs.pipe b/packages/tinybird/pipes/get_audit_logs.pipe similarity index 97% rename from packages/tinybird/pipes/audit_logs.pipe rename to packages/tinybird/pipes/get_audit_logs.pipe index c9af353ae62..916a61ab872 100644 --- a/packages/tinybird/pipes/audit_logs.pipe +++ b/packages/tinybird/pipes/get_audit_logs.pipe @@ -11,7 +11,7 @@ SQL > actor_name, targets, description, - location, + ip_address, user_agent, metadata FROM dub_audit_logs From 6e9240672f054a8ab08ac7cc02400f19592b2949 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 4 Jul 2025 10:50:43 -0700 Subject: [PATCH 24/27] Update audit-logs.tsx --- .../[slug]/(ee)/settings/security/audit-logs.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx index 82787eeda3d..70a8f2eaf2e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/audit-logs.tsx @@ -5,12 +5,18 @@ import useWorkspace from "@/lib/swr/use-workspace"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; import { Button, TooltipContent } from "@dub/ui"; import { subMonths } from "date-fns"; +import { useSearchParams } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; export function AuditLogs() { const [loading, setLoading] = useState(false); const { plan, slug, id: workspaceId } = useWorkspace(); + const searchParams = useSearchParams(); + const start = + searchParams.get("start") || subMonths(new Date(), 12).toISOString(); + const end = searchParams.get("end") || new Date().toISOString(); + const { canExportAuditLogs } = getPlanCapabilities(plan); const exportAuditLogs = async () => { @@ -28,8 +34,8 @@ export function AuditLogs() { { method: "POST", body: JSON.stringify({ - start: subMonths(new Date(), 12).toISOString(), - end: new Date().toISOString(), + start, + end, }), headers: { "Content-Type": "application/json", @@ -73,7 +79,7 @@ export function AuditLogs() { className="w-full sm:max-w-xs" align="start" disabled={!canExportAuditLogs} - defaultInterval="month" + defaultInterval="1y" />