feat(matching): implement 3-tier account system with fairness-based recording assignment

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
This commit is contained in:
Radosław Gierwiało
2025-11-29 23:19:41 +01:00
parent 029b25c9b2
commit aef1a35ee2
6 changed files with 158 additions and 28 deletions

View File

@@ -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;

View File

@@ -11,6 +11,13 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// Account tier enum
enum AccountTier {
BASIC
SUPPORTER
COMFORT
}
// Users table // Users table
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@@ -47,9 +54,15 @@ model User {
failedLoginAttempts Int @default(0) @map("failed_login_attempts") failedLoginAttempts Int @default(0) @map("failed_login_attempts")
lockedUntil DateTime? @map("locked_until") lockedUntil DateTime? @map("locked_until")
avatar String? @db.VarChar(255) avatar String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") // 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 // Relations
messages Message[] messages Message[]
@@ -189,12 +202,13 @@ model Rating {
// Event participants (tracks which users joined which events) // Event participants (tracks which users joined which events)
model EventParticipant { model EventParticipant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int @map("user_id") userId Int @map("user_id")
eventId Int @map("event_id") eventId Int @map("event_id")
competitorNumber Int? @map("competitor_number") // Bib number - one per user per event 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 recorderOptOut Boolean @default(false) @map("recorder_opt_out") // Opt-out from being a recorder
joinedAt DateTime @default(now()) @map("joined_at") 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 // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -4,13 +4,13 @@
const { const {
getTimeSlot, getTimeSlot,
getBufferSlots, getPreDanceBufferSlots,
getCoverableHeats, getCoverableHeats,
hasCollision, hasCollision,
getLocationScore, getLocationScore,
buildDivisionSlotMap, buildDivisionSlotMap,
MAX_RECORDINGS_PER_PERSON, MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER, HEAT_BUFFER_BEFORE,
} = require('../services/matching'); } = require('../services/matching');
describe('Matching Service - Unit Tests', () => { describe('Matching Service - Unit Tests', () => {
@@ -54,11 +54,14 @@ describe('Matching Service - Unit Tests', () => {
]; ];
const result = getCoverableHeats(dancerHeats, recorderHeats); const result = getCoverableHeats(dancerHeats, recorderHeats);
// Heat 2 blocked (recorder dancing), Heat 3 blocked (buffer after heat 2) // With DUAL buffers (BEFORE + AFTER):
expect(result.map(h => h.id)).toEqual([1]); // 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 = [ const dancerHeats = [
{ id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, { id: 1, divisionId: 1, competitionTypeId: 1, heatNumber: 1 },
{ id: 2, divisionId: 1, competitionTypeId: 1, heatNumber: 2 }, { 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 }, { id: 4, divisionId: 1, competitionTypeId: 1, heatNumber: 4 },
]; ];
const recorderHeats = [ const recorderHeats = [
{ divisionId: 1, competitionTypeId: 1, heatNumber: 1 }, { divisionId: 1, competitionTypeId: 1, heatNumber: 2 },
]; ];
const result = getCoverableHeats(dancerHeats, recorderHeats); const result = getCoverableHeats(dancerHeats, recorderHeats);
// Heat 1 blocked (recorder dancing) // Heat 1 blocked (buffer BEFORE heat 2)
// Heat 2 blocked (buffer - HEAT_BUFFER=1 after heat 1) // Heat 2 blocked (recorder dancing)
// Heats 3, 4 available // Heat 3 blocked (buffer AFTER heat 2)
expect(result.map(h => h.id)).toEqual([3, 4]); // Heat 4 available
expect(result.map(h => h.id)).toEqual([4]);
}); });
it('should handle different competition types independently', () => { it('should handle different competition types independently', () => {
@@ -195,8 +199,8 @@ describe('Matching Service - Unit Tests', () => {
expect(MAX_RECORDINGS_PER_PERSON).toBe(3); expect(MAX_RECORDINGS_PER_PERSON).toBe(3);
}); });
it('should have correct heat buffer', () => { it('should have correct heat buffer before', () => {
expect(HEAT_BUFFER).toBe(1); expect(HEAT_BUFFER_BEFORE).toBe(1);
}); });
}); });

