Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Claude Code Review

on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"

jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage

Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.

# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
use_sticky_comment: true
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "game-services",
"version": "0.86.0",
"version": "0.87.0",
"description": "",
"main": "src/index.ts",
"scripts": {
Expand Down
55 changes: 54 additions & 1 deletion src/entities/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,58 @@ export default class Event extends ClickHouseEntity<ClickHouseEvent, [string, Ga
createdAt!: Date
updatedAt: Date = new Date()

static async massHydrate(em: EntityManager, data: ClickHouseEvent[], clickhouse: ClickHouseClient, loadProps: boolean = false): Promise<Event[]> {
const playerAliasIds = Array.from(new Set(data.map((event) => event.player_alias_id)))

const playerAliases = await em.getRepository(PlayerAlias).find({
id: {
$in: playerAliasIds
}
}, { populate: ['player'] })

const playerAliasesMap = new Map<number, PlayerAlias>()
playerAliases.forEach((alias) => playerAliasesMap.set(alias.id, alias))

const propsMap = new Map<string, Prop[]>()
if (loadProps) {
const eventIds = data.map((event) => event.id)
if (eventIds.length > 0) {
const props = await clickhouse.query({
query: 'SELECT * FROM event_props WHERE event_id IN ({eventIds:Array(String)})',
query_params: { eventIds },
format: 'JSONEachRow'
}).then((res) => res.json<ClickHouseEventProp>())

props.forEach((prop) => {
if (!propsMap.has(prop.event_id)) {
propsMap.set(prop.event_id, [])
}
propsMap.get(prop.event_id)!.push(new Prop(prop.prop_key, prop.prop_value))
})
}
}

return data.map((eventData) => {
const playerAlias = playerAliasesMap.get(eventData.player_alias_id)
if (!playerAlias) {
return null
}

const event = new Event()
event.construct(eventData.name, playerAlias.player.game)
event.id = eventData.id
event.playerAlias = playerAlias
event.createdAt = new Date(eventData.created_at)
event.updatedAt = new Date(eventData.updated_at)

if (loadProps) {
event.props = propsMap.get(eventData.id) || []
}

return event
}).filter((event) => !!event)
}

construct(name: string, game: Game): this {
this.name = name
this.game = game
Expand Down Expand Up @@ -91,7 +143,8 @@ export default class Event extends ClickHouseEntity<ClickHouseEvent, [string, Ga

if (loadProps) {
const props = await clickhouse.query({
query: `SELECT * FROM event_props WHERE event_id = '${data.id}'`,
query: 'SELECT * FROM event_props WHERE event_id = {eventId:String}',
query_params: { eventId: data.id },
format: 'JSONEachRow'
}).then((res) => res.json<ClickHouseEventProp>())

Expand Down
2 changes: 1 addition & 1 deletion src/entities/organisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class Organisation {
@Property()
name!: string

@OneToMany(() => Game, (game) => game.organisation, { eager: true })
@OneToMany(() => Game, (game) => game.organisation)
games = new Collection<Game>(this)

@OneToOne({ orphanRemoval: true, eager: true })
Expand Down
4 changes: 2 additions & 2 deletions src/entities/player-auth-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export enum PlayerAuthActivityType {
CHANGED_EMAIL,
PASSWORD_RESET_REQUESTED,
PASSWORD_RESET_COMPLETED,
VERFICIATION_TOGGLED,
VERIFICATION_TOGGLED,
CHANGE_PASSWORD_FAILED,
CHANGE_EMAIL_FAILED,
TOGGLE_VERIFICATION_FAILED,
Expand Down Expand Up @@ -78,7 +78,7 @@ export default class PlayerAuthActivity {
return `A password reset request was made for ${authAlias.identifier}'s account`
case PlayerAuthActivityType.PASSWORD_RESET_COMPLETED:
return `A password reset was completed for ${authAlias.identifier}'s account`
case PlayerAuthActivityType.VERFICIATION_TOGGLED:
case PlayerAuthActivityType.VERIFICATION_TOGGLED:
return `${authAlias.identifier} toggled verification`
case PlayerAuthActivityType.CHANGE_PASSWORD_FAILED:
return `${authAlias.identifier} failed to change their password`
Expand Down
25 changes: 4 additions & 21 deletions src/entities/player-auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Entity, EntityManager, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql'
import { Entity, OneToOne, PrimaryKey, Property } from '@mikro-orm/mysql'
import Player from './player'
import { v4 } from 'uuid'
import PlayerAlias, { PlayerAliasService } from './player-alias'
import PlayerAlias from './player-alias'
import { sign } from '../lib/auth/jwt'
import { getAuthMiddlewareAliasKey, getAuthMiddlewarePlayerKey } from '../middleware/player-auth-middleware'

const errorCodes = [
'INVALID_CREDENTIALS',
Expand Down Expand Up @@ -50,35 +49,19 @@ export default class PlayerAuth {
@Property({ onUpdate: () => new Date() })
updatedAt: Date = new Date()

async createSession(em: EntityManager, alias: PlayerAlias): Promise<string> {
async createSession(alias: PlayerAlias): Promise<string> {
this.player.lastSeenAt = new Date()

this.sessionKey = v4()
this.sessionCreatedAt = new Date()
await this.clearAuthMiddlewareKeys(em)

const payload = { playerId: this.player.id, aliasId: alias.id }
return sign(payload, this.sessionKey)
}

async clearSession(em: EntityManager) {
clearSession() {
this.sessionKey = null
this.sessionCreatedAt = null
await this.clearAuthMiddlewareKeys(em)
}

private async clearAuthMiddlewareKeys(em: EntityManager) {
const alias = await em.repo(PlayerAlias).findOne({
service: PlayerAliasService.TALO,
player: this.player
})

const keysToClear: string[] = [
getAuthMiddlewarePlayerKey(this.player.id),
alias ? getAuthMiddlewareAliasKey(alias.id) : null
].filter((key): key is string => key !== null)

await Promise.all(keysToClear.map((key) => em.clearCache(key)))
}

toJSON() {
Expand Down
14 changes: 9 additions & 5 deletions src/lib/auth/getUserFromToken.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { EntityManager } from '@mikro-orm/mysql'
import { Context } from 'koa'
import User from '../../entities/user'
import { getResultCacheOptions } from '../perf/getResultCacheOptions'

async function getUserFromToken(ctx: Context) {
const em: EntityManager = ctx.em

const getUserFromToken = async (ctx: Context, relations?: string[]): Promise<User> => {
// user with email = loaded entity, user with sub = jwt
if (ctx.state.user.email) {
const user: User = ctx.state.user
await (<EntityManager>ctx.em).getRepository(User).populate(user, relations as never[])
return user
return ctx.state.user as User
}

const userId: number = ctx.state.user.sub
const user = await (<EntityManager>ctx.em).getRepository(User).findOneOrFail(userId, { populate: relations as never[] })
const user = await em.repo(User).findOneOrFail(
userId,
getResultCacheOptions(`user-from-token-${userId}-${ctx.state.user.iat}`)
)
return user
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/billing/getBillablePlayerCount.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { EntityManager } from '@mikro-orm/mysql'
import Organisation from '../../entities/organisation'
import Player from '../../entities/player'
import { getResultCacheOptions } from '../perf/getResultCacheOptions'

export default async function getBillablePlayerCount(em: EntityManager, organisation: Organisation): Promise<number> {
return em.getRepository(Player).count({
game: { organisation }
})
}, getResultCacheOptions(`billable-player-count-${organisation.id}`))
}
23 changes: 9 additions & 14 deletions src/middleware/player-auth-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,6 @@ import { isAPIRoute } from './route-middleware'
import { EntityManager } from '@mikro-orm/mysql'
import PlayerAlias, { PlayerAliasService } from '../entities/player-alias'
import { verify } from '../lib/auth/jwt'
import { getResultCacheOptions } from '../lib/perf/getResultCacheOptions'

export function getAuthMiddlewarePlayerKey(playerId: string) {
return `auth-middleware-player-${playerId}`
}

export function getAuthMiddlewareAliasKey(aliasId: number) {
return `auth-middleware-alias-${aliasId}`
}

export default async function playerAuthMiddleware(ctx: Context, next: Next): Promise<void> {
if (isAPIRoute(ctx) && (ctx.state.currentPlayerId || ctx.state.currentAliasId)) {
Expand All @@ -20,18 +11,22 @@ export default async function playerAuthMiddleware(ctx: Context, next: Next): Pr

if (ctx.state.currentPlayerId) {
alias = await em.getRepository(PlayerAlias).findOne({
player: ctx.state.currentPlayerId,
service: PlayerAliasService.TALO
service: PlayerAliasService.TALO,
player: {
id: ctx.state.currentPlayerId,
game: ctx.state.game
}
}, {
...getResultCacheOptions(getAuthMiddlewarePlayerKey(ctx.state.currentPlayerId)),
populate: ['player.auth']
})
} else {
alias = await em.getRepository(PlayerAlias).findOne({
id: ctx.state.currentAliasId,
service: PlayerAliasService.TALO
service: PlayerAliasService.TALO,
player: {
game: ctx.state.game
}
}, {
...getResultCacheOptions(getAuthMiddlewareAliasKey(ctx.state.currentAliasId)),
populate: ['player.auth']
})
}
Expand Down
5 changes: 3 additions & 2 deletions src/policies/leaderboard.policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import { PolicyDenial, Request, PolicyResponse } from 'koa-clay'
import { UserType } from '../entities/user'
import Leaderboard from '../entities/leaderboard'
import UserTypeGate from './user-type-gate'
import { Populate } from '@mikro-orm/mysql'

export default class LeaderboardPolicy extends Policy {
async index(req: Request): Promise<PolicyResponse> {
const { gameId } = req.params
return await this.canAccessGame(Number(gameId))
}

async canAccessLeaderboard(req: Request, relations: string[] = []): Promise<PolicyResponse> {
async canAccessLeaderboard<T extends string>(req: Request, relations?: Populate<Leaderboard, T>): Promise<PolicyResponse> {
const { id } = req.params

const leaderboard = await this.em.getRepository(Leaderboard).findOne(Number(id), { populate: relations as never[] })
const leaderboard = await this.em.getRepository(Leaderboard).findOne(Number(id), { populate: relations })
if (!leaderboard) return new PolicyDenial({ message: 'Leaderboard not found' }, 404)

this.ctx.state.leaderboard = leaderboard
Expand Down
7 changes: 6 additions & 1 deletion src/policies/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Game from '../entities/game'
import User from '../entities/user'
import getUserFromToken from '../lib/auth/getUserFromToken'
import checkScope from './checkScope'
import { getResultCacheOptions } from '../lib/perf/getResultCacheOptions'

export default class Policy extends ServicePolicy {
em: EntityManager
Expand Down Expand Up @@ -39,7 +40,11 @@ export default class Policy extends ServicePolicy {
}

async canAccessGame(gameId: number): Promise<boolean> {
const game = await this.em.getRepository(Game).findOne(gameId, { populate: ['organisation'] })
const game = await this.em.repo(Game).findOne(gameId, {
...getResultCacheOptions(`can-access-game-${gameId}`),
populate: ['organisation.id']
})

if (!game) this.ctx.throw(404, 'Game not found')
this.ctx.state.game = game

Expand Down
Loading