{
  "openapi": "3.1.0",
  "info": {
    "title": "GiaoAn24h Public API",
    "version": "1.0.0",
    "summary": "Vietnamese digital-education marketplace API for lesson plans, slides, teaching videos, and classroom music.",
    "description": "Public API of GiaoAn24h (giaoan24h.com), a marketplace for Vietnamese K-12 teaching resources sold as instantly downloadable files.\n\n**Scope.** This specification documents the complete third-party-relevant surface: catalog browsing, search, checkout/order polling, promotions, wallet top-up, gift codes, and media downloads, plus the service health probe. Storefront-session surfaces (`/api/v1/account/*`, `/api/v1/chat/*`, `/api/v1/support/*`, `/api/v1/theft/report`, `/api/v1/dmca/inbound`) and staff tooling (`/api/v1/admin/*`) require an interactive browser session or staff RBAC and are intentionally not part of this third-party contract.\n\n**Auth.** There is no OAuth authorization server. Reads are anonymous; writes and account-scoped reads use the HMAC-signed http-only session cookie `better-auth.session_data` obtained by signing in through the website (see `/auth.md`). Mutating requests are additionally same-origin checked.\n\n**Errors.** All catalog-domain endpoints use the envelope `{ \"error\": { \"code\", \"message\" } }` described by `ErrorResponse` (the music quota/download routes predate it and document their exact legacy shapes inline).\n\n**Versioning.** The API is URL-versioned under `/api/v1`; breaking changes ship under a new version prefix and never mutate the v1 contract in place. See the `x-versioning-policy` field below and the [developer portal](https://giaoan24h.com/developers) for the full deprecation and sunset process.\n\n**Rate limiting.** Endpoints with traffic guards (search, checkout, top-up, gift codes, promotions, media downloads) report their budget on every response with the standard RFC 9331 header fields `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. When a limit is exceeded the API returns `429 Too Many Requests` with the `ErrorResponse` envelope (`error.code` = `rate_limited`) plus a `Retry-After` header carrying the number of seconds to wait. Clients SHOULD throttle on `RateLimit-Remaining`/`RateLimit-Reset` and MUST back off for at least `Retry-After` seconds after a 429.\n\n**More docs:** [API reference](https://giaoan24h.com/api-docs) · [auth.md](https://giaoan24h.com/auth.md) · [llms.txt](https://giaoan24h.com/llms.txt) · [API catalog (RFC 9727)](https://giaoan24h.com/.well-known/api-catalog) · [Protected-resource metadata (RFC 9728)](https://giaoan24h.com/.well-known/oauth-protected-resource)",
    "externalDocs": {
      "url": "https://giaoan24h.com/api-docs",
      "description": "Human-readable API documentation"
    },
    "x-versioning-policy": {
      "scheme": "url-prefix",
      "current": "/api/v1",
      "description": "All public API contracts are versioned by URL prefix (/api/v1/...). Breaking changes ship under a new version prefix (e.g. /api/v2/...) and never mutate an existing version in place; additive, optional fields may be introduced within a version. Deprecated endpoints are announced on the developer portal (https://giaoan24h.com/developers) and flagged on every response with `Deprecation: @<unix-timestamp>` (RFC 9745) and `Sunset: <HTTP-date>` (RFC 8594) headers. A deprecated endpoint remains fully supported for at least 6 months from the first `Deprecation` header before its `Sunset` date."
    }
  },
  "servers": [
    {
      "url": "https://giaoan24h.com",
      "description": "Production"
    }
  ],
  "tags": [
    { "name": "System", "description": "Service health." },
    { "name": "Catalog", "description": "Anonymous browsing and search of teaching resources." },
    { "name": "Checkout", "description": "Order creation and status polling." },
    { "name": "Promotions", "description": "Promo-code validation." },
    { "name": "TopUp", "description": "Wallet top-up denominations, orders, promos." },
    { "name": "Wallet", "description": "Wallet balance and ledger." },
    { "name": "GiftCodes", "description": "Gift-code redemption." },
    { "name": "Media", "description": "Classroom-music downloads and token-based file delivery." }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "tags": ["System"],
        "summary": "Service health probe",
        "description": "Liveness/readiness probe covering app health, database connectivity, cache, and background worker status. Anonymous. The public body carries only the aggregate `status`; detailed per-check output is exposed only to internal/dev traffic.",
        "security": [],
        "responses": {
          "200": {
            "description": "System operational",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HealthStatus" },
                "example": { "status": "ok" }
              }
            }
          },
          "503": {
            "description": "One or more dependencies unhealthy",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HealthStatus" },
                "example": { "status": "degraded" }
              }
            }
          }
        }
      }
    },
    "/api/v1/catalog/products/{fullPath}": {
      "get": {
        "operationId": "getCatalogProduct",
        "tags": ["Catalog"],
        "summary": "Product detail",
        "description": "Full metadata for a single product identified by its category path plus slug (e.g. `toan/khoa-hoc-tu-nhien/bai-giang-dien-tu/chuyen-de-ham-so`). Anonymous; an optional user session upgrades the response with the caller's `isPurchased` flag.",
        "security": [],
        "parameters": [
          {
            "name": "fullPath",
            "in": "path",
            "required": true,
            "description": "Category path joined with the product slug, slash-separated.",
            "schema": { "type": "string", "minLength": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Product detail",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ProductDetailResponse" }
              }
            }
          },
          "308": {
            "description": "Permanent redirect - the path is a historical alias of a renamed/moved product; retry against the `Location` target."
          },
          "400": {
            "description": "Malformed path",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "404": {
            "description": "No product at this path",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/catalog/categories/{...path}": {
      "get": {
        "operationId": "listCategoryProducts",
        "tags": ["Catalog"],
        "summary": "Category listing",
        "description": "Paginated products under a category subtree, plus the resolved category and breadcrumb trail. Anonymous.",
        "security": [],
        "parameters": [
          { "name": "...path", "in": "path", "required": true, "description": "Slash-separated category path (may be empty for the root listing).", "schema": { "type": "string" } },
          { "name": "type", "in": "query", "schema": { "$ref": "#/components/schemas/ProductType" }, "description": "Filter by product type." },
          { "name": "minPriceVnd", "in": "query", "schema": { "type": "integer", "minimum": 0 }, "description": "Minimum effective price in VND." },
          { "name": "maxPriceVnd", "in": "query", "schema": { "type": "integer", "minimum": 0 }, "description": "Maximum effective price in VND." },
          { "name": "onSale", "in": "query", "schema": { "type": "boolean" }, "description": "Only products currently discounted." },
          { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["newest", "popular", "rating"] }, "description": "Sort order." },
          { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Opaque pagination cursor from a previous response." },
          { "name": "pageSize", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 12 } }
        ],
        "responses": {
          "200": {
            "description": "Category page",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CategoryProductListResponse" }
              }
            }
          },
          "400": {
            "description": "Invalid filter or pagination parameter",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/search": {
      "get": {
        "operationId": "searchProducts",
        "tags": ["Catalog"],
        "summary": "Keyword search",
        "description": "Full-text search across titles, descriptions, and tags. Anonymous but IP rate-limited; exceeding the limit returns `429` with a `Retry-After` header.",
        "security": [],
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 128 }, "description": "Search keywords (Vietnamese supported)." },
          { "name": "type", "in": "query", "schema": { "$ref": "#/components/schemas/ProductType" } },
          { "name": "categoryId", "in": "query", "schema": { "type": "string" } },
          { "name": "minPrice", "in": "query", "schema": { "type": "integer", "minimum": 0 } },
          { "name": "maxPrice", "in": "query", "schema": { "type": "integer", "minimum": 0 } },
          { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["newest", "popular", "rating"] } },
          { "name": "cursor", "in": "query", "schema": { "type": "string" } },
          { "name": "pageSize", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 24 } }
        ],
        "responses": {
          "200": {
            "description": "Matching products",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SearchResponse" }
              }
            }
          },
          "400": {
            "description": "Invalid search parameters",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Rate limit exceeded - wait for `Retry-After` seconds before retrying",
            "headers": {
              "Retry-After": { "description": "Seconds until the IP bucket resets.", "schema": { "type": "integer" } },
              "RateLimit-Limit": { "description": "Max requests allowed per window.", "schema": { "type": "integer" } },
              "RateLimit-Remaining": { "description": "Requests remaining in the current window.", "schema": { "type": "integer" } },
              "RateLimit-Reset": { "description": "Seconds until the window resets.", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/checkout": {
      "post": {
        "operationId": "createCheckoutOrder",
        "tags": ["Checkout"],
        "summary": "Create an order",
        "description": "Creates a purchase order for one product. Requires a user session, except where guest checkout with explicit consent is enabled (`guest` + `consent`). On success the response also sets a short-lived, HMAC-signed `order_access_<orderId>` cookie so guests can poll their own order. Same-origin enforcement applies to browser callers.",
        "security": [{ "sessionCookie": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateCheckoutRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Order created",
            "headers": {
              "Set-Cookie": { "description": "`order_access_<orderId>` - short-lived per-order polling grant.", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OrderResponse" }
              }
            }
          },
          "400": {
            "description": "Validation failed (unknown product, bad payment method, missing consent, ...)",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "No valid session and guest checkout unavailable",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "409": {
            "description": "Conflict - possible `error.code`: `already_owned`, `flash_sale_sold_out`, `promo_cap_reached`",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "422": {
            "description": "State violation or wallet overflow - possible `error.code`: `state_violation`, `wallet_balance_overflow`",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Rate limited",
            "headers": {
              "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds until retry is sensible." },
              "RateLimit-Limit": { "description": "Max requests allowed per window.", "schema": { "type": "integer" } },
              "RateLimit-Remaining": { "description": "Requests remaining in the current window.", "schema": { "type": "integer" } },
              "RateLimit-Reset": { "description": "Seconds until the window resets.", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "503": {
            "description": "Payment provider temporarily unavailable (`error.code`: `payment_processing`)",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      },
      "get": {
        "operationId": "listCheckoutOrders",
        "tags": ["Checkout"],
        "summary": "List the caller's orders",
        "description": "Cursor-paginated list of orders belonging to the authenticated user, newest first. Session required.",
        "security": [{ "sessionCookie": [] }],
        "parameters": [
          { "name": "cursor", "in": "query", "schema": { "type": "string" }, "description": "Opaque pagination cursor from a previous response." },
          { "name": "pageSize", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 } },
          { "name": "status", "in": "query", "schema": { "$ref": "#/components/schemas/OrderStatus" }, "description": "Filter by order status." }
        ],
        "responses": {
          "200": {
            "description": "Order page",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OrderListResponse" }
              }
            }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/checkout/{orderId}": {
      "get": {
        "operationId": "getCheckoutOrder",
        "tags": ["Checkout"],
        "summary": "Poll one order",
        "description": "Returns the current state of a single order: payment QR/reference when pending, receipt/download URL once paid. Authorized either by the owner's session cookie or by the short-lived `order_access_<orderId>` cookie issued when the order was created (guest polling). Unknown or unauthorized IDs deliberately return `404` to avoid leaking order existence.",
        "security": [{ "sessionCookie": [] }],
        "parameters": [
          { "name": "orderId", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Order identifier returned by createCheckoutOrder." }
        ],
        "responses": {
          "200": {
            "description": "Current order state",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OrderResponse" }
              }
            }
          },
          "404": {
            "description": "Order not found or caller not authorized to see it",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/promos/validate": {
      "post": {
        "operationId": "validatePromoCode",
        "tags": ["Promotions"],
        "summary": "Validate a checkout promo code",
        "description": "Checks a promo code against a specific product for the authenticated buyer and returns the computed discount without creating an order. A syntactically fine but unknown/expired/exhausted code yields HTTP 200 with `valid: false` rather than an error. Session required; rate-limited with `Retry-After` on 429.",
        "security": [{ "sessionCookie": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ValidatePromoRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Validation outcome",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PromoValidationResponse" }
              }
            }
          },
          "400": {
            "description": "Malformed code or productId",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Rate limited",
            "headers": {
              "Retry-After": { "schema": { "type": "integer" } },
              "RateLimit-Limit": { "description": "Max requests allowed per window.", "schema": { "type": "integer" } },
              "RateLimit-Remaining": { "description": "Requests remaining in the current window.", "schema": { "type": "integer" } },
              "RateLimit-Reset": { "description": "Seconds until the window resets.", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/topup/denominations": {
      "get": {
        "operationId": "listTopupDenominations",
        "tags": ["TopUp"],
        "summary": "Wallet top-up amounts",
        "description": "Active wallet top-up denominations with bonus percentages and display ordering. Anonymous.",
        "security": [],
        "responses": {
          "200": {
            "description": "Active denominations ordered by `sortOrder`",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/DenominationListResponse" }
              }
            }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/topup/orders": {
      "post": {
        "operationId": "createTopupOrder",
        "tags": ["TopUp"],
        "summary": "Create a wallet top-up order",
        "description": "Creates a top-up order for one denomination (QR payment). Session required; same-origin enforced; rate-limited with `Retry-After` on 429.",
        "security": [{ "sessionCookie": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateTopupRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Top-up order created",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TopupOrderResponse" }
              }
            }
          },
          "400": {
            "description": "Unknown denomination or malformed input",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Rate limited",
            "headers": {
              "Retry-After": { "schema": { "type": "integer" } },
              "RateLimit-Limit": { "description": "Max requests allowed per window.", "schema": { "type": "integer" } },
              "RateLimit-Remaining": { "description": "Requests remaining in the current window.", "schema": { "type": "integer" } },
              "RateLimit-Reset": { "description": "Seconds until the window resets.", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/topup/orders/{id}": {
      "get": {
        "operationId": "getTopupOrder",
        "tags": ["TopUp"],
        "summary": "Poll a wallet top-up order",
        "description": "Returns the current state of one of the caller's top-up orders. Session required.",
        "security": [{ "sessionCookie": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 } }
        ],
        "responses": {
          "200": {
            "description": "Current top-up order state",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TopupOrderResponse" }
              }
            }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "404": {
            "description": "No such top-up order for this user",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/topup/promos/validate": {
      "post": {
        "operationId": "validateTopupPromoCode",
        "tags": ["TopUp"],
        "summary": "Validate a top-up promo code",
        "description": "Checks a promo code against a top-up amount and returns the discount without creating an order. Invalid codes yield HTTP 200 with `valid: false`. Session required; rate-limited with `Retry-After` on 429.",
        "security": [{ "sessionCookie": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ValidateTopupPromoRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Validation outcome",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TopupPromoValidationResponse" }
              }
            }
          },
          "400": {
            "description": "Malformed code or amount",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Rate limited",
            "headers": {
              "Retry-After": { "schema": { "type": "integer" } },
              "RateLimit-Limit": { "description": "Max requests allowed per window.", "schema": { "type": "integer" } },
              "RateLimit-Remaining": { "description": "Requests remaining in the current window.", "schema": { "type": "integer" } },
              "RateLimit-Reset": { "description": "Seconds until the window resets.", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/wallet/balance": {
      "get": {
        "operationId": "getWalletBalance",
        "tags": ["Wallet"],
        "summary": "Wallet balance",
        "description": "Main and bonus balances (VND, serialized as strings to preserve precision) plus their total. Session required.",
        "security": [{ "sessionCookie": [] }],
        "responses": {
          "200": {
            "description": "Balances",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WalletBalanceResponse" }
              }
            }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/wallet/ledger": {
      "get": {
        "operationId": "listWalletLedger",
        "tags": ["Wallet"],
        "summary": "Wallet ledger entries",
        "description": "Cursor-paginated ledger of the caller's wallet movements (top-ups, purchases, bonuses, refunds). Session required.",
        "security": [{ "sessionCookie": [] }],
        "parameters": [
          { "name": "cursor", "in": "query", "schema": { "type": "string" } },
          { "name": "pageSize", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } }
        ],
        "responses": {
          "200": {
            "description": "Ledger page",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WalletLedgerResponse" }
              }
            }
          },
          "400": {
            "description": "Invalid pagination parameter",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/giftcode/redeem": {
      "post": {
        "operationId": "redeemGiftcode",
        "tags": ["GiftCodes"],
        "summary": "Redeem a gift code",
        "description": "Redeems a single-use gift code into the caller's wallet. Re-redeeming a used code fails without crediting. Rate-limited after repeated failures. Session required.",
        "security": [{ "sessionCookie": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "type": "object", "required": ["code"], "properties": { "code": { "type": "string", "minLength": 1, "maxLength": 64 } } }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Redeemed - credited amount and resulting balance",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/GiftcodeRedeemResponse" }
              }
            }
          },
          "400": {
            "description": "Code rejected - `error.code`: `validation_error` with reasons such as expired, exhausted, already redeemed, or zero amount",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "404": {
            "description": "Unknown code (`error.code`: `not_found`)",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Too many failed attempts (`error.code`: `rate_limited`)",
            "headers": {
              "Retry-After": { "schema": { "type": "integer" } },
              "RateLimit-Limit": { "description": "Max requests allowed per window.", "schema": { "type": "integer" } },
              "RateLimit-Remaining": { "description": "Requests remaining in the current window.", "schema": { "type": "integer" } },
              "RateLimit-Reset": { "description": "Seconds until the window resets.", "schema": { "type": "integer" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/music/quota": {
      "get": {
        "operationId": "getMusicDownloadQuota",
        "tags": ["Media"],
        "summary": "Classroom-music download quota",
        "description": "The caller's classroom-music download quota window. Session required.\n\n**Legacy error shape:** unlike other catalog routes, this route predates the shared `ErrorResponse` envelope and returns `{ \"code\": ..., \"message\": ... }` directly at the top level (codes: `unauthorized`, `service_unavailable`, `internal`).",
        "security": [{ "sessionCookie": [] }],
        "responses": {
          "200": {
            "description": "Quota window state",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MusicQuotaResponse" }
              }
            }
          },
          "401": {
            "description": "Not signed in (legacy shape: `{ code: \"unauthorized\", message }`)",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/LegacyMusicError" },
                "example": { "code": "unauthorized", "message": "Sign in to view your music quota." }
              }
            }
          },
          "500": {
            "description": "Internal error (legacy shape: `{ code: \"internal\", message }`)",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LegacyMusicError" } } }
          },
          "503": {
            "description": "Music service disabled (legacy shape: `{ code: \"service_unavailable\", message }`)",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LegacyMusicError" } } }
          }
        }
      }
    },
    "/api/v1/music/{id}/download": {
      "post": {
        "operationId": "requestMusicDownload",
        "tags": ["Media"],
        "summary": "Request a classroom-music download",
        "description": "Grants a short-lived signed URL for one music asset, consuming quota unless the caller owns the track via purchase. Session required; same-origin enforced. Quota exhaustion returns `429` with an extended body carrying `used`, `limit`, and `resetsAt`.",
        "security": [{ "sessionCookie": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Music product id." }
        ],
        "responses": {
          "200": {
            "description": "Signed URL granted",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MusicDownloadGrant" }
              }
            }
          },
          "400": {
            "description": "Malformed id",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "401": {
            "description": "Not signed in",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "403": {
            "description": "Paid track not owned by the caller",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "429": {
            "description": "Download quota exhausted",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MusicQuotaExceededError" }
              }
            }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "503": {
            "description": "Storage/signing temporarily unavailable",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    },
    "/api/v1/downloads/by-token": {
      "post": {
        "operationId": "exchangeDownloadToken",
        "tags": ["Media"],
        "summary": "Exchange a download token for a file URL",
        "description": "Turns a short-lived, HMAC-signed download token (issued after purchase or via admin grants) into a direct file URL. Authorized by the token itself, not by cookies. Tokens are single-purpose and expire quickly.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["token"],
                "properties": { "token": { "type": "string", "minLength": 1, "maxLength": 512 } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Direct file URL",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TokenDownloadResponse" }
              }
            }
          },
          "400": {
            "description": "Missing or malformed token",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "403": {
            "description": "Invalid or expired token",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          },
          "500": {
            "description": "Unexpected server error",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "sessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "better-auth.session_data",
        "description": "HMAC-signed Better Auth session cookie (http-only, 30-day TTL) obtained by signing in through the website's /api/auth/* flows - see https://giaoan24h.com/auth.md. This is not an OAuth bearer token; there is no token endpoint. Mutating requests must additionally pass the same-origin check, so non-browser clients need a browser-session handoff rather than raw credential reuse."
      }
    },
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": { "type": "string", "examples": ["validation_error", "not_found", "unauthenticated", "rate_limited", "state_violation", "already_owned", "forbidden"], "description": "Stable snake_case machine code." },
              "message": { "type": "string", "description": "Human-readable message (usually Vietnamese)." },
              "details": { "type": "object", "description": "Optional structured context (e.g. field-level reasons)." },
              "trace": { "type": "string", "description": "Internal trace id - present only outside production." },
              "request_id": { "type": "string", "description": "Request correlation id - present only in development." }
            }
          }
        }
      },
      "HealthStatus": {
        "type": "object",
        "required": ["status"],
        "properties": {
          "status": { "type": "string", "enum": ["ok", "degraded"] }
        }
      },
      "ProductType": {
        "type": "string",
        "enum": ["LESSON_PLAN", "SLIDE", "VIDEO", "MUSIC", "COMBO"]
      },
      "MoneyVnd": {
        "description": "Integer VND amount serialized as a string to preserve bigint precision.",
        "type": "string",
        "pattern": "^[0-9]+$"
      },
      "IsoDateTime": { "type": "string", "format": "date-time", "nullable": true },
      "FlashSaleInfo": {
        "type": "object",
        "nullable": true,
        "properties": {
          "active": { "type": "boolean" },
          "endTime": { "type": "string", "format": "date-time" },
          "stockLeft": { "type": "integer", "nullable": true, "description": "Null when the flash sale has no stock cap." }
        }
      },
      "ProductSummary": {
        "type": "object",
        "required": ["id", "title", "slug", "fullPath", "type", "priceVnd", "isOnSale", "ratingAvg", "ratingCount", "previewImageUrls", "tags", "createdAt"],
        "properties": {
          "id": { "type": "string" },
          "title": { "type": "string" },
          "slug": { "type": "string" },
          "fullPath": { "type": "string" },
          "type": { "$ref": "#/components/schemas/ProductType" },
          "priceVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "salePriceVnd": { "$ref": "#/components/schemas/MoneyVnd", "nullable": true },
          "isOnSale": { "type": "boolean" },
          "saleEndsAt": { "$ref": "#/components/schemas/IsoDateTime" },
          "flashSale": { "$ref": "#/components/schemas/FlashSaleInfo" },
          "ratingAvg": { "type": "number" },
          "ratingCount": { "type": "integer" },
          "coverImageUrl": { "type": "string", "nullable": true },
          "previewImageUrls": { "type": "array", "items": { "type": "string" } },
          "tags": { "type": "array", "items": { "type": "string" } },
          "createdAt": { "type": "string", "format": "date-time" }
        }
      },
      "CategoryDetail": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "slug": { "type": "string" },
          "path": { "type": "string" },
          "parent": { "type": "object", "nullable": true, "properties": { "id": { "type": "string" }, "name": { "type": "string" } } }
        }
      },
      "Breadcrumb": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "name": { "type": "string" },
          "path": { "type": "string" }
        }
      },
      "ProductDetailResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "properties": {
              "product": { "$ref": "#/components/schemas/ProductSummary" },
              "category": { "$ref": "#/components/schemas/CategoryDetail" },
              "isPurchased": { "type": "boolean", "description": "Whether the current session's user already owns the product; always false anonymously." }
            }
          },
          "requestId": { "type": "string", "description": "Present in development only." }
        }
      },
      "CategoryProductListResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/ProductSummary" } },
          "nextCursor": { "type": "string", "nullable": true },
          "hasMore": { "type": "boolean" },
          "category": { "$ref": "#/components/schemas/CategoryDetail" },
          "breadcrumbs": { "type": "array", "items": { "$ref": "#/components/schemas/Breadcrumb" } },
          "requestId": { "type": "string", "nullable": true }
        }
      },
      "SearchResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/ProductSummary" } },
          "nextCursor": { "type": "string", "nullable": true },
          "hasMore": { "type": "boolean" },
          "requestId": { "type": "string", "nullable": true }
        }
      },
      "OrderStatus": {
        "type": "string",
        "enum": ["PENDING", "PAID", "EXPIRED", "CANCELLED", "FAILED"]
      },
      "PaymentMethod": {
        "type": "string",
        "enum": ["qr_direct", "wallet", "free_claim", "cash_manual"]
      },
      "GuestBuyer": {
        "type": "object",
        "description": "Guest contact details; required together with `consent` when checking out without a session (where enabled).",
        "properties": {
          "email": { "type": "string", "format": "email" }
        }
      },
      "CreateCheckoutRequest": {
        "type": "object",
        "required": ["productId", "paymentMethod"],
        "properties": {
          "productId": { "type": "string" },
          "paymentMethod": { "$ref": "#/components/schemas/PaymentMethod" },
          "promoCode": { "type": "string", "maxLength": 64 },
          "returnTo": { "type": "string", "description": "Relative storefront URL to return to after payment." },
          "guest": { "$ref": "#/components/schemas/GuestBuyer" },
          "consent": { "type": "boolean", "description": "Explicit guest-checkout consent flag." }
        }
      },
      "Order": {
        "type": "object",
        "required": ["id", "status", "amountVnd", "paymentMethod"],
        "properties": {
          "id": { "type": "string" },
          "status": { "$ref": "#/components/schemas/OrderStatus" },
          "amountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "paymentMethod": { "$ref": "#/components/schemas/PaymentMethod" },
          "qrUrl": { "type": "string", "nullable": true, "description": "Payment QR image/data URL while awaiting payment." },
          "paymentRef": { "type": "string", "nullable": true },
          "paymentInstructions": { "type": "string", "nullable": true },
          "expiresAt": { "$ref": "#/components/schemas/IsoDateTime" },
          "paidAt": { "$ref": "#/components/schemas/IsoDateTime" },
          "receiptStatus": { "type": "string", "nullable": true, "description": "Receipt issuance state once paid." },
          "downloadUrl": { "type": "string", "nullable": true, "description": "Entitlement download link once paid." }
        }
      },
      "OrderResponse": {
        "type": "object",
        "properties": {
          "data": { "$ref": "#/components/schemas/Order" },
          "requestId": { "type": "string", "nullable": true }
        }
      },
      "OrderListResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Order" } },
          "nextCursor": { "type": "string", "nullable": true },
          "hasMore": { "type": "boolean" }
        }
      },
      "ValidatePromoRequest": {
        "type": "object",
        "required": ["code", "productId"],
        "properties": {
          "code": { "type": "string", "minLength": 3, "maxLength": 40 },
          "productId": { "type": "string" }
        }
      },
      "PromoValidationSuccess": {
        "type": "object",
        "properties": {
          "valid": { "type": "boolean", "const": true },
          "code": { "type": "string" },
          "discountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "finalPriceVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "description": { "type": "string", "nullable": true }
        }
      },
      "PromoValidationFailure": {
        "type": "object",
        "properties": {
          "valid": { "type": "boolean", "const": false },
          "reason": { "type": "string", "const": "invalid" }
        }
      },
      "PromoValidationResponse": {
        "oneOf": [
          { "$ref": "#/components/schemas/PromoValidationSuccess" },
          { "$ref": "#/components/schemas/PromoValidationFailure" }
        ]
      },
      "Denomination": {
        "type": "object",
        "required": ["id", "amountVnd", "bonusRatePct", "isActive", "sortOrder"],
        "properties": {
          "id": { "type": "string" },
          "amountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "bonusRatePct": { "type": "number", "description": "Bonus percent credited on top of the base amount." },
          "isActive": { "type": "boolean" },
          "sortOrder": { "type": "integer" },
          "tag": { "type": "string", "nullable": true, "description": "Optional display label." },
          "isPopular": { "type": "boolean" }
        }
      },
      "DenominationListResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Denomination" } }
        }
      },
      "CreateTopupRequest": {
        "type": "object",
        "required": ["denominationId"],
        "properties": {
          "denominationId": { "type": "string" },
          "promoCode": { "type": "string", "maxLength": 40 },
          "returnTo": { "type": "string" }
        }
      },
      "TopupOrderStatus": {
        "type": "string",
        "enum": ["PENDING", "PAID", "EXPIRED", "FAILED"]
      },
      "TopupOrder": {
        "type": "object",
        "required": ["id", "status", "amountVnd", "totalAmountVnd", "paymentMethod"],
        "properties": {
          "id": { "type": "string" },
          "status": { "$ref": "#/components/schemas/TopupOrderStatus" },
          "amountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "bonusVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "promoDiscountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "totalAmountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "paymentMethod": { "$ref": "#/components/schemas/PaymentMethod" },
          "qrUrl": { "type": "string", "nullable": true },
          "expiresAt": { "$ref": "#/components/schemas/IsoDateTime" }
        }
      },
      "TopupOrderResponse": {
        "type": "object",
        "properties": {
          "data": { "$ref": "#/components/schemas/TopupOrder" }
        }
      },
      "ValidateTopupPromoRequest": {
        "type": "object",
        "required": ["code", "amountVnd"],
        "properties": {
          "code": { "type": "string", "minLength": 3, "maxLength": 40 },
          "amountVnd": { "type": "integer", "minimum": 0 }
        }
      },
      "TopupPromoValidationSuccess": {
        "type": "object",
        "properties": {
          "valid": { "type": "boolean", "const": true },
          "discountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "finalAmountVnd": { "$ref": "#/components/schemas/MoneyVnd" },
          "description": { "type": "string", "nullable": true }
        }
      },
      "TopupPromoValidationFailure": {
        "type": "object",
        "properties": {
          "valid": { "type": "boolean", "const": false },
          "reason": { "type": "string", "const": "invalid" }
        }
      },
      "TopupPromoValidationResponse": {
        "oneOf": [
          { "$ref": "#/components/schemas/TopupPromoValidationSuccess" },
          { "$ref": "#/components/schemas/TopupPromoValidationFailure" }
        ]
      },
      "WalletBalanceResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "required": ["main", "bonus", "total"],
            "properties": {
              "main": { "$ref": "#/components/schemas/MoneyVnd" },
              "bonus": { "$ref": "#/components/schemas/MoneyVnd" },
              "total": { "$ref": "#/components/schemas/MoneyVnd" }
            }
          }
        }
      },
      "WalletLedgerEntry": {
        "type": "object",
        "required": ["entryType", "direction", "amount", "balanceAfter", "createdAt"],
        "properties": {
          "entryType": { "type": "string", "enum": ["TOPUP", "PURCHASE", "BONUS", "REFUND", "ADJUSTMENT"] },
          "direction": { "type": "string", "enum": ["CREDIT", "DEBIT"] },
          "amount": { "$ref": "#/components/schemas/MoneyVnd" },
          "balanceAfter": { "$ref": "#/components/schemas/MoneyVnd" },
          "refType": { "type": "string", "nullable": true },
          "refId": { "type": "string", "nullable": true },
          "note": { "type": "string", "nullable": true },
          "createdAt": { "type": "string", "format": "date-time" }
        }
      },
      "WalletLedgerResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/WalletLedgerEntry" } },
          "nextCursor": { "type": "string", "nullable": true },
          "hasMore": { "type": "boolean" }
        }
      },
      "GiftcodeRedeemResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "required": ["xuAmount", "newBalance"],
            "properties": {
              "xuAmount": { "$ref": "#/components/schemas/MoneyVnd", "description": "Credited amount." },
              "newBalance": { "$ref": "#/components/schemas/MoneyVnd" }
            }
          }
        }
      },
      "MusicQuotaResponse": {
        "type": "object",
        "required": ["isVip", "used", "limit", "resetsAt"],
        "properties": {
          "isVip": { "type": "boolean" },
          "used": { "type": "integer", "minimum": 0 },
          "limit": { "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "string", "const": "unlimited" }] },
          "resetsAt": { "type": "string", "format": "date-time" }
        }
      },
      "LegacyMusicError": {
        "type": "object",
        "description": "Pre-catalog legacy error shape used by the music routes: flat `{ code, message }`.",
        "required": ["code", "message"],
        "properties": {
          "code": { "type": "string", "enum": ["unauthorized", "service_unavailable", "internal", "validation_error", "quota_exceeded", "not_found"] },
          "message": { "type": "string" }
        }
      },
      "MusicDownloadGrant": {
        "type": "object",
        "required": ["signedUrl", "expiresAt"],
        "properties": {
          "signedUrl": { "type": "string", "description": "Short-lived signed storage URL." },
          "expiresAt": { "type": "string", "format": "date-time" },
          "logId": { "type": "string", "description": "Download-log entry id." }
        }
      },
      "MusicQuotaExceededError": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message", "used", "limit", "resetsAt"],
            "properties": {
              "code": { "type": "string", "const": "quota_exceeded" },
              "message": { "type": "string" },
              "used": { "type": "integer", "minimum": 0 },
              "limit": { "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "string", "const": "unlimited" }] },
              "resetsAt": { "type": "string", "format": "date-time" }
            }
          }
        }
      },
      "TokenDownloadResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "required": ["url", "expiresIn"],
            "properties": {
              "url": { "type": "string" },
              "expiresIn": { "type": "integer", "description": "Seconds until the URL expires." }
            }
          }
        }
      }
    }
  }
}
