Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion PROJECT_SNAPSHOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
> architecture, database structure, APIs, business rules, infrastructure, or major workflows
> change significantly — see the "Living documentation" section of [`CLAUDE.md`](CLAUDE.md).
>
> Last updated: 2026-07-10, branch `feature/subscription-quotas`.
> Last updated: 2026-07-13, branch `feature/content-delivery-commissions`.

## What this system is

Expand Down Expand Up @@ -119,6 +119,37 @@ be treated as "someone already fixed this."
pure passthroughs (gama-api never returns a token for those flows). Entirely temporary — this
whole bridge, plus the
pre-existing `tokens/old`, is meant to be deleted once the frontend fully migrates off gama-api.
- **Content delivery & owner commissions added** (2026-07-13 — see
[`docs/business/content-delivery.md`](docs/business/content-delivery.md)): new `POST downloads`
resolves a download URL from one of gama-api's three legacy endpoints, selected by a new,
dedicated `DownloadContentType` enum (exactly `PastPaper` → `/tests/download`, `Multimedia` →
`/files/download`, `Exam` → `/exams/download`) — deliberately separate from the broader
`ContentType` (which also has a `Test` member relevant only to the unrelated `games/spends`
endpoint), so this feature's Swagger schema only ever advertises the 3 values it actually
supports, via a new `IContentDeliveryProvider`/`ContentSource`-keyed provider, mirroring the
payment-gateway provider pattern. Only `PastPaper` reports a price/owner — that charges the
existing quota-then-points path only if gama-api hasn't already marked the download as paid, and,
only if that charge succeeds, accrues a commission to the content's owner (resolved from
gama-api's `CoreId`) in a new `ContentOwnerCommission` ledger, deliberately separate from both the
points wallet and subscription quota. `Multimedia`/`Exam` report neither, so they're
unconditionally free through this endpoint. Commission percent and a payout-eligibility threshold
are admin-configurable via `ApplicationSettings`; the points-to-USD rate is a fixed first-phase
constant (100 points = $1). Payout itself (crossing the threshold) is explicitly out of scope for
this phase — no payout mechanism or paid-status column exists yet.
earlier revision of this bridge would have inherited/widened. Trade-off: `tokens/revoke` (this
backend's own store) can't touch a legacy-bridge session, since JWTs are stateless here — use
the bridge's own `GET logout` instead (added 2026-07-13, see below) to end one early. Session
lifetime is otherwise governed by gama-api's own token expiry, not this app's configurable token
lifespan. `register`/`recovery` are pure passthroughs (gama-api never returns a token for those
flows). Entirely temporary — this whole bridge, plus the pre-existing `tokens/old`, is meant to
be deleted once the frontend fully migrates off gama-api.
- **Legacy-auth bridge logout added** (2026-07-13 — see
[`docs/api/authentication.md`](docs/api/authentication.md)'s "Legacy-auth bridge" section):
`GET legacy-auth/logout` proxies gama-api's own `GET /users/logout` (`Core:Logout` config,
bearer-auth), relaying the caller's raw legacy JWT straight from the `Authorization` header. Pure
passthrough like `register`/`recovery` — this backend never stored the token, so gama-api is the
one actually invalidating the session; this is the one legacy-bridge operation that *does* end a
session early, closing the gap called out in the entry above.
- **Quota-based subscription system built** (2026-07-10, phase 1 — see
[`docs/business/subscriptions.md`](docs/business/subscriptions.md)): `SubscriptionPlan` no
longer carries a price — pricing moved to `SubscriptionPlanPrice` (regional-pricing-ready,
Expand All @@ -135,6 +166,13 @@ be treated as "someone already fixed this."
to `[url]text` — instead of `HtmlBody`, the actual message. Every inbound HTML email (the normal
case for anyone using a real email client) arrived in the ticket system already mangled. Now takes
`HtmlBody`, falling back to `TextBody` only when the sender's email genuinely had no HTML part.
- **Legacy-auth bridge forwards the real client IP to gama-api** (2026-07-17 — see
[`docs/api/authentication.md`](docs/api/authentication.md)'s "Legacy-auth bridge" section):
`login`/`google`/`register`/`recovery` were proxied straight through, so gama-api's own
rate-limiting/fraud checks only ever saw this server's IP, never the actual end user's.
`IdentityService` now reads the caller's IP off the inbound request and `CoreProvider` sends it as
a `TRUSTED_FORWARDED_IP` header on those four outgoing calls (`logout` unaffected — gama-api didn't
ask for it there).

## Documentation completeness

Expand Down
22 changes: 20 additions & 2 deletions docs/api/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ alongside `tokens/old` above — once the frontend fully migrates.
(`type`: `request`/`resend_code`/`confirm`/final), and neither ever returns a token at any step
(`{"status":1,"data":{"message":"done"}}` even on the final step) — the frontend calls `login`
afterward to actually get a session, which is where sync happens.
- **`login`/`google`/`register`/`recovery` forward the caller's real IP to gama-api as
`TRUSTED_FORWARDED_IP`.** Since this backend proxies the request, gama-api's own
rate-limiting/fraud checks would otherwise only ever see this server's IP, never the end user's.
`IdentityService` reads the client IP off the inbound request (`HttpContext.GetClientIpAddress()`
— trusts an existing `X-Forwarded-For` header if present, else the raw connection IP) and sets it
on `LegacyLoginRequestDto`/`LegacyGoogleAuthRequestDto`/`LegacyOtpFlowRequestDto` before calling
`ICoreProvider`; `CoreProvider` adds it as a `TRUSTED_FORWARDED_IP` header on the outgoing gama-api
call (`Constants.TrustedForwardedIp`). `logout` doesn't send it — gama-api didn't ask for it there.
- `GET logout` proxies gama-api's `GET /users/logout` (`ICoreProvider.LegacyLogoutAsync`,
`Core:Logout` config) as a **pure passthrough** — same shape as register/recovery. The caller's
raw legacy JWT is read straight from the incoming `Authorization` header
(`TokenAuthenticationHandler.GetTokenFromHeader`) and relayed unchanged as gama-api's own
`bearerAuth`; this backend never stored the token, so there's nothing local to update — gama-api
is the one that actually invalidates the session server-side. This is the one legacy-bridge
operation that **does** end a session early, unlike the trade-off described below for
`tokens/revoke`.

**Why no wrapping.** The natural design would be to mint a gamatrain-back token and hand back some
combination of the two. Instead, gamatrain-back adapts to gama-api's token instead of the other way
Expand Down Expand Up @@ -153,8 +169,10 @@ session):
`IdentityOptions:Tokens:ApiDataProtectorTokenProviderOptions:TokenLifespan` that governs normal
opaque-token sessions.
- **`tokens/revoke` cannot end a legacy-bridge session early.** JWTs are self-contained/stateless —
there is no server-side store to invalidate. This only affects sessions started via
`legacy-auth/login`/`google`; native opaque-token sessions revoke exactly as before.
there is no server-side store *here* to invalidate. This only affects sessions started via
`legacy-auth/login`/`google`; native opaque-token sessions revoke exactly as before. Use
**`GET legacy-auth/logout`** instead for a legacy-bridge session — it proxies gama-api's own
logout, which does hold server-side state on gama-api's side even though this backend doesn't.

**Revocation** — `POST /api/v1/identities/tokens/revoke` (`[Permission(policy: null)]`, i.e.
requires being authenticated first) invalidates the current token
Expand Down
8 changes: 8 additions & 0 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ string is parsed internally instead) — when `CoreId`, `id` is resolved against
| PATCH | `users/{id:long}/subscriptions/toggle` | Toggle subscription to a user's activity feed | User | route: `id` + query `idType` | `bool` |
| POST | `status` | Bulk-check whether the current user follows each of a list of users (by `Id` or `CoreId`, one `idType` per request) — for "Follow"/"Following" button state, avoids duplicate follow requests | User | `ConnectionStatusRequestViewModel` (body) | `IEnumerable<ConnectionStatusResponseViewModel>` |

