Skip to content

Commit 41f8bb6

Browse files
authored
Merge pull request #777 from TaloDev/migrate-headlines-routes
Migrate headlines routes to new router
2 parents 5f54076 + 61c199f commit 41f8bb6

File tree

16 files changed

+406
-322
lines changed

16 files changed

+406
-322
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ Route configurations support:
290290

291291
#### File Organization
292292

293-
**One route per file** for clarity:
293+
**One route per file** unless the router only has one route, in which case it can be inlined in `index.ts`:
294294

295295
```
296296
src/routes/

src/config/protected-routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import LeaderboardService from '../services/leaderboard.service'
99
import DataExportService from '../services/data-export.service'
1010
import APIKeyService from '../services/api-key.service'
1111
import EventService from '../services/event.service'
12-
import HeadlineService from '../services/headline.service'
12+
import { headlineRouter } from '../routes/protected/headline'
1313
import PlayerService from '../services/player.service'
1414
import { billingRouter } from '../routes/protected/billing'
1515
import IntegrationService from '../services/integration.service'
@@ -57,7 +57,6 @@ export default function protectedRoutes(app: Koa) {
5757
app.use(service('/games/:gameId/api-keys', new APIKeyService(), serviceOpts))
5858
app.use(service('/games/:gameId/events', new EventService(), serviceOpts))
5959
app.use(service('/games/:gameId/players', new PlayerService(), serviceOpts))
60-
app.use(service('/games/:gameId/headlines', new HeadlineService(), serviceOpts))
6160
app.use(service('/games/:gameId/integrations', new IntegrationService(), serviceOpts))
6261
app.use(service('/games/:gameId/player-groups', new PlayerGroupService(), serviceOpts))
6362
app.use(service('/games/:gameId/game-feedback', new GameFeedbackService(), serviceOpts))
@@ -67,6 +66,7 @@ export default function protectedRoutes(app: Koa) {
6766
app.use(billingRouter().routes())
6867
app.use(gameActivityRouter().routes())
6968
app.use(gameRouter().routes())
69+
app.use(headlineRouter().routes())
7070
app.use(inviteRouter().routes())
7171
app.use(organisationRouter().routes())
7272
app.use(userRouter().routes())

src/lib/routing/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ export type ProtectedRouteState = {
1111
api?: boolean
1212
}
1313
authenticatedUser: User
14+
includeDevData: boolean
1415
}
1516

1617
export type APIRouteState = {
1718
key: APIKey
1819
game: Game
20+
includeDevData: boolean
1921
}
2022

2123
export type RouteState = PublicRouteState | ProtectedRouteState | APIRouteState
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { isBefore, isSameDay, isValid } from 'date-fns'
2+
import { z } from 'zod'
3+
4+
export const dateRangeSchema = z.object({
5+
startDate: z.string().refine((val) => isValid(new Date(val)), {
6+
message: 'Invalid start date, please use YYYY-MM-DD or a timestamp'
7+
}),
8+
endDate: z.string().refine((val) => isValid(new Date(val)), {
9+
message: 'Invalid end date, please use YYYY-MM-DD or a timestamp'
10+
})
11+
}).refine((data) => {
12+
const start = new Date(data.startDate)
13+
const end = new Date(data.endDate)
14+
return isBefore(start, end) || isSameDay(start, end)
15+
}, {
16+
message: 'Invalid start date, it should be before the end date',
17+
path: ['startDate']
18+
})

src/policies/headline.policy.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { protectedRoute, withMiddleware } from '../../../lib/routing/router'
2+
import { loadGame } from '../../../middleware/game-middleware'
3+
import { endOfDay, startOfDay } from 'date-fns'
4+
import { formatDateForClickHouse } from '../../../lib/clickhouse/formatDateTime'
5+
import { withResponseCache } from '../../../lib/perf/responseCache'
6+
import { HEADLINES_CACHE_TTL_MS } from './common'
7+
import { dateRangeSchema } from '../../../lib/validation/dateRangeSchema'
8+
9+
export const averageSessionDurationRoute = protectedRoute({
10+
method: 'get',
11+
path: '/average_session_duration',
12+
schema: () => ({
13+
query: dateRangeSchema
14+
}),
15+
middleware: withMiddleware(loadGame),
16+
handler: async (ctx) => {
17+
const { startDate: startDateQuery, endDate: endDateQuery } = ctx.state.validated.query
18+
const game = ctx.state.game
19+
const includeDevData = ctx.state.includeDevData
20+
const clickhouse = ctx.clickhouse
21+
22+
return withResponseCache({
23+
key: `average-session-duration-${game.id}-${includeDevData}-${startDateQuery}-${endDateQuery}`,
24+
ttl: HEADLINES_CACHE_TTL_MS / 1000
25+
}, async () => {
26+
const startDate = formatDateForClickHouse(startOfDay(new Date(startDateQuery)))
27+
const endDate = formatDateForClickHouse(endOfDay(new Date(endDateQuery)))
28+
29+
let query = `
30+
SELECT avg(dateDiff('seconds', started_at, ended_at)) AS averageDuration
31+
FROM player_sessions
32+
WHERE started_at BETWEEN '${startDate}' AND '${endDate}'
33+
AND ended_at IS NOT NULL
34+
AND game_id = ${game.id}
35+
`
36+
37+
if (!includeDevData) {
38+
query += ' AND dev_build = false'
39+
}
40+
41+
const result = await clickhouse.query({
42+
query,
43+
format: 'JSONEachRow'
44+
}).then((res) => res.json<{ averageDuration: number }>())
45+
46+
const seconds = result[0].averageDuration
47+
48+
return {
49+
status: 200,
50+
body: {
51+
hours: Math.floor(seconds / 3600),
52+
minutes: Math.floor((seconds % 3600) / 60),
53+
seconds: seconds % 60
54+
}
55+
}
56+
})
57+
}
58+
})
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const HEADLINES_CACHE_TTL_MS = 300_000
2+
export const ONLINE_PLAYERS_CACHE_TTL_MS = 30_000
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { protectedRoute, withMiddleware } from '../../../lib/routing/router'
2+
import { loadGame } from '../../../middleware/game-middleware'
3+
import { endOfDay, startOfDay } from 'date-fns'
4+
import { formatDateForClickHouse } from '../../../lib/clickhouse/formatDateTime'
5+
import { withResponseCache } from '../../../lib/perf/responseCache'
6+
import { HEADLINES_CACHE_TTL_MS } from './common'
7+
import { dateRangeSchema } from '../../../lib/validation/dateRangeSchema'
8+
9+
export const eventsRoute = protectedRoute({
10+
method: 'get',
11+
path: '/events',
12+
schema: () => ({
13+
query: dateRangeSchema
14+
}),
15+
middleware: withMiddleware(loadGame),
16+
handler: async (ctx) => {
17+
const { startDate: startDateQuery, endDate: endDateQuery } = ctx.state.validated.query
18+
const game = ctx.state.game
19+
const includeDevData = ctx.state.includeDevData
20+
const clickhouse = ctx.clickhouse
21+
22+
return withResponseCache({
23+
key: `events-${game.id}-${includeDevData}-${startDateQuery}-${endDateQuery}`,
24+
ttl: HEADLINES_CACHE_TTL_MS / 1000
25+
}, async () => {
26+
const startDate = formatDateForClickHouse(startOfDay(new Date(startDateQuery)))
27+
const endDate = formatDateForClickHouse(endOfDay(new Date(endDateQuery)))
28+
29+
let query = `
30+
SELECT count() AS count
31+
FROM events
32+
WHERE created_at BETWEEN '${startDate}' AND '${endDate}'
33+
AND game_id = ${game.id}
34+
`
35+
36+
if (!includeDevData) {
37+
query += 'AND dev_build = false'
38+
}
39+
40+
const result = await clickhouse.query({
41+
query,
42+
format: 'JSONEachRow'
43+
}).then((res) => res.json<{ count: string }>())
44+
45+
return {
46+
status: 200,
47+
body: {
48+
count: Number(result[0].count)
49+
}
50+
}
51+
})
52+
}
53+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { protectedRouter } from '../../../lib/routing/router'
2+
import { newPlayersRoute } from './new-players'
3+
import { returningPlayersRoute } from './returning-players'
4+
import { eventsRoute } from './events'
5+
import { uniqueEventSubmittersRoute } from './unique-event-submitters'
6+
import { totalPlayersRoute } from './total-players'
7+
import { onlinePlayersRoute } from './online-players'
8+
import { totalSessionsRoute } from './total-sessions'
9+
import { averageSessionDurationRoute } from './average-session-duration'
10+
11+
export function headlineRouter() {
12+
return protectedRouter('/games/:gameId/headlines', ({ route }) => {
13+
route(newPlayersRoute)
14+
route(returningPlayersRoute)
15+
route(eventsRoute)
16+
route(uniqueEventSubmittersRoute)
17+
route(totalPlayersRoute)
18+
route(onlinePlayersRoute)
19+
route(totalSessionsRoute)
20+
route(averageSessionDurationRoute)
21+
})
22+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { protectedRoute, withMiddleware } from '../../../lib/routing/router'
2+
import { loadGame } from '../../../middleware/game-middleware'
3+
import { endOfDay, startOfDay } from 'date-fns'
4+
import Player from '../../../entities/player'
5+
import { getResultCacheOptions } from '../../../lib/perf/getResultCacheOptions'
6+
import { HEADLINES_CACHE_TTL_MS } from './common'
7+
import { dateRangeSchema } from '../../../lib/validation/dateRangeSchema'
8+
9+
export const newPlayersRoute = protectedRoute({
10+
method: 'get',
11+
path: '/new_players',
12+
schema: () => ({
13+
query: dateRangeSchema
14+
}),
15+
middleware: withMiddleware(loadGame),
16+
handler: async (ctx) => {
17+
const { startDate, endDate } = ctx.state.validated.query
18+
const em = ctx.em
19+
20+
const game = ctx.state.game
21+
const includeDevData = ctx.state.includeDevData
22+
const count = await em.repo(Player).count({
23+
game,
24+
...(includeDevData ? {} : { devBuild: false }),
25+
createdAt: {
26+
$gte: startOfDay(new Date(startDate)),
27+
$lte: endOfDay(new Date(endDate))
28+
}
29+
}, getResultCacheOptions(`new-players-${game.id}-${includeDevData}-${startDate}-${endDate}`, HEADLINES_CACHE_TTL_MS))
30+
31+
return {
32+
status: 200,
33+
body: {
34+
count
35+
}
36+
}
37+
}
38+
})

0 commit comments

Comments
 (0)