---
title: Share scoped selections of wagers
type: task
status: completed
description: >-
  Let a wager owner share chosen weeks, games or tickets through an unguessable link that opens only
  that subset, whatever their wager visibility, with its own amount choice and owner revoke.
aliases:
  - ████████████████████-█████-██████-████████
base_uri: user:task/league/share-scoped-wager-selections.md
created_at: '2026-09-28T01:40:15.966Z'
entity_id: 60b509ed-d2e0-410a-89f9-5cc38982dc6a
finished_at: '2026-10-12T02:36:41.811Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
started_at: '2026-10-11T21:15:13.504Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-10-12T03:31:43.507Z'
visibility_analyzed_at: '2026-11-05T23:50:24.439Z'
---

# Implementation Plan: Share scoped selections of wagers

## Overview

- Let the owner share a chosen subset of their wagers — whole weeks, games, or hand-picked tickets — through an unguessable link that opens only that subset.
- The link works for anyone holding it, signed in or not, whatever the owner's `wager_visibility` says. The owner's history can stay `owner_only`.
- Impact:
  - A week's round robins or one game's exposure can be shown to someone without publishing the whole betting history.
  - The `public` level, shipped 2026-10-11, remains the way to open everything. This adds the narrow alternative.
- Supersedes the earlier saved-wagers-views plan, by operator ruling 2026-10-11. A share is the saved, owner-bound selection. A separate saved analytics view (`user_wagers_views`) waits until something needs it.

## Background

- **Access today is all-or-nothing per owner.** `resolve_wager_viewer_access` (`libs-server/wager-visibility.mjs`) admits a viewer to an owner's whole history or to none of it. Its call sites are:
  - the ticket routes in `api/routes/wagers.mjs`;
  - the exposure engine, `libs-server/wagers-view/get-wagers-view-results.mjs`;
  - the record engine, `libs-server/wager-record/get-wager-record-results.mjs`.
- **The capability-share precedent is `view_generation_shares`.**
  - Its `share_id` is 16 hex characters from `gen_random_bytes(8)`, held to that format by a CHECK constraint.
  - Create is owner-only and idempotent, the read is anonymous, and delete is a hard delete by the sharer.
  - It is mounted ahead of the auth guard, and the table is a reader-role exclusion.
  - Its route is `api/routes/view-generation-shares.mjs` and its page is `/s/:share_id`.
- **How each kind of scope is identified.**
  - Week: `placed_wagers.wager_nfl_week_id`, which is indexed. A wager is filed under exactly one week: its earliest leg's game week (`resolve-wager-nfl-week-id.mjs`).
  - Game: `placed_wager_legs.esbid`, which is indexed and nullable. One wager can span several games.
  - Ticket: `wager_id`, which survives re-import because the upsert keys on `book_wager_id`.
- **The wagers page selects by time window only.** `GET /api/wagers/:user_id/active` takes open wagers placed within 7 days, plus wagers with a leg in a game within 36 hours. Grading runs through `grade_wager_legs`, and per-viewer projection through `project_wager_for_viewer`.
- **The page modules a share page can reuse** are `WagerCard`, `SetParlays` and `group_wagers_by_state`, from `app/views/pages/wagers/` and `app/core/wagers/`. The set-parlay files belong to the set-parlay lane ([[████████████████████████-███-███████-██-██████-███████]]), so coordinate before editing them.
- **Earlier ideas this plan drops.** A `group_by` field was superseded by `row_grain`. `user_wagers_views` and a `wager_id` pin filter in the view engines were never built and are not needed here.

## Design

- **Table `wager_shares`**, domain `platform`, like `view_generation_shares`. Columns:
  - `share_id`: text primary key, defaulting to `encode(gen_random_bytes(8), 'hex')`, with a CHECK holding it to 16 hex characters.
  - `user_id`: the owner, referencing `users`.
  - `share_title`: varchar, nullable.
  - `scope_nfl_week_ids`: a varchar(20) array.
  - `scope_esbid_list`: a bigint array.
  - `scope_wager_ids`: an integer array.
  - `is_amount_visible`: boolean, not null, default false.
  - `created_at`: timestamptz.
  - A CHECK that at least one scope array is non-empty. Write it with `coalesce(array_length(...), 0) > 0`, since a CHECK that evaluates to NULL passes.
- **Scope rule, which is a union.** A wager is in scope when it belongs to the share's owner and any of these holds:
  - its week is listed;
  - any of its legs is in a listed game;
  - its id is listed.
  - The rule lives in one helper, `libs-server/wager-shares.mjs`, as a knex where-builder. Every reader uses that helper.
- **Live, not frozen.** The share stores the definition, so each read grades the current rows.
- **Access.**
  - A share admits exactly its scope.
  - `can_view_amounts` comes from `is_amount_visible`, never from the owner's settings.
  - `book_wager_id` is never sent to anyone but the owner.
  - An unknown or deleted `share_id` answers the same 404 as a refused history.
  - The share does not widen any other route. `/api/wagers/:user_id`, the record and the exposure engines keep their current rule.
