diff --git a/src/utils/inviteSuggestions.js b/src/utils/inviteSuggestions.js index d58ad5e8..bef1d682 100644 --- a/src/utils/inviteSuggestions.js +++ b/src/utils/inviteSuggestions.js @@ -1,4 +1,4 @@ -import { uniqueActiveParticipants } from "./participants"; +import { uniqueParticipants } from "./participants"; import { memberMatchesParticipant } from "./memberIdentity"; const candidateIdKeys = [ @@ -31,6 +31,10 @@ const readValue = (subject, key) => { return subject[key]; }; +const isPlainIdKey = (key) => typeof key === "string" && key === "id"; + +const participantIdentityPaths = candidateIdKeys.filter((key) => !isPlainIdKey(key)); + const parseNumericId = (value) => { if (value === null || value === undefined) return null; if (typeof value === "number") { @@ -47,6 +51,10 @@ const parseNumericId = (value) => { }; const extractParticipantId = (participant) => { + if (!participant || typeof participant !== "object") { + return null; + } + for (const key of candidateIdKeys) { const candidate = readValue(participant, key); const parsed = parseNumericId(candidate); @@ -54,9 +62,382 @@ const extractParticipantId = (participant) => { return parsed; } } + return null; }; +const participantContextKeys = [ + "participant_status", + "participantStatus", + "status", + "status_reason", + "statusReason", + "role", + "is_active", + "active", + "joined_at", + "joinedAt", + "checked_in_at", + "checkedInAt", + "invited_at", + "invitedAt", + "left_at", + "leftAt", + "removed_at", + "removedAt", + "cancelled_at", + "cancelledAt", + "canceled_at", + "canceledAt", + "declined_at", + "declinedAt", + "withdrawn_at", + "withdrawnAt", + "profile", + "player", + "invitee", + "user", + "member", + "team", + "identity", + "identity_id", + "identityId", + "identity_ids", + "identityIds", + "full_name", + "fullName", + "display_name", + "displayName", +]; + +const plainIdSupplementalKeys = [ + "participant_status", + "participantStatus", + "status_reason", + "statusReason", + "role", + "profile", + "player", + "invitee", + "user", + "member", + "team", + "identity", + "identity_id", + "identityId", + "identity_ids", + "identityIds", + "full_name", + "fullName", + "display_name", + "displayName", + "joined_at", + "joinedAt", + "checked_in_at", + "checkedInAt", + "invited_at", + "invitedAt", + "left_at", + "leftAt", + "removed_at", + "removedAt", + "cancelled_at", + "cancelledAt", + "canceled_at", + "canceledAt", + "declined_at", + "declinedAt", + "withdrawn_at", + "withdrawnAt", +]; + +const participantStatusValues = new Set([ + "accepted", + "accept", + "active", + "attend", + "attending", + "checked-in", + "checked_in", + "checkedin", + "confirm", + "confirmed", + "confirmed_player", + "confirmed_substitute", + "joining", + "joined", + "participant", + "participating", + "pending", + "playing", + "registered", + "reserve", + "standby", + "sub", + "substitute", + "waitlist", + "waitlisted", + "alternate", + "alternate_player", + "available", + "invited", +]); + +const looksLikeParticipantRecord = (candidate) => { + if (!candidate || typeof candidate !== "object" || candidate instanceof Date) { + return false; + } + + const participantId = extractParticipantId(candidate); + if (participantId === null) { + return false; + } + + const hasParticipantSpecificIdentity = participantIdentityPaths.some((key) => { + const value = readValue(candidate, key); + if (value === undefined || value === null) { + return false; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + return true; + }); + + const hasPlainId = Object.prototype.hasOwnProperty.call(candidate, "id"); + + if (!hasParticipantSpecificIdentity && !hasPlainId) { + return false; + } + + const hasContext = participantContextKeys.some((key) => + Object.prototype.hasOwnProperty.call(candidate, key), + ); + + if (!hasContext) { + return false; + } + + if (hasPlainId && !hasParticipantSpecificIdentity) { + const hasSupplemental = plainIdSupplementalKeys.some((key) => + Object.prototype.hasOwnProperty.call(candidate, key), + ); + + let hasParticipantStatus = false; + if (!hasSupplemental && Object.prototype.hasOwnProperty.call(candidate, "status")) { + const statusValue = candidate.status; + if (statusValue !== undefined && statusValue !== null) { + const normalized = statusValue.toString().trim().toLowerCase(); + hasParticipantStatus = participantStatusValues.has(normalized); + } + } + + if (!hasSupplemental && !hasParticipantStatus) { + return false; + } + } + + return true; +}; + +const participantCollectionPaths = [ + ["participants"], + ["match", "participants"], + ["matchParticipants"], + ["match", "matchParticipants"], + ["match_participants"], + ["match", "match_participants"], +]; + +const collectParticipantsFromSource = (source, participants, visited) => { + if (source === null || source === undefined) { + return; + } + + if (typeof source !== "object" || source instanceof Date) { + return; + } + + if (visited.has(source)) { + return; + } + visited.add(source); + + if (Array.isArray(source)) { + source.forEach((item) => { + if (item === undefined || item === null) { + return; + } + collectParticipantsFromSource(item, participants, visited); + }); + return; + } + + if (looksLikeParticipantRecord(source)) { + participants.push(source); + return; + } + + Object.values(source).forEach((value) => { + if (value === undefined || value === null) { + return; + } + if (typeof value !== "object") { + return; + } + collectParticipantsFromSource(value, participants, visited); + }); +}; + +const collectMatchParticipants = (match) => { + if (!match || typeof match !== "object") { + return []; + } + + const participants = []; + const visited = new Set(); + + participantCollectionPaths.forEach((path) => { + const value = readValue(match, path); + if (value !== undefined && value !== null) { + collectParticipantsFromSource(value, participants, visited); + } + }); + + return participants; +}; + +const departureKeys = [ + "left_at", + "leftAt", + "removed_at", + "removedAt", + "cancelled_at", + "cancelledAt", + "canceled_at", + "canceledAt", + "declined_at", + "declinedAt", + "withdrawn_at", + "withdrawnAt", +]; + +const statusKeys = [ + "status", + "participant_status", + "participantStatus", + "status_reason", + "statusReason", + "response", + "rsvp_status", + "rsvpStatus", +]; + +const normalizeStatus = (value) => { + if (value === undefined || value === null) { + return ""; + } + const normalized = value.toString().trim().toLowerCase(); + if (!normalized) { + return ""; + } + return normalized + .replace(/[_-]+/g, " ") + .replace(/\s{2,}/g, " ") + .trim(); +}; + +const inactiveStatusValues = new Set([ + "left", + "removed", + "cancelled", + "canceled", + "declined", + "rejected", + "withdrawn", + "expired", + "no show", + "did not show", + "did not attend", + "not attending", + "not coming", + "no longer attending", + "no longer playing", + "banned", + "blocked", + "suspended", + "kicked", + "booted", + "removed by host", + "removed by captain", + "cancelled by host", + "canceled by host", + "cancelled by captain", + "canceled by captain", +]); + +const inactiveStatusList = Array.from(inactiveStatusValues); + +const hasMeaningfulValue = (subject, key) => { + if (!subject || typeof subject !== "object") { + return false; + } + if (!Object.prototype.hasOwnProperty.call(subject, key)) { + return false; + } + const value = subject[key]; + if (value === undefined || value === null) { + return false; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (value instanceof Date) { + return !Number.isNaN(value.getTime()); + } + return true; +}; + +const hasDepartureMetadata = (participant) => + departureKeys.some((key) => hasMeaningfulValue(participant, key)); + +const hasInactiveStatus = (participant) => + statusKeys.some((key) => { + const raw = readValue(participant, key); + const normalized = normalizeStatus(raw); + if (!normalized) { + return false; + } + if (inactiveStatusValues.has(normalized)) { + return true; + } + return inactiveStatusList.some((inactive) => + normalized.includes(inactive), + ); + }); + +const isEligibleForSuggestions = (participant) => { + if (!participant || typeof participant !== "object") { + return false; + } + if (participant.is_active === false || participant.active === false) { + return false; + } + if (hasDepartureMetadata(participant)) { + return false; + } + if (hasInactiveStatus(participant)) { + return false; + } + return true; +}; + const buildParticipantName = (participant, fallbackId) => { if (!participant || typeof participant !== "object") { return fallbackId ? `Player ${fallbackId}` : "Unknown player"; @@ -141,7 +522,9 @@ export const buildRecentPartnerSuggestions = ({ matches.forEach((match) => { const { timestamp, iso } = extractMatchMoment(match); - const participants = uniqueActiveParticipants(match?.participants); + const participants = uniqueParticipants( + collectMatchParticipants(match), + ).filter(isEligibleForSuggestions); participants.forEach((participant) => { if ( memberMatchesParticipant(