Skip to content

Commit 0e8e65a

Browse files
authored
Merge pull request #119 from TaloDev/develop
Release 0.19.0
2 parents b430ffd + 637a2fe commit 0e8e65a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+3745
-72
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:lts-alpine AS base
1+
FROM node:lts AS base
22
WORKDIR /usr/backend
33
COPY tsconfig.json .
44
COPY package.json .

envs/.env.dev

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ AUTO_CONFIRM_EMAIL=false
2020
FROM_EMAIL=hello@trytalo.com
2121

2222
RECOVERY_CODES_SECRET=tc0d8e0h0lqv5isajfjw0iivj5pc3d95
23+
STEAM_INTEGRATION_SECRET=PjBw8vy8ZbFqXvZwAABWfbhfXvJ32idf
2324

2425
STRIPE_KEY=

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "game-services",
3-
"version": "0.18.0",
3+
"version": "0.19.0",
44
"description": "",
55
"main": "src/index.ts",
66
"scripts": {
@@ -11,8 +11,8 @@
1111
"test": "./tests/setup-tests.sh",
1212
"up": "yarn dc up --build -d",
1313
"down": "yarn dc down",
14+
"restart": "yarn dc restart backend && yarn logs",
1415
"logs": "yarn dc logs --follow backend",
15-
"restart": "yarn down && yarn up",
1616
"migration:create": "DB_HOST=127.0.0.1 mikro-orm migration:create",
1717
"migration:up": "DB_HOST=127.0.0.1 mikro-orm migration:up",
1818
"service:create": "hygen service new",
@@ -34,6 +34,7 @@
3434
"@types/supertest": "^2.0.12",
3535
"@typescript-eslint/eslint-plugin": "^5.20.0",
3636
"@typescript-eslint/parser": "^5.20.0",
37+
"axios-mock-adapter": "^1.21.2",
3738
"casual": "^1.6.2",
3839
"eslint": "^8.14.0",
3940
"hefty": "^1.1.0",
@@ -58,6 +59,7 @@
5859
"@sentry/node": "^6.19.6",
5960
"@sentry/tracing": "^6.19.6",
6061
"adm-zip": "^0.5.6",
62+
"axios": "^0.27.2",
6163
"bcrypt": "^5.0.1",
6264
"bee-queue": "^1.4.0",
6365
"date-fns": "^2.28.0",
@@ -68,15 +70,17 @@
6870
"jsonwebtoken": "^8.5.1",
6971
"koa": "^2.13.4",
7072
"koa-bodyparser": "^4.3.0",
71-
"koa-clay": "^6.4.0",
73+
"koa-clay": "^6.5.0",
7274
"koa-helmet": "^6.1.0",
7375
"koa-jwt": "^4.0.3",
7476
"koa-logger": "^3.2.1",
7577
"lodash.get": "^4.4.2",
7678
"lodash.groupby": "^4.6.0",
79+
"lodash.pick": "^4.4.0",
7780
"lodash.uniqwith": "^4.5.0",
7881
"otplib": "^12.0.1",
7982
"qrcode": "^1.5.0",
83+
"qs": "^6.11.0",
8084
"stripe": "^9.2.0",
8185
"uuid": "^8.3.2"
8286
},

src/config/protected-routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import HeadlineService from '../services/headline.service'
1313
import PlayerService from '../services/player.service'
1414
import UserService from '../services/user.service'
1515
import BillingService from '../services/billing.service'
16+
import IntegrationService from '../services/integration.service'
1617

