Skip to content

Commit 85995ee

Browse files
authored
Merge pull request #122 from chrispoupart/feat/update-quest-resets
feat(quests): 💄 add admin reset for repeatable quests
2 parents 99cf99c + 753502b commit 85995ee

File tree

6 files changed

+307
-16
lines changed

6 files changed

+307
-16
lines changed

backend/src/controllers/questController.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,14 +510,19 @@ export class QuestController {
510510

511511
const updateData: any = {
512512
status: 'APPROVED',
513-
lastCompletedAt: new Date(),
514513
};
515514

516515
if (quest.isRepeatable) {
516+
// For repeatable quests, use the completion date (when user submitted)
517+
// instead of approval date for cooldown calculation
517518
updateData.status = 'COOLDOWN';
519+
updateData.lastCompletedAt = quest.completedAt || new Date();
518520
updateData.claimedBy = null;
519521
updateData.claimedAt = null;
520522
updateData.completedAt = null;
523+
} else {
524+
// For non-repeatable quests, set lastCompletedAt to completion date
525+
updateData.lastCompletedAt = quest.completedAt || new Date();
521526
}
522527

523528
const updatedQuest = await tx.quest.update({
@@ -587,6 +592,68 @@ export class QuestController {
587592
}
588593
}
589594

595+
/**
596+
* Reset a repeatable quest from cooldown to available (admin only)
597+
*/
598+
static async resetRepeatableQuest(req: Request, res: Response): Promise<void> {
599+
try {
600+
const questId = parseInt(req.params['id']);
601+
const adminId = (req as any).user?.userId;
602+
const adminRole = (req as any).user?.role as UserRole;
603+
604+
if (adminRole !== 'ADMIN') {
605+
res.status(403).json({ success: false, error: { message: 'Only admins can reset repeatable quests' } });
606+
return;
607+
}
608+
609+
const result = await prisma.$transaction(async (tx) => {
610+
const quest = await tx.quest.findUnique({ where: { id: questId } });
611+
if (!quest) {
612+
throw new Error('Quest not found');
613+
}
614+
615+
if (!quest.isRepeatable) {
616+
throw new Error('Only repeatable quests can be reset');
617+
}
618+
619+
if (quest.status !== 'COOLDOWN') {
620+
throw new Error('Quest is not in cooldown status');
621+
}
622+
623+
const updatedQuest = await tx.quest.update({
624+
where: { id: questId },
625+
data: {
626+
status: 'AVAILABLE',
627+
lastCompletedAt: null
628+
}
629+
});
630+
631+
return updatedQuest;
632+
});
633+
634+
res.json({ success: true, data: { quest: result } });
635+
} catch (error) {
636+
if (error instanceof Error) {
637+
const message = error.message;
638+
let statusCode = 500;
639+
if (message === 'Quest not found') {
640+
statusCode = 404;
641+
} else if (message === 'Only repeatable quests can be reset' || message === 'Quest is not in cooldown status') {
642+
statusCode = 400;
643+
}
644+
645+
if (statusCode >= 500) {
646+
console.error('Error resetting repeatable quest:', error);
647+
}
648+
649+
res.status(statusCode).json({ success: false, error: { message } });
650+
} else {
651+
console.error('Error resetting repeatable quest:', error);
652+
res.status(500).json({ success: false, error: { message: 'Internal server error' } });
653+
}
654+
}
655+
}
656+
590657
/**
591658
* Reject a completed quest (admin/editor only)
592659
*/

backend/src/routes/quests.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ router.put('/:id/claim', validateQuestId, QuestController.claimQuest);
5252
router.put('/:id/complete', validateQuestId, QuestController.completeQuest);
5353
router.put('/:id/approve', validateQuestId, QuestController.approveQuest);
5454
router.put('/:id/reject', validateQuestId, QuestController.rejectQuest);
55+
router.put('/:id/reset', validateQuestId, isAdmin, QuestController.resetRepeatableQuest);
5556
router.post('/:id/claim', validateQuestId, QuestController.claimQuest); // Keep POST for backward compatibility
5657
router.post('/:id/complete', validateQuestId, QuestController.completeQuest); // Keep POST for backward compatibility
5758

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Set environment before importing app
2+
process.env['NODE_ENV'] = 'test';
3+
4+
import request from 'supertest';
5+
import { app } from '../src/index';
6+
import { PrismaClient } from '@prisma/client';
7+
import { setupTestDatabase, clearTestData, createTestUser, createTestQuest, createTestToken, getTestPrisma, resetUserCounter } from './setup';
8+
9+
const prisma = new PrismaClient();
10+
11+
jest.setTimeout(30000);
12+
13+
describe('Repeatable Quest Functionality', () => {
14+
beforeAll(async () => {
15+
await setupTestDatabase();
16+
});
17+
18+
beforeEach(async () => {
19+
resetUserCounter(); // Reset counter FIRST to ensure unique emails
20+
await clearTestData();
21+
});
22+
23+
afterAll(async () => {
24+
await prisma.$disconnect();
25+
});
26+
27+
describe('Cooldown based on submission date', () => {
28+
it('should set cooldown based on completion date, not approval date', async () => {
29+
const admin = await createTestUser({ role: 'ADMIN', email: 'admin@test.com' });
30+
const questGiver = await createTestUser({ role: 'EDITOR', email: 'questgiver@test.com' });
31+
const player = await createTestUser({ role: 'PLAYER', email: 'player@test.com' });
32+
33+
// Create a repeatable quest
34+
const quest = await createTestQuest(questGiver.id, {
35+
bounty: 100,
36+
isRepeatable: true,
37+
cooldownDays: 7
38+
});
39+
40+
const playerToken = createTestToken(player.id, player.email, player.role);
41+
const adminToken = createTestToken(admin.id, admin.email, admin.role);
42+
43+
// Player claims the quest
44+
await request(app)
45+
.put(`/quests/${quest.id}/claim`)
46+
.set('Authorization', `Bearer ${playerToken}`);
47+
48+
// Player completes the quest (this sets completedAt)
49+
const completionTime = new Date('2024-01-01T10:00:00Z');
50+
await prisma.quest.update({
51+
where: { id: quest.id },
52+
data: {
53+
status: 'PENDING_APPROVAL',
54+
completedAt: completionTime
55+
}
56+
});
57+
58+
// Admin approves the quest
59+
const response = await request(app)
60+
.put(`/quests/${quest.id}/approve`)
61+
.set('Authorization', `Bearer ${adminToken}`);
62+
63+
expect(response.status).toBe(200);
64+
expect(response.body.data.quest.status).toBe('COOLDOWN');
65+
66+
// Check that lastCompletedAt is set to the completion date, not approval date
67+
const updatedQuest = await prisma.quest.findUnique({ where: { id: quest.id } });
68+
expect(updatedQuest?.lastCompletedAt).toEqual(completionTime);
69+
70+
// Calculate when cooldown should end (based on completion date)
71+
const expectedCooldownEnd = new Date(completionTime.getTime() + 7 * 24 * 60 * 60 * 1000);
72+
expect(expectedCooldownEnd).toEqual(new Date('2024-01-08T10:00:00Z')); // 7 days from completion
73+
});
74+
});
75+
76+
describe('Admin reset functionality', () => {
77+
it('should allow admin to reset repeatable quest from cooldown to available', async () => {
78+
const admin = await createTestUser({ role: 'ADMIN', email: 'admin@test.com' });
79+
const questGiver = await createTestUser({ role: 'EDITOR', email: 'questgiver@test.com' });
80+
const player = await createTestUser({ role: 'PLAYER', email: 'player@test.com' });
81+
82+
// Create a repeatable quest in cooldown status
83+
const quest = await createTestQuest(questGiver.id, {
84+
bounty: 100,
85+
isRepeatable: true,
86+
cooldownDays: 7,
87+
status: 'COOLDOWN',
88+
lastCompletedAt: new Date('2024-01-01T10:00:00Z')
89+
});
90+
91+
const adminToken = createTestToken(admin.id, admin.email, admin.role);
92+
93+
// Admin resets the quest
94+
const response = await request(app)
95+
.put(`/quests/${quest.id}/reset`)
96+
.set('Authorization', `Bearer ${adminToken}`);
97+
98+
expect(response.status).toBe(200);
99+
expect(response.body.success).toBe(true);
100+
expect(response.body.data.quest.status).toBe('AVAILABLE');
101+
expect(response.body.data.quest.lastCompletedAt).toBeNull();
102+
});
103+
104+
it('should not allow non-admin users to reset quests', async () => {
105+
const editor = await createTestUser({ role: 'EDITOR', email: 'editor@test.com' });
106+
const questGiver = await createTestUser({ role: 'EDITOR', email: 'questgiver@test.com' });
107+
108+
// Create a repeatable quest in cooldown status
109+
const quest = await createTestQuest(questGiver.id, {
110+
bounty: 100,
111+
isRepeatable: true,
112+
cooldownDays: 7,
113+
status: 'COOLDOWN',
114+
lastCompletedAt: new Date('2024-01-01T10:00:00Z')
115+
});
116+
117+
const editorToken = createTestToken(editor.id, editor.email, editor.role);
118+
119+
// Editor tries to reset the quest
120+
const response = await request(app)
121+
.put(`/quests/${quest.id}/reset`)
122+
.set('Authorization', `Bearer ${editorToken}`);
123+
124+
expect(response.status).toBe(403);
125+
expect(response.body.success).toBe(false);
126+
expect(response.body.error.message).toBe('Access denied. Admin role required.');
127+
});
128+
129+
it('should not allow resetting non-repeatable quests', async () => {
130+
const admin = await createTestUser({ role: 'ADMIN', email: 'admin@test.com' });
131+
const questGiver = await createTestUser({ role: 'EDITOR', email: 'questgiver@test.com' });
132+
133+
// Create a non-repeatable quest in cooldown status
134+
const quest = await createTestQuest(questGiver.id, {
135+
bounty: 100,
136+
isRepeatable: false,
137+
status: 'COOLDOWN',
138+
lastCompletedAt: new Date('2024-01-01T10:00:00Z')
139+
});
140+
141+
const adminToken = createTestToken(admin.id, admin.email, admin.role);
142+
143+
// Admin tries to reset the quest
144+
const response = await request(app)
145+
.put(`/quests/${quest.id}/reset`)
146+
.set('Authorization', `Bearer ${adminToken}`);
147+
148+
expect(response.status).toBe(400);
149+
expect(response.body.success).toBe(false);
150+
expect(response.body.error.message).toBe('Only repeatable quests can be reset');
151+
});
152+
153+
it('should not allow resetting quests that are not in cooldown', async () => {
154+
const admin = await createTestUser({ role: 'ADMIN', email: 'admin@test.com' });
155+
const questGiver = await createTestUser({ role: 'EDITOR', email: 'questgiver@test.com' });
156+
157+
// Create a repeatable quest in available status
158+
const quest = await createTestQuest(questGiver.id, {
159+
bounty: 100,
160+
isRepeatable: true,
161+
cooldownDays: 7,
162+
status: 'AVAILABLE'
163+
});
164+
165+
const adminToken = createTestToken(admin.id, admin.email, admin.role);
166+
167+
// Admin tries to reset the quest
168+
const response = await request(app)
169+
.put(`/quests/${quest.id}/reset`)
170+
.set('Authorization', `Bearer ${adminToken}`);
171+
172+
expect(response.status).toBe(400);
173+
expect(response.body.success).toBe(false);
174+
expect(response.body.error.message).toBe('Quest is not in cooldown status');
175+
});
176+
});
177+
});

frontend/src/components/QuestDetailsModal.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -302,13 +302,25 @@ const QuestDetailsModal: React.FC<QuestDetailsModalProps> = ({
302302
)}
303303

304304
{quest.status === "COOLDOWN" && (
305-
<Button
306-
disabled={true}
307-
className="flex-1 bg-muted text-muted-foreground font-medium cursor-not-allowed"
308-
>
309-
<Clock className="w-4 h-4 mr-2" />
310-
On Cooldown
311-
</Button>
305+
<div className="flex gap-2 flex-1">
306+
<Button
307+
disabled={true}
308+
className="flex-1 bg-muted text-muted-foreground font-medium cursor-not-allowed"
309+
>
310+
<Clock className="w-4 h-4 mr-2" />
311+
On Cooldown
312+
</Button>
313+
{currentUser.role === "ADMIN" && (
314+
<Button
315+
onClick={() => onAction(quest.id, "reset")}
316+
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800 text-white dark:text-blue-100 font-medium"
317+
title="Reset quest to available immediately"
318+
>
319+
<Clock className="w-4 h-4 mr-2" />
320+
Reset
321+
</Button>
322+
)}
323+
</div>
312324
)}
313325

314326
{quest.status === "CLAIMED" && (quest as any).rejectionReason && currentUser && currentUser.id === quest.claimedBy && (

frontend/src/components/quest-board.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -505,14 +505,28 @@ const QuestCard: React.FC<{
505505
)}
506506

507507
{quest.status === "COOLDOWN" && (
508-
<Button
509-
disabled={true}
510-
className="flex-1 bg-muted text-muted-foreground font-medium cursor-not-allowed"
511-
size="sm"
512-
>
513-
<Clock className="w-4 h-4 mr-1" />
514-
On Cooldown
515-
</Button>
508+
<div className="flex gap-1 flex-1">
509+
<Button
510+
disabled={true}
511+
className="flex-1 bg-muted text-muted-foreground font-medium cursor-not-allowed"
512+
size="sm"
513+
>
514+
<Clock className="w-4 h-4 mr-1" />
515+
On Cooldown
516+
</Button>
517+
{currentUser.role === "ADMIN" && (
518+
<Button
519+
onClick={() => handleAction("reset")}
520+
disabled={actionLoading === "reset"}
521+
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800 text-white dark:text-blue-100 font-medium"
522+
size="sm"
523+
title="Reset quest to available immediately"
524+
>
525+
<Clock className="w-4 h-4" />
526+
{actionLoading === "reset" ? "Resetting..." : "Reset"}
527+
</Button>
528+
)}
529+
</div>
516530
)}
517531

518532
{quest.status === "CLAIMED" && currentUser.id === quest.claimedBy && (
@@ -881,6 +895,9 @@ const QuestBoard: React.FC = () => {
881895
case "reject":
882896
await questService.rejectQuest(questId)
883897
break
898+
case "reset":
899+
await questService.resetRepeatableQuest(questId)
900+
break
884901
case "view":
885902
// Open quest details modal
886903
const questToView = quests.find(q => q.id === questId)

frontend/src/services/questService.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,23 @@ export const questService = {
183183
return quest;
184184
},
185185

186+
/**
187+
* Reset a repeatable quest from cooldown to available (admin only)
188+
*/
189+
async resetRepeatableQuest(id: number): Promise<Quest> {
190+
const response = await api.put<ApiResponse<{ quest: Quest }>>(`/api/quests/${id}/reset`);
191+
192+
if (!response.data.success) {
193+
throw new Error(response.data.error?.message || 'Failed to reset quest');
194+
}
195+
196+
const quest = response.data.data?.quest;
197+
if (!quest) {
198+
throw new Error('No quest data returned from reset quest API');
199+
}
200+
return quest;
201+
},
202+
186203
/**
187204
* Get quests created by current user
188205
*/

0 commit comments

Comments
 (0)