Skip to content

Commit d50c01a

Browse files
committed
Provide context to resolve fraud comments
When fraud reports are resolved without banning the partner, and optional notes are provided, they're added as a comment to the partner comments. But without context, it's not clear what the note is pertaining to. This addition provides context for any fraud notes left, but only for ones added after this feature is added. Because old comments are stored `text only` it'll be difficult and not accurate to back fill previous resolution comments to show like this.
1 parent 14565e3 commit d50c01a

File tree

9 files changed

+454
-35
lines changed

9 files changed

+454
-35
lines changed

apps/web/lib/actions/fraud/bulk-resolve-fraud-groups.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const bulkResolveFraudGroupsAction = authActionClient
3838
id: true,
3939
programId: true,
4040
partnerId: true,
41+
type: true,
4142
},
4243
});
4344

@@ -66,18 +67,19 @@ export const bulkResolveFraudGroupsAction = authActionClient
6667
...(resolutionReason && { resolutionReason }),
6768
});
6869

69-
// Add the resolution reason as a comment to each unique partner
70+
// Add the resolution reason as a comment for each resolved fraud group
7071
if (resolutionReason && count > 0) {
71-
const uniquePartnerIds = Array.from(
72-
new Set(fraudGroups.map((group) => group.partnerId)),
73-
);
74-
7572
await prisma.partnerComment.createMany({
76-
data: uniquePartnerIds.map((partnerId) => ({
73+
data: fraudGroups.map((group) => ({
7774
programId,
78-
partnerId,
75+
partnerId: group.partnerId,
7976
userId: user.id,
8077
text: resolutionReason,
78+
metadata: {
79+
source: "fraudResolution",
80+
groupId: group.id,
81+
type: group.type,
82+
},
8183
})),
8284
});
8385
}

apps/web/lib/actions/fraud/resolve-fraud-group.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const resolveFraudGroupAction = authActionClient
3535
id: true,
3636
programId: true,
3737
partnerId: true,
38+
type: true,
3839
},
3940
});
4041

@@ -60,6 +61,11 @@ export const resolveFraudGroupAction = authActionClient
6061
partnerId: fraudGroup.partnerId,
6162
userId: user.id,
6263
text: resolutionReason,
64+
metadata: {
65+
source: "fraudResolution",
66+
groupId: fraudGroup.id,
67+
type: fraudGroup.type,
68+
},
6369
},
6470
});
6571
}

apps/web/lib/actions/partners/update-partner-comment.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"use server";
22

