diff --git a/prisma/migrations/20240912110438_make_key_unique/migration.sql b/prisma/migrations/20240912110438_make_key_unique/migration.sql new file mode 100644 index 0000000..94f819e --- /dev/null +++ b/prisma/migrations/20240912110438_make_key_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[key]` on the table `Statistic` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Statistic_key_key" ON "Statistic"("key"); diff --git a/prisma/migrations/20240912184048_better_stats_model/migration.sql b/prisma/migrations/20240912184048_better_stats_model/migration.sql new file mode 100644 index 0000000..698801d --- /dev/null +++ b/prisma/migrations/20240912184048_better_stats_model/migration.sql @@ -0,0 +1,53 @@ +/* + Warnings: + + - The primary key for the `Statistic` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `id` on the `Statistic` table. All the data in the column will be lost. + - You are about to drop the `PlayerStatisticTransaction` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `PlayerStatisticValue` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "PlayerStatisticTransaction" DROP CONSTRAINT "PlayerStatisticTransaction_playerUuid_fkey"; + +-- DropForeignKey +ALTER TABLE "PlayerStatisticTransaction" DROP CONSTRAINT "PlayerStatisticTransaction_statisticId_fkey"; + +-- DropForeignKey +ALTER TABLE "PlayerStatisticValue" DROP CONSTRAINT "PlayerStatisticValue_playerUuid_fkey"; + +-- DropForeignKey +ALTER TABLE "PlayerStatisticValue" DROP CONSTRAINT "PlayerStatisticValue_statisticId_fkey"; + +-- DropIndex +DROP INDEX "Statistic_key_key"; + +-- AlterTable +ALTER TABLE "Statistic" DROP CONSTRAINT "Statistic_pkey", +DROP COLUMN "id", +ADD CONSTRAINT "Statistic_pkey" PRIMARY KEY ("key"); + +-- DropTable +DROP TABLE "PlayerStatisticTransaction"; + +-- DropTable +DROP TABLE "PlayerStatisticValue"; + +-- CreateTable +CREATE TABLE "PlayerStatisticRecord" ( + "id" TEXT NOT NULL, + "playerUuid" TEXT NOT NULL, + "statisticKey" TEXT NOT NULL, + "value" INTEGER NOT NULL, + "reason" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlayerStatisticRecord_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "PlayerStatisticRecord" ADD CONSTRAINT "PlayerStatisticRecord_playerUuid_fkey" FOREIGN KEY ("playerUuid") REFERENCES "Player"("uuid") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlayerStatisticRecord" ADD CONSTRAINT "PlayerStatisticRecord_statisticKey_fkey" FOREIGN KEY ("statisticKey") REFERENCES "Statistic"("key") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3368941..da7463f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,8 +51,7 @@ model Player { member Member? @relation(fields: [memberDiscordId], references: [discordId]) memberDiscordId String? @unique - statValues PlayerStatisticValue[] - statTransactions PlayerStatisticTransaction[] + stats PlayerStatisticRecord[] } model Member { @@ -312,44 +311,28 @@ enum StatisticType { } model Statistic { - id String @id @default(uuid()) - key String + key String @id displayName String? color String @default("&7") type StatisticType @default(TRANSACTION) - transactions PlayerStatisticTransaction[] - values PlayerStatisticValue[] + values PlayerStatisticRecord[] } -model PlayerStatisticTransaction { - id String @id @default(uuid()) +model PlayerStatisticRecord { + id String @id @default(uuid()) player Player @relation(fields: [playerUuid], references: [uuid]) playerUuid String - statistic Statistic @relation(fields: [statisticId], references: [id]) - statisticId String + statistic Statistic @relation(fields: [statisticKey], references: [key]) + statisticKey String value Int reason String? timestamp DateTime @default(now()) -} - -model PlayerStatisticValue { - id String @id @default(uuid()) - - player Player @relation(fields: [playerUuid], references: [uuid]) - playerUuid String - - statistic Statistic @relation(fields: [statisticId], references: [id]) - statisticId String - - value Int - reason String? - updatedAt DateTime @updatedAt } diff --git a/src/realms/rest/endpoints/controllers/Batch.controller.ts b/src/realms/rest/endpoints/controllers/Batch.controller.ts new file mode 100644 index 0000000..bffad89 --- /dev/null +++ b/src/realms/rest/endpoints/controllers/Batch.controller.ts @@ -0,0 +1,30 @@ +import { Controller, POST } from "fastify-decorators" +import { HasApiKey, RequestWithKey } from "../../helpers/decorators/HasApiKey" +import { HasSchemaScope } from "../../helpers/decorators/HasSchemaScope" +import { FastifyReply } from "fastify" +import { + PlayerStatBatchBodySchema, + PlayerStatManipulationMultipleSchema +} from "../schemas/Player.schema" +import PlayerService from "../services/Player.service" + +@Controller({ route: "/batch" }) +export default class BatchController { + constructor(private playerService: PlayerService) {} + + @POST({ + url: "/stats", + options: { + schema: PlayerStatManipulationMultipleSchema + } + }) + @HasApiKey() + @HasSchemaScope() + async getAvailableGames( + request: RequestWithKey<{ Body: PlayerStatBatchBodySchema }>, + reply: FastifyReply + ) { + await this.playerService.batchUpdateStats(request.body) + return reply.status(204).send() + } +} diff --git a/src/realms/rest/endpoints/controllers/Player.controller.ts b/src/realms/rest/endpoints/controllers/Player.controller.ts index ac77013..4e005fe 100644 --- a/src/realms/rest/endpoints/controllers/Player.controller.ts +++ b/src/realms/rest/endpoints/controllers/Player.controller.ts @@ -27,7 +27,13 @@ import { PlayerRemovePermissionGroupSchema, PlayerRemovePermissionsBodySchema, PlayerRemovePermissionsSchema, - PlayerSetPermissionGroupsSchema + PlayerSetPermissionGroupsSchema, + PlayerStatGetSchema, + PlayerStatGetSingleSchema, + PlayerStatManipulationSchema, + PlayerStatManageBodySchema, + PlayerStatParamsSchema, + PlayerStatQuerySchema } from "../schemas/Player.schema" import { HasSchemaScope } from "../../helpers/decorators/HasSchemaScope" import { Permission } from "@prisma/client" @@ -368,4 +374,67 @@ export default class PlayerController { const onlinePlayers = await this.playerService.getOnlinePlayers() return reply.code(200).send(onlinePlayers) } + + @PUT({ + url: "/:uuid/stats/:statKey/manipulate", + options: { + schema: PlayerStatManipulationSchema + } + }) + @HasApiKey() + @HasSchemaScope() + async manipulatePlayerStatistic( + req: RequestWithKey<{ + Params: PlayerStatParamsSchema + Querystring: PlayerStatQuerySchema + Body: PlayerStatManageBodySchema + }>, + reply: FastifyReply + ) { + await this.playerService.manipulatePlayerStatistic( + req.params.uuid, + req.params.statKey, + req.body.value, + req.body.reason, + req.query.set + ) + return reply.code(204).send() + } + + @GET({ + url: "/:uuid/stats/:statKey", + options: { + schema: PlayerStatGetSingleSchema + } + }) + @HasApiKey() + @HasSchemaScope() + async getPlayerStatistic( + req: RequestWithKey<{ Params: PlayerStatParamsSchema }>, + reply: FastifyReply + ) { + const playerStatistic = await this.playerService.getPlayerStatistic( + req.params.uuid, + req.params.statKey + ) + return reply.code(200).send(playerStatistic) + } + + @GET({ + url: "/:uuid/stats", + options: { + schema: PlayerStatGetSchema + } + }) + @HasApiKey() + @HasSchemaScope() + async getPlayerStatistics( + req: RequestWithKey<{ Params: PlayerInfoParamsSchema }>, + reply: FastifyReply + ) { + const playerStatistics = await this.playerService.getPlayerStatistics( + req.params.uuid + ) + return reply.code(200).send(playerStatistics) + } } diff --git a/src/realms/rest/endpoints/schemas/Player.schema.ts b/src/realms/rest/endpoints/schemas/Player.schema.ts index 5450c3c..3841502 100644 --- a/src/realms/rest/endpoints/schemas/Player.schema.ts +++ b/src/realms/rest/endpoints/schemas/Player.schema.ts @@ -4,6 +4,7 @@ import PlayerSchema from "../../schemas/Player.schema" import { ApiScope, ChatChannels } from "@prisma/client" import PermissionSchema from "../../schemas/Permission.schema" import PermissionInputSchema from "../../schemas/PermissionInput.schema" +import PlayerStatisticRecordSchema from "../../schemas/PlayerStatisticRecord.schema" const PlayerCreateBodySchema = Type.Object({ memberDiscordId: Type.String(), @@ -367,3 +368,117 @@ export const PlayerMigrateSchema: FastifySchema = { 200: Type.Ref(PlayerSchema) } } + +// Increment Player Stat + +const PlayerStatParamsSchema = Type.Intersect([ + PlayerInfoParamsSchema, + Type.Object({ + statKey: Type.String() + }) +]) + +export type PlayerStatParamsSchema = Static + +const PlayerStatManageBodySchema = Type.Object({ + value: Type.Number(), + reason: Type.Optional(Type.String()) +}) + +export type PlayerStatManageBodySchema = Static< + typeof PlayerStatManageBodySchema +> + +const PlayerStatQuerySchema = Type.Object({ + set: Type.Optional(Type.Boolean()) +}) + +export type PlayerStatQuerySchema = Static + +export const PlayerStatManipulationSchema: FastifySchema = { + tags: ["players"], + summary: "Increment/set a player's stat", + operationId: "manipulatePlayerStatistic", + security: [ + { + apiKey: [ApiScope.PLAYERS] + } + ], + params: PlayerStatParamsSchema, + querystring: PlayerStatQuerySchema, + body: PlayerStatManageBodySchema, + response: { + 204: Type.Object({}), + 404: Type.Object({ + error: Type.String({ enum: ["player-not-found"] }) + }) + } +} + +// Batch Stat Manipulation + +const PlayerStatBatchBodySchema = Type.Array( + Type.Object({ + playerUuid: Type.String(), + statKey: Type.String(), + value: Type.Number(), + reason: Type.Optional(Type.String()) + }) +) + +export type PlayerStatBatchBodySchema = Static + +export const PlayerStatManipulationMultipleSchema: FastifySchema = { + tags: ["batch"], + summary: "Increment/set multiple player stats", + operationId: "batchManipulatePlayerStatistics", + security: [ + { + apiKey: [ApiScope.PLAYERS] + } + ], + body: PlayerStatBatchBodySchema, + response: { + 200: Type.Object({}) + } +} + +// Get All Player Stats + +export const PlayerStatGetSchema: FastifySchema = { + tags: ["players"], + summary: "Get a player's stats", + operationId: "getPlayerStatistics", + security: [ + { + apiKey: [ApiScope.PLAYERS] + } + ], + params: PlayerInfoParamsSchema, + response: { + 200: Type.Array(Type.Ref(PlayerStatisticRecordSchema)), + 404: Type.Object({ + error: Type.String({ enum: ["statistic-not-found"] }) + }) + } +} + +// Get Player Stat + +export const PlayerStatGetSingleSchema: FastifySchema = { + tags: ["players"], + summary: "Get a player's single stat", + operationId: "getPlayerStatistic", + security: [ + { + apiKey: [ApiScope.PLAYERS] + } + ], + params: PlayerStatParamsSchema, + response: { + 200: Type.Ref(PlayerStatisticRecordSchema), + 404: Type.Object({ + error: Type.String({ enum: ["statistic-not-found"] }) + }) + } +} diff --git a/src/realms/rest/endpoints/services/Player.service.ts b/src/realms/rest/endpoints/services/Player.service.ts index 2242d04..719c7ab 100644 --- a/src/realms/rest/endpoints/services/Player.service.ts +++ b/src/realms/rest/endpoints/services/Player.service.ts @@ -1,12 +1,21 @@ import prisma from "../../../../clients/Prisma" -import { ChatChannels, Permission, Player, Prisma } from "@prisma/client" +import { + ChatChannels, + Permission, + Player, + Prisma, + StatisticType +} from "@prisma/client" import { Service } from "fastify-decorators" import { ApiError } from "../../helpers/Error" import { PlayerCreateBodySchema, - PlayerMigrateBodySchema + PlayerMigrateBodySchema, + PlayerStatBatchBodySchema } from "../schemas/Player.schema" import { AnimusRestServer } from "../.." +import PlayerStatisticRecordSchema from "../../schemas/PlayerStatisticRecord.schema" +import { Static } from "@sinclair/typebox" @Service() export default class PlayerService { @@ -586,4 +595,187 @@ export default class PlayerService { select: PlayerService.PlayerPublicSelect }) } + + async getPlayerStatistic( + uuid: string, + statKey: string + ): Promise> { + const stat = await prisma.statistic.findUnique({ + where: { + key: statKey + }, + select: { + displayName: true, + color: true, + type: true, + values: { + where: { + playerUuid: uuid + }, + select: { + id: true, + value: true + } + } + } + }) + + if (!stat) { + throw new ApiError("statistic-not-found", 404) + } + + return { + key: statKey, + displayName: stat.displayName, + color: stat.color, + value: stat.values.reduce((acc, curr) => acc + curr.value, 0) + } + } + + async getPlayerStatistics( + uuid: string + ): Promise[]> { + const stats = await prisma.statistic.findMany({ + select: { + key: true, + displayName: true, + color: true, + type: true, + values: { + where: { + playerUuid: uuid + }, + select: { + id: true, + value: true + } + } + } + }) + + return stats.map((stat) => { + return { + key: stat.key, + displayName: stat.displayName, + color: stat.color, + value: stat.values.reduce((acc, curr) => acc + curr.value, 0) + } + }) + } + + async batchUpdateStats(body: PlayerStatBatchBodySchema) { + return Promise.all( + body.map((stat) => { + return this.manipulatePlayerStatistic( + stat.playerUuid, + stat.statKey, + stat.value, + stat.reason + ) + }) + ) + } + + async manipulatePlayerStatistic( + playerUuid: string, + key: string, + value: number, + reason: string, + set = false + ): Promise { + let stat = await prisma.statistic.findUnique({ + where: { + key + }, + select: { + type: true, + values: { + where: { + playerUuid + } + } + } + }) + + if (!stat) { + stat = await prisma.statistic.create({ + data: { + key + }, + select: { + type: true, + values: { + where: { + playerUuid + } + } + } + }) + } + + if (stat.type === StatisticType.TRANSACTION) { + if (set) { + const existingValues = stat.values.filter( + (value) => value.playerUuid === playerUuid + ) + + if (!existingValues) { + await prisma.playerStatisticRecord.create({ + data: { + playerUuid, + statisticKey: key, + value, + reason + } + }) + } else { + const diff = + value - existingValues.reduce((acc, curr) => acc + curr.value, 0) + + await prisma.playerStatisticRecord.create({ + data: { + playerUuid, + statisticKey: key, + value: diff, + reason + } + }) + } + } else { + await prisma.playerStatisticRecord.create({ + data: { + playerUuid, + statisticKey: key, + value, + reason + } + }) + } + } else { + const existingValue = stat.values.find( + (value) => value.playerUuid === playerUuid + ) + + if (!existingValue) { + await prisma.playerStatisticRecord.create({ + data: { + playerUuid, + statisticKey: key, + value, + reason + } + }) + } else { + await prisma.playerStatisticRecord.update({ + where: { + id: existingValue.id + }, + data: { + value: set ? value : existingValue.value + value, + reason + } + }) + } + } + } } diff --git a/src/realms/rest/schemas/PlayerStatisticRecord.schema.ts b/src/realms/rest/schemas/PlayerStatisticRecord.schema.ts new file mode 100644 index 0000000..42c7cf2 --- /dev/null +++ b/src/realms/rest/schemas/PlayerStatisticRecord.schema.ts @@ -0,0 +1,16 @@ +import { Type } from "@sinclair/typebox" + +export default Type.Object( + { + key: Type.String({ description: "The key of the statistic" }), + displayName: Type.String({ + description: "The display name of the statistic" + }), + color: Type.String({ description: "The color of the statistic" }), + value: Type.Number({ description: "The aggregated value of the statistic" }) + }, + { + $id: "PlayerStatisticRecord", + description: "The schema describing a player statistic record" + } +)