Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions migrations/0014_add_challenge_winner_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add winner_id to challenges so wins can be counted directly
ALTER TABLE challenges ADD COLUMN winner_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_challenges_winner ON challenges(winner_id);
8 changes: 8 additions & 0 deletions worker/repositories/d1/challenge.d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,5 +107,13 @@ export function createD1ChallengeRepository(env: Env): ChallengeRepository {
.bind(status, id)
.run();
},

async setWinner(id: number, userId: number): Promise<void> {
await env.DB.prepare(
`UPDATE challenges SET winner_id = ?, updated_at = datetime('now') WHERE id = ?`,
)
.bind(userId, id)
.run();
},
};
}
2 changes: 1 addition & 1 deletion worker/repositories/d1/stats.d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function createD1StatsRepository(env: Env): StatsRepository {
`SELECT
COALESCE((SELECT SUM(step_count) FROM step_entries WHERE user_id = ?), 0) as total_steps,
(SELECT COUNT(*) FROM challenge_participants WHERE user_id = ?) as challenges_joined,
(SELECT COUNT(*) FROM user_badges WHERE user_id = ? AND badge_type = 'challenge_winner') as challenges_won,
(SELECT COUNT(*) FROM challenges WHERE winner_id = ?) as challenges_won,
(SELECT COUNT(*) FROM user_badges WHERE user_id = ?) as badges_earned`,
)
.bind(userId, userId, userId, userId)
Expand Down
5 changes: 5 additions & 0 deletions worker/repositories/interfaces/challenge.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,9 @@ export interface ChallengeRepository {
* Update a challenge status
*/
updateStatus(id: number, status: Challenge["status"]): Promise<void>;

/**
* Set the winner of a completed challenge
*/
setWinner(id: number, userId: number): Promise<void>;
}
8 changes: 8 additions & 0 deletions worker/repositories/memory/challenge.memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function createMemoryChallengeRepository(
mode: input.mode,
invite_code: input.inviteCode,
status: input.status ?? "pending",
winner_id: null,
timezone: input.timezone,
is_recurring: input.isRecurring ? 1 : 0,
recurring_interval: input.recurringInterval,
Expand Down Expand Up @@ -76,5 +77,12 @@ export function createMemoryChallengeRepository(
challenge.status = status;
challenge.updated_at = new Date().toISOString();
},

async setWinner(id: number, userId: number): Promise<void> {
const challenge = store.challenges.find((c) => c.id === id);
if (!challenge) return;
challenge.winner_id = userId;
challenge.updated_at = new Date().toISOString();
},
};
}
4 changes: 2 additions & 2 deletions worker/repositories/memory/stats.memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export function createMemoryStatsRepository(store: TestStore): StatsRepository {
const challengesJoined = store.participants.filter(
(p) => p.user_id === userId,
).length;
const challengesWon = store.userBadges.filter(
(b) => b.user_id === userId && b.badge_type === "challenge_winner",
const challengesWon = store.challenges.filter(
(c) => c.winner_id === userId,
).length;
const badgesEarned = store.userBadges.filter(
(b) => b.user_id === userId,
Expand Down
2 changes: 2 additions & 0 deletions worker/services/challenge-lifecycle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export async function finalizeChallenge(
}

if (winner) {
await deps.challengeRepository.setWinner(challenge.id, winner.user_id);

const badge = await deps.badgeRepository.award(
winner.user_id,
"challenge_winner",
Expand Down
1 change: 1 addition & 0 deletions worker/test/fixtures/challenge.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function createChallenge(overrides: Partial<Challenge> = {}): Challenge {
mode: overrides.mode ?? "daily_winner",
invite_code: overrides.invite_code ?? `INVITE${id}`,
status: overrides.status ?? "pending",
winner_id: overrides.winner_id ?? null,
timezone: overrides.timezone ?? "UTC",
is_recurring: overrides.is_recurring ?? 0,
recurring_interval: overrides.recurring_interval ?? null,
Expand Down
5 changes: 3 additions & 2 deletions worker/test/http/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ export async function insertChallenge(
): Promise<Challenge> {
const challenge = createChallenge(options);
await env.DB.prepare(
`INSERT INTO challenges (id, title, description, creator_id, start_date, end_date, mode, invite_code, status, timezone, is_recurring, recurring_interval, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO challenges (id, title, description, creator_id, start_date, end_date, mode, invite_code, status, winner_id, timezone, is_recurring, recurring_interval, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
challenge.id,
Expand All @@ -146,6 +146,7 @@ export async function insertChallenge(
challenge.mode,
challenge.invite_code,
challenge.status,
challenge.winner_id,
challenge.timezone,
challenge.is_recurring,
challenge.recurring_interval,
Expand Down
5 changes: 0 additions & 5 deletions worker/test/http/profile.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ describe("HTTP /api/profile", () => {
challenge_id: challenge.id,
user_id: user.id,
});
await insertBadge(env, {
user_id: user.id,
badge_type: "challenge_winner",
});

const { res, body } = await requestJson<{
data: {
user: { id: number };
Expand Down
1 change: 1 addition & 0 deletions worker/test/repositories/stats.d1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("D1 StatsRepository", () => {
isRecurring: false,
recurringInterval: null,
});
await challengeRepo.setWinner(challenge.id, user.id);

const participantRepo = createD1ParticipantRepository(env);
await participantRepo.join(challenge.id, user.id);
Expand Down
8 changes: 8 additions & 0 deletions worker/test/usecases/get-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createMemoryRepos } from "../memory";
import {
createUser,
createUserBadge,
createChallenge,
createStepEntry,
createParticipant,
resetAllFixtureCounters,
Expand All @@ -26,6 +27,12 @@ describe("getProfile", () => {
user_id: 1,
badge_type: "challenge_winner",
});
const challenge = createChallenge({
id: 1,
creator_id: 1,
status: "completed",
winner_id: 1,
});
const step = createStepEntry({
user_id: 1,
date: "2026-01-02",
Expand All @@ -37,6 +44,7 @@ describe("getProfile", () => {
});
seedTestStore(store, {
users: [user],
challenges: [challenge],
userBadges: [badge],
stepEntries: [step],
participants: [participant],
Expand Down
1 change: 1 addition & 0 deletions worker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Challenge {
mode: "daily_winner" | "cumulative";
invite_code: string;
status: "pending" | "active" | "completed";
winner_id: number | null;
timezone: string;
is_recurring: number;
recurring_interval: "weekly" | "monthly" | null;
Expand Down