Skip to content

Commit

Permalink
Clans (#63)
Browse files Browse the repository at this point in the history
* add internal server error

* add clan routes
  • Loading branch information
owens1127 authored Aug 19, 2024
1 parent c750614 commit df3bc1a
Show file tree
Hide file tree
Showing 21 changed files with 1,418 additions and 68 deletions.
1,114 changes: 1,055 additions & 59 deletions open-api/openapi.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src/RaidHubRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { zApiKeyError } from "./schema/errors/ApiKeyError"
import { zBodyValidationError } from "./schema/errors/BodyValidationError"
import { ErrorCode } from "./schema/errors/ErrorCode"
import { zInsufficientPermissionsError } from "./schema/errors/InsufficientPermissionsError"
import { zInternalServerError } from "./schema/errors/InternalServerError"
import { zPathValidationError } from "./schema/errors/PathValidationError"
import { zQueryValidationError } from "./schema/errors/QueryValidationError"
import { httpRequestTimer } from "./services/prometheus/metrics"
Expand Down Expand Up @@ -308,7 +309,8 @@ export class RaidHubRoute<
this.isAdministratorRoute ? [403, "Forbidden", zInsufficientPermissionsError] : null,
this.paramsSchema ? [404, "Not found", zPathValidationError] : null,
this.querySchema ? [400, "Bad request", zQueryValidationError] : null,
this.bodySchema ? [400, "Bad request", zBodyValidationError] : null
this.bodySchema ? [400, "Bad request", zBodyValidationError] : null,
[500, "Internal Server Error", zInternalServerError]
].filter(Boolean) as [number, string, ZodObject<any>][]

const byCode: { [statusCode: string]: ZodType<unknown>[] } = {}
Expand Down
18 changes: 18 additions & 0 deletions src/data-access-layer/__test__/clan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cleanupPostgresAfterAll } from "../../routes/testUtil"
import { zClanLeaderboardEntry } from "../../schema/components/Clan"
import { getClanStats } from "../clan"

cleanupPostgresAfterAll()

describe("getClanStats", () => {
it("returns the correct shape", async () => {
const data = await getClanStats("3148408").catch(console.error)

const parsed = zClanLeaderboardEntry.safeParse(data)
if (!parsed.success) {
expect(parsed.error.errors).toHaveLength(0)
} else {
expect(parsed.success).toBe(true)
}
})
})
23 changes: 23 additions & 0 deletions src/data-access-layer/__test__/leaderboard/clan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from "zod"
import { cleanupPostgresAfterAll } from "../../../routes/testUtil"
import { zClanLeaderboardEntry } from "../../../schema/components/Clan"
import { getClanLeaderboard } from "../../leaderboard/clan"

cleanupPostgresAfterAll()

describe("getClanLeaderboard", () => {
it("returns the correct shape", async () => {
const data = await getClanLeaderboard({
skip: 0,
take: 10,
column: "weighted_contest_score"
}).catch(console.error)

const parsed = z.array(zClanLeaderboardEntry).safeParse(data)
if (!parsed.success) {
expect(parsed.error.errors).toHaveLength(0)
} else {
expect(parsed.data).toHaveLength(10)
}
})
})
33 changes: 33 additions & 0 deletions src/data-access-layer/clan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ClanLeaderboardEntry } from "../schema/components/Clan"
import { postgres } from "../services/postgres"

export const getClanStats = async (groupId: bigint | string) => {
return await postgres.queryRow<ClanLeaderboardEntry>(
`SELECT
JSONB_BUILD_OBJECT(
'groupId', clan."group_id",
'name', clan.name,
'callSign', clan.call_sign,
'motto', clan."motto",
'clanBannerData', clan."clan_banner_data",
'lastUpdated', TO_CHAR(clan."updated_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
) AS "clan",
"known_member_count" AS "knownMemberCount",
"clears",
"average_clears" AS "averageClears",
"fresh_clears" AS "freshClears",
"average_fresh_clears" AS "averageFreshClears",
"sherpas",
"average_sherpas" AS "averageSherpas",
"time_played_seconds" AS "timePlayedSeconds",
"average_time_played_seconds" AS "averageTimePlayedSeconds",
"total_contest_score" AS "totalContestScore",
"weighted_contest_score" AS "weightedContestScore"
FROM clan_leaderboard
INNER JOIN clan USING (group_id)
WHERE group_id = $1::bigint`,
{
params: [groupId]
}
)
}
66 changes: 66 additions & 0 deletions src/data-access-layer/leaderboard/clan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ClanLeaderboardEntry } from "../../schema/components/Clan"
import { postgres } from "../../services/postgres"

