From be48619a4a3139bd674710cfc5491bd5fcd27023 Mon Sep 17 00:00:00 2001 From: Tau Date: Sun, 26 May 2019 20:08:22 -0400 Subject: [PATCH] idz: Add TeamMemberRepository --- src/idz/db/index.ts | 5 ++ src/idz/db/teamMember.ts | 153 +++++++++++++++++++++++++++++++++++++++ src/idz/repo.ts | 23 ++++++ 3 files changed, 181 insertions(+) create mode 100644 src/idz/db/teamMember.ts diff --git a/src/idz/db/index.ts b/src/idz/db/index.ts index 5ff97f6..63b193c 100644 --- a/src/idz/db/index.ts +++ b/src/idz/db/index.ts @@ -9,6 +9,7 @@ import { SqlProfileRepository } from "./profile"; import { SqlSettingsRepository } from "./settings"; import { SqlStoryRepository } from "./story"; import { SqlTeamRepository } from "./team"; +import { SqlTeamMemberRepository } from "./teamMember"; import { SqlTicketsRepository } from "./tickets"; import { SqlTimeAttackRepository } from "./timeAttack"; import { SqlTitlesRepository } from "./titles"; @@ -56,6 +57,10 @@ class TransactionImpl implements Repo.Transaction { return new SqlTeamRepository(this._conn); } + teamMembers(): Repo.TeamMemberRepository { + return new SqlTeamMemberRepository(this._conn); + } + tickets(): Repo.FacetRepository { return new SqlTicketsRepository(this._conn); } diff --git a/src/idz/db/teamMember.ts b/src/idz/db/teamMember.ts new file mode 100644 index 0000000..aaafe57 --- /dev/null +++ b/src/idz/db/teamMember.ts @@ -0,0 +1,153 @@ +import * as sql from "sql-bricks-postgres"; +import { ClientBase } from "pg"; + +import { Profile } from "../model/profile"; +import { Team, TeamMember } from "../model/team"; +import { TeamMemberRepository } from "../repo"; +import { Id, generateId } from "../../db"; +import { _extractProfile } from "./profile"; +import { _extractChara } from "./chara"; + +export class SqlTeamMemberRepository implements TeamMemberRepository { + constructor(private readonly _conn: ClientBase) {} + + async findTeam(profileId: Id): Promise | undefined> { + const findSql = sql + .select("tm.team_id") + .from("idz.team_member tm") + .where("tm.id", profileId) + .toParams(); + + const { rows } = await this._conn.query(findSql); + const row = rows[0]; + + if (row === undefined) { + return undefined; + } + + return row.team_id; + } + + async findLeader(teamId: Id): Promise | undefined> { + const findSql = sql + .select("tm.id") + .from("idz.team_member tm") + .where("tm.team_id", teamId) + .where("tm.leader", true) + .toParams(); + + const { rows } = await this._conn.query(findSql); + const row = rows[0]; + + if (row === undefined) { + return undefined; + } + + return row.id; + } + + async loadRoster(teamId: Id): Promise { + const loadSql = sql + .select("tm.*", "p.*", "c.*", "r.ext_id as aime_id") + .from("idz.team_member tm") + .join("idz.profile p", { "tm.id": "p.id" }) + .join("idz.chara c", { "tm.id": "c.id" }) + .join("aime.player r", { "p.player_id": "r.id" }) + .where("tm.team_id", teamId) + .toParams(); + + const { rows } = await this._conn.query(loadSql); + + return rows.map((row: any) => ({ + profile: _extractProfile(row), + chara: _extractChara(row), + leader: row.leader, + joinTime: new Date(row.join_time), + })); + } + + async join( + teamId: Id, + profileId: Id, + timestamp: Date + ): Promise { + // Lock the team record to avoid race conditions. This way + + const lockSql = sql + .select("id") + .from("idz.team") + .where("id", teamId) + .forUpdate() + .toParams(); + + await this._conn.query(lockSql); + + // Double-check (with lock held) that there is room to join this team. + // If this fails then the error will propagate to the client and it will + // retry, and, assuming we have a race between two new registrations to + // take up the last slot in the current auto-team, hopefully succeed. + // + // There is arguably some business logic pollution here, since we have a + // hard-coded maximum team size imposed by the protocol. This is why a + // three-layered server would be better than our two-layered server. + + const countSql = sql + .select("count(*) as count") + .from("idz.team_member") + .where("team_id", teamId) + .toParams(); + + const { rows } = await this._conn.query(countSql); + const row = rows[0]; + + if (row.count >= 6) { + throw new Error(`Team ${teamId} is full`); + } + + // Do upsert + + const joinSql = sql + .insert("idz.team_member", { + id: profileId, + team_id: teamId, + leader: false, + join_time: timestamp, + }) + .onConflict("id") + .doUpdate(["team_id", "leader", "join_time"]) + .toParams(); + + await this._conn.query(joinSql); + } + + async leave(teamId: Id, profileId: Id): Promise { + const leaveSql = sql + .delete("idz.team_member") + .where("team_id", teamId) + .where("id", profileId) + .toParams(); + + await this._conn.query(leaveSql); + } + + async makeLeader(teamId: Id, profileId: Id): Promise { + const clearSql = sql + .update("idz.team_member", { + leader: false, + }) + .where("team_id", teamId) + .toParams(); + + await this._conn.query(clearSql); + + const setSql = sql + .update("idz.team_member", { + leader: true, + }) + .where("id", profileId) + .where("team_id", teamId) + .toParams(); + + await this._conn.query(setSql); + } +} diff --git a/src/idz/repo.ts b/src/idz/repo.ts index 0bf5307..5e2f238 100644 --- a/src/idz/repo.ts +++ b/src/idz/repo.ts @@ -69,6 +69,27 @@ export interface TeamRepository { delete(id: Id): Promise; } +export interface TeamMemberRepository { + findTeam(profileId: Id): Promise | undefined>; + + findLeader(teamId: Id): Promise | undefined>; + + loadRoster(id: Id): Promise; + + join( + teamId: Id, + profileId: Id, + timestamp: Date + ): Promise; + + leave(teamId: Id, profileId: Id): Promise; + + makeLeader( + team: Id, + profileId: Id + ): Promise; +} + // TODO extend and factorize export interface TopTenResult { driverName: string; @@ -110,6 +131,8 @@ export interface Repositories { teams(): TeamRepository; + teamMembers(): TeamMemberRepository; + tickets(): FacetRepository; timeAttack(): TimeAttackRepository;