### DownloadsController
`src/Presentation/Api/Controllers/DownloadsController.cs` — per-action `[Permission(policy: null)]` (User). Resolves downloadable content from external sources (gama-api's legacy PastPaper/Multimedia/Exam content today) and combines the source lookup, the downloader's charge, and the content owner's commission accrual into one call — see `docs/business/content-delivery.md`.

| Verb | Route | Purpose | Auth | Request model | Response model |
|---|---|---|---|---|---|
| POST | `` | Resolve a download URL; `contentType` (`DownloadContentType`: `PastPaper`/`Multimedia`/`Exam` only — a dedicated 3-member enum, not the broader `ContentType` used elsewhere) selects which gama-api endpoint is called. `PastPaper` requires `fileType` (`pdf`/`word`/`answer`/`extra`, `extraId` only for `extra`) and charges the downloader (quota-then-points) unless gama-api reports the download as already paid, accruing owner commission on success; `Multimedia`/`Exam` are unconditionally free (gama-api reports no price for either) | User (requires the caller's `Authorization` header to carry their gama-api legacy JWT — see `docs/api/authentication.md`) | `DownloadContentRequestViewModel` (body) | `DownloadContentResponseViewModel` |

### ExamsController
`src/Presentation/Api/Controllers/ExamsController.cs` — class-level `[Permission(policy: null)]` (User). **Deviation:** no `[ApiVersion]` attribute (only `[ApiController]`).

Expand Down Expand Up @@ -168,6 +175,7 @@ string is parsed internally instead) — when `CoreId`, `id` is resolved against
| POST | `google` | Proxy gama-api googleAuth; same sync behavior as `login` | Anonymous | `LegacyGoogleAuthRequestViewModel` (body) | `LegacyAuthTokenResponseViewModel` |
| POST | `register` | Pure passthrough to gama-api register (multi-step OTP); no local sync, no token | Anonymous | `LegacyOtpFlowRequestViewModel` (body) | `LegacyMessageResponseViewModel` |
| POST | `recovery` | Pure passthrough to gama-api recovery/reset-password (multi-step OTP); no local sync, no token | Anonymous | `LegacyOtpFlowRequestViewModel` (body) | `LegacyMessageResponseViewModel` |
| GET | `logout` | Pure passthrough to gama-api's `GET /users/logout`; relays the caller's raw legacy JWT from the `Authorization` header. No local state to update | Anonymous (token supplied via header, validated by gama-api itself) | none (`Authorization: Bearer {gama-api JWT}` header) | `Void` |

### LanguagesController
`src/Presentation/Api/Controllers/LanguagesController.cs` — class-level `[Permission(policy: null)]` + `[AllowAnonymous]` (whole controller anonymous)
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/design-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ config).
`src/Infrastructure/Infrastructure/Provider/PaymentGateway/GamaTrainPaymentGatewayProvider.cs` (Solana) and
`.../StripePaymentGatewayProvider.cs`, keyed by the smart enum `src/Domain/Enumeration/PaymentGateway.cs`.
- Other provider families follow the same shape: `Provider/Email/`, `Provider/File/` (Local/Azure/S3),
`Provider/Captcha/`, `Provider/CurrencyConverter/`, `Provider/Authentication/` (Google OAuth), all under
`Provider/Captcha/`, `Provider/CurrencyConverter/`, `Provider/Authentication/` (Google OAuth),
`Provider/ContentDelivery/` (currently one implementation, `GamaApiContentDeliveryProvider`, keyed
by `ContentSource` — see `docs/business/content-delivery.md`), all under
`src/Infrastructure/Infrastructure/Provider/`.

**Rule:** when adding a new external integration with multiple possible backends, define
Expand Down
Loading
Loading