export const clanLeaderboardSortColumns = [
"clears",
"average_clears",
"fresh_clears",
"average_fresh_clears",
"sherpas",
"average_sherpas",
"time_played_seconds",
"average_time_played_seconds",
"total_contest_score",
"weighted_contest_score"
] as const

const validateColumn = (column: (typeof clanLeaderboardSortColumns)[number]) => {
if (!clanLeaderboardSortColumns.includes(column)) {
// Just an extra layer of run-time validation to ensure that the column is one of the valid columns
throw new TypeError(`Invalid column: ${column}`)
}
}

export const getClanLeaderboard = async ({
skip,
take,
column
}: {
skip: number
take: number
column: (typeof clanLeaderboardSortColumns)[number]
}) => {
validateColumn(column)

return await postgres.queryRows<ClanLeaderboardEntry>(
`SELECT
JSONB_BUILD_OBJECT(
'groupId', clan."group_id",
'name', clan.name,
'callSign', clan.call_sign,
'motto', clan."motto",
'clanBannerData', clan."clan_banner_data",
'lastUpdated', TO_CHAR(clan."updated_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
) AS "clan",
"known_member_count" AS "knownMemberCount",
"clears",
"average_clears" AS "averageClears",
"fresh_clears" AS "freshClears",
"average_fresh_clears" AS "averageFreshClears",
"sherpas",
"average_sherpas" AS "averageSherpas",
"time_played_seconds" AS "timePlayedSeconds",
"average_time_played_seconds" AS "averageTimePlayedSeconds",
"total_contest_score" AS "totalContestScore",
"weighted_contest_score" AS "weightedContestScore"
FROM clan_leaderboard
INNER JOIN clan USING (group_id)
ORDER BY ${column} DESC
OFFSET $1
LIMIT $2`,
{
params: [skip, take],
fetchCount: take
}
)
}
4 changes: 3 additions & 1 deletion src/routes/activity.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { activityRoute } from "./activity"
import { expectErr, expectOk } from "./testUtil"
import { cleanupPostgresAfterAll, expectErr, expectOk } from "./testUtil"

cleanupPostgresAfterAll()

