Skip to content

Commit 526532e

Browse files
committed
feat: start implementation
1 parent bf44dab commit 526532e

3 files changed

Lines changed: 154 additions & 0 deletions

File tree

backend/src/dal/sqliteCampaignRepository.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,54 @@ export function createSqliteCampaignRepository({
175175
return info.changes > 0;
176176
}
177177

178+
function clone(id, overrides = {}) {
179+
const source = getById(id);
180+
if (!source) {
181+
return undefined;
182+
}
183+
184+
const clonedName = overrides.name !== undefined ? overrides.name : `Copy of ${source.name}`;
185+
const clonedSlug = overrides.slug !== undefined ? overrides.slug : generateSlug(clonedName);
186+
const clonedDescription = overrides.description !== undefined ? overrides.description : source.description;
187+
const clonedRewardPerAction = overrides.rewardPerAction !== undefined ? overrides.rewardPerAction : source.rewardPerAction;
188+
const clonedCategory = overrides.category !== undefined ? overrides.category : (source.category || null);
189+
const clonedImageUrl = overrides.imageUrl !== undefined ? overrides.imageUrl : (source.imageUrl || null);
190+
const clonedTags = overrides.tags !== undefined ? overrides.tags : (source.tags || null);
191+
192+
const createdAt = new Date().toISOString();
193+
const info = db
194+
.prepare(
195+
'INSERT INTO campaigns (name, slug, description, active, reward_per_action, start_date, end_date, featured, hidden, hidden_reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
196+
)
197+
.run(
198+
clonedName,
199+
clonedSlug,
200+
clonedDescription,
201+
0, // status: draft (active = false)
202+
clonedRewardPerAction,
203+
null, // startDate not copied
204+
null, // endDate not copied
205+
0, // featured = false
206+
source.hidden ? 1 : 0,
207+
source.hiddenReason,
208+
createdAt,
209+
createdAt
210+
);
211+
212+
const newCampaign = getById(info.lastInsertRowid);
213+
if (newCampaign) {
214+
newCampaign.clonedFrom = source.id;
215+
}
216+
return newCampaign;
217+
}
218+
178219
return {
179220
list,
180221
getById,
181222
getBySlug,
182223
create,
183224
update,
184225
delete: remove,
226+
clone,
185227
};
186228
}

