diff --git a/TOURNAMENT_LFG_PLAN.md b/TOURNAMENT_LFG_PLAN.md new file mode 100644 index 000000000..f1dce2d37 --- /dev/null +++ b/TOURNAMENT_LFG_PLAN.md @@ -0,0 +1,329 @@ +# Tournament LFG Implementation Plan + +## Overview + +Add a new `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before the tournament starts. + +## Phase 1: Database Migration + +**File**: `migrations/118-tournament-lfg.js` + +Create three new tables: + +### TournamentLFGGroup +| Column | Type | Notes | +|--------|------|-------| +| id | integer primary key | Auto-increment | +| tournamentId | integer not null | FK to Tournament | +| tournamentTeamId | integer | FK to TournamentTeam (null for unregistered groups) | +| visibility | text | JSON (AssociationVisibility) | +| chatCode | text not null | Unique room code for group chat | +| createdAt | integer | Default now | + +### TournamentLFGGroupMember +| Column | Type | Notes | +|--------|------|-------| +| groupId | integer not null | FK to TournamentLFGGroup | +| userId | integer not null | FK to User | +| role | text not null | OWNER / MANAGER / REGULAR | +| note | text | Public note | +| isStayAsSub | integer | Boolean (0/1), default 0 | +| createdAt | integer | Default now | + +### TournamentLFGLike +| Column | Type | Notes | +|--------|------|-------| +| likerGroupId | integer not null | FK to TournamentLFGGroup | +| targetGroupId | integer not null | FK to TournamentLFGGroup | +| createdAt | integer | Default now | + +All tables use `STRICT` mode, `ON DELETE CASCADE` for FKs, and have indexes on FK columns. + +--- + +## Phase 2: TypeScript Types + +**File**: `app/db/tables.ts` + +Add interfaces for the three new tables following existing patterns (`GeneratedAlways`, `Generated`, `JSONColumnTypeNullable`). + +Add to the `DB` interface: +- `TournamentLFGGroup` +- `TournamentLFGGroupMember` +- `TournamentLFGLike` + +--- + +## Phase 3: Feature File Structure + +``` +app/features/tournament-lfg/ +├── TournamentLFGRepository.server.ts # Database operations +├── tournament-lfg-types.ts # TypeScript types (LFGGroup, LFGGroupMember, etc.) +├── tournament-lfg-schemas.server.ts # Zod validation schemas +├── tournament-lfg-constants.ts # Constants (note max length, etc.) +├── tournament-lfg-utils.ts # Utility functions +├── routes/ +│ └── to.$id.looking.tsx # Main route (loader + action + component) +├── loaders/ +│ └── to.$id.looking.server.ts # Data loader +├── actions/ +│ └── to.$id.looking.server.ts # Action handler +└── components/ + └── LFGGroupCard.tsx # Group card (mirrors SendouQ GroupCard pattern) +``` + +--- + +## Phase 4: Repository Implementation + +**File**: `app/features/tournament-lfg/TournamentLFGRepository.server.ts` + +Mirror `SQGroupRepository.server.ts` pattern. Key functions: + +**Group Management:** +- `findGroupsByTournamentId(tournamentId)` - Get all active groups +- `addMember(groupId, { userId, role, stayAsSub? })` - Add member to group +- `morphGroups({ survivingGroupId, otherGroupId })` - Merge two groups + +**Likes:** +- `addLike({ likerGroupId, targetGroupId })` - Add like +- `deleteLike({ likerGroupId, targetGroupId })` - Remove like +- `allLikesByGroupId(groupId)` - Get { given: [], received: [] } + +**Member Management:** +- `updateMemberNote({ groupId, userId, value })` - Update public note +- `updateMemberRole({ userId, groupId, role })` - Change role +- `updateStayAsSub({ groupId, userId, value })` - Toggle sub preference +- `kickMember({ groupId, userId })` - Owner kicks member + +**Tournament Integration:** +- `cleanupForTournamentStart(tournamentId)` - Delete groups, preserve stayAsSub members +- `getSubsForTournament(tournamentId)` - Get users who opted to stay as sub + +--- + +## Phase 5: Route Implementation + +### 5.1 Route Registration + +**File**: `app/routes.ts` + +Add inside `/to/:id` children: +```typescript +route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"), +``` + +### 5.2 Loader + +**File**: `app/features/tournament-lfg/loaders/to.$id.looking.server.ts` + +Returns: +- `groups` - All visible groups (filtered by visibility) +- `ownGroup` - User's current group (if any) +- `likes` - { given: [], received: [] } for own group +- `privateNotes` - User's private notes on other players (reuse from SendouQ) +- `lastUpdated` - Timestamp for auto-refresh + +### 5.3 Action + +**File**: `app/features/tournament-lfg/actions/to.$id.looking.server.ts` + +Actions: +- `JOIN_QUEUE` - Create new group +- `LIKE` / `UNLIKE` - Like/unlike another group +- `ACCEPT` - Accept mutual like (triggers team creation/merge) +- `LEAVE_GROUP` - Leave current group +- `KICK_FROM_GROUP` - Owner kicks member +- `GIVE_MANAGER` / `REMOVE_MANAGER` - Role management +- `UPDATE_NOTE` - Update public note +- `UPDATE_STAY_AS_SUB` - Toggle sub preference +- `REFRESH_GROUP` - Refresh activity timestamp + +**ACCEPT Action Flow:** +1. Verify mutual like exists +2. Check if either group has `tournamentTeamId` +3. If neither: Create new `TournamentTeam` (use auto-generated name) +4. Merge groups (use `morphGroups`) +5. Add all members to `TournamentTeamMember` +6. Send `TO_LFG_TEAM_FORMED` notification +7. If team reaches `maxMembersPerTeam`: delete the LFG group + +### 5.4 Component + +**File**: `app/features/tournament-lfg/routes/to.$id.looking.tsx` + +Structure (mirror `/q/looking`): +- Three-column desktop layout: My Group | Groups | Invitations +- Tab structure on mobile +- Reuse `GroupCard` display pattern (weapons, VC, tier) +- Reuse `MemberAdder` for quick-add trusted players +- Reuse `GroupLeaver` component +- Chat integration for groups with 2+ members +- "Stay as sub" checkbox in join form + +--- + +## Phase 6: Tournament Integration + +### 6.1 Add "Looking" Tab + +**File**: `app/features/tournament/routes/to.$id.tsx` + +Add new `SubNavLink` (show only before tournament starts, not for invitationals): +```tsx +{!tournament.hasStarted && !tournament.isInvitational && ( + {t("tournament:tabs.looking")} +)} +``` + +### 6.2 Tournament.ts Getter + +**File**: `app/features/tournament-bracket/core/Tournament.ts` + +Add: +```typescript +get lfgEnabled() { + return !this.isInvitational && !this.hasStarted && this.subsFeatureEnabled; +} +``` + +### 6.3 Auto-cleanup on Tournament Start + +When tournament bracket starts, call `TournamentLFGRepository.cleanupForTournamentStart(tournamentId)`: +- Delete all `TournamentLFGGroup` records +- Preserve `TournamentLFGGroupMember` records where `stayAsSub = 1` for subs list + +--- + +## Phase 7: Notifications + +**File**: `app/features/notifications/notifications-types.ts` + +Add three new notification types: + +```typescript +| NotificationItem< + "TO_LFG_LIKED", + { + tournamentId: number; + tournamentName: string; + likerUsername: string; + } + > +| NotificationItem< + "TO_LFG_TEAM_FORMED", + { + tournamentId: number; + tournamentName: string; + teamName: string; + tournamentTeamId: number; + } + > +| NotificationItem< + "TO_LFG_CHAT_MESSAGE", + { + tournamentId: number; + tournamentName: string; + teamName: string; + tournamentTeamId: number; + } + > +``` + +**File**: `app/features/notifications/notifications-utils.ts` + +Add notification link handlers and icon mappings. + +--- + +## Phase 8: Translations + +**File**: `public/locales/en/tournament.json` + +Add keys: +- `tabs.looking` +- `lfg.join.header`, `lfg.join.stayAsSub`, `lfg.join.visibility` +- `lfg.myGroup.header`, `lfg.myGroup.empty` +- `lfg.groups.header`, `lfg.groups.empty` +- `lfg.invitations.header`, `lfg.invitations.empty`, `lfg.invitations.accept` +- `lfg.actions.like`, `lfg.actions.unlike`, `lfg.actions.leave` + +Run `npm run i18n:sync` after adding. + +--- + +## Phase 9: Group Merging Logic + +When two groups merge via ACCEPT: + +| Group A has team | Group B has team | Result | +|------------------|------------------|--------| +| No | No | Create new TournamentTeam, both join it | +| Yes | No | B's members join A's team | +| No | Yes | A's members join B's team | +| Yes | Yes | Accepting team absorbs liker team (accepting team name persists) | + +Auto-generated team name format: `"'s Team"` (e.g., "Sendou's Team" - can be changed later on registration page) + +After merge: +- Combined group stays in LFG queue +- When `maxMembersPerTeam` reached, group is auto-removed from queue + +--- + +## Key Files to Reference + +| Purpose | File | +|---------|------| +| Repository pattern | `app/features/sendouq/SQGroupRepository.server.ts` | +| Route pattern | `app/features/sendouq/routes/q.looking.tsx` | +| Action pattern | `app/features/sendouq/actions/q.looking.server.ts` | +| GroupCard UI | `app/features/sendouq/components/GroupCard.tsx` | +| Tournament tabs | `app/features/tournament/routes/to.$id.tsx` | +| Team creation | `app/features/tournament/TournamentTeamRepository.server.ts` | +| Notification types | `app/features/notifications/notifications-types.ts` | +| Visibility type | `app/features/associations/associations-types.ts` | + +--- + +## Implementation Order + +1. Migration (100-tournament-lfg.js) +2. Types (tables.ts + tournament-lfg-types.ts) +3. Repository (TournamentLFGRepository.server.ts) +4. Schemas (tournament-lfg-schemas.server.ts) +5. Loader (to.$id.looking.server.ts) +6. Action (to.$id.looking.server.ts) +7. Route component (to.$id.looking.tsx) +8. Tournament integration (tab, cleanup hook) +9. Notifications (types + utils) +10. Translations +11. Testing + +--- + +## Verification + +1. **Manual Testing:** + - Join LFG as solo player + - Create group with 2 players + - Like another group, verify notification sent + - Accept mutual like, verify team created + - Verify team appears on registration page + - Test "stay as sub" checkbox + - Test visibility filtering + +2. **Unit Tests:** + - Repository functions (create, merge, delete, likes) + - Visibility filtering logic + +3. **E2E Tests:** + - Full flow: join -> like -> accept -> team formed + - Leave group + - Kick from group + +4. **Run checks:** + ```bash + npm run checks + ``` diff --git a/TOURNAMENT_LFG_SPEC.md b/TOURNAMENT_LFG_SPEC.md new file mode 100644 index 000000000..102431ccd --- /dev/null +++ b/TOURNAMENT_LFG_SPEC.md @@ -0,0 +1,166 @@ +# Tournament LFG Feature Spec + +## Overview + +New `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before tournament starts. + +## Route + +`/to/:id/looking` (new route, mirrors `/q/looking`) + +## Data Model + +Separate tables from SendouQ (cleaner separation): + +### TournamentLFGGroup + +| Column | Type | Description | +|--------|------|-------------| +| id | number | Primary key | +| tournamentTeamId | number | null | FK to TournamentTeam | +| visibility | string | JSON visibility info (/scrims style) | +| chatCode | string | Unique room code for group chat | +| createdAt | number | Timestamp | + +### TournamentLFGGroupMember + +| Column | Type | Description | +|--------|------|-------------| +| groupId | number | FK to TournamentLFGGroup | +| userId | number | FK to User | +| role | string | OWNER / MANAGER / REGULAR | +| note | string | Public note visible to group members | +| stayAsSub | boolean | Convert to sub if team not formed by start | +| createdAt | number | Timestamp | + +### TournamentLFGLike + +| Column | Type | Description | +|--------|------|-------------| +| likerGroupId | number | FK to TournamentLFGGroup | +| targetGroupId | number | FK to TournamentLFGGroup | +| createdAt | number | Timestamp | + +### TournamentSub + +Redundant, removed. + +## Who Can Join + +- **Solo players** - Looking for a team +- **Partial groups (2-3 players)** - Looking for more members +- **Already-registered tournament teams** - Recruiting up to `maxMembersPerTeam` + +## Features + +### Joining the Queue + +- Players reuse weapon/VC data from `/q/settings` (`User.qWeaponPool`, `User.vc`, `User.languages`) +- Checkbox on join: "Add me as sub if I don't find a team" +- Support for notes (like SendouQ public member notes) +- Uses schema based SendouForm (forms.md for details) + +### Visibility System (Scrims-style) + +- **Base visibility**: team/org/public +- **Not-found visibility**: Time-delayed expansion if no match found +- Uses existing `AssociationVisibility` system from scrims + +### Likes & Matching + +1. Players/groups can like each other +2. Target group receives `TO_LFG_LIKED` notification +3. On mutual like, accepting party sees accept button +4. Accept click triggers: + - If neither party is registered team → create a team (some default name is used) + - If one party is registered team → Other party joins that team + - If both parties are teams, the accepting team absorbs the liker team (accepting team's name is used for the new merged team) +5. After merge, combined group stays in queue to recruit more members + +### Team Formation + +- **Immediate registration**: When first two players merge, default name is used +- Newly formed team stays in LFG queue +- Teams can grow up to `maxMembersPerTeam` (typically 6 for 4v4) +- When `maxMembersPerTeam` is reached, team is automatically removed from the queue +- Solo players liking registered teams get absorbed as new members + +### Tournament Start Auto-Cleanup + +When tournament starts: +1. All unregistered LFG groups are deleted +2. Players who checked "stay as sub" are shown in a simple list "`TournamentLFGGroupMember` reused here even if they technically are no longer members of anything) +3. Their sub data uses existing `/q/settings` weapon/VC preferences + +## Notifications + +| Type | When | Meta | +|------|------|------| +| `TO_LFG_LIKED` | Someone likes your group | `{ tournamentId, tournamentName, likerUsername }` | +| `TO_LFG_TEAM_FORMED` | You join/form a team via LFG | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` | +| `TO_LFG_CHAT_MESSAGE` | Chat message sent | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` | + +## UI + +### Reuse from `/q/looking` + +- `GroupCard` component (weapons, VC, tier display) +- Tab structure (My Group, Groups, Invitations) +- `MemberAdder` component (invite link, quick add), note for these same invite link and quick add endpoint is used as on /to/:id/register page +- `GroupLeaver` component +- Private user notes system + +### Tab Structure + +1. **My Group** - Current group members, invite link, leave button, chat (if 2+ members) +2. **Groups** - Other groups looking, with like/unlike buttons +3. **Invitations** - Groups that have liked your group, with accept/decline + +### Accept Flow + +Simple button click (SendouQ-style) + +## Files to Create/Modify + +### New Files + +``` +app/features/tournament-lfg/ +├── core/ +│ └── TournamentLFG.server.ts # Main class (like SendouQ.server.ts) +├── routes/ +│ ├── to.$id.looking.tsx # Main LFG page +│ └── to.$id.looking.new.tsx # Join LFG form (if needed) +├── loaders/ +│ └── to.$id.looking.server.ts # Data loader +├── actions/ +│ └── to.$id.looking.server.ts # Action handler +├── components/ +│ └── (reuse from sendouq where possible) +├── TournamentLFGRepository.server.ts # Database queries +├── tournament-lfg-types.ts # TypeScript types +├── tournament-lfg-schemas.server.ts # Zod validation +└── tournament-lfg-constants.ts # Constants +``` + +### Migrations + +``` +migrations/XXX-tournament-lfg.js # Create new tables +``` + +### Modified Files + +- `app/routes.ts` - Add new route +- `app/features/notifications/notifications-types.ts` - Add new notification types +- `app/features/tournament-bracket/core/Tournament.ts` - Add LFG-related getters +- `app/features/tournament/components/TournamentTabs.tsx` - Add "Looking" tab + +## Differences to SendouQ + +- no invite code (tournament teams have their own invite code by default) +- no expiredAt + +## Open Questions + +- How we should define the autogenerated tournament team name? diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index a8f783d25..2a49a50df 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -37,8 +37,8 @@ import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.s import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import * as VodRepository from "~/features/vods/VodRepository.server"; @@ -70,6 +70,7 @@ import { } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; +import { randomTeamName } from "~/utils/team-name"; import { mySlugify } from "~/utils/urls"; import { getArtFilename, @@ -220,6 +221,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ calendarEventWithToToolsLUTI, calendarEventWithToToolsTeamsLUTI, tournamentSubs, + variation === "NO_TOURNAMENT_TEAMS" ? undefined : tournamentLfgGroups, adminBuilds, manySplattershotBuilds, detailedTeam(variation), @@ -279,6 +281,7 @@ function wipeDB() { "MapPoolMap", "TournamentMatchGameResult", "TournamentTeamCheckIn", + "TournamentLFGLike", "TournamentTeam", "TournamentStage", "TournamentResult", @@ -896,9 +899,7 @@ function calendarEvents() { ) .run({ id, - name: `${R.capitalize(faker.word.adjective())} ${R.capitalize( - faker.word.noun(), - )}`, + name: randomTeamName(), description: faker.lorem.paragraph(), discordInviteCode: faker.lorem.word(), bracketUrl: faker.internet.url(), @@ -1014,7 +1015,7 @@ async function calendarEventResults() { .fill(null) .map((_, i) => ({ placement: i + 1, - teamName: R.capitalize(faker.word.noun()), + teamName: randomTeamName(), players: new Array( faker.helpers.arrayElement([1, 2, 3, 4, 4, 4, 4, 4, 5, 6]), ) @@ -1376,12 +1377,7 @@ function calendarEventWithToToolsToSetMapPool() { } } -const validTournamentTeamName = () => { - while (true) { - const name = faker.music.songName(); - if (name.length <= TOURNAMENT.TEAM_NAME_MAX_LENGTH) return name; - } -}; +const validTournamentTeamName = () => randomTeamName(); const availableStages: StageId[] = [1, 2, 3, 4, 6, 7, 8, 10, 11]; const availablePairs = rankedModesShort @@ -1490,12 +1486,14 @@ function calendarEventWithToToolsTeams( "tournamentTeamId", "userId", "isOwner", - "createdAt" + "createdAt", + "role" ) values ( $tournamentTeamId, $userId, $isOwner, - $createdAt + $createdAt, + $role ) `, ) @@ -1504,6 +1502,7 @@ function calendarEventWithToToolsTeams( userId, isOwner: i === 0 ? 1 : 0, createdAt: dateToDatabaseTimestamp(yesterday), + role: i === 0 ? "OWNER" : "REGULAR", }); } @@ -1635,6 +1634,107 @@ function tournamentSubs() { return null; } +async function tournamentLfgGroups() { + const availableUsers = userIdsInAscendingOrderById().slice(300); + + const MAX_GROUP_SIZE = 6; + + const tournaments = [1, 2, 3]; + + let userIndex = 0; + for (const tournamentId of tournaments) { + const users = availableUsers.slice(userIndex, userIndex + 8); + userIndex += 8; + + // Group 1: solo placeholder, has note, isStayAsSub=1 + const { id: team1Id } = await TournamentLFGRepository.createPlaceholderTeam( + { + tournamentId, + userId: users[0], + isStayAsSub: true, + }, + ); + await TournamentLFGRepository.updateTeamNote({ + teamId: team1Id, + value: "Looking for a team, can play any role", + }); + + // Group 2: solo placeholder + const { id: team2Id } = await TournamentLFGRepository.createPlaceholderTeam( + { + tournamentId, + userId: users[1], + }, + ); + + // Group 3: solo placeholder + const { id: team3Id } = await TournamentLFGRepository.createPlaceholderTeam( + { + tournamentId, + userId: users[2], + }, + ); + + // Group 4: solo placeholder + const { id: team4Id } = await TournamentLFGRepository.createPlaceholderTeam( + { + tournamentId, + userId: users[3], + }, + ); + + // Group 5: 2-member group (merged from two placeholders) + const { id: mergeTarget1 } = + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId, + userId: users[4], + }); + const { id: mergeSource1 } = + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId, + userId: users[5], + }); + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: mergeTarget1, + otherTeamId: mergeSource1, + maxGroupSize: MAX_GROUP_SIZE, + }); + + // Group 6: 2-member group (merged from two placeholders) + const { id: mergeTarget2 } = + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId, + userId: users[6], + }); + const { id: mergeSource2 } = + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId, + userId: users[7], + }); + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: mergeTarget2, + otherTeamId: mergeSource2, + maxGroupSize: MAX_GROUP_SIZE, + }); + + // Team 1 -> Team 2 (one-way like) + await TournamentLFGRepository.addLike({ + likerTeamId: team1Id, + targetTeamId: team2Id, + }); + // Team 2 -> Team 1 (mutual — tests invitation UI) + await TournamentLFGRepository.addLike({ + likerTeamId: team2Id, + targetTeamId: team1Id, + }); + // Team 3 -> Team 4 (one-way like) + await TournamentLFGRepository.addLike({ + likerTeamId: team3Id, + targetTeamId: team4Id, + }); + } +} + const randomAbility = (legalTypes: AbilityType[]) => { const randomOrderAbilities = faker.helpers.shuffle([...abilities]); @@ -1826,12 +1926,7 @@ function otherTeams() { ); for (let i = 3; i < 50; i++) { - const teamName = - i === 3 - ? "Team Olive" - : `${R.capitalize(faker.word.adjective())} ${R.capitalize( - faker.word.noun(), - )}`; + const teamName = i === 3 ? "Team Olive" : randomTeamName(); const teamCustomUrl = mySlugify(teamName); sql diff --git a/app/db/tables.ts b/app/db/tables.ts index c15ed7c76..e51483eae 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -6,6 +6,7 @@ import type { Selectable, Updateable, } from "kysely"; +import type { AssociationIdentifier } from "~/features/associations/associations-constants"; import type { AssociationVisibility } from "~/features/associations/associations-types"; import type { tags } from "~/features/calendar/calendar-constants"; import type { CalendarFilters } from "~/features/calendar/calendar-types"; @@ -740,6 +741,12 @@ export interface TournamentSub { visibility: "+1" | "+2" | "+3" | "ALL"; } +export interface TournamentLFGLike { + likerTeamId: number; + targetTeamId: number; + createdAt: Generated; +} + export interface TournamentStaff { tournamentId: number; userId: number; @@ -760,6 +767,10 @@ export interface TournamentTeam { tournamentId: number; teamId: number | null; avatarImgId: number | null; + isLooking: Generated; + isPlaceholder: Generated; + lfgVisibility: JSONColumnTypeNullable; + lfgNote: string | null; } export interface TournamentTeamCheckIn { @@ -777,6 +788,10 @@ export interface TournamentTeamMember { inGameName: string | null; tournamentTeamId: number; userId: number; + role: Generated<"OWNER" | "MANAGER" | "REGULAR">; + isStayAsSub: Generated; + // denormalized from TournamentTeam.isLooking + isLooking: Generated; } export interface TournamentOrganization { @@ -1282,6 +1297,7 @@ export interface DB { Tournament: Tournament; TournamentStaff: TournamentStaff; TournamentGroup: TournamentGroup; + TournamentLFGLike: TournamentLFGLike; TournamentMatch: TournamentMatch; TournamentMatchPickBanEvent: TournamentMatchPickBanEvent; TournamentMatchGameResult: TournamentMatchGameResult; diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts new file mode 100644 index 000000000..b604a7c5c --- /dev/null +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -0,0 +1,620 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as TournamentLFGRepository from "./TournamentLFGRepository.server"; + +const createTournament = () => + db + .insertInto("Tournament") + .values({ + mapPickingStyle: "TO", + settings: JSON.stringify({ bracketProgression: [] }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createPlaceholder = (tournamentId: number, userId: number) => + TournamentLFGRepository.createPlaceholderTeam({ tournamentId, userId }); + +describe("createPlaceholderTeam", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("creates a placeholder team with owner member", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe(team.id); + }); + + test("owner has OWNER role", async () => { + const tournament = await createTournament(); + await createPlaceholder(tournament.id, 1); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members[0].role).toBe("OWNER"); + }); +}); + +describe("findLookingTeamsByTournamentId", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns looking teams for given tournament", async () => { + const tournament = await createTournament(); + await createPlaceholder(tournament.id, 1); + await createPlaceholder(tournament.id, 2); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(2); + }); + + test("returns empty array when no looking teams exist", async () => { + const tournament = await createTournament(); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + }); + + test("returns groups with member data", async () => { + const tournament = await createTournament(); + await createPlaceholder(tournament.id, 1); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members[0].id).toBe(1); + expect(members[0].username).toBeDefined(); + expect(members[0].role).toBe("OWNER"); + }); + + test("does not return teams from other tournaments", async () => { + const tournament1 = await createTournament(); + const tournament2 = await createTournament(); + await createPlaceholder(tournament1.id, 1); + await createPlaceholder(tournament2.id, 2); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament1.id, + ); + + expect(groups).toHaveLength(1); + }); +}); + +describe("addLike / deleteLike", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("adds a like between two teams", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await TournamentLFGRepository.addLike({ + likerTeamId: team1.id, + targetTeamId: team2.id, + }); + + const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id); + + expect(likes.given).toHaveLength(1); + expect(likes.given[0].teamId).toBe(team2.id); + }); + + test("duplicate like does not throw", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await TournamentLFGRepository.addLike({ + likerTeamId: team1.id, + targetTeamId: team2.id, + }); + await TournamentLFGRepository.addLike({ + likerTeamId: team1.id, + targetTeamId: team2.id, + }); + + const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id); + + expect(likes.given).toHaveLength(1); + }); + + test("deleteLike removes the like", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await TournamentLFGRepository.addLike({ + likerTeamId: team1.id, + targetTeamId: team2.id, + }); + await TournamentLFGRepository.deleteLike({ + likerTeamId: team1.id, + targetTeamId: team2.id, + }); + + const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id); + + expect(likes.given).toHaveLength(0); + }); +}); + +describe("allLikesByTeamId", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("separates likes into given and received", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + const team3 = await createPlaceholder(tournament.id, 3); + + await TournamentLFGRepository.addLike({ + likerTeamId: team1.id, + targetTeamId: team2.id, + }); + await TournamentLFGRepository.addLike({ + likerTeamId: team3.id, + targetTeamId: team1.id, + }); + + const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id); + + expect(likes.given).toHaveLength(1); + expect(likes.given[0].teamId).toBe(team2.id); + expect(likes.received).toHaveLength(1); + expect(likes.received[0].teamId).toBe(team3.id); + }); + + test("returns empty given/received when no likes", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + const likes = await TournamentLFGRepository.allLikesByTeamId(team.id); + + expect(likes.given).toHaveLength(0); + expect(likes.received).toHaveLength(0); + }); +}); + +describe("mergeTeams", () => { + beforeEach(async () => { + await dbInsertUsers(5); + }); + + afterEach(() => { + dbReset(); + }); + + test("merges two teams, other team is deleted", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 4, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe(team1.id); + const members = groups[0].members; + expect(members).toHaveLength(2); + }); + + test("demotes other team's OWNER to MANAGER", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 4, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + const user2Member = members.find((m) => m.id === 2); + + expect(user2Member?.role).toBe("MANAGER"); + }); + + test("throws when merged size exceeds maxGroupSize", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await expect( + TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 1, + }), + ).rejects.toThrow("Group has too many members after merge"); + }); + + test("stops looking when merged team reaches max capacity", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 2, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + }); + + test("clears likes on surviving team after merge", async () => { + const tournament = await createTournament(); + const team1 = await createPlaceholder(tournament.id, 1); + const team2 = await createPlaceholder(tournament.id, 2); + const team3 = await createPlaceholder(tournament.id, 3); + + await TournamentLFGRepository.addLike({ + likerTeamId: team1.id, + targetTeamId: team3.id, + }); + await TournamentLFGRepository.addLike({ + likerTeamId: team3.id, + targetTeamId: team1.id, + }); + + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: team1.id, + otherTeamId: team2.id, + maxGroupSize: 4, + }); + + const likes = await TournamentLFGRepository.allLikesByTeamId(team1.id); + + expect(likes.given).toHaveLength(0); + expect(likes.received).toHaveLength(0); + }); +}); + +describe("updateTeamNote", () => { + beforeEach(async () => { + await dbInsertUsers(1); + }); + + afterEach(() => { + dbReset(); + }); + + test("sets and clears a team note", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + await TournamentLFGRepository.updateTeamNote({ + teamId: team.id, + value: "Looking for support", + }); + + let groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + expect(groups[0].note).toBe("Looking for support"); + + await TournamentLFGRepository.updateTeamNote({ + teamId: team.id, + value: null, + }); + + groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + expect(groups[0].note).toBeNull(); + }); +}); + +describe("updateMemberRole", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("changes role from REGULAR to MANAGER", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: team.id, + userId: 2, + isOwner: 0, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.updateMemberRole({ + userId: 2, + teamId: team.id, + role: "MANAGER", + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + const user2Member = members.find((m) => m.id === 2); + + expect(user2Member?.role).toBe("MANAGER"); + }); + + test("throws when attempting to set OWNER role", () => { + expect(() => + TournamentLFGRepository.updateMemberRole({ + userId: 1, + teamId: 1, + role: "OWNER", + }), + ).toThrow("Can't set role to OWNER with this function"); + }); +}); + +describe("updateStayAsSub", () => { + beforeEach(async () => { + await dbInsertUsers(1); + }); + + afterEach(() => { + dbReset(); + }); + + test("toggles isStayAsSub on/off", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + await TournamentLFGRepository.updateStayAsSub({ + teamId: team.id, + userId: 1, + value: true, + }); + + let subs = await TournamentLFGRepository.getSubsForTournament( + tournament.id, + ); + expect(subs).toContain(1); + + await TournamentLFGRepository.updateStayAsSub({ + teamId: team.id, + userId: 1, + value: false, + }); + + subs = await TournamentLFGRepository.getSubsForTournament(tournament.id); + expect(subs).not.toContain(1); + }); +}); + +describe("leaveLfg", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("removes member from placeholder team", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: team.id, + userId: 2, + isOwner: 0, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.leaveLfg({ + userId: 2, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + + expect(members).toHaveLength(1); + expect(members[0].id).toBe(1); + }); + + test("deletes placeholder team when last member leaves", async () => { + const tournament = await createTournament(); + await createPlaceholder(tournament.id, 1); + + await TournamentLFGRepository.leaveLfg({ + userId: 1, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + }); + + test("promotes MANAGER to OWNER when owner leaves placeholder", async () => { + const tournament = await createTournament(); + const team = await createPlaceholder(tournament.id, 1); + + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: team.id, + userId: 2, + isOwner: 0, + role: "MANAGER", + }) + .execute(); + + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: team.id, + userId: 3, + isOwner: 0, + role: "REGULAR", + }) + .execute(); + + await TournamentLFGRepository.leaveLfg({ + userId: 1, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + const members = groups[0].members; + const newOwner = members.find((m) => m.id === 2); + + expect(newOwner?.role).toBe("OWNER"); + }); + + test("sets isLooking=0 for non-placeholder team", async () => { + const tournament = await createTournament(); + + const team = await db + .insertInto("TournamentTeam") + .values({ + tournamentId: tournament.id, + name: "Real Team", + inviteCode: "abc", + isLooking: 1, + isPlaceholder: 0, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await db + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: team.id, + userId: 1, + isOwner: 1, + role: "OWNER", + }) + .execute(); + + await TournamentLFGRepository.leaveLfg({ + userId: 1, + tournamentId: tournament.id, + }); + + const groups = await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournament.id, + ); + + expect(groups).toHaveLength(0); + + const teamRow = await db + .selectFrom("TournamentTeam") + .select("isLooking") + .where("id", "=", team.id) + .executeTakeFirstOrThrow(); + + expect(teamRow.isLooking).toBe(0); + }); +}); + +describe("getSubsForTournament", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("returns userIds with isStayAsSub", async () => { + const tournament = await createTournament(); + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId: tournament.id, + userId: 1, + isStayAsSub: true, + }); + await createPlaceholder(tournament.id, 2); + + const subs = await TournamentLFGRepository.getSubsForTournament( + tournament.id, + ); + + expect(subs).toEqual([1]); + }); + + test("returns empty when nobody opted in", async () => { + const tournament = await createTournament(); + await createPlaceholder(tournament.id, 1); + + const subs = await TournamentLFGRepository.getSubsForTournament( + tournament.id, + ); + + expect(subs).toHaveLength(0); + }); +}); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts new file mode 100644 index 000000000..ecfc4cc33 --- /dev/null +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -0,0 +1,458 @@ +import type { Transaction } from "kysely"; +import { jsonBuildObject } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import type { DB, Tables } from "~/db/tables"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; +import { + concatUserSubmittedImagePrefix, + userChatNameColorForJson, +} from "~/utils/kysely.server"; +import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; +import { randomTeamName } from "~/utils/team-name"; + +// xxx: handle visibility like scrim post + +export function startLooking(teamId: number) { + return db + .updateTable("TournamentTeam") + .set({ isLooking: 1 }) + .where("id", "=", teamId) + .execute(); +} + +type CreatePlaceholderTeamArgs = { + tournamentId: number; + userId: number; + isStayAsSub?: boolean; + lfgNote?: string; +}; +export function createPlaceholderTeam(args: CreatePlaceholderTeamArgs) { + return db.transaction().execute(async (trx) => { + const createdTeam = await trx + .insertInto("TournamentTeam") + .values({ + tournamentId: args.tournamentId, + name: randomTeamName(), + inviteCode: shortNanoid(), + isPlaceholder: 1, + isLooking: 1, + lfgNote: args.lfgNote ?? null, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: createdTeam.id, + userId: args.userId, + isOwner: 1, + role: "OWNER", + isStayAsSub: args.isStayAsSub ? 1 : 0, + }) + .execute(); + + return createdTeam; + }); +} + +type TournamentLFGMemberObject = { + id: Tables["User"]["id"]; + username: Tables["User"]["username"]; + discordId: Tables["User"]["discordId"]; + discordAvatar: Tables["User"]["discordAvatar"]; + customUrl: Tables["User"]["customUrl"]; + languages: Tables["User"]["languages"]; + vc: Tables["User"]["vc"]; + pronouns: Tables["User"]["pronouns"]; + country: Tables["User"]["country"]; + role: Tables["TournamentTeamMember"]["role"]; + isStayAsSub: Tables["TournamentTeamMember"]["isStayAsSub"]; + weapons: Tables["User"]["qWeaponPool"]; + chatNameColor: string | null; + plusTier: Tables["PlusTier"]["tier"] | null; +}; + +export async function findLookingTeamsByTournamentId(tournamentId: number) { + return db + .selectFrom("TournamentTeam") + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .innerJoin("User", "User.id", "TournamentTeamMember.userId") + .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .leftJoin( + "UserSubmittedImage", + "UserSubmittedImage.id", + "TournamentTeam.avatarImgId", + ) + .select(({ fn, eb }) => [ + "TournamentTeam.id", + "TournamentTeam.isPlaceholder", + "TournamentTeam.lfgVisibility as visibility", + "TournamentTeam.lfgNote as note", + "TournamentTeam.name as teamName", + concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( + "teamAvatarUrl", + ), + fn + .agg("json_group_array", [ + jsonBuildObject({ + id: eb.ref("User.id"), + username: eb.ref("User.username"), + discordId: eb.ref("User.discordId"), + discordAvatar: eb.ref("User.discordAvatar"), + customUrl: eb.ref("User.customUrl"), + languages: eb.ref("User.languages"), + vc: eb.ref("User.vc"), + pronouns: eb.ref("User.pronouns"), + // xxx: is it better to show country rather than languages? + country: eb.ref("User.country"), + role: eb.ref("TournamentTeamMember.role"), + isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), + weapons: eb.ref("User.qWeaponPool"), + chatNameColor: userChatNameColorForJson, + plusTier: eb.ref("PlusTier.tier"), + }), + ]) + .$castTo() + .as("members"), + ]) + .where("TournamentTeam.tournamentId", "=", tournamentId) + .where("TournamentTeam.isLooking", "=", 1) + .groupBy("TournamentTeam.id") + .execute(); +} + +export async function findSubGroups(tournamentId: number) { + const rows = await db + .selectFrom("TournamentTeam") + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .innerJoin("User", "User.id", "TournamentTeamMember.userId") + .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .select(({ fn, eb }) => [ + "TournamentTeam.id", + "TournamentTeam.lfgNote as message", + fn + .agg("json_group_array", [ + jsonBuildObject({ + id: eb.ref("User.id"), + username: eb.ref("User.username"), + discordId: eb.ref("User.discordId"), + discordAvatar: eb.ref("User.discordAvatar"), + customUrl: eb.ref("User.customUrl"), + languages: eb.ref("User.languages"), + vc: eb.ref("User.vc"), + pronouns: eb.ref("User.pronouns"), + country: eb.ref("User.country"), + role: eb.ref("TournamentTeamMember.role"), + isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"), + weapons: eb.ref("User.qWeaponPool"), + chatNameColor: userChatNameColorForJson, + plusTier: eb.ref("PlusTier.tier"), + }), + ]) + .$castTo() + .as("members"), + ]) + // xxx: is this correct? + .where("TournamentTeam.tournamentId", "=", tournamentId) + .where("TournamentTeam.isPlaceholder", "=", 1) + .where("TournamentTeamMember.isStayAsSub", "=", 1) + .groupBy("TournamentTeam.id") + .having(({ fn }) => fn.countAll(), "=", 1) + .execute(); + + return rows; +} + +// xxx: ensure that the team size does not exceed the max, also filter out in frontend +export function mergeTeams({ + survivingTeamId, + otherTeamId, + maxGroupSize, +}: { + survivingTeamId: number; + otherTeamId: number; + maxGroupSize: number; +}) { + return db.transaction().execute(async (trx) => { + const otherMembers = await trx + .selectFrom("TournamentTeamMember") + .select(["TournamentTeamMember.userId", "TournamentTeamMember.role"]) + .where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId) + .execute(); + + for (const member of otherMembers) { + await trx + .updateTable("TournamentTeamMember") + .set({ + role: member.role === "OWNER" ? "MANAGER" : member.role, + tournamentTeamId: survivingTeamId, + }) + .where("TournamentTeamMember.tournamentTeamId", "=", otherTeamId) + .where("TournamentTeamMember.userId", "=", member.userId) + .execute(); + } + + await deleteLikesByTeamId(survivingTeamId, trx); + + await trx + .deleteFrom("TournamentTeam") + .where("TournamentTeam.id", "=", otherTeamId) + .execute(); + + const memberCount = await getMemberCount(survivingTeamId, trx); + + invariant( + memberCount <= maxGroupSize, + "Group has too many members after merge", + ); + + await trx + .updateTable("TournamentTeam") + .set({ + isLooking: memberCount >= maxGroupSize ? 0 : undefined, + isPlaceholder: 0, + }) + .where("id", "=", survivingTeamId) + .execute(); + }); +} + +export async function addLike({ + likerTeamId, + targetTeamId, +}: { + likerTeamId: number; + targetTeamId: number; +}) { + try { + await db + .insertInto("TournamentLFGLike") + .values({ likerTeamId, targetTeamId }) + .onConflict((oc) => + oc.columns(["likerTeamId", "targetTeamId"]).doNothing(), + ) + .execute(); + } catch (error) { + if (errorIsSqliteForeignKeyConstraintFailure(error)) { + return; + } + throw error; + } +} + +export function deleteLike({ + likerTeamId, + targetTeamId, +}: { + likerTeamId: number; + targetTeamId: number; +}) { + return db + .deleteFrom("TournamentLFGLike") + .where("likerTeamId", "=", likerTeamId) + .where("targetTeamId", "=", targetTeamId) + .execute(); +} + +export async function allLikesByTeamId(teamId: number) { + const rows = await db + .selectFrom("TournamentLFGLike") + .select(["TournamentLFGLike.likerTeamId", "TournamentLFGLike.targetTeamId"]) + .where((eb) => + eb.or([ + eb("TournamentLFGLike.likerTeamId", "=", teamId), + eb("TournamentLFGLike.targetTeamId", "=", teamId), + ]), + ) + .execute(); + + return { + given: rows + .filter((row) => row.likerTeamId === teamId) + .map((row) => ({ teamId: row.targetTeamId })), + received: rows + .filter((row) => row.targetTeamId === teamId) + .map((row) => ({ teamId: row.likerTeamId })), + }; +} + +export function updateTeamNote({ + teamId, + value, +}: { + teamId: number; + value: string | null; +}) { + return db + .updateTable("TournamentTeam") + .set({ lfgNote: value }) + .where("id", "=", teamId) + .execute(); +} + +export function updateMemberRole({ + userId, + teamId, + role, +}: { + userId: number; + teamId: number; + role: Tables["TournamentTeamMember"]["role"]; +}) { + if (role === "OWNER") { + throw new Error("Can't set role to OWNER with this function"); + } + + return db + .updateTable("TournamentTeamMember") + .set({ role }) + .where("userId", "=", userId) + .where("tournamentTeamId", "=", teamId) + .execute(); +} + +export function updateStayAsSub({ + teamId, + userId, + value, +}: { + teamId: number; + userId: number; + value: boolean; +}) { + return db + .updateTable("TournamentTeamMember") + .set({ isStayAsSub: value ? 1 : 0 }) + .where("tournamentTeamId", "=", teamId) + .where("userId", "=", userId) + .execute(); +} + +export function leaveLfg({ + userId, + tournamentId, +}: { + userId: number; + tournamentId: number; +}) { + return db.transaction().execute(async (trx) => { + const userTeam = await trx + .selectFrom("TournamentTeamMember") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentTeamMember.tournamentTeamId", + ) + .select([ + "TournamentTeamMember.tournamentTeamId", + "TournamentTeamMember.role", + "TournamentTeam.isPlaceholder", + ]) + .where("TournamentTeamMember.userId", "=", userId) + .where("TournamentTeam.tournamentId", "=", tournamentId) + .where("TournamentTeam.isLooking", "=", 1) + .executeTakeFirst(); + + if (!userTeam) return; + + if (!userTeam.isPlaceholder) { + await trx + .updateTable("TournamentTeam") + .set({ isLooking: 0 }) + .where("id", "=", userTeam.tournamentTeamId) + .execute(); + await trx + .updateTable("TournamentTeamMember") + .set({ isStayAsSub: 0 }) + .where("tournamentTeamId", "=", userTeam.tournamentTeamId) + .execute(); + return; + } + + // xxx: or just call TournamentTeam.unregister? + // xxx: also leaving team should not be possible via this so remove that code + await trx + .deleteFrom("TournamentTeamMember") + .where("userId", "=", userId) + .where("tournamentTeamId", "=", userTeam.tournamentTeamId) + .execute(); + + const remainingMembers = await trx + .selectFrom("TournamentTeamMember") + .select(["userId", "role"]) + .where("tournamentTeamId", "=", userTeam.tournamentTeamId) + .execute(); + + if (remainingMembers.length === 0) { + await trx + .deleteFrom("TournamentTeam") + .where("id", "=", userTeam.tournamentTeamId) + .execute(); + return; + } + + if (userTeam.role === "OWNER") { + const newOwner = + remainingMembers.find((m) => m.role === "MANAGER") ?? + remainingMembers[0]; + + await trx + .updateTable("TournamentTeamMember") + .set({ role: "OWNER" }) + .where("userId", "=", newOwner.userId) + .where("tournamentTeamId", "=", userTeam.tournamentTeamId) + .execute(); + } + }); +} + +export async function getSubsForTournament(tournamentId: number) { + const rows = await db + .selectFrom("TournamentTeamMember") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentTeamMember.tournamentTeamId", + ) + .select("TournamentTeamMember.userId") + .where("TournamentTeam.tournamentId", "=", tournamentId) + .where("TournamentTeamMember.isStayAsSub", "=", 1) + .execute(); + + return rows.map((row) => row.userId); +} + +function deleteLikesByTeamId(teamId: number, trx: Transaction) { + return trx + .deleteFrom("TournamentLFGLike") + .where((eb) => + eb.or([ + eb("TournamentLFGLike.likerTeamId", "=", teamId), + eb("TournamentLFGLike.targetTeamId", "=", teamId), + ]), + ) + .execute(); +} + +async function getMemberCount( + teamId: number, + trx: Transaction, +): Promise { + const members = await trx + .selectFrom("TournamentTeamMember") + .select("TournamentTeamMember.userId") + .where("TournamentTeamMember.tournamentTeamId", "=", teamId) + .execute(); + + return members.length; +} diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts new file mode 100644 index 000000000..aa4c8984b --- /dev/null +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -0,0 +1,252 @@ +import type { ActionFunctionArgs } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import { + errorToastIfFalsy, + parseParams, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { idObject } from "~/utils/zod"; +import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; +import { lookingSchema } from "../tournament-lfg-schemas"; +import { survivingTeamId } from "../tournament-lfg-utils"; + +// xxx: prevent actions in certain cases? like when tournament has started +// xxx: add `requireNotBannedByOrganization` & other checks from deleted to.$id.subs.new.tsx route +// xxx: add `clearTournamentDataCache` + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const user = requireUser(); + const { id: tournamentId } = parseParams({ params, schema: idObject }); + const data = await parseRequestPayload({ + request, + schema: lookingSchema, + }); + + const findOwnGroup = async () => { + const groups = + await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournamentId, + ); + return groups.find((g) => g.members.some((m) => m.id === user.id)); + }; + + const isGroupManager = (group: Awaited>) => { + const member = group?.members.find((m) => m.id === user.id); + return member?.role === "OWNER" || member?.role === "MANAGER"; + }; + + const isGroupOwner = (group: Awaited>) => { + const member = group?.members.find((m) => m.id === user.id); + return member?.role === "OWNER"; + }; + + switch (data._action) { + case "JOIN_QUEUE": { + const existingGroup = await findOwnGroup(); + if (existingGroup) return null; + + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + const team = tournament.teamMemberOfByUser(user); + + if (team) { + errorToastIfFalsy( + team.members.length < tournament.maxMembersPerTeam, + "Team is already at max capacity", + ); + await TournamentLFGRepository.startLooking(team.id); + } else { + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId, + userId: user.id, + isStayAsSub: data.stayAsSub ?? false, + lfgNote: data.note ?? undefined, + }); + } + + break; + } + case "LIKE": { + const ownGroup = await findOwnGroup(); + if (!ownGroup || !isGroupManager(ownGroup)) return null; + + await TournamentLFGRepository.addLike({ + likerTeamId: ownGroup.id, + targetTeamId: data.targetTeamId, + }); + + break; + } + case "UNLIKE": { + const ownGroup = await findOwnGroup(); + if (!ownGroup || !isGroupManager(ownGroup)) return null; + + await TournamentLFGRepository.deleteLike({ + likerTeamId: ownGroup.id, + targetTeamId: data.targetTeamId, + }); + + break; + } + case "ACCEPT": { + const groups = + await TournamentLFGRepository.findLookingTeamsByTournamentId( + tournamentId, + ); + const ownGroup = groups.find((g) => + g.members.some((m) => m.id === user.id), + ); + if (!ownGroup || !isGroupManager(ownGroup)) return null; + + const theirGroup = groups.find((g) => g.id === data.targetTeamId); + if (!theirGroup) return null; + + const theirLikes = await TournamentLFGRepository.allLikesByTeamId( + data.targetTeamId, + ); + if (!theirLikes.given.some((like) => like.teamId === ownGroup.id)) { + return null; + } + + const surviving = survivingTeamId({ + ourGroup: { + id: ownGroup.id, + isPlaceholder: Boolean(ownGroup.isPlaceholder), + teamName: null, + teamAvatarUrl: null, + note: null, + members: [], + usersRole: null, + }, + theirGroup: { + id: theirGroup.id, + isPlaceholder: Boolean(theirGroup.isPlaceholder), + teamName: null, + teamAvatarUrl: null, + note: null, + members: [], + usersRole: null, + }, + }); + + const otherGroup = surviving === ownGroup.id ? theirGroup : ownGroup; + + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + + await TournamentLFGRepository.mergeTeams({ + survivingTeamId: surviving, + otherTeamId: otherGroup.id, + maxGroupSize: tournament.maxMembersPerTeam, + }); + + break; + } + case "GIVE_MANAGER": { + const ownGroup = await findOwnGroup(); + errorToastIfFalsy(ownGroup && isGroupOwner(ownGroup), "Not owner"); + + await TournamentLFGRepository.updateMemberRole({ + teamId: ownGroup!.id, + userId: data.userId, + role: "MANAGER", + }); + + break; + } + case "REMOVE_MANAGER": { + const ownGroup = await findOwnGroup(); + errorToastIfFalsy(ownGroup && isGroupOwner(ownGroup), "Not owner"); + + await TournamentLFGRepository.updateMemberRole({ + teamId: ownGroup!.id, + userId: data.userId, + role: "REGULAR", + }); + + break; + } + case "UPDATE_GROUP": { + const ownGroup = await findOwnGroup(); + if (!ownGroup) return null; + + await TournamentLFGRepository.updateTeamNote({ + teamId: ownGroup.id, + value: data.note ?? null, + }); + + await TournamentLFGRepository.updateStayAsSub({ + teamId: ownGroup.id, + userId: user.id, + value: data.stayAsSub ?? false, + }); + + break; + } + case "LEAVE_GROUP": { + await TournamentLFGRepository.leaveLfg({ + userId: user.id, + tournamentId, + }); + + break; + } + case "ADD_SUB": { + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + errorToastIfFalsy( + tournament.canAddNewSubPost, + "Cannot add sub post at this time", + ); + + const team = tournament.teamMemberOfByUser(user); + errorToastIfFalsy(!team, "Already on a team"); + + const existingSubGroups = + await TournamentLFGRepository.findSubGroups(tournamentId); + const hasExistingSubPost = existingSubGroups.some((g) => + g.members.some((m) => m.id === user.id), + ); + errorToastIfFalsy(!hasExistingSubPost, "Already have a sub post"); + + await TournamentLFGRepository.createPlaceholderTeam({ + tournamentId, + userId: user.id, + isStayAsSub: true, + lfgNote: data.message ?? undefined, + }); + + break; + } + case "DELETE_SUB": { + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + errorToastIfFalsy( + user.id === data.userId || tournament.isOrganizer(user), + "You can only delete your own sub post", + ); + + await TournamentLFGRepository.leaveLfg({ + userId: data.userId, + tournamentId, + }); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/tournament-lfg/components/LFGGroupCard.module.css b/app/features/tournament-lfg/components/LFGGroupCard.module.css new file mode 100644 index 000000000..85d70b412 --- /dev/null +++ b/app/features/tournament-lfg/components/LFGGroupCard.module.css @@ -0,0 +1,90 @@ +.group { + background-color: var(--bg-lighter-solid); + width: 100%; + border-radius: var(--rounded); + padding: var(--s-2-5); + display: flex; + flex-direction: column; + gap: var(--s-4); + position: relative; + color: var(--text); +} + +.member { + display: flex; + gap: var(--s-2); + align-items: center; + background-color: var(--bg-darker); + border-radius: var(--rounded); + font-size: var(--fonts-xsm); + font-weight: var(--semi-bold); + padding-inline-end: var(--s-2-5); +} + +.name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 7.5rem; + font-size: var(--fonts-xs); + color: var(--text); +} + +.avatar { + min-width: 36px; +} + +.extraInfo { + font-size: var(--fonts-xs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + width: max-content; + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--semi-bold); + min-height: 24px; +} + +.extraInfoButton { + font-size: var(--fonts-xs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + width: max-content; + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--semi-bold); + color: var(--text); + border: none; + min-height: 24px; +} + +.vcIcon { + height: 15px; + stroke-width: 2; +} + +.star { + min-width: 18px; + max-width: 18px; + color: var(--theme-secondary); + stroke-width: 2; +} + +.starInactive { + color: var(--text-lighter); +} + +.teamHeader { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.teamName { + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); +} diff --git a/app/features/tournament-lfg/components/LFGGroupCard.tsx b/app/features/tournament-lfg/components/LFGGroupCard.tsx new file mode 100644 index 000000000..1ad2ad43f --- /dev/null +++ b/app/features/tournament-lfg/components/LFGGroupCard.tsx @@ -0,0 +1,386 @@ +import clsx from "clsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useFetcher } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { Divider } from "~/components/Divider"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { Image, WeaponImage } from "~/components/Image"; +import { MicrophoneIcon } from "~/components/icons/Microphone"; +import { SpeakerIcon } from "~/components/icons/Speaker"; +import { SpeakerXIcon } from "~/components/icons/SpeakerX"; +import { StarIcon } from "~/components/icons/Star"; +import { StarFilledIcon } from "~/components/icons/StarFilled"; +import { SubmitButton } from "~/components/SubmitButton"; +import type { Pronouns } from "~/db/tables"; +import { useUser } from "~/features/auth/core/user"; +import { SendouForm } from "~/form/SendouForm"; +import { languagesUnified } from "~/modules/i18n/config"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { navIconUrl, userPage } from "~/utils/urls"; +import { updateGroupFormSchema } from "../tournament-lfg-schemas"; +import styles from "./LFGGroupCard.module.css"; + +export type LFGGroupMember = { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customUrl: string | null; + languages: string[]; + vc: "YES" | "NO" | "LISTEN_ONLY" | null; + pronouns: Pronouns | null; + role: "OWNER" | "MANAGER" | "REGULAR"; + isStayAsSub: boolean; + weapons: Array<{ weaponSplId: MainWeaponId; isFavorite: boolean }> | null; + chatNameColor: string | null; + plusTier: number | null; +}; + +export type LFGGroup = { + id: number; + isPlaceholder: boolean; + teamName: string | null; + teamAvatarUrl: string | null; + note: string | null; + members: LFGGroupMember[]; + usersRole: "OWNER" | "MANAGER" | "REGULAR" | null; +}; + +export function LFGGroupCard({ + group, + action, + ownGroup, +}: { + group: LFGGroup; + action?: "LIKE" | "UNLIKE" | "ACCEPT"; + ownGroup?: LFGGroup; +}) { + const { t } = useTranslation(["common", "q"]); + const fetcher = useFetcher(); + const user = useUser(); + + const isOwnGroup = group.id === ownGroup?.id; + const showActions = isOwnGroup && group.usersRole === "OWNER"; + + const currentMember = user + ? group.members.find((m) => m.id === user.id) + : undefined; + + return ( +
+ {group.teamName ? ( + + {group.teamAvatarUrl ? ( + + ) : null} +
{group.teamName}
+
+ ) : null} +
+ {group.members.map((member) => ( + + ))} +
+ {isOwnGroup ? ( + + ) : null} + {action && + (ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? ( + + + + {action === "LIKE" + ? t("q:looking.groups.actions.invite") + : action === "ACCEPT" + ? t("common:actions.accept") + : t("q:looking.groups.actions.undo")} + + + ) : null} +
+ ); +} + +function LFGGroupMemberRow({ + member, + showActions, +}: { + member: LFGGroupMember; + showActions: boolean; + isOwnGroup: boolean; +}) { + return ( +
+
+
+ + + {member.username} + + {member.pronouns ? ( + + {member.pronouns.subject}/{member.pronouns.object} + + ) : null} +
+
+ {showActions || (!showActions && member.role === "OWNER") ? ( + + ) : null} +
+
+
+
+ {member.vc ? ( +
+ +
+ ) : null} + {member.plusTier ? ( +
+ + {member.plusTier} +
+ ) : null} +
+ {member.weapons && member.weapons.length > 0 ? ( +
+ {member.weapons.map((weapon) => ( + + ))} +
+ ) : null} +
+
+ ); +} + +function LFGTeamNote({ + note, + editable, + isStayAsSub, +}: { + note: string | null; + editable: boolean; + isStayAsSub: boolean; +}) { + const { t } = useTranslation(["common", "q"]); + const [editing, setEditing] = React.useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies(note): when note updates exit editing mode + // biome-ignore lint/correctness/useExhaustiveDependencies(isStayAsSub): when isStayAsSub updates exit editing mode + React.useEffect(() => { + setEditing(false); + }, [note, isStayAsSub]); + + if (editing) { + return ( + setEditing(false)} + /> + ); + } + + if (note) { + return ( +
+ {note}{" "} + {editable ? ( + setEditing(true)} + className="mt-2 ml-auto" + > + {t("q:looking.groups.editNote")} + + ) : null} +
+ ); + } + + if (!editable) return null; + + return ( + setEditing(true)} + > + {t("q:looking.groups.addNote")} + + ); +} + +function LFGEditGroupForm({ + note, + isStayAsSub, + stopEditing, +}: { + note: string | null; + isStayAsSub: boolean; + stopEditing: () => void; +}) { + const { t } = useTranslation(["common"]); + + return ( + + {t("common:actions.cancel")} + + } + > + {({ FormField }) => ( + <> + + + + )} + + ); +} + +function LFGMemberRoleManager({ + member, + showActions, +}: { + member: Pick; + showActions: boolean; +}) { + const fetcher = useFetcher(); + const { t } = useTranslation(["q"]); + const Icon = member.role === "OWNER" ? StarFilledIcon : StarIcon; + + if (!showActions && member.role !== "OWNER") return null; + + return ( + + } + /> + } + > +
+
{t(`q:roles.${member.role}`)}
+ {member.role !== "OWNER" && showActions ? ( + + + {member.role === "REGULAR" ? ( + + {t("q:looking.groups.actions.giveManager")} + + ) : null} + {member.role === "MANAGER" ? ( + + {t("q:looking.groups.actions.removeManager")} + + ) : null} + + ) : null} +
+
+ ); +} + +function LFGVoiceChatInfo({ + member, +}: { + member: Pick; +}) { + const user = useUser(); + const { t } = useTranslation(["q"]); + + if (!member.languages || !member.vc) return null; + + const Icon = + member.vc === "YES" + ? MicrophoneIcon + : member.vc === "LISTEN_ONLY" + ? SpeakerIcon + : SpeakerXIcon; + + const color = () => { + const languagesMatch = + member.id === user?.id || + member.languages?.some((l) => user?.languages.includes(l)); + + if (!languagesMatch) return "text-error"; + + return member.vc === "YES" + ? "text-success" + : member.vc === "LISTEN_ONLY" + ? "text-warning" + : "text-error"; + }; + + const languageToFull = (code: string) => + languagesUnified.find((l) => l.code === code)?.name ?? ""; + + const languagesString = + member.languages.length > 0 + ? `(${member.languages.map(languageToFull).join(", ")})` + : null; + + return ( + } + /> + } + > + {t(`q:vc.${member.vc}`)} {languagesString} + + ); +} diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts new file mode 100644 index 000000000..c7618784e --- /dev/null +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -0,0 +1,228 @@ +import type { LoaderFunctionArgs } from "react-router"; +import type { Pronouns } from "~/db/tables"; +import { getUser } from "~/features/auth/core/user.server"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import type { SerializeFrom } from "~/utils/remix"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; +import type { LFGGroup, LFGGroupMember } from "../components/LFGGroupCard"; +import * as TournamentLFGRepository from "../TournamentLFGRepository.server"; + +export type SubEntry = Extract< + LookingLoaderData, + { mode: "subs" } +>["subs"][number]; + +export type LookingLoaderData = SerializeFrom; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const user = getUser(); + const { id: tournamentId } = parseParams({ params, schema: idObject }); + + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + + if (tournament.registrationOpen) { + return lookingMode({ tournamentId, user }); + } + + return subsMode({ tournamentId, user }); +}; + +async function lookingMode({ + tournamentId, + user, +}: { + tournamentId: number; + user: ReturnType; +}) { + const rawGroups = + await TournamentLFGRepository.findLookingTeamsByTournamentId(tournamentId); + + const groups: LFGGroup[] = rawGroups.map((group) => { + const members = transformMembers(group.members); + + return { + id: group.id, + isPlaceholder: Boolean(group.isPlaceholder), + teamName: group.isPlaceholder ? null : (group.teamName ?? null), + teamAvatarUrl: group.isPlaceholder ? null : (group.teamAvatarUrl ?? null), + note: group.note ?? null, + members, + usersRole: members.find((m) => m.id === user?.id)?.role ?? null, + }; + }); + + const ownGroup = + groups.find((g) => g.members.some((m) => m.id === user?.id)) ?? null; + + const otherGroups = groups.filter((g) => g.id !== ownGroup?.id); + + const likes = ownGroup + ? await TournamentLFGRepository.allLikesByTeamId(ownGroup.id) + : { given: [], received: [] }; + + const ownTeam = await resolveOwnTeam({ + user, + tournamentId, + ownGroup, + }); + + return { + mode: "looking" as const, + groups: otherGroups, + ownGroup, + ownTeam, + likes, + tournamentId, + }; +} + +async function subsMode({ + tournamentId, + user, +}: { + tournamentId: number; + user: ReturnType; +}) { + const rawSubGroups = + await TournamentLFGRepository.findSubGroups(tournamentId); + + const subs = rawSubGroups.map((group) => { + const member = group.members[0]; + const weapons = parseWeapons(member.weapons); + + const languages = + typeof member.languages === "string" + ? member.languages.split(",").filter(Boolean) + : []; + + return { + teamId: group.id, + userId: member.id, + username: member.username, + discordId: member.discordId, + discordAvatar: member.discordAvatar, + customUrl: member.customUrl, + vc: member.vc, + languages, + plusTier: member.plusTier, + weapons, + message: group.message ?? null, + }; + }); + + return { + mode: "subs" as const, + subs, + hasOwnSubPost: subs.some((sub) => sub.userId === user?.id), + tournamentId, + }; +} + +async function resolveOwnTeam({ + user, + tournamentId, + ownGroup, +}: { + user: ReturnType; + tournamentId: number; + ownGroup: LFGGroup | null; +}): Promise { + if (!user) return null; + if (ownGroup) return null; + + const tournament = await tournamentFromDBCached({ + tournamentId, + user, + }); + + const team = tournament.teamMemberOfByUser(user); + if (!team) return null; + + const members: LFGGroupMember[] = team.members.map((m) => ({ + id: m.userId, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + languages: [], + vc: null, + pronouns: null, + role: m.isOwner ? "OWNER" : "REGULAR", + isStayAsSub: false, + weapons: null, + chatNameColor: null, + plusTier: m.plusTier, + })); + + return { + id: team.id, + isPlaceholder: false, + teamName: team.name, + teamAvatarUrl: team.pickupAvatarUrl, + note: null, + members, + usersRole: members.find((m) => m.id === user.id)?.role ?? null, + }; +} + +function transformMembers( + rawMembers: Awaited< + ReturnType + >[number]["members"], +): LFGGroupMember[] { + return rawMembers.map((m) => { + const languages = + typeof m.languages === "string" + ? m.languages.split(",").filter(Boolean) + : []; + + const weapons = parseWeapons(m.weapons); + const pronouns = parsePronouns(m.pronouns); + + return { + id: m.id, + username: m.username, + discordId: m.discordId, + discordAvatar: m.discordAvatar, + customUrl: m.customUrl, + languages, + vc: m.vc, + pronouns, + role: m.role, + isStayAsSub: m.isStayAsSub === 1, + weapons, + chatNameColor: m.chatNameColor, + plusTier: m.plusTier, + }; + }); +} + +function parseWeapons( + raw: unknown, +): Array<{ weaponSplId: MainWeaponId; isFavorite: boolean }> | null { + if (!raw) return null; + + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!Array.isArray(parsed) || parsed.length === 0) return null; + + return parsed.map( + (w: { weaponSplId: MainWeaponId; isFavorite: number | boolean }) => ({ + weaponSplId: w.weaponSplId, + isFavorite: Boolean(w.isFavorite), + }), + ); +} + +function parsePronouns(raw: unknown): Pronouns | null { + if (!raw) return null; + + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!parsed || typeof parsed !== "object") return null; + + return parsed as Pronouns; +} diff --git a/app/features/tournament-subs/routes/to.$id.subs.module.css b/app/features/tournament-lfg/routes/to.$id.looking.module.css similarity index 55% rename from app/features/tournament-subs/routes/to.$id.subs.module.css rename to app/features/tournament-lfg/routes/to.$id.looking.module.css index 55023685d..29f4eccad 100644 --- a/app/features/tournament-subs/routes/to.$id.subs.module.css +++ b/app/features/tournament-lfg/routes/to.$id.looking.module.css @@ -1,4 +1,41 @@ -.listPageContainer { +.container { + display: grid; + justify-content: center; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--s-7); +} + +.containerMobile { + grid-template-columns: 1fr; + min-width: 300px; + margin: 0 auto; +} + +.innerContainer { + max-width: 94vw; +} + +.header { + text-align: center; + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); + text-transform: uppercase; + color: var(--theme); + display: flex; + align-items: center; +} + +.header::before, +.header::after { + flex: 1; + content: ""; + padding: 2px; + background-color: var(--theme-transparent); + margin: 5px; + border-radius: var(--rounded); +} + +.subsContainer { max-width: 52rem; display: flex; flex-direction: column; @@ -6,7 +43,7 @@ margin-inline: auto; } -.section { +.subsSection { display: grid; grid-template-columns: max-content 1fr; grid-template-areas: @@ -14,25 +51,25 @@ "avatar info" "spacer spacer" "weapon-top-text weapon-top-images" - "weapon-bottom-text weapon-bottom-images" "message message"; border-radius: var(--radius-box); background-color: var(--color-bg-high); padding: var(--s-4); - column-gap: var(--s-3); + column-gap: var(--s-2-5); + border: var(--border-style); } -.sectionAvatar { +.subsSectionAvatar { grid-area: avatar; align-self: center; } -.sectionName { +.subsSectionName { grid-area: name; align-self: flex-end; } -.sectionInfo { +.subsSectionInfo { grid-area: info; font-size: var(--font-sm); gap: var(--s-2); @@ -43,67 +80,59 @@ align-items: center; } -.sectionInfoVc { +.subsInfoVc { display: flex; align-items: center; gap: var(--s-0-5); } -.sectionInfoVc > svg { +.subsInfoVc > svg { width: 18px; } -.sectionMessage { +.subsSectionMessage { grid-area: message; font-size: var(--font-sm); padding-block-start: var(--s-4); white-space: pre-wrap; } -.sectionWeaponText { - font-weight: var(--weight-semi); - font-size: var(--font-xs); +.subsSectionWeaponText { + font-weight: var(--semi-bold); + font-size: var(--fonts-xs); align-self: center; } -.sectionWeaponTopText { +.subsSectionWeaponTopText { grid-area: weapon-top-text; } -.sectionWeaponBottomText { - grid-area: weapon-bottom-text; -} - -.sectionWeaponImages { +.subsSectionWeaponImages { display: flex; gap: var(--s-1); width: 11rem; align-items: center; } -.sectionWeaponTopImages { +.subsSectionWeaponTopImages { grid-area: weapon-top-images; } -.sectionWeaponBottomImages { - grid-area: weapon-bottom-images; -} - -.sectionSpacer { +.subsSectionSpacer { grid-area: spacer; height: 18px; } @media screen and (min-width: 640px) { - .section { + .subsSection { grid-template-columns: max-content 1fr max-content max-content; grid-template-areas: "avatar name weapon-top-text weapon-top-images" - "avatar info weapon-bottom-text weapon-bottom-images" + "avatar info . ." "message message message message"; } - .sectionSpacer { + .subsSectionSpacer { display: none; } } diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx new file mode 100644 index 000000000..bd352939b --- /dev/null +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -0,0 +1,479 @@ +import clsx from "clsx"; +import * as React from "react"; +import { Flipper } from "react-flip-toolkit"; +import { useTranslation } from "react-i18next"; +import { Link, useFetcher, useLoaderData } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { + SendouTab, + SendouTabList, + SendouTabPanel, + SendouTabs, +} from "~/components/elements/Tabs"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { WeaponImage } from "~/components/Image"; +import { MicrophoneIcon } from "~/components/icons/Microphone"; +import { TrashIcon } from "~/components/icons/Trash"; +import { Placeholder } from "~/components/Placeholder"; +import { SubmitButton } from "~/components/SubmitButton"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { SendouForm } from "~/form/SendouForm"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { useWindowSize } from "~/hooks/useWindowSize"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { userPage } from "~/utils/urls"; +import { LFGGroupCard } from "../components/LFGGroupCard"; +import { + type LookingLoaderData, + loader, + type SubEntry, +} from "../loaders/to.$id.looking.server"; +import { + addSubFormSchema, + joinQueueFormSchema, +} from "../tournament-lfg-schemas"; + +export { action } from "../actions/to.$id.looking.server"; +export { loader }; + +import styles from "./to.$id.looking.module.css"; + +export const handle: SendouRouteHandle = { + i18n: ["q", "tournament", "forms", "common"], +}; + +export default function TournamentLFGShell() { + const isMounted = useIsMounted(); + + if (!isMounted) return ; + + return ; +} + +// xxx: show one button to join at the top with dialog opening? instead of two inline +// xxx: no button if already in the tournament +// xxx: can we reuse the column layout with sendouq? +function TournamentLFGPage() { + const data = useLoaderData(); + if (data.mode === "subs") { + return ; + } + + return ; +} + +function GroupsView({ + data, +}: { + data: Extract; +}) { + const { t } = useTranslation(["q"]); + const tournament = useTournament(); + const { width } = useWindowSize(); + + const isMobile = width < 750; + + const ownMemberCount = + data.ownGroup?.members.length ?? data.ownTeam?.members.length ?? 0; + const availableSlots = tournament.maxMembersPerTeam - ownMemberCount; + + const compatibleGroups = data.groups.filter( + (group) => group.members.length <= availableSlots, + ); + + const neutralGroups = compatibleGroups.filter( + (group) => + !data.likes.given.some((like) => like.teamId === group.id) && + !data.likes.received.some((like) => like.teamId === group.id), + ); + const likedGroups = compatibleGroups.filter((group) => + data.likes.given.some((like) => like.teamId === group.id), + ); + const groupsReceivedLikesFrom = compatibleGroups.filter((group) => + data.likes.received.some((like) => like.teamId === group.id), + ); + + const flipKey = `${neutralGroups.map((g) => `${g.id}-${isMobile ? true : data.likes.given.some((l) => l.teamId === g.id)}`).join(":")}:${groupsReceivedLikesFrom.map((g) => g.id).join(":")}`; + + const invitedGroupsDesktop = ( +
+ {t("q:looking.columns.invited")} + {likedGroups.map((group) => ( + + ))} +
+ ); + + const ownTabMemberCount = data.ownGroup + ? data.ownGroup.members.length + : data.ownTeam + ? data.ownTeam.members.length + : 0; + + const ownGroupElement = data.ownGroup ? ( +
+ + + {!isMobile ? invitedGroupsDesktop : null} +
+ ) : null; + + const leftColumnContent = data.ownGroup ? ( + ownGroupElement + ) : data.ownTeam ? ( + + ) : ( + + ); + + return ( + +
+ {!isMobile ? ( +
+ + + + {t("q:looking.columns.myGroup")} + + + {leftColumnContent} + +
+ ) : null} +
+ + + + {t("q:looking.columns.groups")} + + {isMobile ? ( + + {t("q:looking.columns.invitations")} + + ) : null} + {isMobile ? ( + + {t("q:looking.columns.myGroup")} + + ) : null} + + +
+ {t("q:looking.columns.available")} + {(isMobile + ? compatibleGroups.filter( + (group) => + !data.likes.received.some( + (like) => like.teamId === group.id, + ), + ) + : neutralGroups + ).map((group) => ( + like.teamId === group.id) + ? "UNLIKE" + : "LIKE" + } + ownGroup={data.ownGroup ?? undefined} + /> + ))} +
+
+ +
+ {groupsReceivedLikesFrom.map((group) => ( + + ))} +
+
+ {leftColumnContent} +
+
+ {!isMobile ? ( +
+ {t("q:looking.columns.invitations")} + {groupsReceivedLikesFrom.map((group) => ( + + ))} +
+ ) : null} +
+
+ ); +} + +function SubsView({ + data, +}: { + data: Extract; +}) { + const user = useUser(); + const tournament = useTournament(); + + const isOnTeam = Boolean(tournament.teamMemberOfByUser(user)); + + return ( +
+ {tournament.canAddNewSubPost && + !data.hasOwnSubPost && + user && + !isOnTeam ? ( + + ) : null} + {data.subs.map((sub) => ( + + ))} +
+ ); +} + +function AddSubForm() { + const { t } = useTranslation(["tournament"]); + + return ( +
+ + {t("tournament:subs.addPost")} + + } + > + + {({ FormField }) => } + + +
+ ); +} + +function SubCard({ sub }: { sub: SubEntry }) { + const { t } = useTranslation(["common", "tournament"]); + const user = useUser(); + const tournament = useTournament(); + + const infos = [ +
+ + {sub.vc === "YES" + ? t("tournament:subs.canVC") + : sub.vc === "LISTEN_ONLY" + ? t("tournament:subs.listenOnlyVC") + : t("tournament:subs.noVC")} +
, + ]; + if (sub.plusTier) { + infos.push( + {"\u00B7"}, +
+{sub.plusTier}
, + ); + } + if (sub.languages.length > 0) { + infos.push( + {"\u00B7"}, +
+ {sub.languages.map((lang) => lang.toUpperCase()).join(" / ")} +
, + ); + } + + return ( +
+
+ + + {sub.username} + +
+
{infos}
+ {sub.weapons ? ( + <> +
+ {t("tournament:subs.prefersToPlay")} +
+
+ {sub.weapons.map((wpn) => ( + + ))} +
+ + ) : null} + {sub.message ? ( +
{sub.message}
+ ) : null} +
+ {user?.id === sub.userId || tournament.isOrganizer(user) ? ( +
+ + } + > + {t("common:actions.delete")} + + +
+ ) : null} +
+ ); +} + +function ColumnHeader({ children }: { children: React.ReactNode }) { + const { width } = useWindowSize(); + + const isMobile = width < 750; + + if (isMobile) return null; + + return
{children}
; +} + +function JoinQueueForm() { + const { t } = useTranslation(["q"]); + + return ( + + {({ FormField }) => ( + <> + + + + )} + + ); +} + +function TeamQueueSection() { + const { t } = useTranslation(["q"]); + const data = useLoaderData() as Extract< + LookingLoaderData, + { mode: "looking" } + >; + const tournament = useTournament(); + const fetcher = useFetcher(); + + if (!data.ownTeam) return null; + + const isAtMaxMembers = + data.ownTeam.members.length >= tournament.maxMembersPerTeam; + + return ( +
+ + {!isAtMaxMembers ? ( + + + {t("q:looking.joinQPromptMembers")} + + + ) : null} +
+ ); +} + +function LFGGroupLeaver({ type }: { type: "LEAVE_GROUP" | "LEAVE_Q" }) { + const { t } = useTranslation(["q"]); + const fetcher = useFetcher(); + + if (type === "LEAVE_GROUP") { + return ( + + + {t("q:looking.groups.actions.stopLooking")} + + + ); + } + + return ( + + + {t("q:looking.groups.actions.leaveQ")} + + + ); +} diff --git a/app/features/tournament-lfg/tournament-lfg-schemas.ts b/app/features/tournament-lfg/tournament-lfg-schemas.ts new file mode 100644 index 000000000..f2f1a25e5 --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-schemas.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { stringConstant, textAreaOptional, toggle } from "~/form/fields"; +import { _action, id } from "~/utils/zod"; + +const noteFieldSchema = textAreaOptional({ + label: "labels.note", + maxLength: 160, +}); + +export const addSubFormSchema = z.object({ + _action: stringConstant("ADD_SUB"), + message: noteFieldSchema, +}); + +const stayAsSubFieldSchema = toggle({ + label: "labels.stayAsSub", + bottomText: "bottomTexts.stayAsSub", +}); + +export const joinQueueFormSchema = z.object({ + _action: stringConstant("JOIN_QUEUE"), + note: noteFieldSchema, + stayAsSub: stayAsSubFieldSchema, +}); + +export const updateGroupFormSchema = z.object({ + _action: stringConstant("UPDATE_GROUP"), + note: noteFieldSchema, + stayAsSub: stayAsSubFieldSchema, +}); + +export const lookingSchema = z.union([ + joinQueueFormSchema, + z.object({ + _action: _action("LIKE"), + targetTeamId: id, + }), + z.object({ + _action: _action("UNLIKE"), + targetTeamId: id, + }), + z.object({ + _action: _action("ACCEPT"), + targetTeamId: id, + }), + z.object({ + _action: _action("GIVE_MANAGER"), + userId: id, + }), + z.object({ + _action: _action("REMOVE_MANAGER"), + userId: id, + }), + updateGroupFormSchema, + z.object({ + _action: _action("LEAVE_GROUP"), + }), + addSubFormSchema, + z.object({ + _action: _action("DELETE_SUB"), + userId: id, + }), +]); diff --git a/app/features/tournament-lfg/tournament-lfg-utils.ts b/app/features/tournament-lfg/tournament-lfg-utils.ts new file mode 100644 index 000000000..2e568629c --- /dev/null +++ b/app/features/tournament-lfg/tournament-lfg-utils.ts @@ -0,0 +1,18 @@ +import type { LFGGroup } from "./components/LFGGroupCard"; + +export function survivingTeamId({ + ourGroup, + theirGroup, +}: { + ourGroup: LFGGroup; + theirGroup: LFGGroup; +}) { + if (!ourGroup.isPlaceholder && theirGroup.isPlaceholder) { + return ourGroup.id; + } + if (ourGroup.isPlaceholder && !theirGroup.isPlaceholder) { + return theirGroup.id; + } + + return ourGroup.id; +} diff --git a/app/features/tournament-subs/TournamentSubRepository.server.ts b/app/features/tournament-subs/TournamentSubRepository.server.ts deleted file mode 100644 index a5de09926..000000000 --- a/app/features/tournament-subs/TournamentSubRepository.server.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { sql } from "kysely"; -import { db } from "~/db/sql"; -import type { TablesInsertable } from "~/db/tables"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; - -export async function findSubsVisibleToUser({ - tournamentId, - userId, -}: { - tournamentId: number; - userId?: number; -}) { - const userPlusTier = await getUserPlusTier(userId); - - const rows = await baseQuery(tournamentId) - .where((eb) => - eb.or([ - eb("TournamentSub.visibility", "=", "ALL"), - eb.and([ - eb("TournamentSub.visibility", "=", "+1"), - eb(sql`${userPlusTier}`, "=", 1), - ]), - eb.and([ - eb("TournamentSub.visibility", "=", "+2"), - eb(sql`${userPlusTier}`, "<=", 2), - ]), - eb.and([ - eb("TournamentSub.visibility", "=", "+3"), - eb(sql`${userPlusTier}`, "<=", 3), - ]), - ]), - ) - .orderBy((eb) => - eb - .case() - .when("TournamentSub.userId", "=", userId ?? null) - .then(0) - .else(1) - .end(), - ) - .orderBy((eb) => - eb - .case() - .when(eb("PlusTier.tier", "is", null)) - .then(4) - .else(eb.ref("PlusTier.tier")) - .end(), - ) - .orderBy("TournamentSub.createdAt", "desc") - .execute(); - - return mapRows(rows); -} - -export async function findUserSubPost({ - tournamentId, - userId, -}: { - tournamentId: number; - userId: number; -}) { - const row = await baseQuery(tournamentId) - .where("TournamentSub.userId", "=", userId) - .executeTakeFirst(); - - if (!row) return null; - - return mapRows([row])[0]; -} - -export function upsert( - args: Omit & { - bestWeapons: MainWeaponId[]; - okWeapons: MainWeaponId[]; - }, -) { - return db.transaction().execute(async (trx) => { - const bestWeaponsStr = args.bestWeapons.join(","); - const okWeaponsStr = - args.okWeapons.length > 0 ? args.okWeapons.join(",") : null; - - await trx - .insertInto("TournamentSub") - .values({ - userId: args.userId, - tournamentId: args.tournamentId, - canVc: args.canVc, - bestWeapons: bestWeaponsStr, - okWeapons: okWeaponsStr, - message: args.message, - visibility: args.visibility, - }) - .onConflict((oc) => - oc.columns(["userId", "tournamentId"]).doUpdateSet({ - canVc: args.canVc, - bestWeapons: bestWeaponsStr, - okWeapons: okWeaponsStr, - message: args.message, - visibility: args.visibility, - }), - ) - .execute(); - - await trx - .updateTable("User") - .set({ lastSubMessage: args.message }) - .where("id", "=", args.userId) - .execute(); - }); -} - -async function getUserPlusTier(userId?: number) { - if (!userId) return 4; - - const row = await db - .selectFrom("PlusTier") - .select("tier") - .where("userId", "=", userId) - .executeTakeFirst(); - - return row?.tier ?? 4; -} - -function baseQuery(tournamentId: number) { - return db - .selectFrom("TournamentSub") - .innerJoin("User", "User.id", "TournamentSub.userId") - .leftJoin("PlusTier", "PlusTier.userId", "User.id") - .select([ - "TournamentSub.canVc", - "TournamentSub.bestWeapons", - "TournamentSub.okWeapons", - "TournamentSub.message", - "TournamentSub.visibility", - "TournamentSub.createdAt", - "TournamentSub.userId", - "User.username", - "User.discordAvatar", - "User.country", - "User.discordId", - "User.customUrl", - "PlusTier.tier as plusTier", - ]) - .where("TournamentSub.tournamentId", "=", tournamentId); -} - -function mapRows< - T extends { - bestWeapons: string; - okWeapons: string | null; - }, ->(rows: T[]) { - return rows.map((row) => ({ - ...row, - bestWeapons: parseWeaponsArray(row.bestWeapons)!, - okWeapons: parseWeaponsArray(row.okWeapons), - })); -} - -function parseWeaponsArray(value: string | null) { - if (!value) return null; - - return value.split(",").map(Number) as MainWeaponId[]; -} diff --git a/app/features/tournament-subs/actions/to.$id.subs.new.server.ts b/app/features/tournament-subs/actions/to.$id.subs.new.server.ts deleted file mode 100644 index 8fa4bcdb1..000000000 --- a/app/features/tournament-subs/actions/to.$id.subs.new.server.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { type ActionFunction, redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; -import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; -import { - errorToastIfFalsy, - parseParams, - parseRequestPayload, -} from "~/utils/remix.server"; -import { tournamentSubsPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; -import * as TournamentSubRepository from "../TournamentSubRepository.server"; -import { subSchema } from "../tournament-subs-schemas.server"; - -export const action: ActionFunction = async ({ params, request }) => { - const user = requireUser(); - const data = await parseRequestPayload({ - request, - schema: subSchema, - }); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - await requireNotBannedByOrganization({ tournament, user }); - errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over"); - errorToastIfFalsy( - tournament.canAddNewSubPost, - "Registration is closed or subs feature disabled", - ); - errorToastIfFalsy( - !tournament.teamMemberOfByUser(user), - "Can't register as a sub and be in a team at the same time", - ); - - await TournamentSubRepository.upsert({ - bestWeapons: data.bestWeapons, - okWeapons: data.okWeapons, - canVc: data.canVc, - visibility: data.visibility, - message: data.message ?? null, - tournamentId, - userId: user.id, - }); - - clearTournamentDataCache(tournamentId); - - throw redirect(tournamentSubsPage(tournamentId)); -}; diff --git a/app/features/tournament-subs/actions/to.$id.subs.server.ts b/app/features/tournament-subs/actions/to.$id.subs.server.ts deleted file mode 100644 index 1583976bc..000000000 --- a/app/features/tournament-subs/actions/to.$id.subs.server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ActionFunction } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; -import { - errorToastIfFalsy, - parseParams, - parseRequestPayload, -} from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; -import { deleteSub } from "../queries/deleteSub.server"; -import { deleteSubSchema } from "../tournament-subs-schemas.server"; - -export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); - const data = await parseRequestPayload({ - request, - schema: deleteSubSchema, - }); - - errorToastIfFalsy( - user.id === data.userId || tournament.isOrganizer(user), - "You can only delete your own sub post", - ); - - deleteSub({ - tournamentId, - userId: data.userId, - }); - - clearTournamentDataCache(tournamentId); - - return null; -}; diff --git a/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts deleted file mode 100644 index 7bbd1f447..000000000 --- a/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type LoaderFunctionArgs, redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { parseParams } from "~/utils/remix.server"; -import { tournamentSubsPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; -import * as TournamentSubRepository from "../TournamentSubRepository.server"; - -export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - if (!tournament.canAddNewSubPost) { - throw redirect(tournamentSubsPage(tournamentId)); - } - - return { - sub: await TournamentSubRepository.findUserSubPost({ - tournamentId, - userId: user.id, - }), - userDefaults: await UserRepository.findSubDefaultsByUserId(user.id), - }; -}; diff --git a/app/features/tournament-subs/loaders/to.$id.subs.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.server.ts index e95db669f..bff2886b5 100644 --- a/app/features/tournament-subs/loaders/to.$id.subs.server.ts +++ b/app/features/tournament-subs/loaders/to.$id.subs.server.ts @@ -1,30 +1,13 @@ import { type LoaderFunctionArgs, redirect } from "react-router"; -import { getUser } from "~/features/auth/core/user.server"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { parseParams } from "~/utils/remix.server"; -import { tournamentRegisterPage } from "~/utils/urls"; +import { tournamentSubsPage } from "~/utils/urls"; import { idObject } from "~/utils/zod"; -import * as TournamentSubRepository from "../TournamentSubRepository.server"; export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = getUser(); const { id: tournamentId } = parseParams({ params, schema: idObject, }); - const tournament = await tournamentFromDB({ tournamentId, user }); - if (!tournament.subsFeatureEnabled) { - throw redirect(tournamentRegisterPage(tournamentId)); - } - - const subs = await TournamentSubRepository.findSubsVisibleToUser({ - tournamentId, - userId: user?.id, - }); - - return { - subs, - hasOwnSubPost: subs.some((sub) => sub.userId === user?.id), - }; + throw redirect(tournamentSubsPage(tournamentId)); }; diff --git a/app/features/tournament-subs/routes/to.$id.subs.new.module.css b/app/features/tournament-subs/routes/to.$id.subs.new.module.css deleted file mode 100644 index 4c109cb67..000000000 --- a/app/features/tournament-subs/routes/to.$id.subs.new.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.selectedWeapon { - padding: var(--s-2); - border-radius: 100%; - background-color: var(--color-bg-high); -} - -.weaponPool { - width: 20rem; -} diff --git a/app/features/tournament-subs/routes/to.$id.subs.new.tsx b/app/features/tournament-subs/routes/to.$id.subs.new.tsx deleted file mode 100644 index 4d950f56a..000000000 --- a/app/features/tournament-subs/routes/to.$id.subs.new.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { Trash } from "lucide-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Form, Link, useLoaderData } from "react-router"; -import { SendouButton } from "~/components/elements/Button"; -import { FormMessage } from "~/components/FormMessage"; -import { WeaponImage } from "~/components/Image"; -import { Label } from "~/components/Label"; -import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; -import { SubmitButton } from "~/components/SubmitButton"; -import { WeaponSelect } from "~/components/WeaponSelect"; -import { useUser } from "~/features/auth/core/user"; -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import type { SendouRouteHandle } from "~/utils/remix.server"; -import { SENDOUQ_SETTINGS_PAGE } from "~/utils/urls"; -import { action } from "../actions/to.$id.subs.new.server"; -import { loader } from "../loaders/to.$id.subs.new.server"; -import { TOURNAMENT_SUB } from "../tournament-subs-constants"; -export { action, loader }; - -import clsx from "clsx"; -import { mainStyles } from "~/components/Main"; -import styles from "./to.$id.subs.new.module.css"; - -export const handle: SendouRouteHandle = { - i18n: ["user"], -}; - -export default function NewTournamentSubPage() { - const user = useUser(); - const { t } = useTranslation(["common", "tournament"]); - const data = useLoaderData(); - - const [bestWeapons, setBestWeapons] = React.useState( - data.sub ? data.sub.bestWeapons : (data.userDefaults?.bestWeapons ?? []), - ); - const [okWeapons, setOkWeapons] = React.useState( - data.sub - ? (data.sub.okWeapons ?? []) - : (data.userDefaults?.okWeapons ?? []), - ); - - return ( -
-
-
-

{t("tournament:subs.addPost")}

- - {t("tournament:subs.defaultsNote")}{" "} - - {t("tournament:subs.defaultsPage")} - - -
- - - - - {user?.plusTier ? : null} - {t("common:actions.save")} - -
- ); -} - -function VCRadios() { - const { t } = useTranslation(["common", "tournament"]); - const data = useLoaderData(); - - const isDefaultChecked = (value: number) => { - if (data.sub) { - return data.sub.canVc === value; - } - - const defaultCanVc = data.userDefaults?.canVc ?? 1; - - return defaultCanVc === value; - }; - - return ( -
- -
-
- - -
-
- - -
-
- - -
-
-
- ); -} - -function Message() { - const { t } = useTranslation(["tournament"]); - const data = useLoaderData(); - const [value, setValue] = React.useState( - data.sub?.message ?? data.userDefaults?.message ?? "", - ); - - return ( -
- -