From 145c9f7ce6ad1f8588604f2ea0c31f2a284d0227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sun, 30 Nov 2025 10:40:43 +0100 Subject: [PATCH] feat: implement recording stats update mechanism for auto-matching Add automatic tracking of recording statistics (recordingsDone/recordingsReceived) for users participating in auto-matched collaborations. Stats are updated when both users complete mutual ratings after a recording session. Changes: - Add suggestionId, source, and statsApplied fields to Match model - Implement applyRecordingStatsForMatch() helper with user role convention (user1 = dancer, user2 = recorder) - Update suggestion status endpoint to create Match on acceptance - Update ratings endpoint to apply stats when match is completed - Add comprehensive unit tests (5) and integration tests (5) Convention: Stats only updated for auto-matches (source='auto') to ensure fairness metrics reflect actual algorithmic assignments, not manual matches. Test Results: 304/305 tests passing (99.7%) Coverage: 74.53% (+1.48%) --- backend/prisma/schema.prisma | 18 +- backend/src/__tests__/matching.test.js | 146 ++++++ .../recording-stats-integration.test.js | 447 ++++++++++++++++++ backend/src/routes/events.js | 86 +++- backend/src/routes/matches.js | 27 +- backend/src/services/matching.js | 39 ++ 6 files changed, 741 insertions(+), 22 deletions(-) create mode 100644 backend/src/__tests__/recording-stats-integration.test.js diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9c7d7bb..c7f1376 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -165,17 +165,24 @@ model Match { user1LastReadAt DateTime? @map("user1_last_read_at") user2LastReadAt DateTime? @map("user2_last_read_at") + // Auto-matching integration + suggestionId Int? @unique @map("suggestion_id") // Link to auto-matching suggestion (optional) + source String @default("manual") @db.VarChar(20) // 'manual' | 'auto' + statsApplied Boolean @default(false) @map("stats_applied") // Flag to ensure stats applied only once + // Relations - user1 User @relation("MatchUser1", fields: [user1Id], references: [id]) - user2 User @relation("MatchUser2", fields: [user2Id], references: [id]) - event Event @relation(fields: [eventId], references: [id]) - room ChatRoom? @relation(fields: [roomId], references: [id]) - ratings Rating[] + user1 User @relation("MatchUser1", fields: [user1Id], references: [id]) + user2 User @relation("MatchUser2", fields: [user2Id], references: [id]) + event Event @relation(fields: [eventId], references: [id]) + room ChatRoom? @relation(fields: [roomId], references: [id]) + suggestion RecordingSuggestion? @relation(fields: [suggestionId], references: [id]) + ratings Rating[] @@unique([user1Id, user2Id, eventId]) @@index([user1Id]) @@index([user2Id]) @@index([eventId]) + @@index([suggestionId]) @@map("matches") } @@ -285,6 +292,7 @@ model RecordingSuggestion { event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) heat EventUserHeat @relation(fields: [heatId], references: [id], onDelete: Cascade) recorder User? @relation("RecorderAssignments", fields: [recorderId], references: [id]) + match Match? // Link to created match (if suggestion was accepted) @@index([eventId]) @@index([recorderId]) diff --git a/backend/src/__tests__/matching.test.js b/backend/src/__tests__/matching.test.js index 81cd5da..26d9c8b 100644 --- a/backend/src/__tests__/matching.test.js +++ b/backend/src/__tests__/matching.test.js @@ -10,11 +10,24 @@ const { hasCollision, getLocationScore, buildDivisionSlotMap, + applyRecordingStatsForMatch, MAX_RECORDINGS_PER_PERSON, HEAT_BUFFER_BEFORE, HEAT_BUFFER_AFTER, } = require('../services/matching'); +// Mock prisma for unit tests +jest.mock('../utils/db', () => ({ + prisma: { + $transaction: jest.fn(), + user: { + update: jest.fn(), + }, + }, +})); + +const { prisma } = require('../utils/db'); + describe('Matching Service - Unit Tests', () => { describe('getTimeSlot', () => { it('should create unique slot identifier', () => { @@ -483,4 +496,137 @@ describe('Matching Service - Unit Tests', () => { expect(hasCollision(dancerHeats, recorderHeats, divisionSlotMap)).toBe(false); }); }); + + describe('applyRecordingStatsForMatch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should increment stats for auto-match with correct user roles', async () => { + const match = { + id: 1, + user1Id: 100, // dancer + user2Id: 200, // recorder + source: 'auto', + statsApplied: false, + }; + + // Mock transaction to execute callback immediately + prisma.$transaction.mockImplementation(async (callback) => { + if (Array.isArray(callback)) { + // Array of promises + return Promise.all(callback); + } else { + // Callback function + return callback(prisma); + } + }); + + prisma.user.update.mockResolvedValue({}); + + await applyRecordingStatsForMatch(match); + + // Verify transaction was called with array of updates + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + const transactionArg = prisma.$transaction.mock.calls[0][0]; + expect(Array.isArray(transactionArg)).toBe(true); + expect(transactionArg).toHaveLength(2); + + // Verify user updates were called with correct parameters + // First call should update recorder (user2Id) + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { + where: { id: 200 }, // recorder + data: { recordingsDone: { increment: 1 } }, + }); + + // Second call should update dancer (user1Id) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { + where: { id: 100 }, // dancer + data: { recordingsReceived: { increment: 1 } }, + }); + }); + + it('should NOT update stats for manual match', async () => { + const match = { + id: 1, + user1Id: 100, + user2Id: 200, + source: 'manual', // Not auto + statsApplied: false, + }; + + await applyRecordingStatsForMatch(match); + + // Should return early without calling prisma + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('should NOT update stats when source is missing', async () => { + const match = { + id: 1, + user1Id: 100, + user2Id: 200, + // source missing (undefined) + statsApplied: false, + }; + + await applyRecordingStatsForMatch(match); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('should handle different user IDs correctly', async () => { + const match = { + id: 2, + user1Id: 999, // dancer + user2Id: 888, // recorder + source: 'auto', + }; + + prisma.$transaction.mockImplementation(async (callback) => { + if (Array.isArray(callback)) { + return Promise.all(callback); + } + }); + + prisma.user.update.mockResolvedValue({}); + + await applyRecordingStatsForMatch(match); + + // Verify correct user IDs were used + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { + where: { id: 888 }, // recorder + data: { recordingsDone: { increment: 1 } }, + }); + + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { + where: { id: 999 }, // dancer + data: { recordingsReceived: { increment: 1 } }, + }); + }); + + it('should execute updates in a transaction', async () => { + const match = { + id: 1, + user1Id: 100, + user2Id: 200, + source: 'auto', + }; + + prisma.$transaction.mockImplementation(async (operations) => { + // Verify it's an array of operations (transaction) + expect(Array.isArray(operations)).toBe(true); + return Promise.all(operations); + }); + + prisma.user.update.mockResolvedValue({}); + + await applyRecordingStatsForMatch(match); + + // Verify transaction was used (atomic operation) + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/backend/src/__tests__/recording-stats-integration.test.js b/backend/src/__tests__/recording-stats-integration.test.js new file mode 100644 index 0000000..cc98a62 --- /dev/null +++ b/backend/src/__tests__/recording-stats-integration.test.js @@ -0,0 +1,447 @@ +/** + * Integration tests for Recording Stats Update Mechanism + * + * Tests the full flow: + * 1. Recording suggestion accepted → Match created + * 2. Both users rate → Match completed + stats updated + */ + +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { hashPassword, generateToken } = require('../utils/auth'); + +// Test data +let dancer, recorder; +let dancerToken, recorderToken; +let testEvent; +let testDivision, testCompetitionType; +let testHeat; +let testSuggestion; + +// Setup test data +beforeAll(async () => { + // Clean up test data + const testUsernames = ['stats_dancer', 'stats_recorder']; + const testEmails = ['stats_dancer@example.com', 'stats_recorder@example.com']; + const testEventSlug = 'stats-test-event-2025'; + + // Find and delete existing test users + const existingUsers = await prisma.user.findMany({ + where: { + OR: [ + { username: { in: testUsernames } }, + { email: { in: testEmails } } + ] + }, + select: { id: true } + }); + const existingUserIds = existingUsers.map(u => u.id); + + if (existingUserIds.length > 0) { + await prisma.eventUserHeat.deleteMany({ where: { userId: { in: existingUserIds } } }); + await prisma.rating.deleteMany({ where: { raterId: { in: existingUserIds } } }); + await prisma.match.deleteMany({ + where: { + OR: [ + { user1Id: { in: existingUserIds } }, + { user2Id: { in: existingUserIds } } + ] + } + }); + await prisma.eventParticipant.deleteMany({ where: { userId: { in: existingUserIds } } }); + await prisma.user.deleteMany({ where: { id: { in: existingUserIds } } }); + } + + // Delete test event + const existingEvent = await prisma.event.findUnique({ + where: { slug: testEventSlug }, + select: { id: true } + }); + + if (existingEvent) { + await prisma.recordingSuggestion.deleteMany({ where: { eventId: existingEvent.id } }); + await prisma.eventUserHeat.deleteMany({ where: { eventId: existingEvent.id } }); + await prisma.eventParticipant.deleteMany({ where: { eventId: existingEvent.id } }); + await prisma.event.delete({ where: { id: existingEvent.id } }); + } + + // Create test users with initial stats = 0 + const hashedPassword = await hashPassword('TestPass123!'); + + dancer = await prisma.user.create({ + data: { + username: 'stats_dancer', + email: 'stats_dancer@example.com', + passwordHash: hashedPassword, + emailVerified: true, + recordingsDone: 0, + recordingsReceived: 0, + }, + }); + + recorder = await prisma.user.create({ + data: { + username: 'stats_recorder', + email: 'stats_recorder@example.com', + passwordHash: hashedPassword, + emailVerified: true, + recordingsDone: 0, + recordingsReceived: 0, + }, + }); + + dancerToken = generateToken({ userId: dancer.id }); + recorderToken = generateToken({ userId: recorder.id }); + + // Create test event + testEvent = await prisma.event.create({ + data: { + slug: testEventSlug, + name: 'Stats Test Event 2025', + location: 'Test City', + startDate: new Date('2025-06-01'), + endDate: new Date('2025-06-03'), + }, + }); + + // Add participants + await prisma.eventParticipant.createMany({ + data: [ + { userId: dancer.id, eventId: testEvent.id, competitorNumber: 100 }, + { userId: recorder.id, eventId: testEvent.id }, + ], + }); + + // Get or create division and competition type + testDivision = await prisma.division.findFirst({ + where: { abbreviation: 'INT' }, + }); + + if (!testDivision) { + testDivision = await prisma.division.create({ + data: { + name: 'Intermediate', + abbreviation: 'INT', + displayOrder: 3, + }, + }); + } + + testCompetitionType = await prisma.competitionType.findFirst({ + where: { abbreviation: 'J&J' }, // Use existing abbreviation + }); + + if (!testCompetitionType) { + testCompetitionType = await prisma.competitionType.create({ + data: { + name: 'Jack & Jill', + abbreviation: 'J&J', + }, + }); + } + + // Create heat for dancer + testHeat = await prisma.eventUserHeat.create({ + data: { + userId: dancer.id, + eventId: testEvent.id, + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 1, + role: 'Leader', + }, + }); + + // Create recording suggestion + testSuggestion = await prisma.recordingSuggestion.create({ + data: { + eventId: testEvent.id, + heatId: testHeat.id, + recorderId: recorder.id, + status: 'pending', + }, + }); +}); + +afterAll(async () => { + // Cleanup + if (dancer && recorder) { + const userIds = [dancer.id, recorder.id]; + await prisma.eventUserHeat.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.rating.deleteMany({ where: { raterId: { in: userIds } } }); + await prisma.match.deleteMany({ + where: { + OR: [ + { user1Id: { in: userIds } }, + { user2Id: { in: userIds } } + ] + } + }); + await prisma.eventParticipant.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + } + + if (testEvent) { + await prisma.recordingSuggestion.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.eventUserHeat.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.eventParticipant.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.event.delete({ where: { id: testEvent.id } }); + } + + await prisma.$disconnect(); +}); + +describe('Recording Stats Integration Tests', () => { + describe('Full Flow: Suggestion → Match → Ratings → Stats', () => { + let createdMatch; + + it('should create Match with correct fields when suggestion is accepted', async () => { + // Recorder accepts the suggestion + const response = await request(app) + .put(`/api/events/${testEvent.slug}/match-suggestions/${testSuggestion.id}/status`) + .set('Authorization', `Bearer ${recorderToken}`) + .send({ status: 'accepted' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.status).toBe('accepted'); + expect(response.body.data.matchId).toBeDefined(); + expect(response.body.data.matchSlug).toBeDefined(); + + // Verify Match was created with correct fields + const match = await prisma.match.findUnique({ + where: { id: response.body.data.matchId }, + include: { room: true }, + }); + + expect(match).toBeDefined(); + expect(match.user1Id).toBe(dancer.id); // Convention: user1 = dancer + expect(match.user2Id).toBe(recorder.id); // Convention: user2 = recorder + expect(match.eventId).toBe(testEvent.id); + expect(match.suggestionId).toBe(testSuggestion.id); + expect(match.source).toBe('auto'); + expect(match.status).toBe('accepted'); + expect(match.statsApplied).toBe(false); + expect(match.roomId).toBeDefined(); // Chat room created + expect(match.room).toBeDefined(); + expect(match.room.type).toBe('private'); + + createdMatch = match; + }); + + it('should be idempotent - accepting again should not create duplicate Match', async () => { + // Update suggestion back to pending + await prisma.recordingSuggestion.update({ + where: { id: testSuggestion.id }, + data: { status: 'pending' }, + }); + + // Accept again + const response = await request(app) + .put(`/api/events/${testEvent.slug}/match-suggestions/${testSuggestion.id}/status`) + .set('Authorization', `Bearer ${recorderToken}`) + .send({ status: 'accepted' }); + + expect(response.status).toBe(200); + expect(response.body.data.matchId).toBe(createdMatch.id); // Same match + + // Verify only one match exists + const matchCount = await prisma.match.count({ + where: { suggestionId: testSuggestion.id }, + }); + expect(matchCount).toBe(1); + }); + + it('should NOT update stats after only one rating', async () => { + // Dancer rates recorder + const response = await request(app) + .post(`/api/matches/${createdMatch.slug}/ratings`) + .set('Authorization', `Bearer ${dancerToken}`) + .send({ + score: 5, + comment: 'Great recorder!', + wouldCollaborateAgain: true, + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + + // Verify match is still 'accepted' (not completed) + const match = await prisma.match.findUnique({ + where: { id: createdMatch.id }, + }); + expect(match.status).toBe('accepted'); + expect(match.statsApplied).toBe(false); + + // Verify stats NOT updated yet + const dancerStats = await prisma.user.findUnique({ + where: { id: dancer.id }, + select: { recordingsDone: true, recordingsReceived: true }, + }); + const recorderStats = await prisma.user.findUnique({ + where: { id: recorder.id }, + select: { recordingsDone: true, recordingsReceived: true }, + }); + + expect(dancerStats.recordingsDone).toBe(0); + expect(dancerStats.recordingsReceived).toBe(0); + expect(recorderStats.recordingsDone).toBe(0); + expect(recorderStats.recordingsReceived).toBe(0); + }); + + it('should update stats and mark match as completed after both users rate', async () => { + // Recorder rates dancer + const response = await request(app) + .post(`/api/matches/${createdMatch.slug}/ratings`) + .set('Authorization', `Bearer ${recorderToken}`) + .send({ + score: 5, + comment: 'Great dancer!', + wouldCollaborateAgain: true, + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + + // Verify match is now 'completed' with statsApplied + const match = await prisma.match.findUnique({ + where: { id: createdMatch.id }, + }); + expect(match.status).toBe('completed'); + expect(match.statsApplied).toBe(true); + + // Verify stats were updated correctly + const dancerStats = await prisma.user.findUnique({ + where: { id: dancer.id }, + select: { recordingsDone: true, recordingsReceived: true }, + }); + const recorderStats = await prisma.user.findUnique({ + where: { id: recorder.id }, + select: { recordingsDone: true, recordingsReceived: true }, + }); + + // Dancer: received +1 (was recorded) + expect(dancerStats.recordingsDone).toBe(0); + expect(dancerStats.recordingsReceived).toBe(1); + + // Recorder: done +1 (did the recording) + expect(recorderStats.recordingsDone).toBe(1); + expect(recorderStats.recordingsReceived).toBe(0); + }); + }); + + describe('Manual Match - Stats NOT Updated', () => { + let manualMatch; + let user1, user2; + let user1Token, user2Token; + + beforeAll(async () => { + // Create two users for manual match + const hashedPassword = await hashPassword('TestPass123!'); + + user1 = await prisma.user.create({ + data: { + username: 'stats_manual_user1', + email: 'stats_manual1@example.com', + passwordHash: hashedPassword, + emailVerified: true, + recordingsDone: 0, + recordingsReceived: 0, + }, + }); + + user2 = await prisma.user.create({ + data: { + username: 'stats_manual_user2', + email: 'stats_manual2@example.com', + passwordHash: hashedPassword, + emailVerified: true, + recordingsDone: 0, + recordingsReceived: 0, + }, + }); + + user1Token = generateToken({ userId: user1.id }); + user2Token = generateToken({ userId: user2.id }); + + // Add participants to event + await prisma.eventParticipant.createMany({ + data: [ + { userId: user1.id, eventId: testEvent.id }, + { userId: user2.id, eventId: testEvent.id }, + ], + }); + + // Create manual match + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${user1Token}`) + .send({ + targetUserId: user2.id, + eventSlug: testEvent.slug, + }); + + expect(response.status).toBe(201); + manualMatch = response.body.data; + }); + + afterAll(async () => { + if (user1 && user2) { + const userIds = [user1.id, user2.id]; + await prisma.rating.deleteMany({ where: { raterId: { in: userIds } } }); + await prisma.match.deleteMany({ + where: { + OR: [ + { user1Id: { in: userIds } }, + { user2Id: { in: userIds } } + ] + } + }); + await prisma.eventParticipant.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + } + }); + + it('should NOT update stats for manual match after ratings', async () => { + // Accept match + await request(app) + .put(`/api/matches/${manualMatch.slug}/accept`) + .set('Authorization', `Bearer ${user2Token}`); + + // Both users rate each other + await request(app) + .post(`/api/matches/${manualMatch.slug}/ratings`) + .set('Authorization', `Bearer ${user1Token}`) + .send({ score: 5 }); + + await request(app) + .post(`/api/matches/${manualMatch.slug}/ratings`) + .set('Authorization', `Bearer ${user2Token}`) + .send({ score: 5 }); + + // Verify match is completed + const match = await prisma.match.findUnique({ + where: { slug: manualMatch.slug }, + }); + expect(match.status).toBe('completed'); + expect(match.source).toBe('manual'); + expect(match.statsApplied).toBe(true); // Flag set, but stats not updated + + // Verify stats were NOT updated (source='manual') + const user1Stats = await prisma.user.findUnique({ + where: { id: user1.id }, + select: { recordingsDone: true, recordingsReceived: true }, + }); + const user2Stats = await prisma.user.findUnique({ + where: { id: user2.id }, + select: { recordingsDone: true, recordingsReceived: true }, + }); + + expect(user1Stats.recordingsDone).toBe(0); + expect(user1Stats.recordingsReceived).toBe(0); + expect(user2Stats.recordingsDone).toBe(0); + expect(user2Stats.recordingsReceived).toBe(0); + }); + }); +}); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 53924fe..2e83b77 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -3,7 +3,7 @@ const { prisma } = require('../utils/db'); const { authenticate } = require('../middleware/auth'); const { getIO } = require('../socket'); const matchingService = require('../services/matching'); -const { SUGGESTION_STATUS } = require('../constants'); +const { SUGGESTION_STATUS, MATCH_STATUS } = require('../constants'); const router = express.Router(); @@ -1260,21 +1260,77 @@ router.put('/:slug/match-suggestions/:suggestionId/status', authenticate, async }); } - // Update status - const updated = await prisma.recordingSuggestion.update({ - where: { id: parseInt(suggestionId) }, - data: { status }, - select: { - id: true, - status: true, - updatedAt: true, - }, - }); + // If accepted, create Match (if doesn't exist) and chat room + if (status === 'accepted') { + const result = await prisma.$transaction(async (tx) => { + // Update suggestion status + const updatedSuggestion = await tx.recordingSuggestion.update({ + where: { id: parseInt(suggestionId) }, + data: { status }, + }); - res.json({ - success: true, - data: updated, - }); + // Check if Match already exists for this suggestion (idempotency) + const existingMatch = await tx.match.findUnique({ + where: { suggestionId: suggestion.id }, + }); + + if (existingMatch) { + // Match already exists - just return the updated suggestion + return { suggestion: updatedSuggestion, match: existingMatch }; + } + + // Create private chat room + const chatRoom = await tx.chatRoom.create({ + data: { + type: 'private', + eventId: event.id, + }, + }); + + // Create Match with convention: user1 = dancer, user2 = recorder + const match = await tx.match.create({ + data: { + user1Id: suggestion.heat.userId, // dancer + user2Id: suggestion.recorderId, // recorder + eventId: event.id, + suggestionId: suggestion.id, + source: 'auto', + status: MATCH_STATUS.ACCEPTED, + roomId: chatRoom.id, + statsApplied: false, + }, + }); + + return { suggestion: updatedSuggestion, match }; + }); + + res.json({ + success: true, + data: { + id: result.suggestion.id, + status: result.suggestion.status, + updatedAt: result.suggestion.updatedAt, + matchId: result.match.id, + matchSlug: result.match.slug, + }, + }); + } else { + // Rejected - just update status + const updated = await prisma.recordingSuggestion.update({ + where: { id: parseInt(suggestionId) }, + data: { status }, + select: { + id: true, + status: true, + updatedAt: true, + }, + }); + + res.json({ + success: true, + data: updated, + }); + } } catch (error) { next(error); } diff --git a/backend/src/routes/matches.js b/backend/src/routes/matches.js index 85b2af6..f19c516 100644 --- a/backend/src/routes/matches.js +++ b/backend/src/routes/matches.js @@ -3,6 +3,7 @@ const { prisma } = require('../utils/db'); const { authenticate } = require('../middleware/auth'); const { getIO } = require('../socket'); const { MATCH_STATUS } = require('../constants'); +const matchingService = require('../services/matching'); const router = express.Router(); @@ -826,10 +827,32 @@ router.post('/:slug/ratings', authenticate, async (req, res, next) => { }); if (otherUserRating) { - // Both users have rated - mark match as completed + // Both users have rated - mark match as completed and apply stats + + // Get full match with required fields for stats update + const fullMatch = await prisma.match.findUnique({ + where: { id: match.id }, + select: { + id: true, + user1Id: true, + user2Id: true, + source: true, + statsApplied: true, + }, + }); + + // Apply recording stats if not already applied (idempotency) + if (fullMatch && !fullMatch.statsApplied) { + await matchingService.applyRecordingStatsForMatch(fullMatch); + } + + // Update match status to completed and mark stats as applied await prisma.match.update({ where: { id: match.id }, - data: { status: MATCH_STATUS.COMPLETED }, + data: { + status: MATCH_STATUS.COMPLETED, + statsApplied: true, + }, }); } diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index ccf2aaf..40925f8 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -544,10 +544,49 @@ async function getUserSuggestions(eventId, userId) { }; } +/** + * Apply recording stats update for a completed match + * Updates recordingsDone for recorder and recordingsReceived for dancer + * + * IMPORTANT: This function assumes the following convention for auto-matches: + * - user1Id = dancer (the one being recorded) + * - user2Id = recorder (the one doing the recording) + * + * Only applies to auto-matches (source='auto') to ensure stats reflect + * factual collaborations from the auto-matching system. + * + * @param {Object} match - Match object with user1Id, user2Id, and source + * @returns {Promise} + */ +async function applyRecordingStatsForMatch(match) { + // Only apply stats for auto-matches + if (match.source !== 'auto') { + return; + } + + // Convention: user1 = dancer, user2 = recorder + const dancerId = match.user1Id; + const recorderId = match.user2Id; + + await prisma.$transaction([ + // Increment recordingsDone for recorder + prisma.user.update({ + where: { id: recorderId }, + data: { recordingsDone: { increment: 1 } } + }), + // Increment recordingsReceived for dancer + prisma.user.update({ + where: { id: dancerId }, + data: { recordingsReceived: { increment: 1 } } + }) + ]); +} + module.exports = { runMatching, saveMatchingResults, getUserSuggestions, + applyRecordingStatsForMatch, hasCollision, getCoverableHeats, getTimeSlot,