View File

@@ -1,6 +1,10 @@
const { MATCH_STATUS, SUGGESTION_STATUS } = require('./statuses'); const { MATCH_STATUS, SUGGESTION_STATUS } = require('./statuses');
const { ACCOUNT_TIER, FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_COMFORT_PENALTY } = require('./tiers');
module.exports = { module.exports = {
MATCH_STATUS, MATCH_STATUS,
SUGGESTION_STATUS, SUGGESTION_STATUS,
ACCOUNT_TIER,
FAIRNESS_SUPPORTER_PENALTY,
FAIRNESS_COMFORT_PENALTY,
}; };

View File

@@ -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,
};

View File

@@ -7,16 +7,23 @@
* - Buffer time BEFORE dancing (1 heat prep time) * - Buffer time BEFORE dancing (1 heat prep time)
* - Buffer time AFTER dancing (1 heat rest time) * - Buffer time AFTER dancing (1 heat rest time)
* - Buffers only apply to dancing, NOT to recording * - 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) * - Location preference (same city > same country > anyone)
* - Load balancing (distribute assignments evenly) * - Load balancing (distribute assignments evenly within event)
* - Max recordings per person (3) * - Max recordings per person (3)
* - Opt-out users are completely excluded from matching * - 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 { prisma } = require('../utils/db');
const { SUGGESTION_STATUS } = require('../constants'); const { SUGGESTION_STATUS, ACCOUNT_TIER, FAIRNESS_SUPPORTER_PENALTY, FAIRNESS_COMFORT_PENALTY } = require('../constants');
// Constants // Constants
const MAX_RECORDINGS_PER_PERSON = 3; const MAX_RECORDINGS_PER_PERSON = 3;
@@ -123,6 +130,47 @@ function getPostDanceBufferSlots(heat, divisionSlotMap = null) {
return bufferSlots; 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<userId, { recordingsDone, recordingsReceived }>
*
* 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 * Check if two users have any time collision
* User A is dancing, User B wants to record * User A is dancing, User B wants to record
@@ -235,6 +283,9 @@ async function runMatching(eventId) {
username: true, username: true,
city: true, city: true,
country: 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(); const busySlots = recorderBusySlots.get(recorder.userId) || new Set();
if (busySlots.has(heatSlot)) continue; // Collision - skip 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); const locationScore = getLocationScore(dancerUser, recorder.user);
candidates.push({ candidates.push({
recorder, recorder,
tier,
locationScore, locationScore,
fairnessDebt,
currentAssignments: currentCount, currentAssignments: currentCount,
}); });
} }
@@ -344,13 +412,19 @@ async function runMatching(eventId) {
continue; continue;
} }
// 5c. Sort candidates: location first, then load balancing // 5c. Sort candidates: Location > Fairness > Load balancing
candidates.sort((a, b) => { candidates.sort((a, b) => {
// Higher location score = better // 1. Location score (higher = better)
if (a.locationScore !== b.locationScore) { if (a.locationScore !== b.locationScore) {
return b.locationScore - a.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; return a.currentAssignments - b.currentAssignments;
}); });
@@ -480,6 +554,8 @@ module.exports = {
getPreDanceBufferSlots, getPreDanceBufferSlots,
getLocationScore, getLocationScore,
buildDivisionSlotMap, buildDivisionSlotMap,
getEffectiveTier,
getRecordingStatsForUsers,
MAX_RECORDINGS_PER_PERSON, MAX_RECORDINGS_PER_PERSON,
HEAT_BUFFER_BEFORE, HEAT_BUFFER_BEFORE,
}; };