Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/danielsl4/TFG_DAM_2526/llms.txt

Use this file to discover all available pages before exploring further.

Matches are the central entity of every FutsalManager season. An admin schedules each fixture, a referee locks it when play begins and records every goal, card, and penalty shootout kick, and the system updates scores, standings, and porra points the moment the referee finalises the result. This page explains the full data model, the match lifecycle, real-time update mechanics, and the fan voting feature.

Match data model

Every match record carries the following fields:
FieldTypeDescription
datetimestampScheduled kick-off date and time
homeTeam / awayTeamobjectTeam ID, name, and logo URL
homeTeamPlaceholder / awayTeamPlaceholderstringLabel used for knockout slots before teams are known (e.g. “Winner Group A”)
fieldobjectVenue ID, name, and location
groupstringGroup name when the match is in the group stage
phasestringCompetition phase (see below)
statusstringCurrent lifecycle state (see below)
homeGoals / awayGoalsintegerRegulation-time score
homePenaltyGoals / awayPenaltyGoalsintegerPenalty shootout goals (knockout only)
observationsstringReferee notes added at or after the final whistle

Competition phases

The default phase. Matches are assigned to a group and count toward the league table. Results update team_stats and feed the standings algorithm.
First knockout round. Teams may be set by placeholder until group stage results determine qualifiers.
Second knockout round.
Third knockout round.
The championship match. Penalty shootout fields are available for all knockout-phase matches that end level.
Only fase_de_grupos matches affect the standings table. Knockout matches do not contribute points to team_stats.

Match lifecycle

A match moves through three statuses. Only referees and admins can advance the status via PUT /matches/:id/status.
1

pendiente — Scheduled

The match exists in the calendar. Fans can browse the fixture and cast a porra vote. The score fields are null. No events can be recorded yet.
2

en_curso — In progress

A referee has locked the match (POST /matches/:id/lock) and set status to en_curso. Goals, cards, and penalty shootout kicks are now accepted via POST /matches/:id/events. Voting is closed — the API rejects new vote submissions while the match is live.
3

finalizado — Finished

The referee calls PUT /matches/:id/finish. The backend runs a single database transaction that:
  • Sets status = 'finalizado'
  • Increments matches_played for every player who appeared in an event
  • Upserts team_stats (points, won, drawn, lost, goals for/against)
  • Awards porra points to users who predicted the correct result
  • Releases the referee lock
After the transaction commits, invalidateMatchCache() and updateGlobalLastActivity() are called so clients polling the last-activity endpoint know to refresh.

Match events

Referees submit individual events during a live match. Each event is tied to a player and a side (home or away).
Event typeEffect
golIncrements home_goals or away_goals on the match; increments goals in player_stats
tarjeta_amarillaIncrements yellow_cards in player_stats and team_stats
tarjeta_rojaIncrements red_cards in player_stats and team_stats
penalti_tanda_marcadoIncrements home_penalty_goals or away_penalty_goals; does not affect individual stats
penalti_tanda_falladoRecorded for audit purposes only; no stat counters change
Every event write runs inside a PostgreSQL transaction. If anything fails, the transaction rolls back and no partial state is saved. After a successful commit the cache is invalidated immediately.
Deleting an event (DELETE /matches/:matchId/events/:eventId) reverses all stat changes in the same transaction. Goal counts on the match row are decremented using GREATEST(0, value - 1) to prevent negative scores.

Real-time updates

The Angular frontend polls GET /matches/last-activity to detect changes without holding a WebSocket connection open. The backend stores a Unix timestamp in Redis and updates it via updateGlobalLastActivity() after every event write, status change, or match finalisation. When the client sees a newer timestamp it re-fetches the affected match or list. Match list and detail responses are cached in Redis with a configurable TTL (CACHE_TTL_MS). invalidateMatchCache(id) deletes the keys for the affected match and the global list so the next request hits PostgreSQL and repopulates the cache.

Thundering herd protection

Concurrent requests for the same uncached resource are deduplicated using an in-memory inflightRequests map. When the first request for a cache key starts a database query it stores the resulting Promise in inflightRequests[cacheKey]. All subsequent concurrent requests for the same key await that same Promise instead of firing duplicate queries. The Promise is removed from the map in the finally block regardless of success or failure.
matches.js
// If a request for this key is already in-flight, wait for it
if (inflightRequests[cacheKey]) {
  const data = await inflightRequests[cacheKey];
  return res.json(data);
}

// Otherwise start the DB query and register it
inflightRequests[cacheKey] = (async () => {
  try {
    // ... database query ...
    await redis.set(cacheKey, JSON.stringify(result), "EX", CACHE_TTL_MS);
    return result;
  } finally {
    delete inflightRequests[cacheKey];
  }
})();

Match locking

Before a referee can record events, they must acquire a lock with POST /matches/:id/lock. The lock is stored as locked_by (user ID) and locked_at (timestamp) on the match row. A lock expires automatically after 2 minutes of inactivity and can be acquired by another referee. Admins can force-release any lock with POST /matches/:id/unlock?force=true. The verifyMatchLock middleware enforces the lock on all event and status mutation routes — requests from a user who does not hold the current lock are rejected with 403.

Match voting (porra)

Authenticated users can predict the outcome of any pendiente match before it starts. The three valid votes are local (home win), empate (draw), and visitante (away win).

Cast a vote

POST /matches/:id/vote with { "vote": "local" | "empate" | "visitante" }. A second vote from the same user overwrites the first via ON CONFLICT DO UPDATE.

Points awarded

When a match finalises, users whose vote matches the actual result receive 1 point added to users.points.

Leaderboard

The global user ranking is exposed at GET /statistics/user-ranking, sorted by points DESC.
Votes are only accepted while status = 'pendiente'. The API returns 400 if you attempt to vote on a match that has already started or finished, or on a fixture where either team is not yet confirmed.

List and filter matches

Query parameters, response shape, and season filtering for the match calendar.

Match detail endpoint

Full match object including events list and voting statistics.

Referee management endpoints

Lock, unlock, add events, change status, and finalize matches.

League standings

How finalized match results feed into the group-stage table.

Build docs developers (and LLMs) love