3+
import {
4+
parseFraudResolutionComment,
5+
} from "@/lib/fraud-resolution-comment";
36
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
47
import { prisma } from "@dub/prisma";
58
import {
@@ -23,6 +26,23 @@ export const updatePartnerCommentAction = authActionClient
2326

2427
const programId = getDefaultProgramIdOrThrow(workspace);
2528

29+
// Preserve fraud resolution metadata from the existing comment
30+
const existing = await prisma.partnerComment.findUniqueOrThrow({
31+
where: { id, programId, userId: user.id },
32+
select: { text: true, metadata: true },
33+
});
34+
35+
const parsedLegacy = parseFraudResolutionComment(existing.text);
36+
const metadata =
37+
existing.metadata ??
38+
(parsedLegacy.metadata
39+
? {
40+
source: "fraudResolution",
41+
groupId: parsedLegacy.metadata.groupId,
42+
type: parsedLegacy.metadata.type,
43+
}
44+
: null);
45+
2646
const comment = await prisma.partnerComment.update({
2747
where: {
2848
id,
@@ -31,6 +51,7 @@ export const updatePartnerCommentAction = authActionClient
3151
},
3252
data: {
3353
text,
54+
metadata,
3455
},
3556
include: {
3657
user: true,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { FraudRuleType } from "@dub/prisma/client";
2+
3+
const FRAUD_RESOLUTION_COMMENT_PREFIX = "[[dub-fraud-resolution:";
4+
const FRAUD_RESOLUTION_COMMENT_SUFFIX = "]]";
5+
6+
export interface FraudResolutionCommentMetadata {
7+
groupId: string;
8+
type: FraudRuleType;
9+
}
10+
11+
export function serializeFraudResolutionComment({
12+
metadata,
13+
note,
14+
}: {
15+
metadata: FraudResolutionCommentMetadata;
16+
note: string;
17+
}) {
18+
return `${FRAUD_RESOLUTION_COMMENT_PREFIX}${JSON.stringify(metadata)}${FRAUD_RESOLUTION_COMMENT_SUFFIX}\n${note}`;
19+
}
20+
21+
export function parseFraudResolutionComment(text: string): {
22+
metadata: FraudResolutionCommentMetadata | null;
23+
note: string;
24+
} {
25+
if (!text.startsWith(FRAUD_RESOLUTION_COMMENT_PREFIX)) {
26+
return {
27+
metadata: null,
28+
note: text,
29+
};
30+
}
31+
32+
const suffixIndex = text.indexOf(FRAUD_RESOLUTION_COMMENT_SUFFIX);
33+
34+
if (suffixIndex === -1) {
35+
return {
36+
metadata: null,
37+
note: text,
38+
};
39+
}
40+
41+
const metadataString = text.slice(
42+
FRAUD_RESOLUTION_COMMENT_PREFIX.length,
43+
suffixIndex,
44+
);
45+
46+
try {
47+
const metadata = JSON.parse(metadataString) as FraudResolutionCommentMetadata;
48+
49+
if (
50+
!metadata ||
51+
typeof metadata.groupId !== "string" ||
52+
typeof metadata.type !== "string"
53+
) {
54+
return {
55+
metadata: null,
56+
note: text,
57+
};
58+
}
59+
60+
const contentStart = suffixIndex + FRAUD_RESOLUTION_COMMENT_SUFFIX.length;
61+
const hasNewLine = text.charAt(contentStart) === "\n";
62+
63+
return {
64+
metadata,
65+
note: text.slice(hasNewLine ? contentStart + 1 : contentStart),
66+
};
67+
} catch {
68+
return {
69+
metadata: null,
70+
note: text,
71+
};
72+
}
73+
}

apps/web/lib/zod/schemas/programs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import {
1010
Category,
1111
EventType,
12+
FraudRuleType,
1213
PartnerBannedReason,
1314
ProgramEnrollmentStatus,
1415
ProgramPayoutMode,
@@ -188,6 +189,12 @@ export const createProgramApplicationSchema = z.object({
188189
formData: programApplicationFormDataWithValuesSchema,
189190
});
190191

192+
export const fraudResolutionCommentMetadataSchema = z.object({
193+
source: z.literal("fraudResolution"),
194+
groupId: z.string(),
195+
type: z.enum(FraudRuleType),
196+
});
197+
191198
export const PartnerCommentSchema = z.object({
192199
id: z.string(),
193200
programId: z.string(),
@@ -199,6 +206,7 @@ export const PartnerCommentSchema = z.object({
199206
image: true,
200207
}),
201208
text: z.string(),
209+
metadata: z.unknown().nullable().default(null),
202210
createdAt: z.date(),
203211
updatedAt: z.date(),
204212
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { parseFraudResolutionComment } from "@/lib/fraud-resolution-comment";
2+
import { prisma } from "@dub/prisma";
3+
import "dotenv-flow/config";
4+
5+
async function main() {
6+
const dryRun = process.argv.includes("--dry-run");
7+
8+
const comments = await prisma.partnerComment.findMany({
9+
where: {
10+
metadata: null,
11+
},
12+
select: {
13+
id: true,
14+
programId: true,
15+
partnerId: true,
16+
userId: true,
17+
text: true,
18+
createdAt: true,
19+
},
20+
orderBy: {
21+
createdAt: "asc",
22+
},
23+
});
24+
25+
let upgradedFromLegacyEncoded = 0;
26+
let upgradedFromResolvedGroupMatch = 0;
27+
let ambiguous = 0;
28+
let unmatched = 0;
29+
30+
for (const comment of comments) {
31+
const legacy = parseFraudResolutionComment(comment.text);
32+
33+
if (legacy.metadata) {
34+
if (!dryRun) {
35+
await prisma.partnerComment.update({
36+
where: { id: comment.id },
37+
data: {
38+
text: legacy.note,
39+
metadata: {
40+
source: "fraudResolution",
41+
groupId: legacy.metadata.groupId,
42+
type: legacy.metadata.type,
43+
},
44+
},
45+
});
46+
}
47+
48+
upgradedFromLegacyEncoded++;
49+
continue;
50+
}
51+
52+
const matchingGroups = await prisma.fraudEventGroup.findMany({
53+
where: {
54+
programId: comment.programId,
55+
partnerId: comment.partnerId,
56+
userId: comment.userId,
57+
status: "resolved",
58+
resolutionReason: comment.text,
59+
},
60+
select: {
61+
id: true,
62+
type: true,
63+
resolvedAt: true,
64+
},
65+
});
66+
67+
if (matchingGroups.length === 0) {
68+
unmatched++;
69+
continue;
70+
}
71+
72+
if (matchingGroups.length > 1) {
73+
ambiguous++;
74+
continue;
75+
}
76+
77+
const [group] = matchingGroups;
78+
79+
if (!dryRun) {
80+
await prisma.partnerComment.update({
81+
where: { id: comment.id },
82+
data: {
83+
metadata: {
84+
source: "fraudResolution",
85+
groupId: group.id,
86+
type: group.type,
87+
},
88+
},
89+
});
90+
}
91+
92+
upgradedFromResolvedGroupMatch++;
93+
}
94+
95+
console.log(
96+
[
97+
`Scanned ${comments.length} comments with null metadata`,
98+
`Upgraded from legacy encoded comments: ${upgradedFromLegacyEncoded}`,
99+
`Upgraded from fraud group match: ${upgradedFromResolvedGroupMatch}`,
100+
`Ambiguous matches skipped: ${ambiguous}`,
101+
`No matches found: ${unmatched}`,
102+
`Mode: ${dryRun ? "dry-run" : "write"}`,
103+
].join("\n"),
104+
);
105+
}
106+
107+
main();

0 commit comments

Comments
 (0)