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
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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);
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
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"));
|
||||
})
|
||||
@@ -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<FileRouteTypes>()
|
||||
|
||||
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<ReturnType<typeof getRouter>>
|
||||
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<RootDocument>
|
||||
@@ -193,7 +198,6 @@ function RootComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
// todo: analytics -> process.env data-website-id
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import type { HeaderConfig } from "@/features/core/types/header-config";
|
||||
import { isAdminRole } from "@/features/core/utils/roles";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin")({
|
||||
component: Outlet,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.auth?.roles?.includes("Admin")) {
|
||||
if (!isAdminRole(context.auth?.roles)) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{withSearch && (
|
||||
<Box px="md" pb="sm">
|
||||
<Skeleton height={42} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
|
||||
<Stack gap={6}>
|
||||
<Skeleton height={14} width={160} radius="sm" />
|
||||
<Skeleton height={10} width={100} radius="sm" />
|
||||
</Stack>
|
||||
<Skeleton height={10} width={60} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Stats() {
|
||||
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
|
||||
|
||||
@@ -60,13 +37,13 @@ function Stats() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="server-functions">
|
||||
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
|
||||
<ActivitiesTable />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="player-activity">
|
||||
<Suspense fallback={<ActivityRowsSkeleton />}>
|
||||
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
|
||||
<PlayersActivityTable />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="baseline">
|
||||
<Skeleton height={28} width={36} radius="sm" />
|
||||
<Skeleton height={14} width={110} radius="sm" />
|
||||
</Group>
|
||||
<StatTileSkeleton align="row" />
|
||||
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%">
|
||||
<Skeleton height={20} width={20} radius="sm" />
|
||||
<Skeleton height={16} width="45%" radius="sm" />
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
<ListSkeleton
|
||||
rows={5}
|
||||
withAvatar
|
||||
avatarSize={20}
|
||||
withTrailing
|
||||
fadeStep={0.14}
|
||||
fadeFloor={0.4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Box px="md">
|
||||
<Skeleton height={12} width={90} radius="sm" />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
||||
<Group p="xs" wrap="nowrap" w="100%">
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="45%" radius="sm" />
|
||||
<Stack ml="auto" gap={6} align="flex-end">
|
||||
<Skeleton height={10} width={90} radius="sm" />
|
||||
<Skeleton height={10} width={70} radius="sm" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
<ListSkeleton rows={8} withAvatar avatarSize={40} withTrailing="double" padding="xs" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%">
|
||||
<Skeleton height={16} width="55%" radius="sm" />
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<Suspense fallback={<TournamentListSkeleton />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
|
||||
}
|
||||
>
|
||||
<ManageTournaments />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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<number, Match[]>();
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
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 (
|
||||
<Container size="md" px={0}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/$')({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, { tokens: number; updatedAt: number }>();
|
||||
|
||||
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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
}));
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Paper p="sm" withBorder radius={radius} style={{ opacity }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
{withAvatar && (
|
||||
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
|
||||
)}
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Skeleton height={16} width="70%" mb={4} />
|
||||
<Skeleton height={12} width="40%" />
|
||||
</Box>
|
||||
{withBadge && <Skeleton height={20} width={60} radius="xl" />}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSkeleton;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./list-skeleton";
|
||||
export * from "./card-skeleton";
|
||||
export * from "./stat-tile-skeleton";
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{withSearch && (
|
||||
<Box px={padding} pb="sm">
|
||||
<Skeleton height={42} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ opacity: Math.max(1 - index * fadeStep, fadeFloor) }}
|
||||
>
|
||||
<Group
|
||||
p={padding}
|
||||
wrap="nowrap"
|
||||
w="100%"
|
||||
justify={withTrailing === "text" ? "space-between" : undefined}
|
||||
>
|
||||
{withAvatar && (
|
||||
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
|
||||
)}
|
||||
{withSubtitle ? (
|
||||
<Stack gap={6}>
|
||||
<Skeleton height={14} width={160} radius="sm" />
|
||||
<Skeleton height={10} width={100} radius="sm" />
|
||||
</Stack>
|
||||
) : (
|
||||
<Skeleton height={16} width={titleWidth} radius="sm" />
|
||||
)}
|
||||
{withTrailing === "double" ? (
|
||||
<Stack ml="auto" gap={6} align="flex-end">
|
||||
<Skeleton height={10} width={90} radius="sm" />
|
||||
<Skeleton height={10} width={70} radius="sm" />
|
||||
</Stack>
|
||||
) : withTrailing === "text" ? (
|
||||
<Skeleton height={10} width={60} radius="sm" />
|
||||
) : withTrailing ? (
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
) : null}
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListSkeleton;
|
||||
@@ -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" ? (
|
||||
<Group gap="xs" align="baseline">
|
||||
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
|
||||
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap={4} align="center">
|
||||
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
|
||||
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!withCard) return content;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatTileSkeleton;
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const isAdmin = useIsAdmin();
|
||||
const config = useConfig();
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(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 = () => {
|
||||
<Button
|
||||
onClick={handleAwardBadge}
|
||||
disabled={!selectedPlayerId}
|
||||
loading={isAwarding}
|
||||
loading={awardBadge.isPending}
|
||||
size="md"
|
||||
>
|
||||
<Trans>Award Badge</Trans>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { awardManualBadge } from "@/features/badges/server";
|
||||
import { badgeKeys } from "@/features/badges/queries";
|
||||
|
||||
interface AwardManualBadgeInput {
|
||||
playerId: string;
|
||||
badgeId: string;
|
||||
}
|
||||
|
||||
export default function useAwardManualBadge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: (data: AwardManualBadgeInput) => awardManualBadge({ data }),
|
||||
onSuccess: (_data, { playerId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: badgeKeys.playerBadges(playerId) });
|
||||
queryClient.invalidateQueries({ queryKey: badgeKeys.allEarnedBadges() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
@@ -15,7 +14,7 @@ export const getPlayerBadges = createServerFn()
|
||||
);
|
||||
|
||||
export const migrateBadgeProgress = createServerFn()
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(async () => startBadgeMigration())
|
||||
);
|
||||
@@ -41,7 +40,7 @@ export const awardManualBadge = createServerFn()
|
||||
playerId: z.string(),
|
||||
badgeId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { isAdminRole } from "../utils/roles";
|
||||
|
||||
export const useIsAdmin = (): boolean => {
|
||||
const { roles } = useAuth();
|
||||
return isAdminRole(roles);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { isAdminRole } from "../utils/roles";
|
||||
|
||||
export const useLinks = (userId: string | undefined, roles: string[]) => {
|
||||
const { t } = useLingui();
|
||||
@@ -31,7 +32,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
|
||||
}
|
||||
]
|
||||
|
||||
if (roles.includes('Admin')) {
|
||||
if (isAdminRole(roles)) {
|
||||
links.push({
|
||||
label: t`Admin`,
|
||||
href: '/admin',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ADMIN_ROLE = "Admin";
|
||||
|
||||
export const isAdminRole = (roles?: string[] | null): boolean =>
|
||||
!!roles?.includes(ADMIN_ROLE);
|
||||
@@ -9,7 +9,6 @@ import { Match, MatchInput } from "@/features/matches/types";
|
||||
import { emitServerEvent } from "@/lib/events/emitter";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { PlayerInfo } from "../players/types";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
const orderedTeamsSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
@@ -18,7 +17,7 @@ const orderedTeamsSchema = z.object({
|
||||
|
||||
export const generateTournamentBracket = createServerFn()
|
||||
.validator(orderedTeamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Generating tournament bracket", {
|
||||
@@ -141,7 +140,7 @@ export const generateTournamentBracket = createServerFn()
|
||||
|
||||
export const startMatch = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Starting match", data);
|
||||
@@ -170,7 +169,7 @@ export const populateKnockoutBracket = createServerFn()
|
||||
.validator(z.object({
|
||||
tournamentId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId } }) =>
|
||||
toServerResult(async () => {
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
@@ -801,7 +800,7 @@ const endMatchSchema = z.object({
|
||||
});
|
||||
export const endMatch = createServerFn()
|
||||
.validator(endMatchSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Ending match", matchId);
|
||||
@@ -827,7 +826,7 @@ const reportScoreSchema = z.object({
|
||||
|
||||
export const reportMatchScore = createServerFn()
|
||||
.validator(reportScoreSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
@@ -890,7 +889,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
|
||||
|
||||
export const confirmMatchScore = createServerFn()
|
||||
.validator(matchIdSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
@@ -929,7 +928,7 @@ export const confirmMatchScore = createServerFn()
|
||||
|
||||
export const clearMatchReport = createServerFn()
|
||||
.validator(matchIdSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
const logger = new Logger('Players');
|
||||
|
||||
@@ -52,7 +51,7 @@ export const getPlayer = createServerFn()
|
||||
|
||||
export const updatePlayer = createServerFn()
|
||||
.validator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -84,7 +83,7 @@ export const updatePlayer = createServerFn()
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -117,7 +116,7 @@ export const createPlayer = createServerFn()
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import {
|
||||
MyPrediction,
|
||||
@@ -56,7 +55,7 @@ const submitPredictionSchema = z.object({
|
||||
|
||||
export const submitPrediction = createServerFn()
|
||||
.validator(submitPredictionSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
||||
toServerResult(async (): Promise<Prediction> => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
// Whitelists: these values flow into Mantine's primaryColor / the
|
||||
// data-mantine-color-scheme attribute — an unknown value crash-loops or
|
||||
@@ -19,7 +18,7 @@ const COLOR_SCHEMES = ["light", "dark", "auto"];
|
||||
|
||||
export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -43,7 +42,7 @@ export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||
|
||||
export const updateUserLocale = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -69,7 +68,7 @@ export const updateUserLocale = createServerFn({ method: "POST" })
|
||||
|
||||
export const updateUserTheme = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -96,7 +95,7 @@ export const updateUserTheme = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -119,7 +118,7 @@ export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
||||
|
||||
export const updateUserAccentColor = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
} from '@phosphor-icons/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useSpotify } from '@/lib/spotify/hooks';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
|
||||
import SpotifySheet from './spotify-sheet';
|
||||
|
||||
const SpotifyControlsBar = () => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const isAdmin = useIsAdmin();
|
||||
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@phosphor-icons/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useSpotify } from '@/lib/spotify/hooks';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
|
||||
import Sheet from '@/components/sheet/sheet';
|
||||
|
||||
interface SpotifySheetProps {
|
||||
@@ -26,8 +26,7 @@ interface SpotifySheetProps {
|
||||
}
|
||||
|
||||
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const isAdmin = useIsAdmin();
|
||||
const spotify = useSpotify();
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { useUnenrolledPlayers } from "@/features/players/queries";
|
||||
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -15,8 +16,8 @@ interface PlayersPickerProps {
|
||||
|
||||
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
||||
const { t } = useLingui();
|
||||
const { roles, user } = useAuth();
|
||||
const isAdmin = roles.includes("Admin");
|
||||
const { user } = useAuth();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data } = useUnenrolledPlayers(tournamentId);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { Match } from "../matches/types";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { emitServerEvent } from "@/lib/events/emitter";
|
||||
|
||||
|
||||
@@ -32,7 +31,7 @@ export const getTeamInfo = createServerFn()
|
||||
|
||||
export const createTeam = createServerFn()
|
||||
.validator(teamInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
@@ -54,7 +53,7 @@ export const updateTeam = createServerFn()
|
||||
id: z.string(),
|
||||
updates: teamUpdateSchema
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { id, updates }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||
@@ -22,10 +23,10 @@ const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { user, roles } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const { t } = useLingui();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { useTournaments } from "../queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
@@ -12,11 +12,11 @@ import EmptyState from "@/components/empty-state";
|
||||
const TournamentCardList = () => {
|
||||
const { t } = useLingui();
|
||||
const { data: tournaments } = useTournaments();
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = useIsAdmin();
|
||||
const sheet = useSheet();
|
||||
return (
|
||||
<Stack>
|
||||
{roles?.includes("Admin") ? (
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
leftSection={<PlusIcon />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense, useMemo } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { Box, Divider, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import Countdown from "@/components/countdown";
|
||||
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
@@ -26,11 +27,11 @@ import EnrolledFreeAgent from "./enrolled-free-agent";
|
||||
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { user, roles } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLingui();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const userTeam = useMemo(
|
||||
() =>
|
||||
tournament.teams?.find((team) =>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveRunState } from "./use-tournament-run-state";
|
||||
import { Match } from "@/features/matches/types";
|
||||
|
||||
const makeMatch = (
|
||||
overrides: Partial<Match> & Pick<Match, "lid" | "round">
|
||||
): Match => ({
|
||||
id: `match-${overrides.lid}`,
|
||||
order: overrides.lid,
|
||||
reset: false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
bye: false,
|
||||
home_from_lid: 0,
|
||||
away_from_lid: 0,
|
||||
home_from_loser: false,
|
||||
away_from_loser: false,
|
||||
is_losers_bracket: false,
|
||||
status: "tbd",
|
||||
tournament: {} as Match["tournament"],
|
||||
created: "",
|
||||
updated: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const team = (id: string) =>
|
||||
({ id, name: id, primary_color: "#000", accent_color: "#000", players: [], private: false }) as Match["home"];
|
||||
|
||||
describe("deriveRunState", () => {
|
||||
it("returns empty state when there are no matches", () => {
|
||||
expect(deriveRunState(undefined)).toEqual({
|
||||
hasGroupStage: false,
|
||||
hasKnockout: false,
|
||||
knockoutBracketPopulated: false,
|
||||
nextUpMatchId: undefined,
|
||||
bracket: { winners: [], losers: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a group-only tournament and finds a ready group match as next up", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: -1, order: 2, status: "ended" }),
|
||||
makeMatch({
|
||||
lid: 2,
|
||||
round: -1,
|
||||
order: 1,
|
||||
status: "ready",
|
||||
home: team("a"),
|
||||
away: team("b"),
|
||||
}),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.hasGroupStage).toBe(true);
|
||||
expect(state.hasKnockout).toBe(false);
|
||||
expect(state.knockoutBracketPopulated).toBe(false);
|
||||
expect(state.nextUpMatchId).toBe("match-2");
|
||||
expect(state.bracket).toEqual({ winners: [], losers: [] });
|
||||
});
|
||||
|
||||
it("detects a knockout-only tournament and builds the bracket", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: 0, home: team("a"), away: team("b") }),
|
||||
makeMatch({ lid: 2, round: 1 }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.hasGroupStage).toBe(false);
|
||||
expect(state.hasKnockout).toBe(true);
|
||||
expect(state.knockoutBracketPopulated).toBe(true);
|
||||
expect(state.bracket.winners).toEqual([[matches[0]], [matches[1]]]);
|
||||
expect(state.bracket.losers).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a knockout round 0 with only placeholder teams as unpopulated", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: -1, status: "ended" }),
|
||||
makeMatch({ lid: 2, round: 0 }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.hasGroupStage).toBe(true);
|
||||
expect(state.hasKnockout).toBe(true);
|
||||
expect(state.knockoutBracketPopulated).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the knockout bracket populated once round 0 has a seeded team", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: -1, status: "ended" }),
|
||||
makeMatch({ lid: 2, round: 0, home: team("a") }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.knockoutBracketPopulated).toBe(true);
|
||||
});
|
||||
|
||||
it("splits winners and losers brackets and sorts each round by lid", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 3, round: 0, is_losers_bracket: false }),
|
||||
makeMatch({ lid: 1, round: 0, is_losers_bracket: false }),
|
||||
makeMatch({ lid: 2, round: 0, is_losers_bracket: true }),
|
||||
makeMatch({ lid: 4, round: 1, is_losers_bracket: false }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.bracket.winners).toEqual([
|
||||
[matches[1], matches[0]],
|
||||
[matches[3]],
|
||||
]);
|
||||
expect(state.bracket.losers).toEqual([[matches[2]]]);
|
||||
});
|
||||
|
||||
it("picks the lowest-order ready match with both teams and no bye as next up", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: 0, order: 5, status: "ready", home: team("a"), away: team("b") }),
|
||||
makeMatch({ lid: 2, round: 0, order: 1, status: "ready", home: team("a"), bye: false }),
|
||||
makeMatch({
|
||||
lid: 3,
|
||||
round: 0,
|
||||
order: 2,
|
||||
status: "ready",
|
||||
home: team("a"),
|
||||
away: team("b"),
|
||||
bye: true,
|
||||
}),
|
||||
makeMatch({
|
||||
lid: 4,
|
||||
round: 0,
|
||||
order: 3,
|
||||
status: "ready",
|
||||
home: team("a"),
|
||||
away: team("b"),
|
||||
}),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.nextUpMatchId).toBe("match-4");
|
||||
});
|
||||
|
||||
it("returns no next-up match when none are ready with both teams assigned", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: 0, status: "tbd" }),
|
||||
makeMatch({ lid: 2, round: 0, status: "ready", home: team("a") }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.nextUpMatchId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
|
||||
export interface TournamentRunState {
|
||||
hasGroupStage: boolean;
|
||||
hasKnockout: boolean;
|
||||
knockoutBracketPopulated: boolean;
|
||||
nextUpMatchId: string | undefined;
|
||||
bracket: BracketData;
|
||||
}
|
||||
|
||||
export const deriveRunState = (matches: Match[] | undefined): TournamentRunState => {
|
||||
const hasGroupStage = matches?.some((match) => match.round === -1) || false;
|
||||
|
||||
const hasKnockout = matches?.some((match) => match.round !== -1) || false;
|
||||
|
||||
const knockoutBracketPopulated =
|
||||
matches?.some(
|
||||
(match) => match.round === 0 && match.lid >= 0 && (match.home || match.away)
|
||||
) || false;
|
||||
|
||||
const ready = (matches ?? [])
|
||||
.filter((match) => match.status === "ready" && match.home && match.away && !match.bye)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const nextUpMatchId = ready[0]?.id;
|
||||
|
||||
const bracket: BracketData = (() => {
|
||||
if (!matches || matches.length === 0) {
|
||||
return { winners: [], losers: [] };
|
||||
}
|
||||
|
||||
const winnersMap = new Map<number, Match[]>();
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
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 };
|
||||
})();
|
||||
|
||||
return { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket };
|
||||
};
|
||||
|
||||
export const useTournamentRunState = (matches: Match[] | undefined): TournamentRunState =>
|
||||
useMemo(() => deriveRunState(matches), [matches]);
|
||||
@@ -5,7 +5,6 @@ import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/typ
|
||||
import { logger } from ".";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { emitServerEvent } from "@/lib/events/emitter";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
@@ -20,7 +19,7 @@ export const listTournaments = createServerFn()
|
||||
|
||||
export const createTournament = createServerFn()
|
||||
.validator(tournamentInputSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const tournament = await pbAdmin.createTournament(data);
|
||||
@@ -34,7 +33,7 @@ export const updateTournament = createServerFn()
|
||||
id: z.string(),
|
||||
updates: tournamentInputSchema.partial()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
|
||||
@@ -61,7 +60,7 @@ export const enrollTeam = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
teamId: z.string()
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
@@ -90,7 +89,7 @@ export const unenrollTeam = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
teamId: z.string()
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const result = await pbAdmin.unenrollTeam(tournamentId, teamId);
|
||||
@@ -115,7 +114,7 @@ export const getFreeAgents = createServerFn()
|
||||
|
||||
export const enrollFreeAgent = createServerFn()
|
||||
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -130,7 +129,7 @@ export const enrollFreeAgent = createServerFn()
|
||||
|
||||
export const unenrollFreeAgent = createServerFn()
|
||||
.validator(z.object({ tournamentId: z.string() }))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -148,7 +147,7 @@ export const generateRandomTeams = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
||||
@@ -332,7 +331,7 @@ export const confirmTeamAssignments = createServerFn()
|
||||
teamName: z.string()
|
||||
}))
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const createdTeams = [];
|
||||
@@ -527,7 +526,7 @@ export const generateKnockoutBracket = createServerFn()
|
||||
.validator(z.object({
|
||||
tournamentId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating knockout bracket', {
|
||||
@@ -731,7 +730,7 @@ export const adminEnrollPlayer = createServerFn()
|
||||
playerId: z.string(),
|
||||
tournamentId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
||||
@@ -745,7 +744,7 @@ export const adminUnenrollPlayer = createServerFn()
|
||||
playerId: z.string(),
|
||||
tournamentId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
||||
@@ -771,7 +770,7 @@ export const generateGroupStage = createServerFn()
|
||||
})),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating group stage', {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createBadgesService } from "./services/badges";
|
||||
import { createGroupsService } from "./services/groups";
|
||||
import { createPredictionsService } from "./services/predictions";
|
||||
import { createPushService } from "./services/push";
|
||||
import { createTelemetryService } from "./services/telemetry";
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
@@ -67,6 +68,7 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createGroupsService(this.pb));
|
||||
Object.assign(this, createPredictionsService(this.pb));
|
||||
Object.assign(this, createPushService(this.pb));
|
||||
Object.assign(this, createTelemetryService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise
|
||||
@@ -156,7 +158,8 @@ interface AdminClient
|
||||
ReturnType<typeof createBadgesService>,
|
||||
ReturnType<typeof createGroupsService>,
|
||||
ReturnType<typeof createPredictionsService>,
|
||||
ReturnType<typeof createPushService> {
|
||||
ReturnType<typeof createPushService>,
|
||||
ReturnType<typeof createTelemetryService> {
|
||||
authPromise: Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { pbFilter } from "../util/filter";
|
||||
import { likePattern } from "../util/like-pattern";
|
||||
|
||||
export type ClientErrorSource = "window" | "unhandledrejection" | "error-boundary" | "sw";
|
||||
|
||||
export interface ClientEventRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
player?: string | PlayerInfo;
|
||||
session_id?: string;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
value?: number;
|
||||
props?: any;
|
||||
user_agent?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ClientEventInput {
|
||||
name: string;
|
||||
player?: string;
|
||||
session_id?: string;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
value?: number;
|
||||
props?: any;
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
export interface ClientErrorRecord {
|
||||
id: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: ClientErrorSource;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
group_hash: string;
|
||||
resolved: boolean;
|
||||
player?: string | PlayerInfo;
|
||||
session_id?: string;
|
||||
user_agent?: string;
|
||||
props?: any;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface ClientErrorInput {
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: ClientErrorSource;
|
||||
path?: string;
|
||||
route_id?: string;
|
||||
group_hash: string;
|
||||
resolved?: boolean;
|
||||
player?: string;
|
||||
session_id?: string;
|
||||
user_agent?: string;
|
||||
props?: any;
|
||||
}
|
||||
|
||||
export interface RollupRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
metric: string;
|
||||
dim: string;
|
||||
value: number;
|
||||
meta?: any;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface RollupInput {
|
||||
date: string;
|
||||
metric: string;
|
||||
dim: string;
|
||||
value: number;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
export interface TelemetryAlertRecord {
|
||||
id: string;
|
||||
kind: string;
|
||||
dim: string;
|
||||
message?: string;
|
||||
value?: number;
|
||||
meta?: any;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface TelemetryAlertInput {
|
||||
kind: string;
|
||||
dim: string;
|
||||
message?: string;
|
||||
value?: number;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
export interface TelemetryListResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface ClientEventSearchParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
name?: string;
|
||||
player?: string;
|
||||
sessionId?: string;
|
||||
path?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export interface ClientErrorSearchParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
groupHash?: string;
|
||||
resolved?: boolean;
|
||||
player?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return (error as { status?: number })?.status === 404;
|
||||
}
|
||||
|
||||
export function createTelemetryService(pb: PocketBase) {
|
||||
const service = {
|
||||
async createClientEvent(data: ClientEventInput): Promise<ClientEventRecord> {
|
||||
return pb.collection("client_events").create<ClientEventRecord>(data);
|
||||
},
|
||||
|
||||
async createClientError(data: ClientErrorInput): Promise<ClientErrorRecord> {
|
||||
return pb.collection("client_errors").create<ClientErrorRecord>(data);
|
||||
},
|
||||
|
||||
async searchClientEvents(
|
||||
params: ClientEventSearchParams = {}
|
||||
): Promise<TelemetryListResult<ClientEventRecord>> {
|
||||
const { page = 1, perPage = 100, name, player, sessionId, path, from, to, sortBy = "-created" } = params;
|
||||
|
||||
const filters: string[] = [];
|
||||
if (name) filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
|
||||
if (player) filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||
if (sessionId) filters.push(pbFilter(pb, "session_id = {:sessionId}", { sessionId }));
|
||||
if (path) filters.push(pbFilter(pb, "path ~ {:path}", { path: likePattern(path) }));
|
||||
if (from) filters.push(pbFilter(pb, "created >= {:from}", { from }));
|
||||
if (to) filters.push(pbFilter(pb, "created < {:to}", { to }));
|
||||
|
||||
const result = await pb.collection("client_events").getList<ClientEventRecord>(page, perPage, {
|
||||
filter: filters.join(" && "),
|
||||
sort: sortBy,
|
||||
expand: "player",
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages,
|
||||
totalItems: result.totalItems,
|
||||
};
|
||||
},
|
||||
|
||||
async searchClientErrors(
|
||||
params: ClientErrorSearchParams = {}
|
||||
): Promise<TelemetryListResult<ClientErrorRecord>> {
|
||||
const { page = 1, perPage = 100, groupHash, resolved, player, from, to, sortBy = "-created" } = params;
|
||||
|
||||
const filters: string[] = [];
|
||||
if (groupHash) filters.push(pbFilter(pb, "group_hash = {:groupHash}", { groupHash }));
|
||||
if (resolved !== undefined) filters.push(pbFilter(pb, "resolved = {:resolved}", { resolved }));
|
||||
if (player) filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||
if (from) filters.push(pbFilter(pb, "created >= {:from}", { from }));
|
||||
if (to) filters.push(pbFilter(pb, "created < {:to}", { to }));
|
||||
|
||||
const result = await pb.collection("client_errors").getList<ClientErrorRecord>(page, perPage, {
|
||||
filter: filters.join(" && "),
|
||||
sort: sortBy,
|
||||
expand: "player",
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages,
|
||||
totalItems: result.totalItems,
|
||||
};
|
||||
},
|
||||
|
||||
// Idempotent by the (date, metric, dim) unique index; dim must be "" (never
|
||||
// null/undefined) or the index stops deduplicating.
|
||||
async upsertRollup(input: RollupInput): Promise<RollupRecord> {
|
||||
const data = { ...input, dim: input.dim ?? "" };
|
||||
const filter = pbFilter(pb, "date = {:date} && metric = {:metric} && dim = {:dim}", {
|
||||
date: data.date,
|
||||
metric: data.metric,
|
||||
dim: data.dim,
|
||||
});
|
||||
|
||||
try {
|
||||
const existing = await pb.collection("telemetry_rollups").getFirstListItem<RollupRecord>(filter);
|
||||
return await pb.collection("telemetry_rollups").update<RollupRecord>(existing.id, data);
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return await pb.collection("telemetry_rollups").create<RollupRecord>(data);
|
||||
} catch {
|
||||
const existing = await pb.collection("telemetry_rollups").getFirstListItem<RollupRecord>(filter);
|
||||
return pb.collection("telemetry_rollups").update<RollupRecord>(existing.id, data);
|
||||
}
|
||||
},
|
||||
|
||||
async getRollupRange(params: {
|
||||
metrics: string[];
|
||||
from: string;
|
||||
to: string;
|
||||
dim?: string;
|
||||
}): Promise<RollupRecord[]> {
|
||||
const { metrics, from, to, dim } = params;
|
||||
if (metrics.length === 0) return [];
|
||||
|
||||
const metricFilter = metrics
|
||||
.map((metric, i) => pbFilter(pb, `metric = {:m${i}}`, { [`m${i}`]: metric }))
|
||||
.join(" || ");
|
||||
const filters = [
|
||||
`(${metricFilter})`,
|
||||
pbFilter(pb, "date >= {:from}", { from }),
|
||||
pbFilter(pb, "date <= {:to}", { to }),
|
||||
];
|
||||
if (dim !== undefined) filters.push(pbFilter(pb, "dim = {:dim}", { dim }));
|
||||
|
||||
return pb.collection("telemetry_rollups").getFullList<RollupRecord>({
|
||||
filter: filters.join(" && "),
|
||||
sort: "date",
|
||||
});
|
||||
},
|
||||
|
||||
async createTelemetryAlert(data: TelemetryAlertInput): Promise<TelemetryAlertRecord> {
|
||||
return pb.collection("telemetry_alerts").create<TelemetryAlertRecord>({ ...data, dim: data.dim ?? "" });
|
||||
},
|
||||
|
||||
async findRecentAlert(kind: string, dim: string, sinceIso: string): Promise<TelemetryAlertRecord | null> {
|
||||
try {
|
||||
return await pb.collection("telemetry_alerts").getFirstListItem<TelemetryAlertRecord>(
|
||||
pbFilter(pb, "kind = {:kind} && dim = {:dim} && created >= {:since}", { kind, dim, since: sinceIso })
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async listTelemetryAlerts(page = 1, perPage = 50): Promise<TelemetryListResult<TelemetryAlertRecord>> {
|
||||
const result = await pb.collection("telemetry_alerts").getList<TelemetryAlertRecord>(page, perPage, {
|
||||
sort: "-created",
|
||||
});
|
||||
return {
|
||||
items: result.items,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
totalPages: result.totalPages,
|
||||
totalItems: result.totalItems,
|
||||
};
|
||||
},
|
||||
|
||||
// Bounded flip of a group's rows; new occurrences arrive resolved=false by
|
||||
// design, so a partial flip on a huge group is acceptable.
|
||||
async resolveErrorsByGroup(groupHash: string, resolved = true): Promise<number> {
|
||||
const MAX_PAGES = 25;
|
||||
let updated = 0;
|
||||
|
||||
for (let i = 0; i < MAX_PAGES; i++) {
|
||||
const batch = await pb.collection("client_errors").getList<ClientErrorRecord>(1, 200, {
|
||||
filter: pbFilter(pb, "group_hash = {:groupHash} && resolved != {:resolved}", { groupHash, resolved }),
|
||||
fields: "id",
|
||||
skipTotal: true,
|
||||
});
|
||||
if (batch.items.length === 0) break;
|
||||
for (const item of batch.items) {
|
||||
await pb.collection("client_errors").update(item.id, { resolved });
|
||||
updated++;
|
||||
}
|
||||
if (batch.items.length < 200) break;
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
// Paged scan over a base collection (never SQL views). Stable created sort;
|
||||
// tight `fields` keeps payloads small for rollup passes.
|
||||
async pageCollection<T>(
|
||||
collection: string,
|
||||
opts: { filter?: string; fields?: string },
|
||||
cb: (items: T[]) => void
|
||||
): Promise<void> {
|
||||
const PER_PAGE = 500;
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await pb.collection(collection).getList<T>(page, PER_PAGE, {
|
||||
filter: opts.filter ?? "",
|
||||
fields: opts.fields,
|
||||
sort: "created",
|
||||
skipTotal: true,
|
||||
});
|
||||
if (result.items.length > 0) cb(result.items);
|
||||
if (result.items.length < PER_PAGE) break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||
import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/types";
|
||||
import { Prediction } from "@/features/predictions/types";
|
||||
import { Activity } from "../services/activities";
|
||||
import { ClientEventRecord, ClientErrorRecord } from "../services/telemetry";
|
||||
|
||||
export function transformPlayerInfo(record: any): PlayerInfo {
|
||||
return {
|
||||
@@ -368,3 +369,38 @@ export function transformActivity(record: any): Activity {
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformClientEvent(record: any): ClientEventRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
|
||||
session_id: record.session_id,
|
||||
path: record.path,
|
||||
route_id: record.route_id,
|
||||
value: record.value,
|
||||
props: record.props,
|
||||
user_agent: record.user_agent,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformClientError(record: any): ClientErrorRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
message: record.message,
|
||||
stack: record.stack,
|
||||
source: record.source,
|
||||
path: record.path,
|
||||
route_id: record.route_id,
|
||||
group_hash: record.group_hash,
|
||||
resolved: !!record.resolved,
|
||||
player: record.expand?.player ? transformPlayerInfo(record.expand.player) : record.player,
|
||||
session_id: record.session_id,
|
||||
user_agent: record.user_agent,
|
||||
props: record.props,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import { withPasswordlessTelemetry } from "@/lib/telemetry/auth-events.server";
|
||||
import { logger } from "../";
|
||||
|
||||
const init = () =>
|
||||
@@ -25,6 +26,9 @@ const init = () =>
|
||||
};
|
||||
},
|
||||
},
|
||||
override: {
|
||||
functions: withPasswordlessTelemetry(),
|
||||
},
|
||||
});
|
||||
|
||||
export default { init };
|
||||
|
||||
@@ -4,6 +4,7 @@ import SuperTokens from "supertokens-node";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
|
||||
import { resolveLocale } from "@/lib/i18n";
|
||||
import { withPasswordlessTelemetry } from "@/lib/telemetry/auth-events.server";
|
||||
|
||||
// The user isn't authenticated yet when the login code is sent, so the SMS
|
||||
// locale comes from the stored preference of whichever user owns the phone
|
||||
@@ -51,10 +52,10 @@ const init = () =>
|
||||
},
|
||||
},
|
||||
override: {
|
||||
functions: (originalImplementation) => {
|
||||
functions: withPasswordlessTelemetry((originalImplementation) => {
|
||||
return {
|
||||
...originalImplementation,
|
||||
consumeCode: async (input) => {
|
||||
consumeCode: async (input: any) => {
|
||||
const session = await useSession({
|
||||
password: input.preAuthSessionId,
|
||||
});
|
||||
@@ -84,7 +85,7 @@ const init = () =>
|
||||
return response;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import UserRoles from "supertokens-node/recipe/userroles";
|
||||
import { appInfo } from "./config";
|
||||
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
|
||||
import PasswordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
|
||||
import { withSessionTelemetry } from "@/lib/telemetry/auth-events.server";
|
||||
import { logger } from "./";
|
||||
import type { TypeInput } from "supertokens-node/types";
|
||||
|
||||
@@ -30,6 +31,10 @@ export const backendConfig = (): TypeInput => {
|
||||
|
||||
// Debug only
|
||||
exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
|
||||
|
||||
override: {
|
||||
functions: withSessionTelemetry(),
|
||||
},
|
||||
}),
|
||||
Dashboard.init(),
|
||||
UserRoles.init(),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { recordActivity } from "@/utils/activities";
|
||||
import { getRequestActor } from "./request-context.server";
|
||||
|
||||
// Request-type counterpart of serverFnLoggingMiddleware for createFileRoute
|
||||
// API handlers, which the global function middleware never sees. Not for the
|
||||
// SSE route (a long-lived stream is not a request-shaped unit) or /api/health.
|
||||
export const apiLoggingMiddleware = createMiddleware({
|
||||
type: "request",
|
||||
}).server(async ({ next, request }) => {
|
||||
const name = `api:${new URL(request.url).pathname}`;
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await next();
|
||||
const status = result.response?.status ?? 200;
|
||||
|
||||
recordActivity({
|
||||
name,
|
||||
player: getRequestActor(request)?.playerId,
|
||||
duration: Date.now() - startTime,
|
||||
success: status < 400,
|
||||
error: status >= 400 ? `HTTP ${status}` : undefined,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Response) throw error;
|
||||
|
||||
recordActivity({
|
||||
name,
|
||||
player: getRequestActor(request)?.playerId,
|
||||
duration: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
const logger = new Logger("Telemetry");
|
||||
|
||||
interface AuthEventInput {
|
||||
authUserId?: string;
|
||||
phone?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Fire-and-forget: auth flows must never fail or slow down because telemetry
|
||||
// did. The phone number is used only to resolve the player, never stored.
|
||||
export const recordAuthEvent = (name: string, input: AuthEventInput) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
await pbAdmin.authPromise;
|
||||
|
||||
let playerId: string | undefined;
|
||||
let authUserId = input.authUserId;
|
||||
|
||||
if (!authUserId && input.phone) {
|
||||
try {
|
||||
const SuperTokens = (await import("supertokens-node")).default;
|
||||
const users = await SuperTokens.listUsersByAccountInfo("public", {
|
||||
phoneNumber: input.phone,
|
||||
});
|
||||
authUserId = users[0]?.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (authUserId) {
|
||||
try {
|
||||
const player = await pbAdmin.getPlayerByAuthId(authUserId);
|
||||
playerId = player?.id;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await pbAdmin.createActivity({
|
||||
name,
|
||||
player: playerId,
|
||||
duration: 0,
|
||||
success: input.success,
|
||||
error: input.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to record auth event", error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
type PasswordlessOverride = (originalImplementation: any) => any;
|
||||
|
||||
// Composes with a recipe's existing override.functions so telemetry wraps the
|
||||
// final implementation (Twilio verify included).
|
||||
export const withPasswordlessTelemetry =
|
||||
(existing?: PasswordlessOverride): PasswordlessOverride =>
|
||||
(originalImplementation: any) => {
|
||||
const base = existing ? existing(originalImplementation) : originalImplementation;
|
||||
|
||||
return {
|
||||
...base,
|
||||
createCode: async (input: any) => {
|
||||
try {
|
||||
const response = await base.createCode(input);
|
||||
recordAuthEvent("auth.otp_sent", {
|
||||
phone: input.phoneNumber,
|
||||
success: response.status === "OK",
|
||||
error: response.status === "OK" ? undefined : response.status,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
recordAuthEvent("auth.otp_sent", {
|
||||
phone: input.phoneNumber,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
consumeCode: async (input: any) => {
|
||||
try {
|
||||
const response = await base.consumeCode(input);
|
||||
if (response.status === "OK") {
|
||||
recordAuthEvent("auth.otp_consumed", {
|
||||
authUserId: response.user?.id,
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
recordAuthEvent("auth.otp_failed", {
|
||||
success: false,
|
||||
error: response.status,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
recordAuthEvent("auth.otp_failed", {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const withSessionTelemetry =
|
||||
(): ((originalImplementation: any) => any) =>
|
||||
(originalImplementation: any) => ({
|
||||
...originalImplementation,
|
||||
refreshSession: async (input: any) => {
|
||||
const session = await originalImplementation.refreshSession(input);
|
||||
recordAuthEvent("auth.session_refresh", {
|
||||
authUserId: session?.getUserId?.(),
|
||||
success: true,
|
||||
});
|
||||
return session;
|
||||
},
|
||||
revokeSession: async (input: any) => {
|
||||
const result = await originalImplementation.revokeSession(input);
|
||||
recordAuthEvent("auth.logout", { success: true });
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beaconPayloadSchema, MAX_BEACON_EVENTS } from "./beacon-schema";
|
||||
|
||||
const validSession = { sessionId: "12345678-session" };
|
||||
|
||||
describe("beaconPayloadSchema", () => {
|
||||
it("accepts a mixed batch of valid events", () => {
|
||||
const result = beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [
|
||||
{ kind: "event", name: "page_view", path: "/tournaments", routeId: "/_authed/tournaments/" },
|
||||
{ kind: "vital", metric: "LCP", value: 1234.5, rating: "good" },
|
||||
{ kind: "error", message: "boom", source: "window", stack: "Error: boom\n at x" },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown event kinds", () => {
|
||||
const result = beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "pixel", name: "x" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty and oversized batches", () => {
|
||||
expect(beaconPayloadSchema.safeParse({ ...validSession, events: [] }).success).toBe(false);
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: Array.from({ length: MAX_BEACON_EVENTS + 1 }, () => ({
|
||||
kind: "event",
|
||||
name: "x",
|
||||
})),
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects short session ids", () => {
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
sessionId: "short",
|
||||
events: [{ kind: "event", name: "x" }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces string length caps", () => {
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "error", message: "x".repeat(2001), source: "window" }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "event", name: "x".repeat(65) }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid vital metrics", () => {
|
||||
expect(
|
||||
beaconPayloadSchema.safeParse({
|
||||
...validSession,
|
||||
events: [{ kind: "vital", metric: "FID", value: 1 }],
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import z from "zod";
|
||||
|
||||
export const MAX_BEACON_EVENTS = 50;
|
||||
export const MAX_BEACON_BYTES = 64 * 1024;
|
||||
|
||||
export const WEB_VITAL_METRICS = ["LCP", "CLS", "INP", "FCP", "TTFB"] as const;
|
||||
|
||||
const commonLocation = {
|
||||
path: z.string().max(512).optional(),
|
||||
routeId: z.string().max(256).optional(),
|
||||
};
|
||||
|
||||
const trackEventSchema = z.object({
|
||||
kind: z.literal("event"),
|
||||
name: z.string().min(1).max(64),
|
||||
value: z.number().finite().optional(),
|
||||
props: z.unknown().optional(),
|
||||
ts: z.number().optional(),
|
||||
...commonLocation,
|
||||
});
|
||||
|
||||
const errorEventSchema = z.object({
|
||||
kind: z.literal("error"),
|
||||
message: z.string().min(1).max(2000),
|
||||
stack: z.string().max(8000).optional(),
|
||||
source: z.enum(["window", "unhandledrejection", "error-boundary", "sw"]),
|
||||
props: z.unknown().optional(),
|
||||
...commonLocation,
|
||||
});
|
||||
|
||||
const vitalEventSchema = z.object({
|
||||
kind: z.literal("vital"),
|
||||
metric: z.enum(WEB_VITAL_METRICS),
|
||||
value: z.number().finite(),
|
||||
rating: z.enum(["good", "needs-improvement", "poor"]).optional(),
|
||||
...commonLocation,
|
||||
});
|
||||
|
||||
export const beaconPayloadSchema = z.object({
|
||||
sessionId: z.string().min(8).max(64),
|
||||
events: z
|
||||
.array(z.discriminatedUnion("kind", [trackEventSchema, errorEventSchema, vitalEventSchema]))
|
||||
.min(1)
|
||||
.max(MAX_BEACON_EVENTS),
|
||||
});
|
||||
|
||||
export type BeaconPayload = z.infer<typeof beaconPayloadSchema>;
|
||||
export type BeaconEvent = BeaconPayload["events"][number];
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { AnyRouter } from "@tanstack/react-router";
|
||||
|
||||
// Provider-free by design: renders nothing and mounts no React context, so it
|
||||
// cannot disturb the LinguiProvider -> AuthProvider order. Browser-only; every
|
||||
// entry point is a no-op during SSR and in dev unless VITE_TELEMETRY_DEV=1.
|
||||
|
||||
type QueuedEvent = Record<string, unknown> & { kind: "event" | "error" | "vital" };
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000;
|
||||
const FLUSH_AT = 25;
|
||||
const MAX_PER_PAYLOAD = 50;
|
||||
const ERROR_DEDUPE_MS = 30_000;
|
||||
const MAX_ERRORS_PER_SESSION = 20;
|
||||
|
||||
let initialized = false;
|
||||
let queue: QueuedEvent[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastRouteId: string | undefined;
|
||||
let errorCount = 0;
|
||||
const recentErrors = new Map<string, number>();
|
||||
|
||||
const isEnabled = (): boolean =>
|
||||
typeof window !== "undefined" &&
|
||||
(import.meta.env.PROD || import.meta.env.VITE_TELEMETRY_DEV === "1");
|
||||
|
||||
const getSessionId = (): string => {
|
||||
try {
|
||||
let id = sessionStorage.getItem("flxn-tsid");
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
sessionStorage.setItem("flxn-tsid", id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return "anon-session";
|
||||
}
|
||||
};
|
||||
|
||||
const location = (): { path: string; routeId?: string } => ({
|
||||
path: window.location.pathname,
|
||||
routeId: lastRouteId,
|
||||
});
|
||||
|
||||
const send = (events: QueuedEvent[], useBeacon: boolean) => {
|
||||
const body = JSON.stringify({ sessionId: getSessionId(), events });
|
||||
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/telemetry/beacon", new Blob([body], { type: "application/json" }));
|
||||
return;
|
||||
}
|
||||
|
||||
void fetch("/api/telemetry/beacon", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const flush = (useBeacon = false) => {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
send(queue.slice(0, MAX_PER_PAYLOAD), useBeacon);
|
||||
queue = queue.slice(MAX_PER_PAYLOAD);
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (event: QueuedEvent) => {
|
||||
if (!isEnabled()) return;
|
||||
queue.push(event);
|
||||
if (queue.length >= FLUSH_AT) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => flush(), FLUSH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
export const track = (name: string, props?: Record<string, unknown>, value?: number) => {
|
||||
enqueue({ kind: "event", name, props, value, ...location() });
|
||||
};
|
||||
|
||||
const captureError = (
|
||||
source: "window" | "unhandledrejection" | "error-boundary" | "sw",
|
||||
error: unknown
|
||||
) => {
|
||||
if (!isEnabled()) return;
|
||||
if (errorCount >= MAX_ERRORS_PER_SESSION) return;
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: (() => {
|
||||
try {
|
||||
return JSON.stringify(error)?.slice(0, 500) || "Unknown error";
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
})();
|
||||
const stack = error instanceof Error ? error.stack?.slice(0, 8000) : undefined;
|
||||
|
||||
const now = Date.now();
|
||||
const dedupeKey = `${source}|${message}`;
|
||||
const lastSeen = recentErrors.get(dedupeKey);
|
||||
if (lastSeen && now - lastSeen < ERROR_DEDUPE_MS) return;
|
||||
recentErrors.set(dedupeKey, now);
|
||||
|
||||
errorCount++;
|
||||
enqueue({
|
||||
kind: "error",
|
||||
message: message.slice(0, 2000) || "Unknown error",
|
||||
stack,
|
||||
source,
|
||||
...location(),
|
||||
});
|
||||
flush();
|
||||
};
|
||||
|
||||
export const captureBoundaryError = (error: unknown) => {
|
||||
captureError("error-boundary", error);
|
||||
};
|
||||
|
||||
export const initTelemetry = (router: AnyRouter) => {
|
||||
if (initialized || !isEnabled()) return;
|
||||
initialized = true;
|
||||
|
||||
router.subscribe("onResolved", (event) => {
|
||||
const matches = router.state.matches;
|
||||
lastRouteId = matches[matches.length - 1]?.routeId;
|
||||
if (event.pathChanged) {
|
||||
track("page_view");
|
||||
}
|
||||
});
|
||||
|
||||
const initialMatches = router.state.matches;
|
||||
lastRouteId = initialMatches[initialMatches.length - 1]?.routeId;
|
||||
track("page_view");
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
captureError("window", event.error ?? event.message);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
captureError("unhandledrejection", event.reason);
|
||||
});
|
||||
|
||||
// Vitals mostly report at hidden-visibility; their callbacks are registered
|
||||
// before the flush listeners so those reports make the final beacon.
|
||||
void import("web-vitals")
|
||||
.then(({ onLCP, onCLS, onINP, onFCP, onTTFB }) => {
|
||||
const report = (metric: { name: string; value: number; rating: string }) => {
|
||||
enqueue({
|
||||
kind: "vital",
|
||||
metric: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
...location(),
|
||||
});
|
||||
};
|
||||
onLCP(report);
|
||||
onCLS(report);
|
||||
onINP(report);
|
||||
onFCP(report);
|
||||
onTTFB(report);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
track("pwa_installed");
|
||||
flush();
|
||||
});
|
||||
window.addEventListener("beforeinstallprompt", () => {
|
||||
track("pwa_prompt_available");
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flush(true);
|
||||
});
|
||||
window.addEventListener("pagehide", () => flush(true));
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { errorGroupHash, firstMeaningfulFrame } from "./error-hash";
|
||||
|
||||
describe("errorGroupHash", () => {
|
||||
it("is stable across ids, numbers, and URLs in the message", () => {
|
||||
const a = errorGroupHash({
|
||||
message: "Failed to load record abc123def456ghi from https://s3.yohler.net/x/1",
|
||||
source: "window",
|
||||
});
|
||||
const b = errorGroupHash({
|
||||
message: "Failed to load record zzz999yyy888xxx from https://other.host/y/42",
|
||||
source: "window",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("is stable across asset content-hash changes in the stack", () => {
|
||||
const stackA = "TypeError: x is not a function\n at t (https://flxn.app/assets/index-Bq2z1a3f.js:1:5000)";
|
||||
const stackB = "TypeError: x is not a function\n at t (https://flxn.app/assets/index-Cd4e9b21.js:1:6789)";
|
||||
const a = errorGroupHash({ message: "x is not a function", stack: stackA, source: "window" });
|
||||
const b = errorGroupHash({ message: "x is not a function", stack: stackB, source: "window" });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("distinguishes different messages", () => {
|
||||
const a = errorGroupHash({ message: "cannot read foo", source: "window" });
|
||||
const b = errorGroupHash({ message: "network request failed", source: "window" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("distinguishes sources", () => {
|
||||
const a = errorGroupHash({ message: "boom", source: "window" });
|
||||
const b = errorGroupHash({ message: "boom", source: "unhandledrejection" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces 8-char hex", () => {
|
||||
expect(errorGroupHash({ message: "boom" })).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstMeaningfulFrame", () => {
|
||||
it("returns empty for missing stacks", () => {
|
||||
expect(firstMeaningfulFrame(undefined)).toBe("");
|
||||
expect(firstMeaningfulFrame("Error: boom")).toBe("");
|
||||
});
|
||||
|
||||
it("strips line/column numbers and query strings", () => {
|
||||
const frame = firstMeaningfulFrame(
|
||||
"Error: boom\n at handler (/assets/app-Ab12Cd34.js:10:20)"
|
||||
);
|
||||
expect(frame).not.toMatch(/:\d+:\d+/);
|
||||
expect(frame).toContain("app.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// Deterministic client-error grouping across deploys: volatile fragments
|
||||
// (ids, URLs, asset content hashes) are normalized away before hashing.
|
||||
|
||||
const normalizeMessage = (message: string): string =>
|
||||
message
|
||||
.toLowerCase()
|
||||
.replace(/https?:\/\/[^\s)'"]+/g, "<url>")
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "<uuid>")
|
||||
.replace(/(['"`])(?:(?!\1).)*\1/g, "<str>")
|
||||
.replace(/\b[a-z0-9]{15}\b/g, "<id>")
|
||||
.replace(/\d+/g, "<n>")
|
||||
.trim();
|
||||
|
||||
const FRAME_NOISE = /^(at\s+)?(async\s+)?(module code|<anonymous>|eval)/i;
|
||||
|
||||
// Vite emits content-hashed asset names (index-Bq2z1a3f.js); strip the hash so
|
||||
// the same frame groups across deploys.
|
||||
const normalizeFrame = (frame: string): string =>
|
||||
frame
|
||||
.trim()
|
||||
.replace(/https?:\/\/[^\s)]+\//g, "/")
|
||||
.replace(/(\/[\w.-]+)-[\w]{8,}\.(m?js|css)/g, "$1.$2")
|
||||
.replace(/:\d+:\d+/g, "")
|
||||
.replace(/\?[^\s)]*/g, "");
|
||||
|
||||
export const firstMeaningfulFrame = (stack?: string): string => {
|
||||
if (!stack) return "";
|
||||
const lines = stack.split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || FRAME_NOISE.test(trimmed)) continue;
|
||||
return normalizeFrame(trimmed);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const fnv1a = (input: string): string => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = (hash * 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, "0");
|
||||
};
|
||||
|
||||
export const errorGroupHash = (input: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: string;
|
||||
}): string =>
|
||||
fnv1a(
|
||||
[normalizeMessage(input.message), firstMeaningfulFrame(input.stack), input.source ?? ""].join(
|
||||
"|"
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redactValue } from "@/lib/redact";
|
||||
|
||||
export const MAX_ARGUMENTS_CHARS = 2048;
|
||||
|
||||
export const redactArguments = (data: unknown): unknown => {
|
||||
if (data === undefined) return undefined;
|
||||
|
||||
const redacted = redactValue(data);
|
||||
const serialized = JSON.stringify(redacted) ?? "";
|
||||
if (serialized.length > MAX_ARGUMENTS_CHARS) {
|
||||
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
|
||||
}
|
||||
return redacted;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
// Per-request side-channel between the session layer and the global logging
|
||||
// middleware, which runs outside session middleware and cannot see its context.
|
||||
|
||||
const actorByRequest = new WeakMap<Request, { playerId?: string }>();
|
||||
const deniedRecorded = new WeakSet<Request>();
|
||||
|
||||
export const setRequestActor = (request: Request, actor: { playerId?: string }) => {
|
||||
actorByRequest.set(request, actor);
|
||||
};
|
||||
|
||||
export const getRequestActor = (request: Request): { playerId?: string } | undefined =>
|
||||
actorByRequest.get(request);
|
||||
|
||||
export const markDeniedRecorded = (request: Request) => {
|
||||
deniedRecorded.add(request);
|
||||
};
|
||||
|
||||
export const wasDeniedRecorded = (request: Request): boolean => deniedRecorded.has(request);
|
||||
@@ -21,6 +21,8 @@ vi.mock("@/lib/logger", () => ({
|
||||
}));
|
||||
|
||||
import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities";
|
||||
import { setRequestActor } from "@/lib/telemetry/request-context.server";
|
||||
import { redirect } from "@tanstack/react-router";
|
||||
|
||||
type ServerHandler = (opts: {
|
||||
next: () => Promise<unknown>;
|
||||
@@ -192,6 +194,130 @@ describe("serverFnLoggingMiddleware", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const flushWrites = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
describe("serverFnLoggingMiddleware read handling", () => {
|
||||
it("skips successful read fns", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/x");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: {},
|
||||
serverFnMeta: { id: "x", name: "getPlayerStats" },
|
||||
});
|
||||
|
||||
await flushWrites();
|
||||
expect(h.createActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records failed read fns", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/x");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => ({
|
||||
result: { success: false, error: { code: "NOT_FOUND" } },
|
||||
}),
|
||||
data: undefined,
|
||||
context: {},
|
||||
serverFnMeta: { id: "x", name: "getPlayerStats" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||
name: "getPlayerStats",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("records thrown errors from read fns", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/x");
|
||||
|
||||
await expect(
|
||||
runMiddleware({
|
||||
next: async () => {
|
||||
throw new Error("db down");
|
||||
},
|
||||
data: undefined,
|
||||
context: {},
|
||||
serverFnMeta: { id: "x", name: "listPlayers" },
|
||||
})
|
||||
).rejects.toThrow("db down");
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("serverFnLoggingMiddleware control flow and dedup", () => {
|
||||
it("does not record a thrown Response (session refresh)", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
|
||||
await expect(
|
||||
runMiddleware({
|
||||
next: async () => {
|
||||
throw new Response("{}", { status: 401 });
|
||||
},
|
||||
data: undefined,
|
||||
context: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(Response);
|
||||
|
||||
await flushWrites();
|
||||
expect(h.createActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not record thrown redirects", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
|
||||
await expect(
|
||||
runMiddleware({
|
||||
next: async () => {
|
||||
throw redirect({ to: "/" });
|
||||
},
|
||||
data: undefined,
|
||||
context: {},
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
|
||||
await flushWrites();
|
||||
expect(h.createActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not double-record when a denial was already recorded for the request", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
recordDeniedServerFn(h.request as unknown as Request, { player_id: "p1" }, { name: "doThing" });
|
||||
|
||||
await expect(
|
||||
runMiddleware({
|
||||
next: async () => {
|
||||
throw new Error("Unauthorized");
|
||||
},
|
||||
data: undefined,
|
||||
context: {},
|
||||
serverFnMeta: { id: "x", name: "doThing" },
|
||||
})
|
||||
).rejects.toThrow("Unauthorized");
|
||||
|
||||
await flushWrites();
|
||||
expect(h.createActivity).toHaveBeenCalledTimes(1);
|
||||
expect(h.createActivity.mock.calls[0][0].error).toContain("FORBIDDEN");
|
||||
});
|
||||
|
||||
it("prefers the stashed request actor over middleware context", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
setRequestActor(h.request as unknown as Request, { playerId: "p9" });
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: { metadata: { player_id: "p1" } },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0].player).toBe("p9");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordDeniedServerFn", () => {
|
||||
const deniedRequest = (url: string) =>
|
||||
({ url, headers: new Headers({ "user-agent": "vitest" }) }) as Request;
|
||||
|
||||
+56
-31
@@ -1,12 +1,18 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { isRedirect } from "@tanstack/react-router";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { redactValue } from "@/lib/redact";
|
||||
import { redactArguments } from "@/lib/telemetry/redact-args";
|
||||
import {
|
||||
getRequestActor,
|
||||
markDeniedRecorded,
|
||||
wasDeniedRecorded,
|
||||
} from "@/lib/telemetry/request-context.server";
|
||||
import type { ActivityInput } from "@/lib/pocketbase/services/activities";
|
||||
|
||||
const logger = new Logger("Activities");
|
||||
|
||||
const recordActivity = (activity: ActivityInput) => {
|
||||
export const recordActivity = (activity: ActivityInput) => {
|
||||
import("@/lib/pocketbase/client")
|
||||
.then(async ({ pbAdmin }) => {
|
||||
await pbAdmin.authPromise;
|
||||
@@ -17,19 +23,6 @@ const recordActivity = (activity: ActivityInput) => {
|
||||
});
|
||||
};
|
||||
|
||||
const MAX_ARGUMENTS_CHARS = 2048;
|
||||
|
||||
const redactArguments = (data: unknown): unknown => {
|
||||
if (data === undefined) return undefined;
|
||||
|
||||
const redacted = redactValue(data);
|
||||
const serialized = JSON.stringify(redacted) ?? "";
|
||||
if (serialized.length > MAX_ARGUMENTS_CHARS) {
|
||||
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
|
||||
}
|
||||
return redacted;
|
||||
};
|
||||
|
||||
const auditErrorMessage = (
|
||||
error?: { code?: string; userMessage?: string }
|
||||
): string => {
|
||||
@@ -48,11 +41,32 @@ const serverFnName = (request: Request, meta?: ServerFnMeta): string => {
|
||||
return pathParts[pathParts.length - 1] || "unknown";
|
||||
};
|
||||
|
||||
// Successful reads are skipped (client page views cover usage); read failures
|
||||
// are always recorded. Naming is uniform across every feature server.ts.
|
||||
export const READ_FN_NAME = /^(get|list|search|fetch|preview|is[A-Z])/;
|
||||
|
||||
const readSampleRate = (): number => {
|
||||
const raw = Number(process.env.TELEMETRY_READ_SAMPLE);
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 1) : 0;
|
||||
};
|
||||
|
||||
const shouldRecordSuccess = (name: string): boolean => {
|
||||
if (!READ_FN_NAME.test(name)) return true;
|
||||
const sample = readSampleRate();
|
||||
return sample > 0 && Math.random() < sample;
|
||||
};
|
||||
|
||||
// Thrown Responses/redirects are control flow (session refresh 401s, route
|
||||
// redirects), not failures.
|
||||
const isControlFlowError = (error: unknown): boolean =>
|
||||
error instanceof Response || isRedirect(error);
|
||||
|
||||
export const recordDeniedServerFn = (
|
||||
request: Request,
|
||||
actor?: Actor,
|
||||
meta?: ServerFnMeta
|
||||
) => {
|
||||
markDeniedRecorded(request);
|
||||
recordActivity({
|
||||
name: serverFnName(request, meta),
|
||||
player: actor?.player_id,
|
||||
@@ -69,10 +83,15 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
||||
const request = getRequest();
|
||||
|
||||
const name = serverFnName(request, serverFnMeta);
|
||||
const playerId = (context as any)?.metadata?.player_id as string | undefined;
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const safeArgs = redactArguments(data);
|
||||
|
||||
// Registered globally, so session middleware runs inside this one; the actor
|
||||
// is stashed on the Request by getSessionContextCore and read after next().
|
||||
const resolveActor = (): string | undefined =>
|
||||
getRequestActor(request)?.playerId ??
|
||||
((context as any)?.metadata?.player_id as string | undefined);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
@@ -86,29 +105,35 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
||||
"success" in envelope &&
|
||||
(envelope as { success: boolean }).success === false;
|
||||
|
||||
recordActivity({
|
||||
name,
|
||||
player: playerId,
|
||||
duration,
|
||||
success: !failed,
|
||||
error: failed
|
||||
? auditErrorMessage(
|
||||
(envelope as { error?: { code?: string; userMessage?: string } })
|
||||
.error
|
||||
)
|
||||
: undefined,
|
||||
arguments: safeArgs,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
if (failed || shouldRecordSuccess(name)) {
|
||||
recordActivity({
|
||||
name,
|
||||
player: resolveActor(),
|
||||
duration,
|
||||
success: !failed,
|
||||
error: failed
|
||||
? auditErrorMessage(
|
||||
(envelope as { error?: { code?: string; userMessage?: string } })
|
||||
.error
|
||||
)
|
||||
: undefined,
|
||||
arguments: safeArgs,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isControlFlowError(error) || wasDeniedRecorded(request)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
recordActivity({
|
||||
name,
|
||||
player: playerId,
|
||||
player: resolveActor(),
|
||||
duration,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { PUBLIC_ROUTES } from "./supertokens";
|
||||
import { setRequestActor } from "@/lib/telemetry/request-context.server";
|
||||
import type { Player } from "@/features/players/types";
|
||||
|
||||
const logger = new Logger("Middleware");
|
||||
@@ -116,6 +117,7 @@ export const getSessionContextCore = async (
|
||||
try {
|
||||
player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId);
|
||||
if (player) {
|
||||
setRequestActor(request, { playerId: player.id });
|
||||
const now = Date.now();
|
||||
const lastWrite = lastActivityWriteAt.get(session.context.userAuthId) ?? 0;
|
||||
if (now - lastWrite > LAST_ACTIVITY_THROTTLE_MS) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { isAdminRole } from "@/features/core/utils/roles";
|
||||
import z from "zod";
|
||||
|
||||
const logger = new Logger("Middleware");
|
||||
@@ -25,6 +26,28 @@ export const superTokensRequestMiddleware = createMiddleware({
|
||||
return next({ context });
|
||||
});
|
||||
|
||||
// Session context when available, anonymous otherwise — never throws or
|
||||
// redirects. For endpoints that must accept logged-out traffic (telemetry
|
||||
// beacon: login-page errors are exactly the signal we want).
|
||||
export const superTokensOptionalRequestMiddleware = createMiddleware({
|
||||
type: "request",
|
||||
}).server(async ({ next, request }) => {
|
||||
type SessionContext = Awaited<ReturnType<typeof getSessionContext>>;
|
||||
let context: SessionContext;
|
||||
try {
|
||||
context = await getSessionContext(request);
|
||||
} catch {
|
||||
context = {
|
||||
userAuthId: null,
|
||||
roles: [],
|
||||
metadata: undefined,
|
||||
phone: undefined,
|
||||
player: null,
|
||||
} as unknown as SessionContext;
|
||||
}
|
||||
return next({ context });
|
||||
});
|
||||
|
||||
export const superTokensFunctionMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next }) => {
|
||||
@@ -58,7 +81,7 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
|
||||
try {
|
||||
const context = await getSessionContext(request, { isServerFunction: true });
|
||||
|
||||
if (context.roles?.includes("Admin")) {
|
||||
if (isAdminRole(context.roles)) {
|
||||
return next({ context });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user