Skip to content

Commit e761485

Browse files
committed
feat: near-duplicate campaign title detection with X-Allow-Duplicate override
1 parent 10f827c commit e761485

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

backend/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
CampaignStatus,
2727
claimCampaign,
2828
createCampaign,
29+
findNearDuplicate,
2930
getCampaign,
3031
getCampaignWithProgress,
3132
getContributorSummary,
@@ -492,6 +493,19 @@ app.post(
492493
throw new AppError('deadline must be in the future.', 400, 'INVALID_DEADLINE');
493494
}
494495

496+
const allowDuplicate = req.headers['x-allow-duplicate'] === 'true';
497+
498+
if (!allowDuplicate) {
499+
const duplicate = findNearDuplicate(body.title, body.creator);
500+
if (duplicate) {
501+
throw new AppError(
502+
`Near-duplicate campaign found: "${duplicate.title}" (Levenshtein distance: ${duplicate.distance}). Use X-Allow-Duplicate: true header to override.`,
503+
409,
504+
'NEAR_DUPLICATE_CAMPAIGN',
505+
);
506+
}
507+
}
508+
495509
const campaignInput = {
496510
...body,
497511
maxPerContributor:

backend/src/services/campaignStore.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ let getPledges: CampaignStoreModule['getPledges'];
2222
let getDb: DbModule['getDb'];
2323
let getCampaignHistory: EventHistoryModule['getCampaignHistory'];
2424
let addPledge: CampaignStoreModule['addPledge'];
25+
let findNearDuplicate: CampaignStoreModule['findNearDuplicate'];
2526

2627
const CREATOR = `G${'A'.repeat(55)}`;
2728
const CONTRIBUTOR = `G${'B'.repeat(55)}`;
@@ -41,6 +42,7 @@ beforeAll(async () => {
4142
getCampaign,
4243
getPledges,
4344
addPledge,
45+
findNearDuplicate,
4446
} = await import('./campaignStore'));
4547
({ getDb } = await import('./db'));
4648
({ getCampaignHistory } = await import('./eventHistory'));
@@ -164,6 +166,73 @@ describe('on-chain pledge reconciliation', () => {
164166
});
165167
});
166168

169+
describe('near-duplicate detection', () => {
170+
const FUTURE_DEADLINE = Math.floor(Date.now() / 1000) + 86400;
171+
172+
it('returns duplicate when same creator creates near-identical title', () => {
173+
createCampaign({
174+
creator: CREATOR,
175+
title: 'Help me build a rocket',
176+
description: 'desc',
177+
assetCode: 'USDC',
178+
targetAmount: 100,
179+
deadline: FUTURE_DEADLINE,
180+
});
181+
182+
const result = findNearDuplicate('Help me build a rocket!!', CREATOR);
183+
expect(result).toBeDefined();
184+
expect(result!.distance).toBeLessThan(5);
185+
});
186+
187+
it('allows same title from different creator', () => {
188+
createCampaign({
189+
creator: CREATOR,
190+
title: 'Fund my project',
191+
description: 'desc',
192+
assetCode: 'USDC',
193+
targetAmount: 100,
194+
deadline: FUTURE_DEADLINE,
195+
});
196+
197+
const otherCreator = `G${'D'.repeat(55)}`;
198+
const result = findNearDuplicate('Fund my project', otherCreator);
199+
expect(result).toBeUndefined();
200+
});
201+
202+
it('returns undefined when title is sufficiently different', () => {
203+
createCampaign({
204+
creator: CREATOR,
205+
title: 'Completely different topic',
206+
description: 'desc',
207+
assetCode: 'USDC',
208+
targetAmount: 100,
209+
deadline: FUTURE_DEADLINE,
210+
});
211+
212+
const result = findNearDuplicate('Totally unrelated campaign', CREATOR);
213+
expect(result).toBeUndefined();
214+
});
215+
216+
it('ignores campaigns older than 24 hours', () => {
217+
const db = getDb();
218+
const oldTimestamp = Math.floor(Date.now() / 1000) - 60 * 60 * 25;
219+
220+
const campaign = createCampaign({
221+
creator: CREATOR,
222+
title: 'Old campaign title',
223+
description: 'desc',
224+
assetCode: 'USDC',
225+
targetAmount: 100,
226+
deadline: FUTURE_DEADLINE,
227+
});
228+
229+
db.prepare(`UPDATE campaigns SET created_at = ? WHERE id = ?`).run(oldTimestamp, campaign.id);
230+
231+
const result = findNearDuplicate('Old campaign title', CREATOR);
232+
expect(result).toBeUndefined();
233+
});
234+
});
235+
167236
describe('campaign pledge pagination', () => {
168237
it('returns pledges in reverse chronological order with pagination metadata inputs', () => {
169238
const futureDeadline = Math.floor(Date.now() / 1000) + 86400;

backend/src/services/campaignStore.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,51 @@ function round(value: number): number {
133133
return Number(value.toFixed(2));
134134
}
135135

136+
function levenshtein(a: string, b: string): number {
137+
const an = a.length;
138+
const bn = b.length;
139+
if (an === 0) return bn;
140+
if (bn === 0) return an;
141+
const matrix: number[] = new Array(bn + 1);
142+
for (let i = 0; i <= bn; i++) matrix[i] = i;
143+
for (let i = 1; i <= an; i++) {
144+
let prev = i;
145+
for (let j = 1; j <= bn; j++) {
146+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
147+
const val = Math.min(
148+
prev + 1,
149+
matrix[j] + 1,
150+
matrix[j - 1] + cost,
151+
);
152+
matrix[j - 1] = prev;
153+
prev = val;
154+
}
155+
matrix[bn] = prev;
156+
}
157+
return matrix[bn];
158+
}
159+
160+
export function findNearDuplicate(
161+
title: string,
162+
creator: string,
163+
): { campaignId: string; title: string; distance: number } | undefined {
164+
const db = getDb();
165+
const twentyFourHoursAgo = nowInSeconds() - 60 * 60 * 24;
166+
const rows = db
167+
.prepare(
168+
`SELECT id, title FROM campaigns WHERE creator = ? AND created_at >= ? AND deleted_at IS NULL`,
169+
)
170+
.all(creator, twentyFourHoursAgo) as Array<{ id: string; title: string }>;
171+
172+
for (const row of rows) {
173+
const distance = levenshtein(title.trim().toLowerCase(), row.title.trim().toLowerCase());
174+
if (distance < 5) {
175+
return { campaignId: row.id, title: row.title, distance };
176+
}
177+
}
178+
return undefined;
179+
}
180+
136181
function rowToCampaign(row: CampaignRow): CampaignRecord {
137182
const acceptedTokens = JSON.parse(row.accepted_tokens_json);
138183
return {

0 commit comments

Comments
 (0)