describe("activity 200", () => {
const t = async (instanceId: string) => {
Expand Down
30 changes: 30 additions & 0 deletions src/routes/clan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { clanStatsRoute } from "./clan"
import { cleanupPostgresAfterAll, expectErr, expectOk } from "./testUtil"

cleanupPostgresAfterAll()

describe("clan 200", () => {
const t = async (groupId: string) => {
const result = await clanStatsRoute.$mock({ params: { groupId } })

expectOk(result)
}

test("Elysium", () => t("3148408"))

test("Passion", () => t("4999487"))
})

describe("clan 404", () => {
const t = async (groupId: string) => {
const result = await clanStatsRoute.$mock({
params: {
groupId
}
})

expectErr(result)
}

test("1", () => t("1"))
})
41 changes: 41 additions & 0 deletions src/routes/clan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { z } from "zod"
import { RaidHubRoute } from "../RaidHubRoute"
import { getClanStats } from "../data-access-layer/clan"
import { cacheControl } from "../middlewares/cache-control"
import { zClanLeaderboardEntry } from "../schema/components/Clan"
import { ErrorCode } from "../schema/errors/ErrorCode"
import { zBigIntString } from "../schema/util"

export const clanStatsRoute = new RaidHubRoute({
method: "get",
description: "Get the stats for a clan. Data updates weekly.",
params: z.object({
groupId: zBigIntString()
}),
middleware: [cacheControl(30)],
response: {
success: {
statusCode: 200,
schema: zClanLeaderboardEntry
},
errors: [
{
statusCode: 404,
type: ErrorCode.ClanNotFound,
schema: z.object({
groupId: zBigIntString()
})
}
]
},
async handler({ params }) {
const groupId = params.groupId

const stats = await getClanStats(groupId)
if (!stats) {
return RaidHubRoute.fail(ErrorCode.ClanNotFound, { groupId })
}

return RaidHubRoute.ok(stats)
}
})
5 changes: 5 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { activityRoute } from "./activity"
import { adminRouter } from "./admin"
import { adminAuthorizationRoute } from "./authorize/admin"
import { userAuthorizationRoute } from "./authorize/user"
import { clanStatsRoute } from "./clan"
import { leaderboardRouter } from "./leaderboard"
import { manifestRoute } from "./manifest"
import { pgcrRoute } from "./pgcr"
Expand Down Expand Up @@ -30,6 +31,10 @@ export const router = new RaidHubRouter({
path: "/pgcr/:instanceId",
route: pgcrRoute
},
{
path: "/clan/:groupId",
route: clanStatsRoute
},
{
path: "/admin",
route: adminRouter
Expand Down
41 changes: 41 additions & 0 deletions src/routes/leaderboard/clan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { cleanupPostgresAfterAll, expectOk } from "../testUtil"
import { clanLeaderboardRoute } from "./clan"

cleanupPostgresAfterAll()

describe("clan leaderboard 200", () => {
const t = async (query?: Record<string, unknown>) => {
const result = await clanLeaderboardRoute.$mock({ query })

expectOk(result)
expect(result.parsed.length).toBeGreaterThan(0)
}

test("weighted contest ranking", () =>
t({
count: 61,
page: 1,
column: "weighted_contest_score"
}))

test("sherpas", () =>
t({
count: 14,
page: 3,
column: "sherpas"
}))

test("average_sherpas", () =>
t({
count: 27,
page: 2,
column: "average_sherpas"
}))

test("clears", () =>
t({
count: 10,
page: 5,
column: "clears"
}))
})
38 changes: 38 additions & 0 deletions src/routes/leaderboard/clan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from "zod"
import { RaidHubRoute } from "../../RaidHubRoute"
import {
clanLeaderboardSortColumns,
getClanLeaderboard
} from "../../data-access-layer/leaderboard/clan"
import { cacheControl } from "../../middlewares/cache-control"
import { zClanLeaderboardEntry } from "../../schema/components/Clan"
import { zPage } from "../../schema/util"

export const clanLeaderboardRoute = new RaidHubRoute({
method: "get",
description: "Get a page of the clan leaderboard based on query parameters",
query: z.object({
count: z.coerce.number().int().min(10).max(100).default(50),
page: zPage(),
column: z.enum(clanLeaderboardSortColumns).default("weighted_contest_score")
}),
response: {
errors: [],
success: {
statusCode: 200,
schema: z.array(zClanLeaderboardEntry)
}
},
middleware: [cacheControl(60)],
async handler(req) {
const { page, count, column } = req.query

const entries = await getClanLeaderboard({
skip: (page - 1) * count,
take: count,
column
})

return RaidHubRoute.ok(entries)
}
})
5 changes: 5 additions & 0 deletions src/routes/leaderboard/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RaidHubRouter } from "../../RaidHubRouter"
import { clanLeaderboardRoute } from "./clan"
import { leaderboardIndividualGlobalRoute } from "./individual/global"
import { leaderboardIndividualPantheonRoute } from "./individual/pantheon"
import { leaderboardIndividualRaidRoute } from "./individual/raid"
Expand Down Expand Up @@ -34,6 +35,10 @@ export const leaderboardRouter = new RaidHubRouter({
}
]
})
},
{
path: "/clan",
route: clanLeaderboardRoute
}
]
})
2 changes: 1 addition & 1 deletion src/routes/leaderboard/individual/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {
} from "../../../data-access-layer/leaderboard/individual/power-rankings"
import { cacheControl } from "../../../middlewares/cache-control"
import { zLeaderboardData } from "../../../schema/components/LeaderboardData"
import { zLeaderboardPagination } from "../../../schema/components/LeaderboardPagination"
import { ErrorCode } from "../../../schema/errors/ErrorCode"
import { zLeaderboardPagination } from "../../../schema/query.ts/LeaderboardPagination"
import { zBigIntString } from "../../../schema/util"

const zCategory = z.enum(["clears", "freshClears", "sherpas", "speedrun", "powerRankings"])
Expand Down
2 changes: 1 addition & 1 deletion src/routes/leaderboard/individual/pantheon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
} from "../../../data-access-layer/leaderboard/individual/pantheon"
import { cacheControl } from "../../../middlewares/cache-control"
import { zLeaderboardData } from "../../../schema/components/LeaderboardData"
import { zLeaderboardPagination } from "../../../schema/components/LeaderboardPagination"
import { ErrorCode } from "../../../schema/errors/ErrorCode"
import { zLeaderboardPagination } from "../../../schema/query.ts/LeaderboardPagination"
import { zBigIntString } from "../../../schema/util"

const zCategory = z.enum(["clears", "freshClears", "score"])
Expand Down
2 changes: 1 addition & 1 deletion src/routes/leaderboard/individual/raid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
} from "../../../data-access-layer/leaderboard/individual/raid"
import { cacheControl } from "../../../middlewares/cache-control"
import { zLeaderboardData } from "../../../schema/components/LeaderboardData"
import { zLeaderboardPagination } from "../../../schema/components/LeaderboardPagination"
import { ErrorCode } from "../../../schema/errors/ErrorCode"
import { zLeaderboardPagination } from "../../../schema/query.ts/LeaderboardPagination"
import { zBigIntString } from "../../../schema/util"

const zCategory = z.enum(["clears", "freshClears", "sherpas"])
Expand Down
Loading

0 comments on commit df3bc1a

Please sign in to comment.