1718
export default (app: Koa) => {
1819
app.use(async (ctx: Context, next: Next): Promise<void> => {
@@ -40,6 +41,7 @@ export default (app: Koa) => {
4041
app.use(service('/games/:gameId/events', new EventService(), serviceOpts))
4142
app.use(service('/games/:gameId/players', new PlayerService(), serviceOpts))
4243
app.use(service('/games/:gameId/headlines', new HeadlineService(), serviceOpts))
44+
app.use(service('/games/:gameId/integrations', new IntegrationService(), serviceOpts))
4345
app.use(service('/games', new GameService(), serviceOpts))
4446
app.use(service('/users', new UserService(), serviceOpts))
4547
}

src/entities/game-activity.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
2+
import upperFirst from '../lib/lang/upperFirst'
23
import Game from './game'
34
import User from './user'
45

@@ -16,7 +17,12 @@ export enum GameActivityType {
1617
GAME_STAT_DELETED,
1718
INVITE_CREATED,
1819
INVITE_ACCEPTED,
19-
DATA_EXPORT_REQUESTED
20+
DATA_EXPORT_REQUESTED,
21+
GAME_INTEGRATION_ADDED,
22+
GAME_INTEGRATION_UPDATED,
23+
GAME_INTEGRATION_DELETED,
24+
GAME_INTEGRATION_STEAMWORKS_LEADERBOARDS_SYNCED,
25+
GAME_INTEGRATION_STEAMWORKS_STATS_SYNCED,
2026
}
2127

2228
@Entity()
@@ -80,6 +86,16 @@ export default class GameActivity {
8086
return `${this.user.username} joined the organisation`
8187
case GameActivityType.DATA_EXPORT_REQUESTED:
8288
return `${this.user.username} requested a data export`
89+
case GameActivityType.GAME_INTEGRATION_ADDED:
90+
return `${this.user.username} enabled the ${upperFirst(this.extra.integrationType as string)}} integration`
91+
case GameActivityType.GAME_INTEGRATION_UPDATED:
92+
return `${this.user.username} updated the ${upperFirst(this.extra.integrationType as string)} integration`
93+
case GameActivityType.GAME_INTEGRATION_DELETED:
94+
return `${this.user.username} disabled the ${upperFirst(this.extra.integrationType as string)} integration`
95+
case GameActivityType.GAME_INTEGRATION_STEAMWORKS_LEADERBOARDS_SYNCED:
96+
return `${this.user.username} initiated a manual sync for Steamworks leaderboards`
97+
case GameActivityType.GAME_INTEGRATION_STEAMWORKS_STATS_SYNCED:
98+
return `${this.user.username} initiated a manual sync for Steamworks stats`
8399
default:
84100
return ''
85101
}

src/entities/game-stat.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ export default class GameStat {
1111
@Required({
1212
validation: async (val: unknown, req: Request): Promise<ValidationCondition[]> => {
1313
const { gameId, id } = req.params
14-
const em: EntityManager = req.ctx.em
15-
const duplicateInternalName = await em.getRepository(GameStat).findOne({
14+
const duplicateInternalName = await (<EntityManager>req.ctx.em).getRepository(GameStat).findOne({
1615
id: { $ne: Number(id ?? null) },
1716
internalName: val,
1817
game: Number(gameId)

src/entities/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ import PricingPlan from './pricing-plan'
2323
import PricingPlanAction from './pricing-plan-action'
2424
import OrganisationPricingPlan from './organisation-pricing-plan'
2525
import OrganisationPricingPlanAction from './organisation-pricing-plan-action'
26+
import Integration from './integration'
27+
import SteamworksIntegrationEvent from './steamworks-integration-event'
28+
import SteamworksLeaderboardMapping from './steamworks-leaderboard-mapping'
2629

2730
export default [
31+
SteamworksLeaderboardMapping,
32+
SteamworksIntegrationEvent,
33+
Integration,
2834
OrganisationPricingPlanAction,
2935
OrganisationPricingPlan,
3036
PricingPlanAction,

src/entities/integration.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { Entity, EntityManager, Enum, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
2+
import { Request, Required, ValidationCondition } from 'koa-clay'
3+
import { decrypt, encrypt } from '../lib/crypto/string-encryption'
4+
import Game from './game'
5+
import { createSteamworksLeaderboard, createSteamworksLeaderboardEntry, deleteSteamworksLeaderboard, deleteSteamworksLeaderboardEntry, setSteamworksStat, syncSteamworksLeaderboards, syncSteamworksStats } from '../lib/integrations/steamworks-integration'
6+
import Leaderboard from './leaderboard'
7+
import pick from 'lodash.pick'
8+
import LeaderboardEntry from './leaderboard-entry'
9+
import { PlayerAliasService } from './player-alias'
10+
import PlayerGameStat from './player-game-stat'
11+
12+
export enum IntegrationType {
13+
STEAMWORKS = 'steamworks'
14+
}
15+
16+
export type SteamIntegrationConfig = {
17+
apiKey: string
18+
appId: number
19+
syncLeaderboards: boolean
20+
syncStats: boolean
21+
}
22+
23+
export type IntegrationConfig = SteamIntegrationConfig
24+
25+
@Entity()
26+
@Filter({ name: 'active', cond: { deletedAt: null }, default: true })
27+
export default class Integration {
28+
@PrimaryKey()
29+
id: number
30+
31+
@Required({
32+
methods: ['POST'],
33+
validation: async (val: unknown, req: Request): Promise<ValidationCondition[]> => {
34+
const keys = Object.keys(IntegrationType).map((key) => IntegrationType[key])
35+
36+
const { gameId, id } = req.params
37+
const duplicateIntegrationType = await (<EntityManager>req.ctx.em).getRepository(Integration).findOne({
38+
id: { $ne: Number(id ?? null) },
39+
type: val,
40+
game: Number(gameId)
41+
})
42+
43+
return [
44+
{
45+
check: keys.includes(val),
46+
error: `Integration type must be one of ${keys.join(', ')}`
47+
},
48+
{
49+
check: !duplicateIntegrationType,
50+
error: `This game already has an integration for ${val}`
51+
}
52+
]
53+
}
54+
})
55+
@Enum(() => IntegrationType)
56+
type: IntegrationType
57+
58+
@ManyToOne(() => Game)
59+
game: Game
60+
61+
@Required({ methods: ['POST', 'PATCH'] })
62+
@Property({ type: 'json' })
63+
private config: IntegrationConfig
64+
65+
@Property({ nullable: true })
66+
deletedAt: Date
67+
68+
@Property()
69+
createdAt: Date = new Date()
70+
71+
@Property({ onUpdate: () => new Date() })
72+
updatedAt: Date = new Date()
73+
74+
constructor(type: IntegrationType, game: Game, config: IntegrationConfig) {
75+
this.type = type
76+
this.game = game
77+
78+
this.config = {
79+
...config,
80+
apiKey: encrypt(config.apiKey, process.env.STEAM_INTEGRATION_SECRET)
81+
}
82+
}
83+
84+
updateConfig(config: Partial<IntegrationConfig>) {
85+
if (config.apiKey) config.apiKey = encrypt(config.apiKey, process.env.STEAM_INTEGRATION_SECRET)
86+
87+
this.config = {
88+
...this.config,
89+
...config
90+
}
91+
}
92+
93+
getConfig(): IntegrationConfig {
94+
switch (this.type) {
95+
case IntegrationType.STEAMWORKS:
96+
return pick(this.config, ['appId', 'syncLeaderboards', 'syncStats'])
97+
}
98+
}
99+
100+
getSteamAPIKey(): string {
101+
return decrypt(this.config.apiKey, process.env.STEAM_INTEGRATION_SECRET)
102+
}
103+
104+
async handleLeaderboardCreated(em: EntityManager, leaderboard: Leaderboard) {
105+
switch (this.type) {
106+
case IntegrationType.STEAMWORKS:
107+
if (this.config.syncLeaderboards) {
108+
await createSteamworksLeaderboard(em, this, leaderboard)
109+
}
110+
}
111+
}
112+
113+
async handleLeaderboardUpdated(em: EntityManager, leaderboard: Leaderboard) {
114+
switch (this.type) {
115+
case IntegrationType.STEAMWORKS:
116+
if (this.config.syncLeaderboards) {
117+
await createSteamworksLeaderboard(em, this, leaderboard) // create if doesn't exist
118+
}
119+
}
120+
}
121+
122+
async handleLeaderboardDeleted(em: EntityManager, leaderboardInternalName: string) {
123+
switch (this.type) {
124+
case IntegrationType.STEAMWORKS:
125+
if (this.config.syncLeaderboards) {
126+
await deleteSteamworksLeaderboard(em, this, leaderboardInternalName)
127+
}
128+
}
129+
}
130+
131+
async handleLeaderboardEntryCreated(em: EntityManager, entry: LeaderboardEntry) {
132+
switch (this.type) {
133+
case IntegrationType.STEAMWORKS:
134+
if (entry.playerAlias.service === PlayerAliasService.STEAM && this.config.syncLeaderboards) {
135+
await createSteamworksLeaderboardEntry(em, this, entry)
136+
}
137+
}
138+
}
139+
140+
async handleLeaderboardEntryVisibilityToggled(em: EntityManager, entry: LeaderboardEntry) {
141+
switch (this.type) {
142+
case IntegrationType.STEAMWORKS:
143+
if (entry.playerAlias.service === PlayerAliasService.STEAM && this.config.syncLeaderboards) {
144+
if (entry.hidden) {
145+
await deleteSteamworksLeaderboardEntry(em, this, entry)
146+
} else {
147+
await createSteamworksLeaderboardEntry(em, this, entry)
148+
}
149+
}
150+
}
151+
}
152+
153+
async handleSyncLeaderboards(em: EntityManager) {
154+
switch (this.type) {
155+
case IntegrationType.STEAMWORKS:
156+
await syncSteamworksLeaderboards(em, this)
157+
}
158+
}
159+
160+
async handleStatUpdated(em: EntityManager, playerStat: PlayerGameStat) {
161+
await playerStat.player.aliases.loadItems()
162+
const steamAlias = playerStat.player.aliases.getItems().find((alias) => alias.service === PlayerAliasService.STEAM)
163+
164+
switch (this.type) {
165+
case IntegrationType.STEAMWORKS:
166+
if (steamAlias && this.config.syncStats) {
167+
await setSteamworksStat(em, this, playerStat, steamAlias)
168+
}
169+
}
170+
}
171+
172+
async handleSyncStats(em: EntityManager) {
173+
switch (this.type) {
174+
case IntegrationType.STEAMWORKS:
175+
await syncSteamworksStats(em, this)
176+
}
177+
}
178+
179+
toJSON() {
180+
return {
181+
id: this.id,
182+
type: this.type,
183+
config: this.getConfig(),
184+
createdAt: this.createdAt,
185+
updatedAt: this.updatedAt
186+
}
187+
}
188+
}

src/entities/leaderboard.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ export default class Leaderboard {
1616
@Required({
1717
validation: async (val: unknown, req: Request): Promise<ValidationCondition[]> => {
1818
const { gameId, id } = req.params
19-
const em: EntityManager = req.ctx.em
20-
const duplicateInternalName = await em.getRepository(Leaderboard).findOne({
19+
const duplicateInternalName = await (<EntityManager>req.ctx.em).getRepository(Leaderboard).findOne({
2120
id: { $ne: Number(id ?? null) },
2221
internalName: val,
2322
game: Number(gameId)

src/entities/player-alias.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
import { Cascade, Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
1+
import { Cascade, Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
22
import Player from './player'
33

4+
export enum PlayerAliasService {
5+
STEAM = 'steam',
6+
EPIC = 'epic',
7+
USERNAME = 'username',
8+
EMAIL = 'email',
9+
CUSTOM = 'custom'
10+
}
11+
412
@Entity()
513
export default class PlayerAlias {
614
@PrimaryKey()
715
id: number
816

9-
@Property()
10-
service: string
17+
@Enum(() => PlayerAliasService)
18+
service: PlayerAliasService
1119

1220
@Property()
1321
identifier: string

0 commit comments

Comments
 (0)