From aef1a35ee2fbab8b5e444b7e8697c22ac52e2f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sat, 29 Nov 2025 23:19:41 +0100 Subject: [PATCH] feat(matching): implement 3-tier account system with fairness-based recording assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add account tier system (BASIC/SUPPORTER/COMFORT) to reduce recording burden for premium users while maintaining fairness through karma-based assignment. Database Changes: - Add AccountTier enum (BASIC, SUPPORTER, COMFORT) - Add User.accountTier with BASIC default - Add User.recordingsDone and User.recordingsReceived for karma tracking - Add EventParticipant.accountTierOverride for event-specific tier upgrades - Migration: 20251129220604_add_account_tiers_and_recording_stats Matching Algorithm Updates: - Implement fairness debt calculation: receivedCount - doneCount - Apply tier penalties: SUPPORTER (-10), COMFORT (-50) - New sorting priority: Location > Fairness > Load balancing - Add getEffectiveTier() helper for tier resolution with override support - Add getRecordingStatsForUsers() for fetching karma statistics Tier Behavior: - BASIC: Normal recording frequency (baseline, no penalty) - SUPPORTER: Moderately reduced frequency (fairness penalty -10) - COMFORT: Significantly reduced frequency (fairness penalty -50) - All tiers can still be assigned when no better candidates available Constants: - ACCOUNT_TIER enum in src/constants/tiers.js - FAIRNESS_SUPPORTER_PENALTY = 10 - FAIRNESS_COMFORT_PENALTY = 50 Tests: - Update tests for dual buffer system semantics - All 30 tests passing - Fix imports: HEAT_BUFFER → HEAT_BUFFER_BEFORE --- .../migration.sql | 10 +++ backend/prisma/schema.prisma | 32 +++++-- backend/src/__tests__/matching.test.js | 28 +++--- backend/src/constants/index.js | 4 + backend/src/constants/tiers.js | 22 +++++ backend/src/services/matching.js | 90 +++++++++++++++++-- 6 files changed, 158 insertions(+), 28 deletions(-) create mode 100644 backend/prisma/migrations/20251129220604_add_account_tiers_and_recording_stats/migration.sql create mode 100644 backend/src/constants/tiers.js diff --git a/backend/prisma/migrations/20251129220604_add_account_tiers_and_recording_stats/migration.sql b/backend/prisma/migrations/20251129220604_add_account_tiers_and_recording_stats/migration.sql new file mode 100644 index 0000000..cb4314a --- /dev/null +++ b/backend/prisma/migrations/20251129220604_add_account_tiers_and_recording_stats/migration.sql @@ -0,0 +1,10 @@ +-- CreateEnum +CREATE TYPE "AccountTier" AS ENUM ('BASIC', 'SUPPORTER', 'COMFORT'); + +-- AlterTable +ALTER TABLE "event_participants" ADD COLUMN "account_tier_override" "AccountTier"; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "account_tier" "AccountTier" NOT NULL DEFAULT 'BASIC', +ADD COLUMN "recordings_done" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "recordings_received" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 921214c..9c7d7bb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,6 +11,13 @@ datasource db { url = env("DATABASE_URL") } +// Account tier enum +enum AccountTier { + BASIC + SUPPORTER + COMFORT +} + // Users table model User { id Int @id @default(autoincrement()) @@ -47,9 +54,15 @@ model User { failedLoginAttempts Int @default(0) @map("failed_login_attempts") lockedUntil DateTime? @map("locked_until") - avatar String? @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + avatar String? @db.VarChar(255) + + // Account Tier & Recording Stats + accountTier AccountTier @default(BASIC) @map("account_tier") + recordingsDone Int @default(0) @map("recordings_done") // How many times this user recorded others + recordingsReceived Int @default(0) @map("recordings_received") // How many times others recorded this user + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // Relations messages Message[] @@ -189,12 +202,13 @@ model Rating { // Event participants (tracks which users joined which events) model EventParticipant { - id Int @id @default(autoincrement()) - userId Int @map("user_id") - eventId Int @map("event_id") - competitorNumber Int? @map("competitor_number") // Bib number - one per user per event - recorderOptOut Boolean @default(false) @map("recorder_opt_out") // Opt-out from being a recorder - joinedAt DateTime @default(now()) @map("joined_at") + id Int @id @default(autoincrement()) + userId Int @map("user_id") + eventId Int @map("event_id") + competitorNumber Int? @map("competitor_number") // Bib number - one per user per event + recorderOptOut Boolean @default(false) @map("recorder_opt_out") // Opt-out from being a recorder + accountTierOverride AccountTier? @map("account_tier_override") // Override user's global tier for this event (e.g., Comfort Pass) + joinedAt DateTime @default(now()) @map("joined_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/backend/src/__tests__/matching.test.js b/backend/src/__tests__/matching.test.js index 0243c27..7453b08 100644 --- a/backend/src/__tests__/matching.test.js +++ b/backend/src/__tests__/matching.test.js @@ -4,13 +4,13 @@ const { getTimeSlot, - getBufferSlots, + getPreDanceBufferSlots, getCoverableHeats, hasCollision, getLocationScore, buildDivisionSlotMap, MAX_RECORDINGS_PER_PERSON, - HEAT_BUFFER, + HEAT_BUFFER_BEFORE, } = require('../services/matching'); describe('Matching Service - Unit Tests', () => { @@ -54,11 +54,14 @@ describe('Matching Service - Unit Tests', () => { ]; const result = getCoverableHeats(dancerHeats, recorderHeats); - // Heat 2 blocked (recorder dancing), Heat 3 blocked (buffer after heat 2) - expect(result.map(h => h.id)).toEqual([1]); + // With DUAL buffers (BEFORE + AFTER): + // Heat 1 blocked (buffer BEFORE heat 2) + // Heat 2 blocked (recorder dancing) + // Heat 3 blocked (buffer AFTER heat 2) + expect(result.map(h => h.id)).toEqual([]); }); - it('should apply buffer after recorder heats', () => { + it('should apply buffer before and after recorder heats', () => { const dancerHeats = [ { id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, { id: 2, divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, @@ -66,14 +69,15 @@ describe('Matching Service - Unit Tests', () => { { id: 4, divisionId: 1, competitionTypeId: 1, heatNumber: 4 }, ]; const recorderHeats = [ - { divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, + { divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, ]; const result = getCoverableHeats(dancerHeats, recorderHeats); - // Heat 1 blocked (recorder dancing) - // Heat 2 blocked (buffer - HEAT_BUFFER=1 after heat 1) - // Heats 3, 4 available - expect(result.map(h => h.id)).toEqual([3, 4]); + // Heat 1 blocked (buffer BEFORE heat 2) + // Heat 2 blocked (recorder dancing) + // Heat 3 blocked (buffer AFTER heat 2) + // Heat 4 available + expect(result.map(h => h.id)).toEqual([4]); }); it('should handle different competition types independently', () => { @@ -195,8 +199,8 @@ describe('Matching Service - Unit Tests', () => { expect(MAX_RECORDINGS_PER_PERSON).toBe(3); }); - it('should have correct heat buffer', () => { - expect(HEAT_BUFFER).toBe(1); + it('should have correct heat buffer before', () => { + expect(HEAT_BUFFER_BEFORE).toBe(1); }); }); diff --git a/backend/src/constants/index.js b/backend/src/constants/index.js index 7d106fc..2614455 100644 --- a/backend/src/constants/index.js +++ b/backend/src/constants/index.js @@ -1,6 +1,10 @@ const { MATCH_STATUS, SUGGESTION_STATUS } = require('./statuses'); +const { ACCOUNT_TIER, FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_COMFORT_PENALTY } = require('./tiers'); module.exports = { MATCH_STATUS, SUGGESTION_STATUS, + ACCOUNT_TIER, + FAIRNESS_SUPPORTER_PENALTY, + FAIRNESS_COMFORT_PENALTY, }; diff --git a/backend/src/constants/tiers.js b/backend/src/constants/tiers.js new file mode 100644 index 0000000..924e091 --- /dev/null +++ b/backend/src/constants/tiers.js @@ -0,0 +1,22 @@ +/** + * Account tier constants + * Tiers affect recording assignment priority in matchmaking + */ +const ACCOUNT_TIER = { + BASIC: 'BASIC', // Default tier - normal recording assignment frequency + SUPPORTER: 'SUPPORTER', // Moderately reduced recording assignment frequency + COMFORT: 'COMFORT', // Significantly reduced recording assignment frequency +}; + +/** + * Fairness penalties for different tiers + * Higher penalty = less likely to be assigned as recorder + */ +const FAIRNESS_SUPPORTER_PENALTY = 10; // Supporter slightly less likely to record +const FAIRNESS_COMFORT_PENALTY = 50; // Comfort significantly less likely to record + +module.exports = { + ACCOUNT_TIER, + FAIRNESS_SUPPORTER_PENALTY, + FAIRNESS_COMFORT_PENALTY, +}; diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index b0e62d1..7ddfb7a 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -7,16 +7,23 @@ * - Buffer time BEFORE dancing (1 heat prep time) * - Buffer time AFTER dancing (1 heat rest time) * - Buffers only apply to dancing, NOT to recording + * - Account tiers (BASIC / SUPPORTER / COMFORT) affecting recording assignment frequency + * - Fairness debt (recordingsReceived - recordingsDone) with tier penalties * - Location preference (same city > same country > anyone) - * - Load balancing (distribute assignments evenly) + * - Load balancing (distribute assignments evenly within event) * - Max recordings per person (3) * - Opt-out users are completely excluded from matching * - * Sorting priority: Location > Load balancing + * Sorting priority: Location > Fairness (with tier penalties) > Load balancing + * + * Tier behavior: + * - BASIC: Normal recording frequency (baseline) + * - SUPPORTER: Moderately reduced recording frequency (-10 fairness penalty) + * - COMFORT: Significantly reduced recording frequency (-50 fairness penalty) */ const { prisma } = require('../utils/db'); -const { SUGGESTION_STATUS } = require('../constants'); +const { SUGGESTION_STATUS, ACCOUNT_TIER, FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_COMFORT_PENALTY } = require('../constants'); // Constants const MAX_RECORDINGS_PER_PERSON = 3; @@ -123,6 +130,47 @@ function getPostDanceBufferSlots(heat, divisionSlotMap = null) { return bufferSlots; } +/** + * Get effective tier for a participant (override or global tier) + * @param {Object} participant - EventParticipant with user relation + * @returns {string} - Effective tier (BASIC, SUPPORTER, or COMFORT) + */ +function getEffectiveTier(participant) { + return ( + participant.accountTierOverride || + participant.user?.accountTier || + ACCOUNT_TIER.BASIC + ); +} + +/** + * Get recording stats for list of user IDs + * Returns Map + * + * Note: Stats are currently stored globally in User table. + * Future enhancement: could track per-season or per-event. + */ +async function getRecordingStatsForUsers(userIds) { + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { + id: true, + recordingsDone: true, + recordingsReceived: true, + } + }); + + const statsMap = new Map(); + for (const user of users) { + statsMap.set(user.id, { + recordingsDone: user.recordingsDone, + recordingsReceived: user.recordingsReceived, + }); + } + + return statsMap; +} + /** * Check if two users have any time collision * User A is dancing, User B wants to record @@ -235,6 +283,9 @@ async function runMatching(eventId) { username: true, city: true, country: true, + accountTier: true, + recordingsDone: true, + recordingsReceived: true, } } } @@ -323,12 +374,29 @@ async function runMatching(eventId) { const busySlots = recorderBusySlots.get(recorder.userId) || new Set(); if (busySlots.has(heatSlot)) continue; // Collision - skip - // Recorder is available - calculate score + // --- TIER & FAIRNESS --- + const tier = getEffectiveTier(recorder); + + // Calculate fairness debt (basic formula: received - done) + const recordingsDone = recorder.user.recordingsDone || 0; + const recordingsReceived = recorder.user.recordingsReceived || 0; + let fairnessDebt = recordingsReceived - recordingsDone; + + // Apply tier penalties + if (tier === ACCOUNT_TIER.SUPPORTER) { + fairnessDebt -= FAIRNESS_SUPPORTER_PENALTY; + } else if (tier === ACCOUNT_TIER.COMFORT) { + fairnessDebt -= FAIRNESS_COMFORT_PENALTY; + } + + // Location score const locationScore = getLocationScore(dancerUser, recorder.user); candidates.push({ recorder, + tier, locationScore, + fairnessDebt, currentAssignments: currentCount, }); } @@ -344,13 +412,19 @@ async function runMatching(eventId) { continue; } - // 5c. Sort candidates: location first, then load balancing + // 5c. Sort candidates: Location > Fairness > Load balancing candidates.sort((a, b) => { - // Higher location score = better + // 1. Location score (higher = better) if (a.locationScore !== b.locationScore) { return b.locationScore - a.locationScore; } - // Fewer assignments = better (load balancing) + + // 2. Fairness debt (higher debt = higher priority to record) + if (a.fairnessDebt !== b.fairnessDebt) { + return b.fairnessDebt - a.fairnessDebt; + } + + // 3. Load balancing within event (fewer assignments = better) return a.currentAssignments - b.currentAssignments; }); @@ -480,6 +554,8 @@ module.exports = { getPreDanceBufferSlots, getLocationScore, buildDivisionSlotMap, + getEffectiveTier, + getRecordingStatsForUsers, MAX_RECORDINGS_PER_PERSON, HEAT_BUFFER_BEFORE, };