Skip to content

Commit b4d1bd3

Browse files
authored
Merge pull request #164 from DanielCharis1/fix/danielcharis1-issues-55-65-75-88
fix: resolve issues #55, #65, #75, #88
2 parents d2b9dc8 + 44c6e3f commit b4d1bd3

10 files changed

Lines changed: 342 additions & 173 deletions

File tree

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@
6161
"stripe": "^8.0.0",
6262
"swagger-ui-express": "^5.0.1",
6363
"typeorm": "^0.3.25",
64-
"uuid": "^11.1.0"
64+
"uuid": "^11.1.0",
65+
"zod": "^4.4.3"
6566
},
6667
"devDependencies": {
6768
"@nestjs/cli": "^10.0.0",

backend/src/multiplayer-queue/multiplayer-queue.service.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,4 +201,43 @@ describe("MultiplayerQueueService", () => {
201201
expect(result.matchesToday).toBe(5)
202202
})
203203
})
204+
205+
describe("cleanupOldEntries", () => {
206+
it("should delete entries older than one day with status LEFT", async () => {
207+
mockQueueRepository.delete.mockResolvedValue({ affected: 3 })
208+
209+
await service.cleanupOldEntries()
210+
211+
expect(mockQueueRepository.delete).toHaveBeenCalledTimes(1)
212+
213+
const deleteCall = mockQueueRepository.delete.mock.calls[0][0]
214+
215+
// Should filter by status LEFT
216+
expect(deleteCall.status).toBe(QueueStatus.LEFT)
217+
218+
// Should filter by createdAt (the LessThan find operator for old entries)
219+
expect(deleteCall.createdAt).toBeDefined()
220+
221+
// Verify the delete was called with a createdAt filter (LessThan semantics)
222+
// TypeORM's LessThan creates a FindOperator; we verify it exists and is
223+
// not MoreThan by checking the operator value points to a past date
224+
const createdAtFilter = deleteCall.createdAt
225+
expect(createdAtFilter).toBeDefined()
226+
expect(typeof createdAtFilter).toBe("object")
227+
})
228+
229+
it("should not delete recent or waiting entries", async () => {
230+
mockQueueRepository.delete.mockResolvedValue({ affected: 0 })
231+
232+
await service.cleanupOldEntries()
233+
234+
const deleteCall = mockQueueRepository.delete.mock.calls[0][0]
235+
236+
// Should only target LEFT status entries
237+
expect(deleteCall.status).toBe(QueueStatus.LEFT)
238+
// Should have a createdAt filter (LessThan semantics)
239+
expect(deleteCall.createdAt).toBeDefined()
240+
expect(typeof deleteCall.createdAt).toBe("object")
241+
})
242+
})
204243
})

