Appearance
Collectibles, trades and ownership
8. Collectibles
Source: routes/v1/usercollectible_route.ts → usercollectible_controller.ts
Two of these look interchangeable and are not. 8.1 is the catalogue: every available, non-archived collectible the brand has, each carrying a user_collectibles[] array that is empty when the customer does not own it — which is what lets the vault draw locked cards beside owned ones. 8.2 is the holding: only rows the customer owns, and only those with an APPROVED status. A trade is keyed by the id of an owned row, so anything that offers a card must read 8.2; anything that draws a collection can read 8.1.
| # | Endpoint | Auth | Handler | Params |
|---|---|---|---|---|
| 8.1 | GET /api/v1/brand/mobile/collectibles | Customer | getAllCollectible | query tag — the brand's full collectible catalogue, annotated with what the user owns |
| 8.2 | GET /api/v1/mobile/user/collectibles | Customer | getAllMobileUserCollectible | query tag — only what the user owns |
| 8.3 | GET /api/v1/mobile/user/collectible/:id | Customer | getUserCollectibleById | path id (number) ✅ — id of the user_collectible row |
| 8.4 | GET /api/v1/mobile/brand/collectible/:id | Brand | getCollectibleById | path id (number) ✅ — id of the collectible definition |
| 8.5 | GET /api/v1/brand/mobile/user/collectibles/special-assets | Customer | getAllUserCollectiblesWithSplAssets | none |
| 8.6 | GET /api/v1/mobile/open/user/collectible/:user_id | Brand | get_all_usercollectibles_open | path user_id (number) ✅, query tag — public profile view of another user's collection |
⚠️ 8.6 fails rather than empties. When the target customer has show_collections_for_trade off it throws "User not allowed to show collectibles for trade", which handleCatch returns as HTTP 200 with success: false. A client that reads only result cannot tell a hidden collection from an empty one.
8.7 Collectible leaderboards
GET /api/v1/mobile/user/collectibles/leaderboards— BrandGET /api/v1/user/mobile/user/collectibles/leaderboards— Customer (adds the caller's own rank)
Handler: get_collectibles_leaderboards (one handler, two mounts). Query: campaignId (number, optional).
campaignId does not just filter — it changes what is counted:
Without campaignId | With campaignId | |
|---|---|---|
| Set counted | every collectible row the customer holds | only the collectibles named by that campaign's steps |
collectibles_count | row count, duplicates included | distinct collectible_id count |
total_collectibles_count | absent | the size of the campaign's set — the denominator in a 3 / 5 |
The campaign's set is read off its steps: each entry in the campaign's rewards[] whose detail.constraints[] carries restriction: "collectible_id" contributes its value. A campaign with no steps is rejected with "No rewards found for the given campaign" rather than returning an empty board.
Rows are ranked by count descending, then by name. The customer mount marks the caller with current_user: true and keeps that row in the response even when it falls outside the top of the list.
9. Trades
Source: routes/v1/trade_route.ts → trade_controller.ts · Auth: Customer on all.
Four types, and the difference that matters is not open versus direct but whether anything is asked in return:
type | Names a recipient | Asks for a card back | Where it lands |
|---|---|---|---|
open_trade | no | yes | the board, status: open |
direct_trade | receiverUserId ✅ | yes | that person's hub, status: open |
open_gift | no | no | the board, status: open — first claim wins |
direct_gift | receiverUserId ✅ | no | nowhere. It completes at creation |
status is its own vocabulary — open, inprogress, completed, expired, rejected, cancelled — and is never sent by a client. See the enum reference.
Two ids, easily confused. user_collectible_id and receiving_usercollectible_id are rows someone owns. The entries in desired_collectible_ids are definitions, keyed by collectible_id. A reply is matched by comparing the offered row's collectible_id against that list, so any copy of a wanted collectible satisfies it.
open_to_trade gates the caller, on both create and reply: with it off, either answers "User Not Open To Trade". It is read off the person making the call, so it stops you trading rather than hiding you from others.
9.1 POST /api/v1/brand/mobile/trade — create
Handler: create_trade · Body payload:
| field | type | req | notes |
|---|---|---|---|
type | enum | ✅ | tradeTypes: open_trade, direct_trade, open_gift, direct_gift |
uid | string | ✅ | client-generated trade uid |
user_collectible_id | number | ✅ | the item you are offering |
desired_collectible_ids | array | conditional | required for open_trade/direct_trade, forbidden for gifts. Items: { name ✅, collectible_id ✅, asset ✅ } |
receiverUserId | number | conditional | required for direct_trade/direct_gift, forbidden for open ones |
expiration | ISO date | — | absent means the listing never lapses |
A direct_gift is completed by this call: the collectible moves to receiverUserId immediately, any other open trade offering the same user_collectible_id is marked expired, and the response comes back with status: "completed". Every other type is created open.
One card, one listing: a second open trade on a user_collectible_id that already has one is refused with "Same User Collectible Already Exists for Trade with open status".
9.2 GET /api/v1/brand/mobile/trades — the open-trade board
Handler: get_all_open_trades · Query: search (string, "" allowed), limit (>0) and offset (≥0) — limit and offset must be sent together (.and).
Returns open_gift and open_trade rows only, at status: open, whose expiration is null or still in the future, newest-updated first. Each row carries the offered user_collectible (with its collectible's name and description) and the owner's username and user_profile.
search is one term matched three ways: the trade uid, the collectible name, and the owner's username.
⚠️ This
GETmutates. Before it selects anything it sweeps every open trade past itsexpirationintoexpired, inside the same transaction. A listing expires because somebody loaded the board, not on a schedule — so on a quiet brand, expired listings persist until the next read.
There is no type filter. The validator accepts
search,limitandoffsetand nothing else, so a board that offers "open trade / open gift / all" is filtering client-side, over the page it already holds. Combined withlimit/offsetthat hides rows instead of fetching them.
9.3 GET /api/v1/brand/mobile/trade/:trade_id
Handler: get_trade_by_id · Path: trade_id (number) ✅.
9.4 GET /api/v1/brand/mobile/trade-logs
Handler: get_all_trade_logs · Query — both required:
participant✅ —sender|receiver|bothtype✅ —active|completed|history
type selects a set of statuses, and the same set is applied to the parent trade as well as the log row, so a log only appears when its trade agrees:
type | Statuses | Then |
|---|---|---|
active | inprogress, open, rejected | drops direct_gift rows that are rejected |
history | completed, expired, rejected, cancelled | drops every rejected row that is not a direct_gift |
completed | completed | — |
⚠️ The second filter is easy to miss: a rejected
open_tradeordirect_tradeappears underactive, never underhistory. Do not build a "past trades" screen onhistoryalone and expect rejections in it.
Soft-deleted rows are included (paranoid: false), which is how a cancelled listing still shows up. There is no completed-trades counter endpoint — the number on the hub is the length of participant=both&type=completed.
9.5 DELETE /api/v1/brand/mobile/trade/:trade_id — take a listing down
Handler: delete_trade · Path: trade_id (number) ✅.
Only the owner, only at status: open, and only for open_trade, open_gift or direct_trade — a direct_gift completed at creation and cannot be recalled. Anything else is "Invalid Open Trade". The row is set to cancelled and soft-deleted rather than removed.
9.6 POST /api/v1/brand/mobile/trade/reply — claim, offer, accept, reject
Handler: reply_on_trade · Body payload:
| field | type | req | notes |
|---|---|---|---|
trade_id | number | ✅ | |
toUserId | number | ✅ | trade owner |
type | enum | ✅ | tradeTypes |
receiving_usercollectible_id | number | conditional | required for open_trade/direct_trade; forbidden when reject_trade: true or for gift types |
accept_offer | boolean | — | completes only when the caller is the trade's owner |
reject_trade | boolean | — | cannot be sent together with accept_offer |
One endpoint, four outcomes. Which one you get is decided by the trade's type and by whether the card you offered is on the wanted list — not by a field you set:
| Situation | Result | current_trade_log_status |
|---|---|---|
type is open_gift | the collectible transfers; the trade completes | completed |
reject_trade: true | logs a rejection, and notifies | rejected |
Offered card's collectible_id is in desired_collectible_ids | both collectibles transfer; the trade completes | completed |
| Anything else | a counter offer is recorded; nothing moves | inprogress |
Read current_trade_log_status off the response — it is how a client tells an instant swap from an offer that is now waiting.
Further constraints, all enforced server-side:
- A
direct_giftcan never be replied to; it is excluded from the lookup. - The trade must be
openorinprogress, and the caller must be either its owner or thetoUserIdnamed. - Both collectibles must be
APPROVEDand held by the side offering them. - Rejecting flips the trade to
rejectedonly fordirect_trade. Reject an offer on anopen_tradeand the listing stays open for the next person. - A completed transfer sets the collectible's
sourcetotransfer.
Notifications fire per outcome: TRADE_INIT when a direct_trade is created, TRADE_COUNTER on a counter, TRADE_REJECTED on a rejection, and COLLECTIBLE_REDACTED when a direct_gift hands a card over.
10. Product ownership
Source: routes/v1/productownership_route.ts → productownership_controller.ts · Auth: Customer on all.
| # | Endpoint | Handler | Body payload / params |
|---|---|---|---|
| 10.1 | POST /api/v1/mobile/request/product/ownership | requestProductOwnership | brl_id (number) ✅ — claim a product someone else already registered |
| 10.2 | GET /api/v1/mobile/product/ownership/:id | getProductOwnershipById | path id (number) ✅ |
| 10.3 | POST /api/v1/mobile/approve-reject/product/ownership | approveOrRejectProductOwnership | id (number) ✅ + exactly one (xor) of approve / reject (booleans) |
| 10.4 | POST /api/v1/mobile/initiate/transfer/product/ownership | initiateProductTransfer | brl_id (number) ✅, email (email) ✅ — sends an OTP to the recipient |
| 10.5 | POST /api/v1/mobile/transfer/product/ownership | transferProductOwnership | id (number) ✅, otp (string) ✅ — completes 10.4 |