QDG Knowledge Base Read-only viewer QWebHub
general

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 gates TroyenAPI is not referenced by this project. ApiKeysController and CustomerDataTemplatesController administer keys/tokens used by other consumers — they don't gate this API itself. Access must be enforced at the network layer (matches the TD-Internal site naming), not by this codebase. Verify network isolation before treating this as a safe assumption.

Routing & versioning

  • Base route: api/v{version:apiVersion}/{Controller} via BaseApiController — same shape as TroyenAPI, one version (1.0) only.
  • Inconsistencies found (worth knowing before writing a client against this API):
    • ResultController bypasses versioning entirely: api/v1/Result, no {version:apiVersion} segment, no [ApiVersion] attribute.
    • DashboardV2Controller uses api/v{version}/DashboardV2 — {version} without the :apiVersion constraint that every other controller uses.
    • SystemController.cs is declared in namespace TroyenAPI.Controllers.v1 (not TroyenDataHelpers...) with an explicit using to pull in this project's BaseApiController — an apparent copy-paste from TroyenAPI that still compiles/works but is a namespace-hygiene smell.
  • Swagger/ReDoc mounted the same way as TroyenAPI, but the ReDoc DocumentTitle is hardcoded "TroyenAPI" — a copy/paste leftover, not actually wrong functionally but misleading in the UI.
  • CORS: AllowAll. IISServerOptions/Kestrel AllowSynchronousIO = true is 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)

  1. CommonForMongo.frozenFields guard — the shared base-class implementation (Common/ScraperHelpers.cs: AddFrozenField<TObj>/RemoveFrozenField<TObj>/ViewFrozenFields<TObj>, constrained where TObj : CommonForMongo). Confirmed used on Meeting (MeetingsController.freeze-field/unfreeze-field/frozen-fields) and on Race with a fallback to Horses (RacesController.freeze-field/unfreeze-field/frozen-fields, and a check-only use in update-runner-scratch against Horses.isScratched). This is the base-class version — [[project-troyenraceingestor]] reimplements its own copy because it doesn't inherit CommonForMongo.
  2. FinalResult.isLocked — an unrelated, result-specific lock set via ResultController.save-final-results?isLocked=. RaceUpdateController.ValidateAndCreateOfficialResults respects it: on a mismatch against a locked final result it sets needsReview=true and leaves the result alone; against an unlocked one it deletes the FinalResult and reverts the race to INTERIM.

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 over InRunningComment.
  • 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 a RaceCard'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, computes resultStatusColor/frozenFields/hasSpeedMap), get-meeting?meetingId=, get-unmapped-meetings, meeting-lookup, meetings-with-missing-silks — read-only.
  • POST save-meeting/{id} — partial update from MeetingUpdateDTO; cascades isHidden to all child Races; also directly sets isLocked (meeting-level flag, distinct from frozenFields) and logs it. WRITE → meetings, races.
  • GET regenarate-bypassed-meetings — triggers DataHelpers.CopyFlagFile (file-system side effect) despite being a GET.
  • POST freeze-field/{id} / POST unfreeze-field/{id} — the frozenFields guard on Meeting. WRITE. GET frozen-fields/{id} reads it.
  • DELETE delete-meeting?meetingId= — cascading hard delete across FinalResult, RaceCards, DataDumps, LlmContent, Races, SpeedMaps, InRunningComments, then the Meeting. 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 (SaveOrGetBySlug call). Not purely read-only despite the verb.
  • POST save-race/{id} — partial update from RaceUpdateDTO (full runner-list replacement possible), syncs rClass to race cards. WRITE → races (+ racecards on 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 the Horses frozen-field guard for isScratched first and refuses (returns isLocked:true) rather than erroring if locked; otherwise WRITE → races, synced to cards/dumps.
  • POST toggle-field-lock/{id} — generic frozen-field guard on Race. WRITE.
  • POST freeze-field/{id} / unfreeze-field/{id} / GET frozen-fields/{id} — tries Race first, falls back to Horses using the same id as 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, updates Race.rStatus/isOpen/isAbandoned, unless a FinalResult already exists (implicit result-based lock, ignores incoming status). WRITE → raceStatus, races.
  • POST results-update?sourceId= — saves raw RaceResult per source, computes resultString once top-4 positions are complete (dead-heat aware), then runs ValidateAndCreateOfficialResults. WRITE → raceResults, conditionally finalResults/races.
  • POST race-update?sourceId= — pre-race updates (start time, jockey subs), audit-logged to raceUpdate, patches Race/RaceCard. WRITE.
  • POST results-update-by-date?meetingDate= / POST force-results-update?meetingDate= — backfill/recovery tools reapplying or re-resolving RaceResults. WRITE (destructive in the recovery path).
  • ValidateAndCreateOfficialResults (private): merges RAS+SRP results into a FinalResult when both agree (SRP wins IDs/names, RAS wins price/margin/dnf); on mismatch, defers to FinalResult.isLocked (see Lock mechanisms above) rather than the frozenFields guard.

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=&notes=&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.json categories: Logging, AppSettings, SilkSettings (paths to silk resources), ApiKeySettings (pass-key + AllowEmptyKey — used by ApiKeyService, not for gating this API itself).
  • launchSettings.json dev 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 for punter-cache and troyen-resources, SILK_BASE_URL/SILK_RESOURCE_PREFIX.
  • Docker: multi-stage build pulling in Common/, SilkLibrary/, TroyenModels/; copies SilkLibrary/Resources + SilkLibrary/appsettings.silk.json into the runtime image; EXPOSE 8080. Compose image ghcr.io/tpl-tech-titans/troyen-internal-api, container port map 7503:8080.
  • Secrets present in appsettings.json/launchSettings.json are not reproduced here (Mongo, S3, ApiKeySettings:PassKey) — scrub before any external sharing, rotate if ever exposed.
Updated by Claude on Aug. 11, 2026, 9:55 a.m. · Task: updatewiki create project TroyenDataHelper API doc