server for refapp and refbot and other stuff
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(refserver): review fixes for version, restart, bulk-update, eventId validation

alpine (Jul 15, 2026, 4:08 AM +0200) 1fd80eae 4396d821

+264 -48
+9
src/routes/auth.test.ts
··· 11 11 refereeApiKey, 12 12 } from './_testHelpers.js'; 13 13 import { createEvent } from '../engine/event.js'; 14 + import { BotMeta } from '../models/index.js'; 14 15 15 16 before(async () => { 16 17 await setupTestEnv(); ··· 39 40 const res = await request(app).get('/v1/version'); 40 41 assert.equal(res.status, 200); 41 42 assert.ok(res.body.refserver); 43 + }); 44 + 45 + test('GET /v1/version includes refbot version from BotMeta', async () => { 46 + await BotMeta.create({ type: 'production', version: '1.8.2' }); 47 + const res = await request(app).get('/v1/version'); 48 + assert.equal(res.status, 200); 49 + assert.equal(res.body.refbot, '1.8.2'); 50 + assert.ok(res.body.botDeployedAt); 42 51 }); 43 52 44 53 // ---------- auth: no header ----------
+101 -40
src/routes/matches.ts
··· 1 1 import { Router } from 'express'; 2 2 import { Types } from 'mongoose'; 3 - import { Match, Player, Chart } from '../models/index.js'; 3 + import { Match, Event, Player, Chart } from '../models/index.js'; 4 4 import { getActiveEvent } from '../engine/event.js'; 5 5 import { 6 6 initMatchState, ··· 33 33 const status = req.query.status as string | undefined; 34 34 const eventId = req.query.eventId as string | undefined; 35 35 const matchId = req.query.matchId as string | undefined; 36 + const limitStr = req.query.limit as string | undefined; 37 + const skipStr = req.query.skip as string | undefined; 38 + const limit = Math.min(Math.max(parseInt(limitStr ?? '200', 10) || 200, 1), 500); 39 + const skip = Math.max(parseInt(skipStr ?? '0', 10) || 0, 0); 40 + 36 41 const filter: Record<string, unknown> = {}; 37 42 if (status) filter.status = status; 38 - if (eventId) filter['meta.eventId'] = new Types.ObjectId(eventId); 43 + if (eventId) { 44 + if (!Types.ObjectId.isValid(eventId)) { 45 + throw new HttpError(400, 'invalid_eventId', 'eventId must be a valid ObjectId'); 46 + } 47 + filter['meta.eventId'] = new Types.ObjectId(eventId); 48 + } 39 49 if (matchId) filter.matchId = matchId; 40 50 41 - const matches = await Match.find(filter).sort({ 'meta.startedAt': -1 }).limit(200); 42 - res.json(matches); 51 + const [matches, total] = await Promise.all([ 52 + Match.find(filter).sort({ 'meta.startedAt': -1 }).skip(skip).limit(limit).lean(), 53 + Match.countDocuments(filter), 54 + ]); 55 + 56 + // Enrich with event names 57 + const eventIds = [...new Set(matches.map(m => m.meta?.eventId).filter(Boolean).map(String))]; 58 + const events = eventIds.length > 0 59 + ? await Event.find({ _id: { $in: eventIds.map(id => new Types.ObjectId(id)) } }).select('name').lean() 60 + : []; 61 + const eventNameMap = new Map(events.map(e => [String(e._id), e.name])); 62 + 63 + const enriched = matches.map(m => { 64 + const plain = m as unknown as Record<string, unknown>; 65 + const meta = plain.meta as Record<string, unknown> | undefined; 66 + if (meta?.eventId) { 67 + meta.eventName = eventNameMap.get(String(meta.eventId)) ?? null; 68 + } 69 + return plain; 70 + }); 71 + 72 + const resp = { matches: enriched, total }; 73 + res.json(resp); 43 74 } 44 75 catch (err) { 45 76 next(err); ··· 48 79 49 80 matchesRouter.get('/matches/:id', requireAuth('read'), async (req, res, next) => { 50 81 try { 51 - const match = await findMatch(param(req, 'id')); 52 - res.json(match); 82 + const id = param(req, 'id'); 83 + let match; 84 + if (Types.ObjectId.isValid(id)) { 85 + match = await Match.findById(id).lean(); 86 + } 87 + if (!match) { 88 + match = await Match.findOne({ matchId: id }).lean(); 89 + } 90 + if (!match) throw new HttpError(404, 'not_found', 'match not found'); 91 + const plain = match as unknown as Record<string, unknown>; 92 + const meta = plain.meta as Record<string, unknown> | undefined; 93 + if (meta?.eventId) { 94 + const event = await Event.findById(meta.eventId).select('name').lean(); 95 + meta.eventName = event?.name ?? null; 96 + } 97 + res.json(plain); 53 98 } 54 99 catch (err) { 55 100 next(err); ··· 436 481 throw new HttpError(409, 'not_completed', 'only completed matches can be restarted'); 437 482 } 438 483 439 - // Mark old match as restarted 484 + // Mark old match as restarted. The caller (e.g. refbot) is responsible 485 + // for creating a new match via the standard creation flow. 440 486 doc.status = 'restarted'; 487 + (doc.meta as Record<string, unknown>).completedAt = new Date(); 441 488 await doc.save(); 442 489 443 - // Create new pending match with same parameters 444 - const newMatch = await createPendingMatch({ 445 - players: (doc.players ?? []).map(p => ({ 446 - slot: p.slot, 447 - discordId: p.discordId ?? '', 448 - displayName: p.displayName ?? null, 449 - spinshareId: p.spinshareId ?? null, 450 - spinshareUsername: p.spinshareUsername ?? null, 451 - spinshareAvatarUrl: p.spinshareAvatarUrl ?? null, 452 - pronouns: p.pronouns ?? null, 453 - })) as [PlayerData, PlayerData], 454 - bestOf: doc.meta?.bestOf ?? 5, 455 - mapPool: (doc.mappool ?? []).map(m => ({ 456 - songId: m.songId ?? null, 457 - title: m.title ?? null, 458 - artist: m.artist ?? null, 459 - charter: m.charter ?? null, 460 - cover: m.cover ?? null, 461 - thumbnailUrl: m.thumbnailUrl ?? null, 462 - difficulty: m.difficulty ?? null, 463 - tags: m.tags ?? [], 464 - displayName: m.displayName ?? null, 465 - })), 466 - round: doc.meta?.round ?? 'Restarted', 467 - matchNumber: doc.meta?.matchNumber ?? 1, 468 - tier: doc.meta?.tier ?? null, 469 - startggSetId: doc.meta?.startggSetId ?? null, 470 - type: doc.meta?.type ?? 'tournament', 471 - channelId: doc.meta?.channelId ?? null, 472 - }); 473 - 474 - res.json({ restarted: true, oldMatchId: doc.matchId, state: newMatch }); 490 + res.json({ restarted: true, oldMatchId: doc.matchId }); 475 491 } 476 492 catch (err) { 477 493 next(err); ··· 562 578 } 563 579 }); 564 580 581 + // ---------- PATCH (referee field override) ---------- 582 + 583 + const PATCH_WHITELIST = [ 584 + 'status', 585 + 'progressLevel', 586 + 'currentPickerDiscordId', 587 + 'currentChart', 588 + 'players', 589 + 'mappool', 590 + 'banPhase', 591 + ]; 592 + 593 + matchesRouter.patch('/matches/:id', requireAuth('referee'), async (req, res, next) => { 594 + try { 595 + const id = param(req, 'id'); 596 + const body = req.body as Record<string, unknown>; 597 + const update: Record<string, unknown> = {}; 598 + for (const key of PATCH_WHITELIST) { 599 + if (Object.prototype.hasOwnProperty.call(body, key)) { 600 + update[key] = body[key]; 601 + } 602 + } 603 + if (Object.keys(update).length === 0) { 604 + throw new HttpError(400, 'no_updatable_fields', 'no updatable fields supplied'); 605 + } 606 + 607 + const doc = await findMatch(id); 608 + for (const [k, v] of Object.entries(update)) { 609 + (doc as unknown as Record<string, unknown>)[k] = v; 610 + } 611 + if (update.status === 'completed' || update.status === 'restarted') { 612 + (doc.meta as Record<string, unknown>).completedAt = new Date(); 613 + } 614 + await doc.save(); 615 + res.json(doc); 616 + } 617 + catch (err) { 618 + next(err); 619 + } 620 + }); 621 + 565 622 // ---------- helpers ---------- 566 623 567 624 async function findMatch(idOrMatchId: string) { ··· 601 658 if (!ids?.length || !status) throw new HttpError(400, 'missing_params', 'ids and status are required'); 602 659 603 660 const objectIds = ids.filter(id => Types.ObjectId.isValid(id)).map(id => new Types.ObjectId(id)); 661 + const update: Record<string, unknown> = { status }; 662 + if (status === 'completed' || status === 'restarted') { 663 + update['meta.completedAt'] = new Date(); 664 + } 604 665 const result = await Match.updateMany( 605 666 { _id: { $in: objectIds } }, 606 - { $set: { status, 'meta.completedAt': new Date() } }, 667 + { $set: update }, 607 668 ); 608 669 res.json({ updated: result.modifiedCount }); 609 670 }
+142 -6
src/routes/routes.test.ts
··· 121 121 .set('Authorization', auth(readApiKey)); 122 122 assert.equal(res.status, 200); 123 123 assert.equal(res.body.name, 'Lookup'); 124 + assert.equal(typeof res.body.matchCount, 'number'); 125 + assert.equal(typeof res.body.activeMatchCount, 'number'); 126 + assert.equal(typeof res.body.playerCount, 'number'); 124 127 }); 125 128 126 129 test('GET /v1/events/:id returns 404 for unknown id', async () => { ··· 266 269 assert.equal(res.status, 400); 267 270 }); 268 271 272 + test('GET /v1/matches rejects invalid eventId', async () => { 273 + const res = await request(app) 274 + .get('/v1/matches?eventId=not-valid') 275 + .set('Authorization', auth(readApiKey)); 276 + assert.equal(res.status, 400); 277 + assert.equal(res.body.error.code, 'invalid_eventId'); 278 + }); 279 + 269 280 test('GET /v1/matches lists matches', async () => { 270 281 await createEvent('Test'); 271 282 await loadActiveEvent(); ··· 285 296 .get('/v1/matches') 286 297 .set('Authorization', auth(readApiKey)); 287 298 assert.equal(res.status, 200); 288 - assert.equal(res.body.length, 1); 299 + assert.equal(res.body.matches.length, 1); 300 + assert.equal(res.body.total, 1); 301 + assert.equal(typeof res.body.matches[0].meta.eventName, 'string'); 302 + assert.equal(res.body.matches[0].meta.eventName, 'Test'); 289 303 }); 290 304 291 305 test('GET /v1/matches/:id returns a match', async () => { ··· 379 393 .get('/v1/charts') 380 394 .set('Authorization', auth(readApiKey)); 381 395 assert.equal(res.status, 200); 382 - assert.ok(Array.isArray(res.body)); 396 + assert.ok(Array.isArray(res.body.charts)); 397 + assert.equal(res.body.total, 0); 383 398 }); 384 399 385 400 // ---------- Players ---------- ··· 389 404 .get('/v1/players') 390 405 .set('Authorization', auth(readApiKey)); 391 406 assert.equal(res.status, 200); 392 - assert.ok(Array.isArray(res.body)); 407 + assert.ok(Array.isArray(res.body.players)); 408 + assert.equal(res.body.total, 0); 393 409 }); 394 410 395 411 // ---------- Check-in / Approval ---------- ··· 508 524 509 525 // ---------- Restart ---------- 510 526 511 - test('POST /v1/matches/:id/restart creates a new pending match', async () => { 527 + test('POST /v1/matches/:id/restart marks old match as restarted', async () => { 512 528 await createEvent('Test'); 513 529 await loadActiveEvent(); 514 530 // create a match directly via engine (already in in_progress status) ··· 535 551 .set('Authorization', auth(refereeApiKey)); 536 552 assert.equal(res.status, 200); 537 553 assert.equal(res.body.restarted, true); 538 - assert.equal(res.body.state.progressLevel, 'check-in'); 539 - assert.equal(res.body.state.status, 'pending'); 554 + assert.equal(res.body.oldMatchId, state.matchId); 555 + 556 + const old = await request(app) 557 + .get(`/v1/matches/${state.matchId}`) 558 + .set('Authorization', auth(readApiKey)); 559 + assert.equal(old.body.status, 'restarted'); 560 + assert.ok(old.body.meta.completedAt); 540 561 }); 541 562 542 563 test('POST /v1/matches/:id/restart rejects non-completed match', async () => { ··· 576 597 .set('Authorization', auth(readApiKey)); 577 598 assert.equal(res.status, 404); 578 599 }); 600 + 601 + // ---------- PATCH /v1/matches/:id (referee override) ---------- 602 + 603 + test('PATCH /v1/matches/:id updates whitelisted fields', async () => { 604 + await createEvent('Test'); 605 + await loadActiveEvent(); 606 + const matchId = await createAndApproveMatch(); 607 + 608 + const res = await request(app) 609 + .patch(`/v1/matches/${matchId}`) 610 + .set('Authorization', auth(refereeApiKey)) 611 + .send({ status: 'completed', progressLevel: 'finished' }); 612 + assert.equal(res.status, 200); 613 + assert.equal(res.body.status, 'completed'); 614 + assert.equal(res.body.progressLevel, 'finished'); 615 + }); 616 + 617 + test('PATCH /v1/matches/:id rejects empty update', async () => { 618 + await createEvent('Test'); 619 + await loadActiveEvent(); 620 + const matchId = await createAndApproveMatch(); 621 + 622 + const res = await request(app) 623 + .patch(`/v1/matches/${matchId}`) 624 + .set('Authorization', auth(refereeApiKey)) 625 + .send({ foo: 'bar' }); 626 + assert.equal(res.status, 400); 627 + }); 628 + 629 + // ---------- PATCH /v1/events/:id/deactivate ---------- 630 + 631 + test('PATCH /v1/events/:id/deactivate deactivates an event', async () => { 632 + const created = await request(app) 633 + .post('/v1/events') 634 + .set('Authorization', auth(adminApiKey)) 635 + .send({ name: 'Active Event' }); 636 + assert.equal(created.body.active, true); 637 + 638 + const res = await request(app) 639 + .patch(`/v1/events/${created.body._id}/deactivate`) 640 + .set('Authorization', auth(refereeApiKey)); 641 + assert.equal(res.status, 200); 642 + assert.equal(res.body.active, false); 643 + }); 644 + 645 + test('PATCH /v1/events/:id/deactivate returns 404 for unknown id', async () => { 646 + const res = await request(app) 647 + .patch('/v1/events/000000000000000000000000/deactivate') 648 + .set('Authorization', auth(refereeApiKey)); 649 + assert.equal(res.status, 404); 650 + }); 651 + 652 + // ---------- Event count enrichment ---------- 653 + 654 + test('GET /v1/events includes matchCount, activeMatchCount, playerCount', async () => { 655 + const created = await request(app) 656 + .post('/v1/events') 657 + .set('Authorization', auth(adminApiKey)) 658 + .send({ name: 'Counted Event' }); 659 + 660 + const res = await request(app) 661 + .get('/v1/events') 662 + .set('Authorization', auth(readApiKey)); 663 + assert.equal(res.status, 200); 664 + const evt = res.body.find((e: { name: string }) => e.name === 'Counted Event'); 665 + assert.ok(evt); 666 + assert.equal(typeof evt.matchCount, 'number'); 667 + assert.equal(typeof evt.activeMatchCount, 'number'); 668 + assert.equal(typeof evt.playerCount, 'number'); 669 + }); 670 + 671 + // ---------- Admin bulk operations ---------- 672 + 673 + test('POST /v1/matches/bulk-update sets completedAt only for completed/restarted', async () => { 674 + await createEvent('Test'); 675 + await loadActiveEvent(); 676 + const created = await request(app) 677 + .post('/v1/matches') 678 + .set('Authorization', auth(refereeApiKey)) 679 + .send({ 680 + round: 'WR1', 681 + matchNumber: 1, 682 + bestOf: 3, 683 + player1DiscordId: '111', 684 + player2DiscordId: '222', 685 + mapPool: [{ songId: 1001 }, { songId: 1002 }], 686 + }); 687 + const id = created.body._id; 688 + 689 + const inProgressRes = await request(app) 690 + .post('/v1/matches/bulk-update') 691 + .set('Authorization', auth(adminApiKey)) 692 + .send({ ids: [id], status: 'in_progress' }); 693 + assert.equal(inProgressRes.status, 200); 694 + assert.equal(inProgressRes.body.updated, 1); 695 + 696 + const inProgressDoc = await request(app) 697 + .get(`/v1/matches/${id}`) 698 + .set('Authorization', auth(readApiKey)); 699 + assert.equal(inProgressDoc.body.status, 'in_progress'); 700 + assert.equal(inProgressDoc.body.meta.completedAt, null); 701 + 702 + const completedRes = await request(app) 703 + .post('/v1/matches/bulk-update') 704 + .set('Authorization', auth(adminApiKey)) 705 + .send({ ids: [id], status: 'completed' }); 706 + assert.equal(completedRes.status, 200); 707 + assert.equal(completedRes.body.updated, 1); 708 + 709 + const completedDoc = await request(app) 710 + .get(`/v1/matches/${id}`) 711 + .set('Authorization', auth(readApiKey)); 712 + assert.equal(completedDoc.body.status, 'completed'); 713 + assert.ok(completedDoc.body.meta.completedAt); 714 + });
+12 -2
src/routes/version.ts
··· 1 1 import { Router } from 'express'; 2 + import { BotMeta } from '../models/index.js'; 2 3 import pkg from '../../package.json' with { type: 'json' }; 3 4 4 5 export const versionRouter = Router(); 5 6 6 - versionRouter.get('/version', (_req, res) => { 7 + versionRouter.get('/version', async (_req, res) => { 8 + let botMeta = null; 9 + try { 10 + botMeta = await BotMeta.findOne({ type: 'production' }).lean(); 11 + } 12 + catch { 13 + // ignore — BotMeta may not exist yet 14 + } 15 + 7 16 res.json({ 8 17 refserver: pkg.version, 9 - refbot: null, 18 + refbot: botMeta?.version ?? null, 10 19 refapp: null, 20 + botDeployedAt: botMeta?.updatedAt ?? null, 11 21 }); 12 22 });