TroyenDataHelpers API Reference
Version 1 · Create initial TroyenDataHelpers internal admin API reference from controller source and Program.cs
TroyenDataHelpers API Reference
TroyenDataHelpers is the internal, read/write admin API behind the TroyenDataWeb admin portal — meetings, races, horses, results, silks, comments, users, system prompts, logs. 21 controllers, all flat under TroyenDataHelpers/Controllers/v1/ (no v2). Deployed to IIS TroyenDataHelpersAPI/TD-Internal, Docker image troyen-internal-api (compose maps 7503:8080). See the sibling TroyenAPI Reference for the external read-only API.
Key difference from TroyenAPI: there is no authentication in code at all. No
AddAuthentication()/UseAuthentication(), no[Authorize]/[AllowAnonymous]anywhere, and the API-key middleware (TokenAuthenticationHandler/ApiKeyMiddleware) that gatesTroyenAPIis not referenced by this project.ApiKeysControllerandCustomerDataTemplatesControlleradminister keys/tokens used by other consumers — they don't gate this API itself. Access must be enforced at the network layer (matches theTD-Internalsite naming), not by this codebase. Verify network isolation before treating this as a safe assumption.
Routing & versioning
- Base route:
api/v{version:apiVersion}/{Controller}viaBaseApiController— same shape as TroyenAPI, one version (1.0) only. - Inconsistencies found (worth knowing before writing a client against this API):
ResultControllerbypasses versioning entirely:api/v1/Result, no{version:apiVersion}segment, no[ApiVersion]attribute.DashboardV2Controllerusesapi/v{version}/DashboardV2—{version}without the:apiVersionconstraint that every other controller uses.SystemController.csis declared in namespaceTroyenAPI.Controllers.v1(notTroyenDataHelpers...) with an explicitusingto pull in this project'sBaseApiController— an apparent copy-paste fromTroyenAPIthat still compiles/works but is a namespace-hygiene smell.
- Swagger/ReDoc mounted the same way as TroyenAPI, but the ReDoc
DocumentTitleis hardcoded"TroyenAPI"— a copy/paste leftover, not actually wrong functionally but misleading in the UI. - CORS:
AllowAll.IISServerOptions/KestrelAllowSynchronousIO = trueis set (needed for some PUT handling).
Health check
GET api/v1/Monitoring/heartbeat → {Status:"Healthy", Timestamp, Version:"1.0.0"}. Note the naming difference from TroyenAPI's heart-beat (no hyphen here) — don't assume parity when wiring up monitoring.
Lock mechanisms (two independent systems)
CommonForMongo.frozenFieldsguard — the shared base-class implementation (Common/ScraperHelpers.cs:AddFrozenField<TObj>/RemoveFrozenField<TObj>/ViewFrozenFields<TObj>, constrainedwhere TObj : CommonForMongo). Confirmed used onMeeting(MeetingsController.freeze-field/unfreeze-field/frozen-fields) and onRacewith a fallback toHorses(RacesController.freeze-field/unfreeze-field/frozen-fields, and a check-only use inupdate-runner-scratchagainstHorses.isScratched). This is the base-class version — [[project-troyenraceingestor]] reimplements its own copy because it doesn't inheritCommonForMongo.FinalResult.isLocked— an unrelated, result-specific lock set viaResultController.save-final-results?isLocked=.RaceUpdateController.ValidateAndCreateOfficialResultsrespects it: on a mismatch against a locked final result it setsneedsReview=trueand leaves the result alone; against an unlocked one it deletes theFinalResultand reverts the race toINTERIM.
Don't conflate the two when reasoning about why a field won't update.
Known defect
CommonTools.GetConfig(configType) (Controllers/v1/CommonTools.cs:25-35) — the method body calls itself (return GetConfig(configType);) with no base case. Every call to GET api/v1/CommonTools/GetConfig recurses until stack overflow. This is a live bug, not a documented behavior — flagged separately, not filed as a fix here.
Controllers
AccountController — api/v1/Account
GET get-staff?email= — distinct staff usernames from userInvites. Read-only.
ApiKeysController (file: ApiTokensController.cs) — api/v1/ApiKeys
Administers client API keys (the ones TroyenAPI validates) via ApiKeyService: POST create, POST revoke/{id}, POST decrypt, GET all, PUT update/{id} (rules: countries/endpoints/caps). All non-GET are WRITE → client_api_keys.
CommentsController — api/v1/Comments
GET final-comments/GET inrunning-comments— read filters overInRunningComment.POST save-comment— WRITE →inRunningComment.POST generate-comments(multipart PDF upload) — extracts Equibase race tables, LLM-generates comments (GPT-4o-mini), saves them. WRITE.POST push-comment— pushes an LLM comment payload into aRaceCard's comments/spotlights. WRITE →racecards.
CommonTools — api/v1/CommonTools
POST decompress-json (utility); GET GetConfig?configType= — see known defect above, do not call.
CountriesController — api/v1/Countries
GET country-list; POST save-country (WRITE, create-or-update, deterministic GUID id on create); POST import-exchange-rates (bulk CSV, WRITE); POST remove-state/{id} (WRITE).
CoursesController — api/v1/Courses
GET course-list; POST add-course (WRITE, slug = {DISPLAYNAME}-{STATE|ISO}-{ISO}, uniqueness-checked); POST save-course/{id} (WRITE, direct ReplaceOne, bypassing the usual SaveOrGetBySlug helper).
CustomerDataTemplatesController — api/v1/CustomerDataTemplates
Mirrors ApiKeysController's shape for the TroyenDataWeb dashboard, delegating to TemplateTokenService: GET all, POST create, POST rotate/{id}, POST deactivate/{id}, PUT update/{id} — all non-GET are WRITE.
DashboardController — api/v1/Dashboard
Read-only reporting: race-result-summary, race-results?limit=, latest-horses, meetings-summary, status-changes, weekly-meeting-summary.
DashboardV2Controller — api/v{version}/DashboardV2
GET get-overview — one large KPI payload (upcoming races, runner counts, race-card/comment completion, breakdowns by country/class/discipline/distance, missing-field stats). Read-only. See versioning note above.
HorsesController — api/v1/Horses
GET horse-list, GET get-horse?horseId=, GET get-horse-forms?horseId=&raceId= (currency conversion on the returned blob only, no write), POST get-formlines-for-meeting; POST save-horse/{id} — partial update (name/pedigree/silk/forms). WRITE → horses. No frozen-field check on this path.
LogsController — api/v1/Logs
Read-only: bare GET (paginated apiRequestLogs), GET get-audits (paginated audit logs, meetingId filter expands to related dataDumps/finalResults/inRunningComment/raceResults/raceStatus/raceUpdate/races/speedMaps/racecards ids), GET details/{id}.
LookUpController — api/v1/LookUp
GET get-lookups; POST map-lookups?lookupId=&mappedEntityId=&mappedReferance= — sets mapping fields + mappingStatus=MAPPED. WRITE → lookups. GET get-duplicate-lookups — groups by hash, count > 1.
MeetingsController — api/v1/Meetings
GET meeting-list(heavy filtered/aggregated list, computesresultStatusColor/frozenFields/hasSpeedMap),get-meeting?meetingId=,get-unmapped-meetings,meeting-lookup,meetings-with-missing-silks— read-only.POST save-meeting/{id}— partial update fromMeetingUpdateDTO; cascadesisHiddento all childRaces; also directly setsisLocked(meeting-level flag, distinct fromfrozenFields) and logs it. WRITE →meetings,races.GET regenarate-bypassed-meetings— triggersDataHelpers.CopyFlagFile(file-system side effect) despite being a GET.POST freeze-field/{id}/POST unfreeze-field/{id}— thefrozenFieldsguard onMeeting. WRITE.GET frozen-fields/{id}reads it.DELETE delete-meeting?meetingId=— cascading hard delete acrossFinalResult,RaceCards,DataDumps,LlmContent,Races,SpeedMaps,InRunningComments, then theMeeting. Destructive WRITE across 7 collections.
MonitoringController — api/v1/Monitoring
GET status-latest?count= / result-latest?count= (max 100 each) — latest RaceStatus/RaceResult. GET heartbeat — health check (see above). GET get-pending-meetings. GET clearWebCache?raceId= / GET clearCloudflareCache — side-effecting GETs (cache invalidation only).
RacesController — api/v1/Races
GET race-list,get-race-card,get-datadump,upcoming-races,unvalidated,race-with-missing-silks,get-race-video— read-only, except:GET get-race?raceId=— writes a recomputed runner-name hash as a side effect of a GET (SaveOrGetBySlugcall). Not purely read-only despite the verb.POST save-race/{id}— partial update fromRaceUpdateDTO(full runner-list replacement possible), syncsrClassto race cards. WRITE →races(+racecardson class change).POST add-runner/{id}/DELETE remove-runner/{id}?runnerId=— sync to cards/dumps, clear cache. WRITE →races,racecards,datadumps.POST abandon-race/{id}— sets/clears abandoned status with status-recomputation on un-abandon. WRITE →races.POST update-runner-scratch— checks theHorsesfrozen-field guard forisScratchedfirst and refuses (returnsisLocked:true) rather than erroring if locked; otherwise WRITE →races, synced to cards/dumps.POST toggle-field-lock/{id}— generic frozen-field guard onRace. WRITE.POST freeze-field/{id}/unfreeze-field/{id}/GET frozen-fields/{id}— triesRacefirst, falls back toHorsesusing the sameidas either a race or horse id — a dual-purpose endpoint, don't assume the id type from the route alone.
RaceUpdateController — api/v1/RaceUpdate
Machine-to-machine ingestion from external sources RAS/SRP (via ?sourceId=), unauthenticated like everything else here:
POST status-update?sourceId=— maps external status →RaceStatus, updatesRace.rStatus/isOpen/isAbandoned, unless aFinalResultalready exists (implicit result-based lock, ignores incoming status). WRITE →raceStatus,races.POST results-update?sourceId=— saves rawRaceResultper source, computesresultStringonce top-4 positions are complete (dead-heat aware), then runsValidateAndCreateOfficialResults. WRITE →raceResults, conditionallyfinalResults/races.POST race-update?sourceId=— pre-race updates (start time, jockey subs), audit-logged toraceUpdate, patchesRace/RaceCard. WRITE.POST results-update-by-date?meetingDate=/POST force-results-update?meetingDate=— backfill/recovery tools reapplying or re-resolvingRaceResults. WRITE (destructive in the recovery path).ValidateAndCreateOfficialResults(private): mergesRAS+SRPresults into aFinalResultwhen both agree (SRP wins IDs/names, RAS wins price/margin/dnf); on mismatch, defers toFinalResult.isLocked(see Lock mechanisms above) rather than thefrozenFieldsguard.
ResultController — api/v1/Result (unversioned route, see inconsistency note above)
Read: get-results-for-meeting[s], get-final-results-for-meeting[s]. Write: POST save-final-results?raceId=&resultString=&acceptResultId=¬es=&uncertainTabs=&isLocked= — manual result confirmation (accept-by-id or dead-heat string parse, e.g. "4-5/6-8-7"; uncertain tabs → position 99); sets the FinalResult.isLocked flag. DELETE delete-race-result?raceId= / delete-srp-result / delete-ras-result — destructive deletes of FinalResult/source-specific RaceResult.
SilksController — api/v1/Silks
POST generate-silk?raceId= (body: {Runners:[{RunnerId, SilkDescription}]}) — per runner: loads Horses, derives/confirms hSilkURL, calls SilkLibrary.SilkGenerator.GenerateAndSaveSilkImage to render+persist the PNG, sets isSilkFinal=true/hSilkDescription, mirrors the URL onto the in-memory Race and every RaceCard for that race. WRITE → horses (per runner), races (once), racecards (once per card). Per-runner errors are collected, not fatal to the batch. Uses SVG pattern resources bundled under Resources/patterns/{body,cap,sleeve} + Resources/JSON/colors-v3.json + Resources/silk-v3.svg, configured via SilkSettings in appsettings.json.
SystemController — api/v1/System (namespace mismatch, see above)
GET system-logs, recent-error-count?hours=, recent-errors?hours=&limit= — read-only. POST resolve-log/{logId} / resolve-multiple-logs — WRITE → Logs.
SystemPromptsController — api/v1/SystemPrompts
GET selected-prompt?type=, prompt-list?type=&isSelected= — read-only. POST save-prompt — toggles/creates an LLMSystemPrompt, deselecting siblings of the same type. WRITE → lLMSystemPrompt (collection name casing is intentional/consistent, not a typo).
TransformationRulesController — api/v1/TransformationRules
File/service-backed (via TransformationService, not directly MongoDbHelper): GET list, GET {fingerprint} — read. POST save (body {CustomerFingerprint, Rules}) / DELETE {fingerprint} — WRITE. POST invalidate-cache.
UsersController — api/v1/Users
GET user-list?username=&email=&role= (never returns password hash/salt). POST save-user — create (uniqueness-checked, HMAC-SHA512 password hash with per-user random salt) or update. WRITE → users.
Config / deployment
appsettings.jsoncategories:Logging,AppSettings,SilkSettings(paths to silk resources),ApiKeySettings(pass-key +AllowEmptyKey— used byApiKeyService, not for gating this API itself).launchSettings.jsondev ports:5035/7255(Kestrel),8042(IIS Express). Env-var categories (not appsettings): Mongo connection string + DB name, timezone, Docker flag, S3 bucket/endpoint/access/secret forpunter-cacheandtroyen-resources,SILK_BASE_URL/SILK_RESOURCE_PREFIX.- Docker: multi-stage build pulling in
Common/,SilkLibrary/,TroyenModels/; copiesSilkLibrary/Resources+SilkLibrary/appsettings.silk.jsoninto the runtime image;EXPOSE 8080. Compose imageghcr.io/tpl-tech-titans/troyen-internal-api, container port map7503:8080. - Secrets present in
appsettings.json/launchSettings.jsonare not reproduced here (Mongo, S3,ApiKeySettings:PassKey) — scrub before any external sharing, rotate if ever exposed.