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%)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
447
backend/src/__tests__/recording-stats-integration.test.js
Normal file
447
backend/src/__tests__/recording-stats-integration.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user