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:
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
22
backend/src/constants/tiers.js
Normal file
22
backend/src/constants/tiers.js
Normal 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,
|
||||
};
|
||||
@@ -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<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
|
||||
* 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user