From e2863c1f6cce7372a939880dac78261359644244 Mon Sep 17 00:00:00 2001 From: yohlo Date: Tue, 25 Aug 2026 21:33:25 -0700 Subject: [PATCH] feat(telemetry): collections, universal server-fn coverage, beacon, client tracker - activities indexes + client_events/client_errors/rollups/alerts collections - global function middleware via createStart; reads skipped on success - actor stash, denial dedup, control-flow error guards - api route logging, auth event overrides, beacon endpoint, web vitals - centralized admin role checks, shared skeletons, award-badges fix - extracted deriveRunState with tests --- bun.lock | 3 + package.json | 1 + .../1787961600_activities_indexes.js | 23 ++ .../1787961700_created_client_events.js | 167 +++++++++ .../1787961800_created_client_errors.js | 207 +++++++++++ ...00_created_telemetry_rollups_and_alerts.js | 240 +++++++++++++ src/app/routeTree.gen.ts | 24 +- src/app/routes/__root.tsx | 10 +- src/app/routes/_authed/admin.tsx | 3 +- src/app/routes/_authed/admin/activities.tsx | 31 +- .../admin/tournaments/$id/assign-partners.tsx | 6 +- .../_authed/admin/tournaments/$id/index.tsx | 22 +- .../_authed/admin/tournaments/$id/teams.tsx | 19 +- .../_authed/admin/tournaments/index.tsx | 24 +- .../_authed/admin/tournaments/run.$id.tsx | 69 +--- .../_authed/tournaments/$tournamentId.tsx | 3 +- src/app/routes/api/auth.$.ts | 2 - src/app/routes/api/push/subscribe.ts | 3 +- src/app/routes/api/push/test.ts | 3 +- src/app/routes/api/push/unsubscribe.ts | 3 +- src/app/routes/api/teams/upload-logo.ts | 3 +- src/app/routes/api/telemetry/beacon.ts | 128 +++++++ src/app/routes/api/tournaments/upload-logo.ts | 3 +- src/app/start.ts | 8 + src/components/DefaultCatchBoundary.tsx | 2 + src/components/ios-install-prompt.tsx | 7 +- src/components/skeletons/card-skeleton.tsx | 35 ++ src/components/skeletons/index.ts | 3 + src/components/skeletons/list-skeleton.tsx | 76 ++++ .../skeletons/stat-tile-skeleton.tsx | 42 +++ src/contexts/spotify-context.tsx | 5 +- .../admin/components/award-badges.tsx | 44 +-- .../badges/hooks/use-award-manual-badge.ts | 21 ++ src/features/badges/server.ts | 5 +- src/features/core/hooks/use-is-admin.ts | 7 + src/features/core/hooks/use-links.ts | 3 +- src/features/core/utils/roles.ts | 4 + src/features/matches/server.ts | 15 +- src/features/players/server.ts | 7 +- src/features/predictions/server.ts | 3 +- src/features/settings/server.ts | 11 +- .../components/spotify-controls-bar.tsx | 5 +- .../spotify/components/spotify-sheet.tsx | 5 +- .../components/team-form/players-picker.tsx | 5 +- src/features/teams/server.ts | 5 +- .../components/started-tournament/index.tsx | 5 +- .../components/tournament-card-list.tsx | 6 +- .../components/upcoming-tournament/index.tsx | 5 +- .../hooks/use-tournament-run-state.test.ts | 159 +++++++++ .../hooks/use-tournament-run-state.ts | 68 ++++ src/features/tournaments/server.ts | 25 +- src/lib/pocketbase/client.ts | 5 +- src/lib/pocketbase/services/telemetry.ts | 325 ++++++++++++++++++ src/lib/pocketbase/util/transform-types.ts | 36 ++ .../recipes/passwordless-development-mode.ts | 4 + .../recipes/passwordless-twilio-verify.ts | 7 +- src/lib/supertokens/server.ts | 5 + src/lib/telemetry/api-logging.server.ts | 43 +++ src/lib/telemetry/auth-events.server.ts | 125 +++++++ src/lib/telemetry/beacon-schema.test.ts | 72 ++++ src/lib/telemetry/beacon-schema.ts | 48 +++ src/lib/telemetry/client/index.ts | 185 ++++++++++ src/lib/telemetry/error-hash.test.ts | 55 +++ src/lib/telemetry/error-hash.ts | 55 +++ src/lib/telemetry/redact-args.ts | 14 + src/lib/telemetry/request-context.server.ts | 18 + src/utils/activities.test.ts | 126 +++++++ src/utils/activities.ts | 87 +++-- src/utils/supertokens-core.server.ts | 2 + src/utils/supertokens.ts | 25 +- 70 files changed, 2545 insertions(+), 275 deletions(-) create mode 100644 pb_migrations/1787961600_activities_indexes.js create mode 100644 pb_migrations/1787961700_created_client_events.js create mode 100644 pb_migrations/1787961800_created_client_errors.js create mode 100644 pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js create mode 100644 src/app/routes/api/telemetry/beacon.ts create mode 100644 src/app/start.ts create mode 100644 src/components/skeletons/card-skeleton.tsx create mode 100644 src/components/skeletons/index.ts create mode 100644 src/components/skeletons/list-skeleton.tsx create mode 100644 src/components/skeletons/stat-tile-skeleton.tsx create mode 100644 src/features/badges/hooks/use-award-manual-badge.ts create mode 100644 src/features/core/hooks/use-is-admin.ts create mode 100644 src/features/core/utils/roles.ts create mode 100644 src/features/tournaments/hooks/use-tournament-run-state.test.ts create mode 100644 src/features/tournaments/hooks/use-tournament-run-state.ts create mode 100644 src/lib/pocketbase/services/telemetry.ts create mode 100644 src/lib/telemetry/api-logging.server.ts create mode 100644 src/lib/telemetry/auth-events.server.ts create mode 100644 src/lib/telemetry/beacon-schema.test.ts create mode 100644 src/lib/telemetry/beacon-schema.ts create mode 100644 src/lib/telemetry/client/index.ts create mode 100644 src/lib/telemetry/error-hash.test.ts create mode 100644 src/lib/telemetry/error-hash.ts create mode 100644 src/lib/telemetry/redact-args.ts create mode 100644 src/lib/telemetry/request-context.server.ts diff --git a/bun.lock b/bun.lock index 50968ac..a5f208e 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "twilio": "^5.8.0", "vaul": "^1.1.2", "web-push": "^3.6.7", + "web-vitals": "^6.2.0", "zod": "^4.0.15", }, "devDependencies": { @@ -1590,6 +1591,8 @@ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-vitals": ["web-vitals@6.2.0", "", {}, "sha512-i2jhLkq6eB38jgJX5OIjEkyzQKgsG7FR56v7GphJNjzKAySgbRbwAfsFC0dDfHROWmCycf912SJpU2QoYIcMVg=="], + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], diff --git a/package.json b/package.json index c14bbab..c7cb483 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "twilio": "^5.8.0", "vaul": "^1.1.2", "web-push": "^3.6.7", + "web-vitals": "^6.2.0", "zod": "^4.0.15" }, "devDependencies": { diff --git a/pb_migrations/1787961600_activities_indexes.js b/pb_migrations/1787961600_activities_indexes.js new file mode 100644 index 0000000..bcefa6b --- /dev/null +++ b/pb_migrations/1787961600_activities_indexes.js @@ -0,0 +1,23 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_1262591861") + + unmarshal({ + "indexes": [ + "CREATE INDEX `idx_activities_created` ON `activities` (`created`)", + "CREATE INDEX `idx_activities_name_created` ON `activities` (`name`, `created`)", + "CREATE INDEX `idx_activities_player_created` ON `activities` (`player`, `created`)", + "CREATE INDEX `idx_activities_success_created` ON `activities` (`success`, `created`)" + ] + }, collection) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1262591861") + + unmarshal({ + "indexes": [] + }, collection) + + return app.save(collection) +}) diff --git a/pb_migrations/1787961700_created_client_events.js b/pb_migrations/1787961700_created_client_events.js new file mode 100644 index 0000000..972f8d0 --- /dev/null +++ b/pb_migrations/1787961700_created_client_events.js @@ -0,0 +1,167 @@ +/// +migrate((app) => { + const collection = new Collection({ + "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" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_name", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation_ce_player", + "maxSelect": 1, + "minSelect": 0, + "name": "player", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_session_id", + "max": 0, + "min": 0, + "name": "session_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_path", + "max": 0, + "min": 0, + "name": "path", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_route_id", + "max": 0, + "min": 0, + "name": "route_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number_ce_value", + "max": null, + "min": null, + "name": "value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json_ce_props", + "maxSize": 0, + "name": "props", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ce_user_agent", + "max": 0, + "min": 0, + "name": "user_agent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "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_client_events", + "indexes": [ + "CREATE INDEX `idx_client_events_created` ON `client_events` (`created`)", + "CREATE INDEX `idx_client_events_name_created` ON `client_events` (`name`, `created`)", + "CREATE INDEX `idx_client_events_player_created` ON `client_events` (`player`, `created`)", + "CREATE INDEX `idx_client_events_session_id` ON `client_events` (`session_id`)" + ], + "listRule": null, + "name": "client_events", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_client_events"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1787961800_created_client_errors.js b/pb_migrations/1787961800_created_client_errors.js new file mode 100644 index 0000000..2600f24 --- /dev/null +++ b/pb_migrations/1787961800_created_client_errors.js @@ -0,0 +1,207 @@ +/// +migrate((app) => { + const collection = new Collection({ + "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" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_message", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_stack", + "max": 0, + "min": 0, + "name": "stack", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "select_cerr_source", + "maxSelect": 1, + "name": "source", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "window", + "unhandledrejection", + "error-boundary", + "sw" + ] + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_path", + "max": 0, + "min": 0, + "name": "path", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_route_id", + "max": 0, + "min": 0, + "name": "route_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_group_hash", + "max": 0, + "min": 0, + "name": "group_hash", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool_cerr_resolved", + "name": "resolved", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation_cerr_player", + "maxSelect": 1, + "minSelect": 0, + "name": "player", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_session_id", + "max": 0, + "min": 0, + "name": "session_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_cerr_user_agent", + "max": 0, + "min": 0, + "name": "user_agent", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json_cerr_props", + "maxSize": 0, + "name": "props", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "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_client_errors", + "indexes": [ + "CREATE INDEX `idx_client_errors_group_created` ON `client_errors` (`group_hash`, `created`)", + "CREATE INDEX `idx_client_errors_created` ON `client_errors` (`created`)", + "CREATE INDEX `idx_client_errors_resolved_created` ON `client_errors` (`resolved`, `created`)" + ], + "listRule": null, + "name": "client_errors", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_client_errors"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js b/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js new file mode 100644 index 0000000..2e699f7 --- /dev/null +++ b/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js @@ -0,0 +1,240 @@ +/// +migrate((app) => { + const rollups = new Collection({ + "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" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_tr_date", + "max": 0, + "min": 0, + "name": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_tr_metric", + "max": 0, + "min": 0, + "name": "metric", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_tr_dim", + "max": 0, + "min": 0, + "name": "dim", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number_tr_value", + "max": null, + "min": null, + "name": "value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json_tr_meta", + "maxSize": 0, + "name": "meta", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "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_telemetry_rollups", + "indexes": [ + "CREATE UNIQUE INDEX `idx_rollups_date_metric_dim` ON `telemetry_rollups` (`date`, `metric`, `dim`)", + "CREATE INDEX `idx_rollups_metric_date` ON `telemetry_rollups` (`metric`, `date`)" + ], + "listRule": null, + "name": "telemetry_rollups", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + app.save(rollups); + + const alerts = new Collection({ + "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" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ta_kind", + "max": 0, + "min": 0, + "name": "kind", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ta_dim", + "max": 0, + "min": 0, + "name": "dim", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text_ta_message", + "max": 0, + "min": 0, + "name": "message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number_ta_value", + "max": null, + "min": null, + "name": "value", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "json_ta_meta", + "maxSize": 0, + "name": "meta", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "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_telemetry_alerts", + "indexes": [ + "CREATE INDEX `idx_alerts_kind_dim_created` ON `telemetry_alerts` (`kind`, `dim`, `created`)" + ], + "listRule": null, + "name": "telemetry_alerts", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(alerts); +}, (app) => { + app.delete(app.findCollectionByNameOrId("pbc_telemetry_alerts")); + return app.delete(app.findCollectionByNameOrId("pbc_telemetry_rollups")); +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index cdc5fb1..0b8d5e6 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo' +import { Route as ApiTelemetryBeaconRouteImport } from './routes/api/telemetry/beacon' import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo' import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token' import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search' @@ -117,6 +118,11 @@ const ApiTournamentsUploadLogoRoute = path: '/api/tournaments/upload-logo', getParentRoute: () => rootRouteImport, } as any) +const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({ + id: '/api/telemetry/beacon', + path: '/api/telemetry/beacon', + getParentRoute: () => rootRouteImport, +} as any) const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({ id: '/api/teams/upload-logo', path: '/api/teams/upload-logo', @@ -303,6 +309,7 @@ export interface FileRoutesByFullPath { '/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute + '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/admin/': typeof AuthedAdminIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute @@ -345,6 +352,7 @@ export interface FileRoutesByTo { '/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute + '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/admin': typeof AuthedAdminIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute @@ -390,6 +398,7 @@ export interface FileRoutesById { '/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute + '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute @@ -435,6 +444,7 @@ export interface FileRouteTypes { | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' + | '/api/telemetry/beacon' | '/api/tournaments/upload-logo' | '/admin/' | '/tournaments/' @@ -477,6 +487,7 @@ export interface FileRouteTypes { | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' + | '/api/telemetry/beacon' | '/api/tournaments/upload-logo' | '/admin' | '/tournaments' @@ -521,6 +532,7 @@ export interface FileRouteTypes { | '/api/spotify/search' | '/api/spotify/token' | '/api/teams/upload-logo' + | '/api/telemetry/beacon' | '/api/tournaments/upload-logo' | '/_authed/admin/' | '/_authed/tournaments/' @@ -555,6 +567,7 @@ export interface RootRouteChildren { ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute + ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute } @@ -652,6 +665,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport parentRoute: typeof rootRouteImport } + '/api/telemetry/beacon': { + id: '/api/telemetry/beacon' + path: '/api/telemetry/beacon' + fullPath: '/api/telemetry/beacon' + preLoaderRoute: typeof ApiTelemetryBeaconRouteImport + parentRoute: typeof rootRouteImport + } '/api/teams/upload-logo': { id: '/api/teams/upload-logo' path: '/api/teams/upload-logo' @@ -944,6 +964,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSpotifySearchRoute: ApiSpotifySearchRoute, ApiSpotifyTokenRoute: ApiSpotifyTokenRoute, ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute, + ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute, ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute, ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute, } @@ -952,10 +973,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' +import type { startInstance } from './start.ts' declare module '@tanstack/react-start' { interface Register { ssr: true router: Awaited> + config: Awaited> } } diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index ba1d90b..a81eb22 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -5,11 +5,13 @@ import { Scripts, createRootRouteWithContext, isRedirect, + useRouter, } from "@tanstack/react-router"; import * as React from "react"; import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary"; import { type QueryClient } from "@tanstack/react-query"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; +import { initTelemetry, track } from "@/lib/telemetry/client"; import { AuthContextType } from "@/contexts/auth-context"; import Providers from "@/features/core/components/providers"; import { SessionMonitor } from "@/components/session-monitor"; @@ -175,12 +177,15 @@ export const Route = createRootRouteWithContext<{ }); function RootComponent() { + const router = useRouter(); + React.useEffect(() => { ensureSuperTokensFrontend(); + initTelemetry(router); if (import.meta.env.PROD && 'serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(() => {}); + navigator.serviceWorker.register('/sw.js').catch(() => track('sw_register_failed')); } - }, []); + }, [router]); return ( @@ -193,7 +198,6 @@ function RootComponent() { ); } -// todo: analytics -> process.env data-website-id function RootDocument({ children }: { children: React.ReactNode }) { return ( { - if (!context.auth?.roles?.includes("Admin")) { + if (!isAdminRole(context.auth?.roles)) { throw redirect({ to: "/" }); } diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx index 0cc963a..a62fc78 100644 --- a/src/app/routes/_authed/admin/activities.tsx +++ b/src/app/routes/_authed/admin/activities.tsx @@ -2,7 +2,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { ActivitiesTable, activityQueries } from "@/features/activities"; import { PlayersActivityTable, playerQueries } from "@/features/players"; -import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; +import { Tabs } from "@mantine/core"; import { Suspense, useState } from "react"; import { Trans } from "@lingui/react/macro"; import { msg } from "@lingui/core/macro"; @@ -25,30 +26,6 @@ export const Route = createFileRoute("/_authed/admin/activities")({ }), }); -function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) { - return ( - - {withSearch && ( - - - - )} - {Array.from({ length: 8 }).map((_, index) => ( -
- - - - - - - - -
- ))} -
- ); -} - function Stats() { const [activeTab, setActiveTab] = useState("server-functions"); @@ -60,13 +37,13 @@ function Stats() { - }> + }> - }> + }> diff --git a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx index f49fb71..3df4c0f 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx @@ -1,6 +1,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import { StatTileSkeleton } from "@/components/skeletons"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core"; import { useState } from "react"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; @@ -37,10 +38,7 @@ function AssignPartnersPending() { return ( - - - - + diff --git a/src/app/routes/_authed/admin/tournaments/$id/index.tsx b/src/app/routes/_authed/admin/tournaments/$id/index.tsx index ba24ea8..ae2db70 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/index.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { tournamentQueries } from "@/features/tournaments/queries"; import ManageTournament from "@/features/tournaments/components/manage-tournament"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; -import { Divider, Group, Skeleton, Stack } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; import { msg } from "@lingui/core/macro"; export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ @@ -31,18 +31,14 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ function ManageTournamentPending() { return ( - - {Array.from({ length: 5 }).map((_, index) => ( -
- - - - - - -
- ))} -
+ ); } diff --git a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx index fa96f99..c717617 100644 --- a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx +++ b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx @@ -2,7 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { tournamentQueries } from "@/features/tournaments/queries"; import ManageTeams from "@/features/teams/components/manage-teams"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; -import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; +import { Box, Skeleton, Stack } from "@mantine/core"; import { msg } from "@lingui/core/macro"; export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ @@ -38,21 +39,7 @@ function ManageTeamsPending() { - - {Array.from({ length: 8 }).map((_, index) => ( -
- - - - - - - - - -
- ))} -
+ ); } diff --git a/src/app/routes/_authed/admin/tournaments/index.tsx b/src/app/routes/_authed/admin/tournaments/index.tsx index 7210102..205cb95 100644 --- a/src/app/routes/_authed/admin/tournaments/index.tsx +++ b/src/app/routes/_authed/admin/tournaments/index.tsx @@ -1,7 +1,7 @@ import ManageTournaments from "@/features/admin/components/manage-tournaments"; import { tournamentQueries } from "@/features/tournaments/queries"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; -import { Divider, Group, Skeleton, Stack } from "@mantine/core"; +import { ListSkeleton } from "@/components/skeletons"; import { createFileRoute } from "@tanstack/react-router"; import { Suspense } from "react"; import { msg } from "@lingui/core/macro"; @@ -22,25 +22,13 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({ component: RouteComponent, }); -function TournamentListSkeleton() { - return ( - - {Array.from({ length: 6 }).map((_, index) => ( -
- - - - - -
- ))} -
- ); -} - function RouteComponent() { return ( - }> + + } + > ); diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index ad68e03..216ce86 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -8,12 +8,10 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import GroupStageView from "@/features/tournaments/components/group-stage-view"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core"; -import { useMemo } from "react"; -import { BracketData } from "@/features/bracket/types"; -import { Match } from "@/features/matches/types"; import BracketView from "@/features/bracket/components/bracket-view"; import { SpotifyControlsBar } from "@/features/spotify/components"; -import { useAuth } from "@/contexts/auth-context"; +import { useIsAdmin } from "@/features/core/hooks/use-is-admin"; +import { useTournamentRunState } from "@/features/tournaments/hooks/use-tournament-run-state"; import { Trans } from "@lingui/react/macro"; import { msg } from "@lingui/core/macro"; @@ -83,67 +81,10 @@ function RunTournamentPending() { function RouteComponent() { const { id } = Route.useParams(); const { data: tournament } = useTournament(id); - const { roles } = useAuth(); - const isAdmin = roles?.includes('Admin') || false; + const isAdmin = useIsAdmin(); - const hasGroupStage = useMemo(() => { - return tournament.matches?.some((match) => match.round === -1) || false; - }, [tournament.matches]); - - const hasKnockout = useMemo(() => { - return tournament.matches?.some((match) => match.round !== -1) || false; - }, [tournament.matches]); - - const knockoutBracketPopulated = useMemo(() => { - return tournament.matches?.some((match) => - match.round === 0 && match.lid >= 0 && (match.home || match.away) - ) || false; - }, [tournament.matches]); - - const nextUpMatchId = useMemo(() => { - const ready = (tournament.matches ?? []) - .filter( - (match) => - match.status === "ready" && match.home && match.away && !match.bye - ) - .sort((a, b) => a.order - b.order); - return ready[0]?.id; - }, [tournament.matches]); - - const bracket: BracketData = useMemo(() => { - if (!tournament.matches || tournament.matches.length === 0) { - return { winners: [], losers: [] }; - } - - const winnersMap = new Map(); - const losersMap = new Map(); - - tournament.matches - .filter((match) => match.round !== -1) - .sort((a, b) => a.lid - b.lid) - .forEach((match) => { - if (!match.is_losers_bracket) { - if (!winnersMap.has(match.round)) { - winnersMap.set(match.round, []); - } - winnersMap.get(match.round)!.push(match); - } else { - if (!losersMap.has(match.round)) { - losersMap.set(match.round, []); - } - losersMap.get(match.round)!.push(match); - } - }); - - const winners = Array.from(winnersMap.entries()) - .sort(([a], [b]) => a - b) - .map(([, matches]) => matches); - - const losers = Array.from(losersMap.entries()) - .sort(([a], [b]) => a - b) - .map(([, matches]) => matches); - return { winners, losers }; - }, [tournament.matches]); + const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } = + useTournamentRunState(tournament.matches); return ( diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx index 9ed811c..bf42b78 100644 --- a/src/app/routes/_authed/tournaments/$tournamentId.tsx +++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { tournamentQueries } from '@/features/tournaments/queries'; import Profile from '@/features/tournaments/components/profile'; +import { isAdminRole } from '@/features/core/utils/roles'; import { z } from "zod"; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; import { Suspense } from 'react'; @@ -20,7 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ header: { collapsed: true, withBackButton: true, - settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined + settingsLink: isAdminRole(context.auth.roles) ? `/admin/tournaments/${params.tournamentId}` : undefined }, refresh: [tournamentQueries.details(params.tournamentId).queryKey], withPadding: false diff --git a/src/app/routes/api/auth.$.ts b/src/app/routes/api/auth.$.ts index 3b617e2..0c560c7 100644 --- a/src/app/routes/api/auth.$.ts +++ b/src/app/routes/api/auth.$.ts @@ -8,8 +8,6 @@ ensureSuperTokensBackend(); // forwards all supertokens api methods to our API const superTokensHandler = handleAuthAPIRequest(); const handleRequest = async ({ request }: {request: Request}) => { - console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); - console.log("Handling auth request:", request.method, request.url); return superTokensHandler(request); }; export const Route = createFileRoute('/api/auth/$')({ diff --git a/src/app/routes/api/push/subscribe.ts b/src/app/routes/api/push/subscribe.ts index 51b77b7..bbcc4b4 100644 --- a/src/app/routes/api/push/subscribe.ts +++ b/src/app/routes/api/push/subscribe.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; @@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) => export const Route = createFileRoute("/api/push/subscribe")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const player = (context as any).player; diff --git a/src/app/routes/api/push/test.ts b/src/app/routes/api/push/test.ts index 3f8f587..7cf43c9 100644 --- a/src/app/routes/api/push/test.ts +++ b/src/app/routes/api/push/test.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { sendPushToPlayer } from "@/lib/push"; import { isPushConfigured } from "@/lib/config"; import { logger } from "@/lib/logger"; @@ -13,7 +14,7 @@ const json = (body: unknown, status = 200) => export const Route = createFileRoute("/api/push/test")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ context }) => { const player = (context as any).player; diff --git a/src/app/routes/api/push/unsubscribe.ts b/src/app/routes/api/push/unsubscribe.ts index 8e3c687..92b3faf 100644 --- a/src/app/routes/api/push/unsubscribe.ts +++ b/src/app/routes/api/push/unsubscribe.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; @@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) => export const Route = createFileRoute("/api/push/unsubscribe")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const player = (context as any).player; diff --git a/src/app/routes/api/teams/upload-logo.ts b/src/app/routes/api/teams/upload-logo.ts index 1ea5723..d2a8f74 100644 --- a/src/app/routes/api/teams/upload-logo.ts +++ b/src/app/routes/api/teams/upload-logo.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; import { localizedFor } from "@/lib/i18n/server-messages"; @@ -12,7 +13,7 @@ const uploadSchema = z.object({ export const Route = createFileRoute("/api/teams/upload-logo")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const i18n = localizedFor(context.metadata); diff --git a/src/app/routes/api/telemetry/beacon.ts b/src/app/routes/api/telemetry/beacon.ts new file mode 100644 index 0000000..38981d8 --- /dev/null +++ b/src/app/routes/api/telemetry/beacon.ts @@ -0,0 +1,128 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { superTokensOptionalRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; +import { beaconPayloadSchema, MAX_BEACON_BYTES, type BeaconEvent } from "@/lib/telemetry/beacon-schema"; +import { errorGroupHash } from "@/lib/telemetry/error-hash"; +import { redactArguments } from "@/lib/telemetry/redact-args"; +import { Logger } from "@/lib/logger"; +import type { Player } from "@/features/players/types"; + +const logger = new Logger("Telemetry"); + +const BUCKET_CAPACITY = 120; +const REFILL_PER_MS = BUCKET_CAPACITY / 60_000; +const buckets = new Map(); + +const takeTokens = (key: string, count: number): boolean => { + const now = Date.now(); + const bucket = buckets.get(key) ?? { tokens: BUCKET_CAPACITY, updatedAt: now }; + bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + (now - bucket.updatedAt) * REFILL_PER_MS); + bucket.updatedAt = now; + if (bucket.tokens < count) { + buckets.set(key, bucket); + return false; + } + bucket.tokens -= count; + buckets.set(key, bucket); + + if (buckets.size > 2000) { + for (const [k, v] of buckets) { + if (now - v.updatedAt > 5 * 60_000) buckets.delete(k); + } + } + return true; +}; + +const clientIp = (request: Request): string => + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "unknown"; + +const writeEvents = async ( + events: BeaconEvent[], + base: { sessionId: string; playerId?: string; userAgent?: string } +) => { + const { pbAdmin } = await import("@/lib/pocketbase/client"); + await pbAdmin.authPromise; + + // Sequential on purpose: SQLite is single-writer; a burst of parallel + // creates just queues behind the lock. + for (const event of events) { + try { + if (event.kind === "error") { + await pbAdmin.createClientError({ + message: event.message, + stack: event.stack, + source: event.source, + path: event.path, + route_id: event.routeId, + group_hash: errorGroupHash(event), + resolved: false, + player: base.playerId, + session_id: base.sessionId, + user_agent: base.userAgent, + props: redactArguments(event.props), + }); + } else { + await pbAdmin.createClientEvent({ + name: event.kind === "vital" ? `vital.${event.metric}` : event.name, + player: base.playerId, + session_id: base.sessionId, + path: event.path, + route_id: event.routeId, + value: event.value, + props: + event.kind === "vital" + ? event.rating + ? { rating: event.rating } + : undefined + : redactArguments(event.props), + user_agent: base.userAgent, + }); + } + } catch (error) { + logger.error("Failed to persist beacon event", error); + } + } +}; + +export const Route = createFileRoute("/api/telemetry/beacon")({ + server: { + middleware: [apiLoggingMiddleware, superTokensOptionalRequestMiddleware], + handlers: { + POST: async ({ request, context }) => { + const player = (context as unknown as { player?: Player | null }).player ?? undefined; + + const raw = await request.text(); + if (raw.length > MAX_BEACON_BYTES) { + return new Response(null, { status: 413 }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return new Response(null, { status: 400 }); + } + + const result = beaconPayloadSchema.safeParse(parsed); + if (!result.success) { + return new Response(null, { status: 400 }); + } + + const key = player ? `p:${player.id}` : `ip:${clientIp(request)}`; + if (!takeTokens(key, result.data.events.length)) { + return new Response(null, { status: 429 }); + } + + void writeEvents(result.data.events, { + sessionId: result.data.sessionId, + playerId: player?.id, + userAgent: request.headers.get("user-agent") ?? undefined, + }).catch((error) => logger.error("Beacon write batch failed", error)); + + return new Response(null, { status: 204 }); + }, + }, + }, +}); diff --git a/src/app/routes/api/tournaments/upload-logo.ts b/src/app/routes/api/tournaments/upload-logo.ts index 9b7f990..7121c65 100644 --- a/src/app/routes/api/tournaments/upload-logo.ts +++ b/src/app/routes/api/tournaments/upload-logo.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { msg } from "@lingui/core/macro"; import { superTokensRequestMiddleware } from "@/utils/supertokens"; +import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server"; import { pbAdmin } from "@/lib/pocketbase/client"; import { logger } from "@/lib/logger"; import { localizedFor } from "@/lib/i18n/server-messages"; @@ -12,7 +13,7 @@ const uploadSchema = z.object({ export const Route = createFileRoute("/api/tournaments/upload-logo")({ server: { - middleware: [superTokensRequestMiddleware], + middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], handlers: { POST: async ({ request, context }) => { const i18n = localizedFor(context.metadata); diff --git a/src/app/start.ts b/src/app/start.ts new file mode 100644 index 0000000..7f79732 --- /dev/null +++ b/src/app/start.ts @@ -0,0 +1,8 @@ +import { createStart } from "@tanstack/react-start"; +import { serverFnLoggingMiddleware } from "@/utils/activities"; + +// Global function middleware: every server fn (current and future) is audited +// without opting in at the definition site. +export const startInstance = createStart(() => ({ + functionMiddleware: [serverFnLoggingMiddleware], +})); diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx index 81f4f8a..19cca0a 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/src/components/DefaultCatchBoundary.tsx @@ -22,6 +22,7 @@ import { useDisclosure } from '@mantine/hooks' import { useEffect } from 'react' import toast from '@/lib/sonner' import { logger } from '@/lib/logger' +import { captureBoundaryError } from '@/lib/telemetry/client' import { XCircleIcon, WarningIcon } from '@phosphor-icons/react' import { Trans, useLingui } from '@lingui/react/macro' import Button from './button' @@ -41,6 +42,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { useEffect(() => { logger.error('DefaultCatchBoundary | ', error) + captureBoundaryError(error) if (errorMessage.toLowerCase().includes('unauthenticated')) { toast.error(t`You've been logged out`) diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx index e6f17d5..b24ecf7 100644 --- a/src/components/ios-install-prompt.tsx +++ b/src/components/ios-install-prompt.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { track } from '@/lib/telemetry/client' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { Trans, useLingui } from '@lingui/react/macro' @@ -26,13 +27,17 @@ export function IOSInstallPrompt() { if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) { setPlatform(isIOS ? 'ios' : 'android') - const timer = setTimeout(() => setShow(true), 3000) + const timer = setTimeout(() => { + setShow(true) + track('pwa_ios_prompt_shown') + }, 3000) return () => clearTimeout(timer) } }, []) const handleDismiss = () => { localStorage.setItem('pwa-install-prompt-dismissed', 'true') + track('pwa_ios_prompt_dismissed') setShow(false) } diff --git a/src/components/skeletons/card-skeleton.tsx b/src/components/skeletons/card-skeleton.tsx new file mode 100644 index 0000000..8b6ee7d --- /dev/null +++ b/src/components/skeletons/card-skeleton.tsx @@ -0,0 +1,35 @@ +import { Box, Group, Paper, Skeleton } from "@mantine/core"; +import type { MantineRadius } from "@mantine/core"; + +export interface CardSkeletonProps { + withAvatar?: boolean; + avatarSize?: number; + withBadge?: boolean; + radius?: MantineRadius; + opacity?: number; +} + +export const CardSkeleton = ({ + withAvatar = true, + avatarSize = 32, + withBadge = true, + radius = "md", + opacity = 1, +}: CardSkeletonProps) => { + return ( + + + {withAvatar && ( + + )} + + + + + {withBadge && } + + + ); +}; + +export default CardSkeleton; diff --git a/src/components/skeletons/index.ts b/src/components/skeletons/index.ts new file mode 100644 index 0000000..eaec0ec --- /dev/null +++ b/src/components/skeletons/index.ts @@ -0,0 +1,3 @@ +export * from "./list-skeleton"; +export * from "./card-skeleton"; +export * from "./stat-tile-skeleton"; diff --git a/src/components/skeletons/list-skeleton.tsx b/src/components/skeletons/list-skeleton.tsx new file mode 100644 index 0000000..8344eda --- /dev/null +++ b/src/components/skeletons/list-skeleton.tsx @@ -0,0 +1,76 @@ +import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core"; +import type { MantineSpacing } from "@mantine/core"; + +export interface ListSkeletonProps { + rows?: number; + withSearch?: boolean; + withAvatar?: boolean; + avatarSize?: number; + withSubtitle?: boolean; + withTrailing?: boolean | "double" | "text"; + titleWidth?: string | number; + padding?: MantineSpacing; + fadeStep?: number; + fadeFloor?: number; +} + +export const ListSkeleton = ({ + rows = 6, + withSearch = false, + withAvatar = false, + avatarSize = 40, + withSubtitle = false, + withTrailing = false, + titleWidth = "45%", + padding = "md", + fadeStep = 0.1, + fadeFloor = 0.35, +}: ListSkeletonProps) => { + return ( + + {withSearch && ( + + + + )} + {Array.from({ length: rows }).map((_, index) => ( +
+ + {withAvatar && ( + + )} + {withSubtitle ? ( + + + + + ) : ( + + )} + {withTrailing === "double" ? ( + + + + + ) : withTrailing === "text" ? ( + + ) : withTrailing ? ( + + ) : null} + + +
+ ))} +
+ ); +}; + +export default ListSkeleton; diff --git a/src/components/skeletons/stat-tile-skeleton.tsx b/src/components/skeletons/stat-tile-skeleton.tsx new file mode 100644 index 0000000..23068ff --- /dev/null +++ b/src/components/skeletons/stat-tile-skeleton.tsx @@ -0,0 +1,42 @@ +import { Card, Group, Skeleton, Stack } from "@mantine/core"; + +export interface StatTileSkeletonProps { + withCard?: boolean; + align?: "row" | "column"; + numberWidth?: number; + numberHeight?: number; + labelWidth?: number; + labelHeight?: number; +} + +export const StatTileSkeleton = ({ + withCard = false, + align = "column", + numberWidth = align === "row" ? 36 : 48, + numberHeight = 28, + labelWidth = align === "row" ? 110 : 80, + labelHeight = align === "row" ? 14 : 12, +}: StatTileSkeletonProps) => { + const content = + align === "row" ? ( + + + + + ) : ( + + + + + ); + + if (!withCard) return content; + + return ( + + {content} + + ); +}; + +export default StatTileSkeleton; diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx index c2ff000..e013aa3 100644 --- a/src/contexts/spotify-context.tsx +++ b/src/contexts/spotify-context.tsx @@ -1,7 +1,7 @@ import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react'; import { useLingui } from '@lingui/react/macro'; import { SpotifyAuth } from '@/lib/spotify/auth'; -import { useAuth } from './auth-context'; +import { useIsAdmin } from '@/features/core/hooks/use-is-admin'; import { useConfig } from '@/hooks/use-config'; import type { SpotifyContextType, @@ -40,8 +40,7 @@ const deepEqual = (a: unknown, b: unknown): boolean => { }; export const SpotifyProvider: React.FC = ({ children }) => { - const { roles } = useAuth(); - const isAdmin = roles?.includes('Admin') || false; + const isAdmin = useIsAdmin(); const config = useConfig(); const { t } = useLingui(); diff --git a/src/features/admin/components/award-badges.tsx b/src/features/admin/components/award-badges.tsx index abab1d5..9b60a00 100644 --- a/src/features/admin/components/award-badges.tsx +++ b/src/features/admin/components/award-badges.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core"; import { Trans, useLingui } from "@lingui/react/macro"; -import { awardManualBadge } from "@/features/badges/server"; +import useAwardManualBadge from "@/features/badges/hooks/use-award-manual-badge"; import { useAllBadges } from "@/features/badges/queries"; import toast from "@/lib/sonner"; import { usePlayers } from "@/features/players/queries"; @@ -13,35 +13,29 @@ const AwardBadges = () => { const [selectedPlayerId, setSelectedPlayerId] = useState(null); const [selectedBadgeId, setSelectedBadgeId] = useState(null); - const [isAwarding, setIsAwarding] = useState(false); + + const awardBadge = useAwardManualBadge(); const manualBadges = allBadges.filter((badge) => badge.type === "manual"); - const handleAwardBadge = async () => { + const handleAwardBadge = () => { if (!selectedPlayerId || !selectedBadgeId) return; - setIsAwarding(true); - try { - await awardManualBadge({ - data: { - playerId: selectedPlayerId, - badgeId: selectedBadgeId, + awardBadge.mutate( + { playerId: selectedPlayerId, badgeId: selectedBadgeId }, + { + onSuccess: () => { + const selectedPlayer = players.find((p) => p.id === selectedPlayerId); + const playerName = selectedPlayer + ? `${selectedPlayer.first_name} ${selectedPlayer.last_name}` + : t`Player`; + + toast.success(t`Badge awarded to ${playerName}`); + + setSelectedPlayerId(null); }, - }); - - const selectedPlayer = players.find((p) => p.id === selectedPlayerId); - const playerName = selectedPlayer - ? `${selectedPlayer.first_name} ${selectedPlayer.last_name}` - : t`Player`; - - toast.success(t`Badge awarded to ${playerName}`); - - setSelectedPlayerId(null); - } catch (error) { - toast.error(t`Failed to award badge`); - } finally { - setIsAwarding(false); - } + } + ); }; const playerOptions = players.map((player) => ({ @@ -98,7 +92,7 @@ const AwardBadges = () => {