diff --git a/migrations/0014_add_challenge_winner_id.sql b/migrations/0014_add_challenge_winner_id.sql new file mode 100644 index 0000000..19c9547 --- /dev/null +++ b/migrations/0014_add_challenge_winner_id.sql @@ -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); diff --git a/worker/repositories/d1/challenge.d1.ts b/worker/repositories/d1/challenge.d1.ts index ea40cba..84ecb58 100644 --- a/worker/repositories/d1/challenge.d1.ts +++ b/worker/repositories/d1/challenge.d1.ts @@ -107,5 +107,13 @@ export function createD1ChallengeRepository(env: Env): ChallengeRepository { .bind(status, id) .run(); }, + + async setWinner(id: number, userId: number): Promise { + await env.DB.prepare( + `UPDATE challenges SET winner_id = ?, updated_at = datetime('now') WHERE id = ?`, + ) + .bind(userId, id) + .run(); + }, }; } diff --git a/worker/repositories/d1/stats.d1.ts b/worker/repositories/d1/stats.d1.ts index d55329a..8d9321c 100644 --- a/worker/repositories/d1/stats.d1.ts +++ b/worker/repositories/d1/stats.d1.ts @@ -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) diff --git a/worker/repositories/interfaces/challenge.repository.ts b/worker/repositories/interfaces/challenge.repository.ts index 1d2bacd..2de3305 100644 --- a/worker/repositories/interfaces/challenge.repository.ts +++ b/worker/repositories/interfaces/challenge.repository.ts @@ -51,4 +51,9 @@ export interface ChallengeRepository { * Update a challenge status */ updateStatus(id: number, status: Challenge["status"]): Promise; + + /** + * Set the winner of a completed challenge + */ + setWinner(id: number, userId: number): Promise; } diff --git a/worker/repositories/memory/challenge.memory.ts b/worker/repositories/memory/challenge.memory.ts index 3b09b7c..b493d09 100644 --- a/worker/repositories/memory/challenge.memory.ts +++ b/worker/repositories/memory/challenge.memory.ts @@ -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, @@ -76,5 +77,12 @@ export function createMemoryChallengeRepository( challenge.status = status; challenge.updated_at = new Date().toISOString(); }, + + async setWinner(id: number, userId: number): Promise { + const challenge = store.challenges.find((c) => c.id === id); + if (!challenge) return; + challenge.winner_id = userId; + challenge.updated_at = new Date().toISOString(); + }, }; } diff --git a/worker/repositories/memory/stats.memory.ts b/worker/repositories/memory/stats.memory.ts index 83948c8..d5bc35a 100644 --- a/worker/repositories/memory/stats.memory.ts +++ b/worker/repositories/memory/stats.memory.ts @@ -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, diff --git a/worker/services/challenge-lifecycle.service.ts b/worker/services/challenge-lifecycle.service.ts index 7bcce2c..ee7c27e 100644 --- a/worker/services/challenge-lifecycle.service.ts +++ b/worker/services/challenge-lifecycle.service.ts @@ -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", diff --git a/worker/test/fixtures/challenge.fixture.ts b/worker/test/fixtures/challenge.fixture.ts index e488005..c57d56c 100644 --- a/worker/test/fixtures/challenge.fixture.ts +++ b/worker/test/fixtures/challenge.fixture.ts @@ -30,6 +30,7 @@ export function createChallenge(overrides: Partial = {}): 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, diff --git a/worker/test/http/helpers.ts b/worker/test/http/helpers.ts index 919d804..3e7de32 100644 --- a/worker/test/http/helpers.ts +++ b/worker/test/http/helpers.ts @@ -133,8 +133,8 @@ export async function insertChallenge( ): Promise { 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, @@ -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, diff --git a/worker/test/http/profile.http.test.ts b/worker/test/http/profile.http.test.ts index df6444d..e7368ce 100644 --- a/worker/test/http/profile.http.test.ts +++ b/worker/test/http/profile.http.test.ts @@ -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 }; diff --git a/worker/test/repositories/stats.d1.test.ts b/worker/test/repositories/stats.d1.test.ts index 631e2b5..52d315d 100644 --- a/worker/test/repositories/stats.d1.test.ts +++ b/worker/test/repositories/stats.d1.test.ts @@ -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); diff --git a/worker/test/usecases/get-profile.test.ts b/worker/test/usecases/get-profile.test.ts index dc05d1b..d7dfece 100644 --- a/worker/test/usecases/get-profile.test.ts +++ b/worker/test/usecases/get-profile.test.ts @@ -5,6 +5,7 @@ import { createMemoryRepos } from "../memory"; import { createUser, createUserBadge, + createChallenge, createStepEntry, createParticipant, resetAllFixtureCounters, @@ -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", @@ -37,6 +44,7 @@ describe("getProfile", () => { }); seedTestStore(store, { users: [user], + challenges: [challenge], userBadges: [badge], stepEntries: [step], participants: [participant], diff --git a/worker/types.ts b/worker/types.ts index d1b3280..e3e043a 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -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;