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
3 changes: 2 additions & 1 deletion src/entities/data-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export enum DataExportAvailableEntities {
LEADERBOARD_ENTRIES = 'leaderboardEntries',
GAME_STATS = 'gameStats',
PLAYER_GAME_STATS = 'playerGameStats',
GAME_ACTIVITIES = 'gameActivities'
GAME_ACTIVITIES = 'gameActivities',
GAME_FEEDBACK = 'gameFeedback'
}

@Entity()
Expand Down
43 changes: 40 additions & 3 deletions src/services/data-export.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Job, Queue } from 'bullmq'
import createEmailQueue from '../lib/queues/createEmailQueue'
import { EmailConfig } from '../lib/messaging/sendEmail'
import createClickhouseClient from '../lib/clickhouse/createClient'
import GameFeedback from '../entities/game-feedback'

type PropCollection = Collection<PlayerProp, Player>

Expand All @@ -45,7 +46,7 @@ type DataExportJob = {
includeDevData: boolean
}

type ExportableEntity = Event | Player | PlayerAlias | LeaderboardEntry | GameStat | PlayerGameStat | GameActivity
type ExportableEntity = Event | Player | PlayerAlias | LeaderboardEntry | GameStat | PlayerGameStat | GameActivity | GameFeedback
type ExportableEntityWithProps = ExportableEntity & EntityWithProps

@Routes([
Expand Down Expand Up @@ -221,7 +222,7 @@ export default class DataExportService extends Service {
where.player = devDataPlayerFilter(em)
}

return await em.getRepository(PlayerGameStat).find(where)
return await em.getRepository(PlayerGameStat).find(where, { populate: ['player'] })
}

private async getGameActivities(dataExport: DataExport, em: EntityManager): Promise<GameActivity[]> {
Expand All @@ -232,6 +233,26 @@ export default class DataExportService extends Service {
})
}

private async getGameFeedback(dataExport: DataExport, em: EntityManager, includeDevData: boolean): Promise<GameFeedback[]> {
const where: FilterQuery<GameFeedback> = {
playerAlias: {
player: {
game: dataExport.game
}
}
}

if (!includeDevData) {
where.playerAlias = Object.assign(where.playerAlias, {
player: devDataPlayerFilter(em)
})
}

return await em.getRepository(GameFeedback).find(where, {
populate: ['playerAlias.player']
})
}

private async createZip(dataExport: DataExport, em: EntityManager, includeDevData: boolean): Promise<AdmZip> {
const zip = new AdmZip()

Expand Down Expand Up @@ -270,6 +291,11 @@ export default class DataExportService extends Service {
zip.addFile(`${DataExportAvailableEntities.GAME_ACTIVITIES}.csv`, this.buildCSV(DataExportAvailableEntities.GAME_ACTIVITIES, items))
}

if (dataExport.entities.includes(DataExportAvailableEntities.GAME_FEEDBACK)) {
const items = await this.getGameFeedback(dataExport, em, includeDevData)
zip.addFile(`${DataExportAvailableEntities.GAME_FEEDBACK}.csv`, this.buildCSV(DataExportAvailableEntities.GAME_FEEDBACK, items))
}

return zip
}

Expand All @@ -286,9 +312,11 @@ export default class DataExportService extends Service {
case DataExportAvailableEntities.GAME_STATS:
return ['id', 'internalName', 'name', 'defaultValue', 'minValue', 'maxValue', 'global', 'globalValue', 'createdAt', 'updatedAt']
case DataExportAvailableEntities.PLAYER_GAME_STATS:
return ['id', 'value', 'stat.id', 'stat.internalName', 'createdAt', 'updatedAt']
return ['id', 'player.id', 'value', 'stat.id', 'stat.internalName', 'createdAt', 'updatedAt']
case DataExportAvailableEntities.GAME_ACTIVITIES:
return ['id', 'user.username', 'gameActivityType', 'gameActivityExtra', 'createdAt']
case DataExportAvailableEntities.GAME_FEEDBACK:
return ['id', 'category.internalName', 'comment', 'playerAlias.id', 'playerAlias.service', 'playerAlias.identifier', 'playerAlias.player.id', 'createdAt']
}
}

