diff --git a/app/package.json b/app/package.json index 34c994a..05005ec 100644 --- a/app/package.json +++ b/app/package.json @@ -35,6 +35,7 @@ "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf", "dependencies": { "@tabler/icons-svelte": "^3.26.0", - "pocketbase": "^0.24.0" + "pocketbase": "^0.24.0", + "zod": "^3.24.1" } } diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index a8612ca..495aa2a 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: pocketbase: specifier: ^0.24.0 version: 0.24.0 + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: '@sveltejs/adapter-auto': specifier: ^3.0.0 @@ -1580,6 +1583,9 @@ packages: zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3052,3 +3058,5 @@ snapshots: yaml@2.6.1: {} zimmerframe@1.1.2: {} + + zod@3.24.1: {} diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts index aa3ae2c..31c9718 100644 --- a/app/src/hooks.server.ts +++ b/app/src/hooks.server.ts @@ -1,8 +1,10 @@ import PocketBase from 'pocketbase'; -import { redirect, type Handle } from '@sveltejs/kit'; +import { error, redirect, type Handle } from '@sveltejs/kit'; import type { TypedPocketBase } from '$lib/types/pocketbase'; import { sequence } from '@sveltejs/kit/hooks'; import { INTERNAL_PB_URL } from '$env/static/private'; +import { _betCreateSchema } from './routes/api/user/bet/+server'; +import type { AnyZodObject } from 'zod'; const authentication: Handle = async ({ event, resolve }) => { event.locals.pb = new PocketBase(INTERNAL_PB_URL) as TypedPocketBase; @@ -42,4 +44,23 @@ const authorization: Handle = async ({ event, resolve }) => { return response; }; -export const handle = sequence(authentication, authorization); +const schemaMap: { [keys: string]: AnyZodObject } = { + '/api/user/bet': _betCreateSchema +}; + +const validation: Handle = async ({ event, resolve }) => { + const schema = schemaMap[event.url.pathname]; + if (schema) { + // apparently sveltekit doesn't like it if you read the request body twice, so we need to clone the request every time + const clonedRequest = event.request.clone(); + const result = schema.safeParse(await clonedRequest.json()); + if (!result.success) { + return error(400, result.error); + } + } + + const response = await resolve(event); + return response; +}; + +export const handle = sequence(authentication, authorization, validation); diff --git a/app/src/lib/types/pocketbase.d.ts b/app/src/lib/types/pocketbase.d.ts index 4c1c470..5cae4ea 100644 --- a/app/src/lib/types/pocketbase.d.ts +++ b/app/src/lib/types/pocketbase.d.ts @@ -11,6 +11,7 @@ export enum Collections { Mfas = '_mfas', Otps = '_otps', Superusers = '_superusers', + BetPool = 'betPool', Bets = 'bets', Events = 'events', Standings = 'standings', @@ -89,6 +90,15 @@ export type SuperusersRecord = { verified?: boolean; }; +export type BetPoolRecord = { + amount?: number; + created?: IsoDateString; + event: RecordIdString; + id: string; + team: RecordIdString; + updated?: IsoDateString; +}; + export type BetsRecord = { amount: number; created?: IsoDateString; @@ -128,6 +138,7 @@ export type EventsRecord = { id: string; location: string; sport: EventsSportOptions; + standingsUpdated?: boolean; startTime: IsoDateString; teams: RecordIdString[]; title: string; @@ -175,6 +186,8 @@ export type MfasResponse = Required & BaseSystemF export type OtpsResponse = Required & BaseSystemFields; export type SuperusersResponse = Required & AuthSystemFields; +export type BetPoolResponse = Required & + BaseSystemFields; export type BetsResponse = Required & BaseSystemFields; export type EventsResponse = Required & BaseSystemFields; export type StandingsResponse = Required & @@ -190,6 +203,7 @@ export type CollectionRecords = { _mfas: MfasRecord; _otps: OtpsRecord; _superusers: SuperusersRecord; + betPool: BetPoolRecord; bets: BetsRecord; events: EventsRecord; standings: StandingsRecord; @@ -203,6 +217,7 @@ export type CollectionResponses = { _mfas: MfasResponse; _otps: OtpsResponse; _superusers: SuperusersResponse; + betPool: BetPoolResponse; bets: BetsResponse; events: EventsResponse; standings: StandingsResponse; @@ -219,6 +234,7 @@ export type TypedPocketBase = PocketBase & { collection(idOrName: '_mfas'): RecordService; collection(idOrName: '_otps'): RecordService; collection(idOrName: '_superusers'): RecordService; + collection(idOrName: 'betPool'): RecordService; collection(idOrName: 'bets'): RecordService; collection(idOrName: 'events'): RecordService; collection(idOrName: 'standings'): RecordService; diff --git a/app/src/routes/api/user/bet/+server.ts b/app/src/routes/api/user/bet/+server.ts index 7617529..64f6cae 100644 --- a/app/src/routes/api/user/bet/+server.ts +++ b/app/src/routes/api/user/bet/+server.ts @@ -1,15 +1,30 @@ import { error, json, type RequestHandler } from '@sveltejs/kit'; import pb from '$lib/server/database'; +import { z } from 'zod'; import type { BetsResponse } from '$lib/types/pocketbase'; import type { BetExpand } from '$lib/types/expand'; +export const _betCreateSchema = z.object({ + teamId: z.string(), + eventId: z.string(), + amount: z.number().int() +}); + const handlePOST: RequestHandler = async ({ request, locals }) => { if (!locals.user) { return error(401, 'User not found'); } const { teamId, eventId, amount } = await request.json(); - const userid = locals.user.id; + + const event = (await pb.collection('events').getFullList({ filter: `id="${eventId}"` })).at(0); + if (!event) { + return error(400, 'Event not found!'); + } + + if (!event.teams.includes(teamId)) { + return error(400, 'Team not found!'); + } if (locals.user.balance < amount) { return error(400, 'Balance too low!'); @@ -41,7 +56,6 @@ const handlePOST: RequestHandler = async ({ request, locals }) => { return error(400, 'Bet amount cannot be negative!'); } - const event = await pb.collection('events').getFirstListItem(`id="${eventId}"`); const startTime = new Date(event.startTime).getTime(); if (now > startTime) { return error(400, 'Bets are closed!'); @@ -52,7 +66,19 @@ const handlePOST: RequestHandler = async ({ request, locals }) => { .create({ user: locals.user.id, team: teamId, event: eventId, amount }); } - await pb.collection('users').update(userid, { balance: locals.user.balance - amount }); + let betPool = ( + await pb.collection('betPool').getFullList({ + filter: `team="${teamId}" && event="${eventId}"` + }) + ).at(0); + + if (betPool) { + await pb.collection('betPool').update(betPool.id, { amount: betPool.amount + amount }); + } else { + await pb.collection('betPool').create({ event: eventId, team: teamId, amount }); + } + + await pb.collection('users').update(locals.user.id, { balance: locals.user.balance - amount }); return json(newBet); }; diff --git a/db/hooks/bet_status_update.pb.js b/db/hooks/bet_status_update.pb.js new file mode 100644 index 0000000..8443268 --- /dev/null +++ b/db/hooks/bet_status_update.pb.js @@ -0,0 +1,65 @@ +onRecordUpdate((e) => { + const event = e.record; + if (!event.getBool("standingsUpdated")) { + return e.next(); + } + + // unfortunately pocketbase admin UI doesn't support showing timestamps in local time and defaults to UTC + // i don't think it's realistic or convenient to expect them to convert local time to UTC while adding events + // so this means a lot of reverse timezone shenanigans will be needed in the frontend too + let now = new Date(new Date().getTime() + 1000 * 60 * 330); + + if (now / 1000 < e.record.getDateTime("endTime").unix()) { + throw new BadRequestError("Failed to trigger bet payout: Event has not ended yet!"); + } + + const winners = $app + .findAllRecords( + "standings", + $dbx.exp("position == 1 AND event == {:id}", { id: event.id }) + ) + .map((standing) => standing.get("team")); + + if (!winners.length) { + throw new BadRequestError("Failed to trigger bet payout: No winners found!"); + } + + const bets = $app.findAllRecords( + "bets", + $dbx.exp("event == {:id}", { id: event.id }) + ); + + const wonBets = bets.filter((bet) => winners.includes(bet.get("team"))); + + const betPools = $app.findAllRecords( + "betPool", + $dbx.exp("event == {:id}", { id: event.id }) + ); + + let totalPool = 0; + let wonPool = 0; + + // one passTM + for (const betPool of betPools) { + totalPool += betPool.getInt("amount"); + if (winners.includes(betPool.get("team"))) + wonPool += betPool.getInt("amount"); + } + + if (wonPool === 0) { + return e.next(); + } + + for (const bet of wonBets) { + const amount = bet.getInt("amount"); + const payout = Math.floor((amount * totalPool) / wonPool); + + // not using the user expands on bets since it might have stale data (which means money could be disappearing) + // does this make things safe from race conditions? i hope so + const user = $app.findRecordById("users", bet.get("user")); + user.set("balance", user.getInt("balance") + payout); + $app.save(user); + } + + e.next(); +}, "events"); diff --git a/db/migrations/1735284047_collections_snapshot.js b/db/migrations/1735284047_collections_snapshot.js index 358dd46..755b682 100644 --- a/db/migrations/1735284047_collections_snapshot.js +++ b/db/migrations/1735284047_collections_snapshot.js @@ -926,6 +926,15 @@ migrate((app) => { "thumbs": [], "type": "file" }, + { + "hidden": false, + "id": "bool847678146", + "name": "standingsUpdated", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, { "hidden": false, "id": "autodate2990389176", @@ -1230,6 +1239,92 @@ migrate((app) => { "type": "base", "updateRule": null, "viewRule": null + }, + { + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1687431684", + "hidden": false, + "id": "relation1001261735", + "maxSelect": 1, + "minSelect": 0, + "name": "event", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation3303056927", + "maxSelect": 1, + "minSelect": 0, + "name": "team", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "number2392944706", + "max": null, + "min": 0, + "name": "amount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1788541978", + "indexes": [], + "listRule": null, + "name": "betPool", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null } ]; diff --git a/docker-compose.yml b/docker-compose.yml index bd30257..bad091d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: volumes: - ./db/migrations:/pb/pb_migrations - ./db/data:/pb/pb_data + - ./db/hooks:/pb/pb_hooks profiles: - dev