backend/src/dal/sqliteCampaignRepository.test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ test('computeCampaignStatus prioritises ended over upcoming', () => {
170170

171171
test('campaign repository attaches computed status to returned campaigns', async () => {
172172
const repository = await setupTestRepository();
173+
const future = new Date(Date.now() + 86_400_000).toISOString();
174+
const past = new Date(Date.now() - 86_400_000).toISOString();
173175

174176
const upcoming = repository.create({
175177
name: 'Future Campaign',
@@ -290,3 +292,72 @@ test('list includeHidden option exposes hidden campaigns', async () => {
290292
assert.equal(repository.list().length, 1);
291293
assert.equal(repository.list({ includeHidden: true }).length, 2);
292294
});
295+
296+
// #458 — clone campaign functionality
297+
test('clone creates a new campaign with copied metadata', async () => {
298+
const repository = await setupTestRepository();
299+
300+
const original = repository.create({
301+
name: 'Weekly Challenge',
302+
description: 'Complete tasks to earn rewards',
303+
rewardPerAction: 50,
304+
active: true,
305+
featured: true,
306+
});
307+
308+
const cloned = repository.clone(original.id);
309+
310+
assert.ok(cloned);
311+
assert.notEqual(cloned.id, original.id);
312+
assert.equal(cloned.name, `Copy of ${original.name}`);
313+
assert.equal(cloned.description, original.description);
314+
assert.equal(cloned.rewardPerAction, original.rewardPerAction);
315+
assert.equal(cloned.active, false); // cloned campaigns are draft
316+
assert.equal(cloned.startDate, null); // dates not copied
317+
assert.equal(cloned.endDate, null);
318+
assert.equal(cloned.clonedFrom, original.id);
319+
});
320+
321+
test('clone with overrides applies custom values', async () => {
322+
const repository = await setupTestRepository();
323+
324+
const original = repository.create({
325+
name: 'Original Campaign',
326+
description: 'Original description',
327+
rewardPerAction: 100,
328+
});
329+
330+
const cloned = repository.clone(original.id, {
331+
name: 'Custom Name',
332+
description: 'Custom description',
333+
});
334+
335+
assert.ok(cloned);
336+
assert.equal(cloned.name, 'Custom Name');
337+
assert.equal(cloned.description, 'Custom description');
338+
assert.equal(cloned.rewardPerAction, original.rewardPerAction); // not overridden
339+
assert.equal(cloned.clonedFrom, original.id);
340+
});
341+
342+
test('clone returns undefined for non-existent campaign', async () => {
343+
const repository = await setupTestRepository();
344+
345+
const cloned = repository.clone('99999');
346+
assert.equal(cloned, undefined);
347+
});
348+
349+
test('clone generates unique slug for cloned campaign', async () => {
350+
const repository = await setupTestRepository();
351+
352+
const original = repository.create({
353+
name: 'Test Campaign',
354+
slug: 'test-campaign',
355+
rewardPerAction: 10,
356+
});
357+
358+
const cloned = repository.clone(original.id);
359+
360+
assert.ok(cloned);
361+
assert.notEqual(cloned.slug, original.slug);
362+
assert.equal(cloned.slug, 'copy-of-test-campaign');
363+
});

backend/src/index.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export async function createApp(options = {}) {
394394
campaignById: `GET ${API_V1_PREFIX}/campaigns/:id`,
395395
campaignBySlug: `GET ${API_V1_PREFIX}/campaigns/by-slug/:slug`,
396396
createCampaign: `POST ${API_V1_PREFIX}/campaigns`,
397+
cloneCampaign: `POST ${API_V1_PREFIX}/campaigns/:id/clone`,
397398
updateCampaign: `PUT ${API_V1_PREFIX}/campaigns/:id`,
398399
deleteCampaign: `DELETE ${API_V1_PREFIX}/campaigns/:id`,
399400
auditLogs: `GET ${API_V1_PREFIX}/audit-logs`,
@@ -597,6 +598,45 @@ export async function createApp(options = {}) {
597598
return res.status(204).end();
598599
}
599600

601+
/** @param {import('express').Request} req @param {import('express').Response} res */
602+
function cloneCampaign(req, res) {
603+
const sourceId = req.params.id;
604+
const source = campaignRepository.getById(sourceId);
605+
606+
if (!source) {
607+
return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' });
608+
}
609+
610+
const overrides = req.body?.overrides || {};
611+
612+
try {
613+
const clonedCampaign = campaignRepository.clone(sourceId, overrides);
614+
615+
if (!clonedCampaign) {
616+
return res.status(500).json({ error: 'Failed to clone campaign', code: 'CLONE_FAILED' });
617+
}
618+
619+
recordAuditEntry(req, {
620+
action: 'clone',
621+
entity: 'campaign',
622+
entityId: clonedCampaign.id,
623+
diff: { cloned_from: sourceId, overrides },
624+
});
625+
626+
shortCache.clear();
627+
return res.status(201).json(clonedCampaign);
628+
} catch (error) {
629+
if (/** @type {any} */ (error).message?.includes('UNIQUE constraint failed')) {
630+
return res.status(409).json({
631+
error: 'Slug already exists',
632+
code: 'SLUG_CONFLICT',
633+
details: ['A campaign with this slug already exists'],
634+
});
635+
}
636+
throw error;
637+
}
638+
}
639+
600640
/** @param {import('express').Request} req @param {import('express').Response} res */
601641
function listAuditLogs(req, res) {
602642
const entity = typeof req.query.entity === 'string' ? req.query.entity.trim() : '';
@@ -651,6 +691,7 @@ export async function createApp(options = {}) {
651691
app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState);
652692
app.post(`${prefix}/indexer/cursor`, rateLimiter, requireApiKey, setIndexerCursorState);
653693
app.post(`${prefix}/campaigns`, rateLimiter, requireApiKey, createCampaign);
694+
app.post(`${prefix}/campaigns/:id/clone`, rateLimiter, requireApiKey, cloneCampaign);
654695
app.put(`${prefix}/campaigns/:id`, rateLimiter, requireApiKey, updateCampaign);
655696
app.delete(`${prefix}/campaigns/:id`, rateLimiter, requireApiKey, deleteCampaign);
656697
}

0 commit comments

Comments
 (0)