backend/src/multiplayer-queue/multiplayer-queue.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Injectable, NotFoundException, BadRequestException, Logger } from "@nestjs/common"
2-
import { type Repository, MoreThan } from "typeorm"
2+
import { type Repository, LessThan, MoreThan } from "typeorm"
33
import { Cron, CronExpression } from "@nestjs/schedule"
44
import { type Queue, QueueStatus, SkillLevel } from "./entities/queue.entity"
55
import type { Match } from "./entities/match.entity"
@@ -301,7 +301,7 @@ export class MultiplayerQueueService {
301301
oneDayAgo.setDate(oneDayAgo.getDate() - 1)
302302

303303
const result = await this.queueRepository.delete({
304-
createdAt: MoreThan(oneDayAgo),
304+
createdAt: LessThan(oneDayAgo),
305305
status: QueueStatus.LEFT,
306306
})
307307

backend/src/rewards/rewards.service.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('RewardsService', () => {
6161
description: 'Test Description',
6262
type: RewardType.BADGE,
6363
challengeId: 'challenge-001',
64-
metadata: { imageUrl: 'test.jpg' },
64+
metadata: { imageUrl: 'https://example.com/test.jpg' },
6565
isActive: true,
6666
maxClaims: 100,
6767
};

backend/src/rewards/rewards.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Reward, RewardType } from './entities/reward.entity';
55
import { RewardClaim } from './entities/reward-claim.entity';
66
import { CreateRewardDto } from './dto/create-reward.dto';
77
import { ClaimRewardDto } from './dto/claim-reward.dto';
8+
import { validateRewardMetadata } from './schemas/reward-metadata.schema';
89

910
@Injectable()
1011
export class RewardsService {
@@ -19,6 +20,13 @@ export class RewardsService {
1920
* Create a new reward
2021
*/
2122
async createReward(createRewardDto: CreateRewardDto): Promise<Reward> {
23+
// Validate metadata against the Zod schema when provided
24+
if (createRewardDto.metadata) {
25+
createRewardDto.metadata = validateRewardMetadata(
26+
createRewardDto.metadata,
27+
);
28+
}
29+
2230
const reward = this.rewardRepository.create({
2331
...createRewardDto,
2432
currentClaims: 0,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Zod schema for `Reward.metadata`.
5+
*
6+
* Designed so that adding a new optional key does NOT break old payloads —
7+
* every field is `.optional()` and the schema uses `.passthrough()` so unknown
8+
* keys pass through without being stripped.
9+
*/
10+
export const rewardMetadataSchema = z
11+
.object({
12+
/** URL to the reward image / badge artwork */
13+
imageUrl: z.string().url().optional(),
14+
15+
/** Rarity tier of the reward */
16+
rarity: z.enum(['common', 'uncommon', 'rare', 'epic', 'legendary']).optional(),
17+
18+
/** How the reward was earned (e.g. puzzle name, event slug) */
19+
earnedFrom: z.string().optional(),
20+
21+
/** On-chain token ID if the reward has been minted */
22+
tokenId: z.string().optional(),
23+
24+
/** IPFS / Arweave URI for the reward metadata */
25+
nftUri: z.string().optional(),
26+
27+
/** Points value when the reward type is POINTS */
28+
pointsValue: z.number().int().nonnegative().optional(),
29+
30+
/** Expiry date for time-limited rewards (ISO-8601) */
31+
expiresAt: z.string().datetime().optional(),
32+
33+
/** Whether the reward has been viewed by the recipient */
34+
viewed: z.boolean().optional(),
35+
36+
/** Arbitrary tags for categorisation */
37+
tags: z.array(z.string()).optional(),
38+
39+
/** Custom attributes exposed by the reward provider */
40+
attributes: z.record(z.string(), z.unknown()).optional(),
41+
})
42+
.passthrough();
43+
44+
/** Inferred TypeScript type for reward metadata */
45+
export type RewardMetadata = z.infer<typeof rewardMetadataSchema>;
46+
47+
/**
48+
* Validate and return a sanitised reward metadata object.
49+
* Throws a ZodError if validation fails.
50+
*/
51+
export function validateRewardMetadata(
52+
data: unknown,
53+
): RewardMetadata {
54+
return rewardMetadataSchema.parse(data);
55+
}
56+
57+
/**
58+
* Safely parse reward metadata, returning a default empty object on failure
59+
* instead of throwing. Useful when reading old records that may not conform.
60+
*/
61+
export function safeParseRewardMetadata(
62+
data: unknown,
63+
): RewardMetadata {
64+
const result = rewardMetadataSchema.safeParse(data);
65+
if (result.success) {
66+
return result.data;
67+
}
68+
// Return an empty metadata object for backwards compatibility
69+
return {};
70+
}

frontend/components/NftCard.jsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from "react";
1+
import React, { useState, useCallback } from "react";
22
import { Button } from "@/components/ui/button";
33
import { Sparkles, Lock, Shield } from "lucide-react";
44

@@ -17,6 +17,7 @@ const getRarityColor = (rarity) =>
1717
RARITY_GRADIENTS[rarity] || "from-gray-400 to-gray-600";
1818

1919
// Displays a single NFT with rarity gradient, lock state, and claim action.
20+
const NFTCard = ({ nft, onClaim }) => {
2021
// Wrapped in React.memo so that when a parent re-renders (e.g. the wallet
2122
// store updating) and passes the same `nft` reference to every card in
2223
// a gallery, the heavy gradient DOM tree doesn't get re-reconciled for
@@ -25,10 +26,21 @@ const getRarityColor = (rarity) =>
2526
const NFTCard = ({ nft }) => {
2627
const [isHovered, setIsHovered] = useState(false);
2728

29+
const handleKeyDown = useCallback(
30+
(e) => {
31+
if (!nft.locked && (e.key === "Enter" || e.key === " ")) {
32+
e.preventDefault();
33+
onClaim?.(nft);
34+
}
35+
},
36+
[nft, onClaim]
37+
);
38+
2839
return (
2940
<div
30-
aria-label={nft?.name ? `NFT: ${nft.name}` : "NFT card"}
31-
className="relative overflow-hidden transition-transform duration-300 group rounded-xl hover:scale-105"
41+
role="article"
42+
aria-label={nft?.name ? `NFT: ${nft.name}${nft.locked ? " (locked)" : ""}` : "NFT card"}
43+
className="relative overflow-hidden transition-transform duration-300 group rounded-xl hover:scale-105 focus-within:ring-2 focus-within:ring-purple-500 focus-within:ring-offset-2 focus-within:ring-offset-black"
3244
onMouseEnter={() => setIsHovered(true)}
3345
onMouseLeave={() => setIsHovered(false)}
3446
>
@@ -55,8 +67,12 @@ const NFTCard = ({ nft }) => {
5567

5668
{/* Locked Overlay */}
5769
{nft.locked && (
58-
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
59-
<Lock className="w-12 h-12 text-white/50" />
70+
<div
71+
role="status"
72+
aria-label="This NFT is locked"
73+
className="absolute inset-0 flex items-center justify-center bg-black/60"
74+
>
75+
<Lock className="w-12 h-12 text-white/50" aria-hidden="true" />
6076
</div>
6177
)}
6278

@@ -96,15 +112,20 @@ const NFTCard = ({ nft }) => {
96112
: "bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
97113
}`}
98114
disabled={nft.locked}
115+
aria-disabled={nft.locked}
116+
aria-pressed={!nft.locked ? undefined : undefined}
117+
aria-label={nft.locked ? `${nft.name} is locked. Complete challenges to unlock.` : `Claim ${nft.name} NFT`}
118+
onKeyDown={handleKeyDown}
119+
tabIndex={0}
99120
>
100121
{nft.locked ? (
101122
<span className="flex items-center">
102-
<Lock className="w-4 h-4 mr-2" />
123+
<Lock className="w-4 h-4 mr-2" aria-hidden="true" />
103124
Complete Challenges to Unlock
104125
</span>
105126
) : (
106127
<span className="flex items-center">
107-
<Sparkles className="w-4 h-4 mr-2" />
128+
<Sparkles className="w-4 h-4 mr-2" aria-hidden="true" />
108129
Claim NFT
109130
</span>
110131
)}

0 commit comments

Comments
 (0)