- **Routes**, in `api/routes/wager-shares.mjs`, mounted ahead of the guard. Write each condition so that absent auth falls into the restricted branch.
  - `POST /`: signed in only. Creates a share for the caller and validates the scope arrays. A scope matching none of the caller's wagers is refused.
  - `GET /`: the caller's own shares, with the count of wagers in each scope.
  - `DELETE /:share_id`: the owner only. A hard delete.
  - `GET /:share_id`: anonymous. Returns the title, the owner's username, `is_amount_visible`, and the scoped wagers, graded and projected with the share's amount choice.
    - Rows are capped, matching the exposure engine's 2000-wager guard, and the response says so when the cap cuts it off.
  - `GET /options`: the caller's weeks that hold wagers and, per week, the games their legs touch, for the pickers.
- **Pages.**
  - `/m/:username/wagers/:share_id` renders the shared subset.
    - Reused: the title, the set parlays, then the wager cards from the wagers page.
    - Omitted: no record line and no Record link, because the record view covers the whole history.
    - If the username does not match the share's owner, the page shows not available rather than a misleading name.
    - Its `libs-shared/page-routes.mjs` entry is private robots with a generic title.
  - On the owner's `/wagers`, a "Share a selection" control beside Copy link opens a panel with:
    - week and game checkboxes from `GET /options`;
    - "these wagers", for ticked cards;
    - a title field and an amounts toggle.
    - On create it copies the new link.
  - Wager-card checkboxes need a hunk in the set-parlay lane's `wager-card.js`. Agree it with that lane first.
  - Settings gains a "Shared selections" list: title, scope summary, link copy, and revoke.
- **Registrations.** As a new table it needs:
  - `scripts/dev-fixture-projection.json`;
  - `db_user_tables` in `scripts/postgres-backup.sh`;
  - `EXCLUDED_RELATIONS` in `db/tools/generate-reader-role-grants.mjs`, because it is user-owned;
  - `db/table-domain-manifest.json`;
  - regenerated schema types.

Knowledge Base Impact:
- In league `docs/guides/api.md`, the wager visibility paragraph gains the share rule: a share admits its scope only and carries its own amount choice.
- Document the page in a line beside the `/m/:username/wagers` route comment in `app/views/routes.js`.

Scratch: none.

## Notes

- Apply the DDL, export the schema and commit the dependent code as one unit (league `docs/guides/schema.md`). Build a candidate schema in a worktree first if the code needs testing before the apply.
- Bring the final column list to the operator before the apply.

## Plan

- [x] **Create `wager_shares` and register it**
  - Files:
    - `private/db/adhoc/<date>-create-wager-shares.sql`
    - `db/schema.postgres.sql` and the schema types
    - `scripts/dev-fixture-projection.json`
    - `scripts/postgres-backup.sh`
    - `db/tools/generate-reader-role-grants.mjs`
    - `db/table-domain-manifest.json`
  - Verify:
    - `yarn verify` passes, including the dev-fixture scrub gate and the conformance ratchet with a negative control.
    - The reader-role generator emits no grant for `wager_shares`, against a peer table as the positive control.
- [x] **Add the scope helper and the share routes**
  - Files: `libs-server/wager-shares.mjs`, `api/routes/wager-shares.mjs`, `api/routes/index.mjs`, `api/index.mjs`
  - Verify with a new spec, `test/api.wager-shares.spec.mjs`, using the dedicated wager-visibility fixture users:
    - Each scope kind returns exactly its wagers. The union counts a multi-game wager once.
    - An anonymous holder sees the scope under `owner_only` but not the owner's other wagers.
    - Amounts follow `is_amount_visible`, and `book_wager_id` never appears.
    - A deleted or unknown id answers 404.
    - Only the owner can create, list or delete, and anonymous create is refused.
    - Run `node db/gates/check-api-response-shapes.mjs --gate 1` before the suite.
- [x] **Shared page**
  - Files:
    - `app/core/wager-shares/` (actions, reducer, sagas)
    - `app/core/api/service.js`, `app/core/api/sagas.js`, `app/core/api/index.js`
    - `app/views/pages/wager-share/`
    - `app/views/routes.js`
    - `libs-shared/page-routes.mjs`
  - Verify:
    - `test/app.route-registration.spec.mjs` passes, and so does a slice spec for the share state.
    - Under `yarn dev:smoke`, a signed-out browser opens a share and sees only its scope.
    - A wrong username or a deleted share shows Not available.
- [x] **Owner controls: create and revoke**
  - Files:
    - `app/views/pages/wagers/share-selection.js` and its `.styl`
    - `app/views/pages/wagers/wagers.js` (one render line)
    - `app/views/pages/wagers/wager-card.js` (the checkbox, agreed with the set-parlay lane)
    - `app/views/components/user-settings-wager-shares/`
    - `app/views/pages/user-settings/user-settings.js`
  - Verify in a browser at desktop and phone widths:
    - Create a week share, a game share and a three-ticket share, and confirm each link opens its subset.
    - Revoke one from settings, and confirm its link goes to Not available.
- [x] **Document the share rule**
  - Files: league `docs/guides/api.md`
  - Verify: `rg -n "wager_shares" docs/guides/api.md` returns at least 1.