Expand Down Expand Up @@ -319,6 +347,15 @@ export default class DataExportService extends Service {
return `"${JSON.stringify(value).replace(/"/g, '\'')}"`
case 'globalValue':
return get(object, 'hydratedGlobalValue') ?? 'N/A'
case 'playerAlias.id':
case 'playerAlias.service':
case 'playerAlias.identifier':
case 'playerAlias.player.id':
if (object instanceof GameFeedback && object.anonymised) {
return 'Anonymous'
} else {
return String(value)
}
default:
return String(value)
}
Expand Down
25 changes: 12 additions & 13 deletions tests/lib/integrations/steamworksSyncStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('Steamworks integration - sync stats', () => {
it('should pull in stats from steamworks', async () => {
const [, game] = await createOrganisationAndGame(em)

const statDisplayName = casual.word
const statDisplayName = casual.words(3)

const config = await new IntegrationConfigFactory().one()
const integration = await new IntegrationFactory().construct(IntegrationType.STEAMWORKS, game, config).one()
Expand All @@ -44,7 +44,7 @@ describe('Steamworks integration - sync stats', () => {
availableGameStats: {
stats: [
{
name: 'stat_' + casual.word,
name: 'stat_' + casual.array_of_words(3).join('-'),
defaultvalue: 500,
displayName: statDisplayName
}
Expand Down Expand Up @@ -81,8 +81,8 @@ describe('Steamworks integration - sync stats', () => {
it('should update existing stats with the name and default value from steamworks', async () => {
const [, game] = await createOrganisationAndGame(em)

const statName = 'stat_' + casual.word
const statDisplayName = casual.word
const statName = 'stat_' + casual.array_of_words(3).join('-')
const statDisplayName = casual.words(3)

const stat = await new GameStatFactory([game]).state(() => ({ internalName: statName })).one()

Expand Down Expand Up @@ -121,7 +121,7 @@ describe('Steamworks integration - sync stats', () => {
it('should pull in player stats from steamworks', async () => {
const [, game] = await createOrganisationAndGame(em)

const statName = 'stat_' + casual.word
const statName = 'stat_' + casual.array_of_words(3).join('-')

const player = await new PlayerFactory([game]).withSteamAlias().one()

Expand All @@ -139,7 +139,7 @@ describe('Steamworks integration - sync stats', () => {
{
name: statName,
defaultvalue: 500,
displayName: casual.word
displayName: casual.words(3)
}
],
achievements: []
Expand Down Expand Up @@ -187,9 +187,9 @@ describe('Steamworks integration - sync stats', () => {
availableGameStats: {
stats: [
{
name: 'stat_' + casual.word,
name: 'stat_' + casual.array_of_words(3).join('-'),
defaultvalue: 500,
displayName: casual.word
displayName: casual.words(3)
}
],
achievements: []
Expand All @@ -213,7 +213,7 @@ describe('Steamworks integration - sync stats', () => {
it('should update player stats with the ones from steamworks', async () => {
const [, game] = await createOrganisationAndGame(em)

const statName = 'stat_' + casual.word
const statName = 'stat_' + casual.array_of_words(3).join('-')

const player = await new PlayerFactory([game]).withSteamAlias().one()
const stat = await new GameStatFactory([game]).state(() => ({ internalName: statName })).one()
Expand All @@ -233,7 +233,7 @@ describe('Steamworks integration - sync stats', () => {
{
name: statName,
defaultvalue: 500,
displayName: casual.word
displayName: casual.words(3)
}
],
achievements: []
Expand All @@ -260,14 +260,13 @@ describe('Steamworks integration - sync stats', () => {
expect(getSchemaMock).toHaveBeenCalledTimes(1)
expect(getUserStatsMock).toHaveBeenCalledTimes(1)

await em.refresh(playerStat)
expect(playerStat.value).toBe(301)
})

it('should push through player stats that only exist in talo', async () => {
const [, game] = await createOrganisationAndGame(em)

const statName = 'stat_' + casual.word
const statName = 'stat_' + casual.array_of_words(3).join('-')

const player = await new PlayerFactory([game]).withSteamAlias().one()
const stat = await new GameStatFactory([game]).state(() => ({ internalName: statName })).one()
Expand All @@ -287,7 +286,7 @@ describe('Steamworks integration - sync stats', () => {
{
name: statName,
defaultvalue: 500,
displayName: casual.word
displayName: casual.words(3)
}
],
achievements: []
Expand Down
2 changes: 1 addition & 1 deletion tests/services/data-export/entities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ describe('Data export service - available entities', () => {
.auth(token, { type: 'bearer' })
.expect(200)

expect(res.body.entities).toStrictEqual([ 'events', 'players', 'playerAliases', 'leaderboardEntries', 'gameStats', 'playerGameStats', 'gameActivities' ])
expect(res.body.entities).toStrictEqual([ 'events', 'players', 'playerAliases', 'leaderboardEntries', 'gameStats', 'playerGameStats', 'gameActivities', 'gameFeedback' ])
})
})
27 changes: 27 additions & 0 deletions tests/services/data-export/generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import PricingPlanFactory from '../../fixtures/PricingPlanFactory'
import PlayerProp from '../../../src/entities/player-prop'
import createOrganisationAndGame from '../../utils/createOrganisationAndGame'
import createUserAndToken from '../../utils/createUserAndToken'
import GameFeedbackFactory from '../../fixtures/GameFeedbackFactory'

describe('Data export service - generation', () => {
it('should transform basic columns', async () => {
Expand Down Expand Up @@ -201,4 +202,30 @@ describe('Data export service - generation', () => {
const planActions = await (<EntityManager>global.em).getRepository(OrganisationPricingPlanAction).find({ organisationPricingPlan: orgPlan })
expect(planActions).toHaveLength(0)
})

it('should transform anonymised feedback columns', async () => {
const [, game] = await createOrganisationAndGame()

const service = new DataExportService()
const proto = Object.getPrototypeOf(service)

const feedback = await new GameFeedbackFactory(game).state(() => ({ anonymised: true })).one()

for (const key of ['playerAlias.id', 'playerAlias.service', 'playerAlias.identifier', 'playerAlias.player.id']) {
expect(proto.transformColumn(key, feedback)).toBe('Anonymous')
}
})

it('should not transform non-anonymised feedback columns', async () => {
const [, game] = await createOrganisationAndGame()

const service = new DataExportService()
const proto = Object.getPrototypeOf(service)

const feedback = await new GameFeedbackFactory(game).state(() => ({ anonymised: false })).one()

for (const key of ['playerAlias.id', 'playerAlias.service', 'playerAlias.identifier', 'playerAlias.player.id']) {
expect(proto.transformColumn(key, feedback)).not.toBe('Anonymous')
}
})
})
35 changes: 35 additions & 0 deletions tests/services/data-export/included-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import GameStatFactory from '../../fixtures/GameStatFactory'
import PlayerGameStatFactory from '../../fixtures/PlayerGameStatFactory'
import createOrganisationAndGame from '../../utils/createOrganisationAndGame'
import { NodeClickHouseClient } from '@clickhouse/client/dist/client'
import GameFeedbackFactory from '../../fixtures/GameFeedbackFactory'

describe('Data export service - included data', () => {
it('should not include events from dev build players without the dev data header', async () => {
Expand Down Expand Up @@ -202,4 +203,38 @@ describe('Data export service - included data', () => {
const items = await proto.getPlayerGameStats(dataExport, global.em, true)
expect(items).toHaveLength(1)
})

it('should not include feedback from dev build players without the dev data header', async () => {
const [, game] = await createOrganisationAndGame()

const service = new DataExportService()
const proto = Object.getPrototypeOf(service)

const player = await new PlayerFactory([game]).devBuild().one()
const feedback = await new GameFeedbackFactory(game).state(() => ({
playerAlias: player.aliases[0]
})).one()
const dataExport = await new DataExportFactory(game).one()
await (<EntityManager>global.em).persistAndFlush([feedback, dataExport])

const items = await proto.getGameFeedback(dataExport, global.em, false)
expect(items).toHaveLength(0)
})

it('should include player stats from dev build players with the dev data header', async () => {
const [, game] = await createOrganisationAndGame()

const service = new DataExportService()
const proto = Object.getPrototypeOf(service)

const player = await new PlayerFactory([game]).devBuild().one()
const feedback = await new GameFeedbackFactory(game).state(() => ({
playerAlias: player.aliases[0]
})).one()
const dataExport = await new DataExportFactory(game).one()
await (<EntityManager>global.em).persistAndFlush([feedback, dataExport])

const items = await proto.getGameFeedback(dataExport, global.em, true)
expect(items).toHaveLength(1)
})
})
13 changes: 13 additions & 0 deletions tests/services/data-export/post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ describe('Data export service - post', () => {
expect(res.body.dataExport.entities).toStrictEqual([DataExportAvailableEntities.GAME_ACTIVITIES])
})

it('should create a data export for game feedback', async () => {
const [organisation, game] = await createOrganisationAndGame()
const [token] = await createUserAndToken({ type: UserType.ADMIN, emailConfirmed: true }, organisation)

const res = await request(global.app)
.post(`/games/${game.id}/data-exports`)
.send({ entities: [DataExportAvailableEntities.GAME_FEEDBACK] })
.auth(token, { type: 'bearer' })
.expect(200)

expect(res.body.dataExport.entities).toStrictEqual([DataExportAvailableEntities.GAME_FEEDBACK])
})

it('should not create a data export for users with unconfirmed emails', async () => {
const [organisation, game] = await createOrganisationAndGame()
const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation)
Expand Down