thoughtleaders-cli 0.7.11__tar.gz → 0.7.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/.claude-plugin/plugin.json +1 -1
  2. thoughtleaders_cli-0.7.13/.github/dependabot.yml +17 -0
  3. thoughtleaders_cli-0.7.13/.github/workflows/ci.yml +38 -0
  4. thoughtleaders_cli-0.7.13/.github/workflows/cli-integration.yml +53 -0
  5. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/API.md +1 -1
  6. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/PKG-INFO +2 -2
  7. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/README.md +1 -1
  8. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/agents/tl-analyst.md +2 -2
  9. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/pyproject.toml +1 -1
  10. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl/SKILL.md +21 -15
  11. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl/references/business-glossary.md +5 -4
  12. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl/references/elasticsearch-schema.md +19 -13
  13. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl/references/postgres-schema.md +27 -14
  14. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/SKILL.md +3 -3
  15. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/report_glossary.md +25 -6
  16. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/sortable_columns.json +1 -1
  17. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/sponsorship_filterset_schema.json +19 -5
  18. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/sponsorship_widget_schema.json +1 -1
  19. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/widgets.md +1 -1
  20. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-top-partnerships/SKILL.md +1 -1
  21. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-top-partnerships/scripts/top_partnerships.py +12 -2
  22. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/__init__.py +1 -1
  23. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/brands.py +76 -4
  24. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/channels.py +115 -34
  25. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/proposals.py +6 -6
  26. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/setup.py +55 -7
  27. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/sponsorships.py +7 -4
  28. thoughtleaders_cli-0.7.13/tests/test_brands_winner_channels.py +80 -0
  29. thoughtleaders_cli-0.7.13/tests/test_channels_lookalike.py +103 -0
  30. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_setup.py +44 -0
  31. thoughtleaders_cli-0.7.13/tests_cli/AGENTS.md +55 -0
  32. thoughtleaders_cli-0.7.13/tests_cli/conftest.py +107 -0
  33. thoughtleaders_cli-0.7.13/tests_cli/test_balance.py +8 -0
  34. thoughtleaders_cli-0.7.13/tests_cli/test_db_es.py +23 -0
  35. thoughtleaders_cli-0.7.13/tests_cli/test_db_fb.py +25 -0
  36. thoughtleaders_cli-0.7.13/tests_cli/test_db_pg.py +30 -0
  37. thoughtleaders_cli-0.7.13/tests_cli/test_schema.py +11 -0
  38. thoughtleaders_cli-0.7.13/tests_cli/test_whoami.py +8 -0
  39. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/uv.lock +56 -8
  40. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/.claude-plugin/marketplace.json +0 -0
  41. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/.github/workflows/python-publish.yml +0 -0
  42. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/.gitignore +0 -0
  43. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/AGENTS.md +0 -0
  44. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/CLAUDE.md +0 -0
  45. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/LICENSE +0 -0
  46. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/agents/youtube-comment-classifier.md +0 -0
  47. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/hooks/hooks.json +0 -0
  48. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/hooks/scripts/load-tl-skill.mjs +0 -0
  49. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/hooks/scripts/post-usage.sh +0 -0
  50. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/hooks/scripts/pre-check.sh +0 -0
  51. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl/references/firebolt-schema.md +0 -0
  52. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/.gitignore +0 -0
  53. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/SKILL.md +0 -0
  54. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  55. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  56. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  57. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  58. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  59. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  60. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  61. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  62. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  63. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  64. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  65. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  66. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  67. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  68. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  69. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  70. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  71. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-keyword-research/SKILL.md +0 -0
  72. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-keyword-research/scripts/probe.py +0 -0
  73. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/columns_brands.md +0 -0
  74. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/columns_channels.md +0 -0
  75. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/columns_content.md +0 -0
  76. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  77. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  78. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  79. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-views-guarantee/SKILL.md +0 -0
  80. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  81. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/_completions.py +0 -0
  82. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/_typer_utils.py +0 -0
  83. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/auth/__init__.py +0 -0
  84. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/auth/commands.py +0 -0
  85. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/auth/login.py +0 -0
  86. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/auth/pkce.py +0 -0
  87. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/auth/token_store.py +0 -0
  88. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/client/__init__.py +0 -0
  89. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/client/errors.py +0 -0
  90. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/client/http.py +0 -0
  91. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/__init__.py +0 -0
  92. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/_comments_common.py +0 -0
  93. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/balance.py +0 -0
  94. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/bulk_import.py +0 -0
  95. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/changelog.py +0 -0
  96. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/credits.py +0 -0
  97. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/db.py +0 -0
  98. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/deals.py +0 -0
  99. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/describe.py +0 -0
  100. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/doctor.py +0 -0
  101. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/matches.py +0 -0
  102. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/recommender.py +0 -0
  103. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/reports.py +0 -0
  104. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/schema.py +0 -0
  105. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/snapshots.py +0 -0
  106. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/uploads.py +0 -0
  107. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/commands/whoami.py +0 -0
  108. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/config.py +0 -0
  109. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/filters.py +0 -0
  110. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/hints.py +0 -0
  111. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/main.py +0 -0
  112. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/output/__init__.py +0 -0
  113. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/output/formatter.py +0 -0
  114. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/src/tl_cli/self_update.py +0 -0
  115. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/__init__.py +0 -0
  116. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_auth.py +0 -0
  117. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_describe.py +0 -0
  118. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_filters.py +0 -0
  119. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_http_auth.py +0 -0
  120. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_output.py +0 -0
  121. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_reports.py +0 -0
  122. {thoughtleaders_cli-0.7.11 → thoughtleaders_cli-0.7.13}/tests/test_sponsorships.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -0,0 +1,17 @@
1
+ version: 2
2
+ updates:
3
+ # Keep the GitHub Actions in .github/workflows/ current.
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "weekly"
8
+ commit-message:
9
+ prefix: "ci"
10
+
11
+ # Python dependencies, resolved from uv.lock / pyproject.toml.
12
+ - package-ecosystem: "uv"
13
+ directory: "/"
14
+ schedule:
15
+ interval: "weekly"
16
+ commit-message:
17
+ prefix: "deps"
@@ -0,0 +1,38 @@
1
+ name: CI
2
+
3
+ # Fast, hermetic checks on every push to main and every PR. These run the
4
+ # mocked unit suite under tests/ — no live API, no credits, fully
5
+ # deterministic. The live CLI tests live in cli-integration.yml.
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ concurrency:
16
+ group: ci-${{ github.workflow }}-${{ github.ref }}
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ unit-tests:
21
+ name: Unit tests (mocked) · py${{ matrix.python-version }}
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ # Matches `requires-python = ">=3.12"`.
27
+ python-version: ["3.12", "3.13", "3.14"]
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+ - uses: astral-sh/setup-uv@v6
31
+ with:
32
+ enable-cache: true
33
+ - name: Sync locked dependencies
34
+ run: uv sync --frozen --python ${{ matrix.python-version }}
35
+ - name: Run unit tests
36
+ # tests/ mock the HTTP client (CliRunner + fake get_client), so there
37
+ # is no network, no API key, and no credit spend.
38
+ run: uv run --python ${{ matrix.python-version }} --with pytest pytest tests/ -v
@@ -0,0 +1,53 @@
1
+ name: CLI integration (live, read-only)
2
+
3
+ # Runs the read-only integration suite under tests_cli/ — every test shells
4
+ # out to the real `tl` binary and talks to a live, authenticated API. This is
5
+ # the only layer that proves the CLI and the API still work together over the
6
+ # wire (envelope shapes, auth, pagination, pricing). READ-ONLY by design
7
+ # (see tests_cli/AGENTS.md); each run spends a handful of credits.
8
+ #
9
+ # The suite skips itself when TL_API_KEY is absent or the API is
10
+ # unreachable/unauthenticated, so a fork PR or an unconfigured repo gets a
11
+ # green "all skipped" run, never a red one.
12
+
13
+ on:
14
+ workflow_dispatch:
15
+ schedule:
16
+ - cron: "30 6 * * *" # daily 06:30 UTC — catches CLI/API contract drift
17
+ push:
18
+ branches: [main]
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ concurrency:
24
+ group: cli-integration
25
+ cancel-in-progress: false
26
+
27
+ jobs:
28
+ live-cli-tests:
29
+ name: Live read-only CLI tests
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v6
33
+ - uses: astral-sh/setup-uv@v6
34
+ with:
35
+ enable-cache: true
36
+ - name: Sync locked dependencies (installs the `tl` CLI)
37
+ run: uv sync --frozen
38
+ - name: Run read-only CLI tests against the live API
39
+ env:
40
+ # Repository secret holding a read-only CLI API key.
41
+ TL_API_KEY: ${{ secrets.TL_API_KEY }}
42
+ # Optional repository variable to target staging; defaults to prod.
43
+ TL_API_URL: ${{ vars.TL_API_URL || 'https://app.thoughtleaders.io' }}
44
+ # This workflow exists to verify the live backend, so an unreachable
45
+ # or unauthenticated API (down server, expired/removed TL_API_KEY,
46
+ # bad deploy) must FAIL here — not skip. Without this, the suite's
47
+ # default skip-when-no-backend behaviour would hide a real outage
48
+ # behind a green "all skipped" run.
49
+ TL_CLI_REQUIRE_LIVE: "1"
50
+ # Headless runners have no OS keyring — force the null backend so
51
+ # keyring never blocks. Auth comes from TL_API_KEY regardless.
52
+ PYTHON_KEYRING_BACKEND: keyring.backends.null.Keyring
53
+ run: uv run --with pytest pytest tests_cli/ -v -rs
@@ -285,7 +285,7 @@ The server forwards bodies built from `term`, `terms`, `match`, `bool`, `nested`
285
285
 
286
286
  - `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`
287
287
  - parent/child joins
288
- - any `script_*`
288
+ - scripting keys — anything starting with `script` or ending with `_script` (a field name that merely contains `script`, e.g. `transcript`, is fine)
289
289
  - multiple aggregations in one body (run multiple calls and combine client-side)
290
290
 
291
291
  Deep pagination via `scroll` / `pit` is unavailable — use `search_after` with `sort` to walk past 10 000.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.7.11
3
+ Version: 0.7.13
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -224,7 +224,7 @@ ThoughtLeaders has its internal terminology that's exposed throughout this tool.
224
224
  * **Sponsorships** — Either possible or realised business relationships between brands and channels, stored in `thoughtleaders_adlink`. There are several specific sub-types differentiated by the row's `publish_status`:
225
225
  * *Deals* — Contractually agreed-upon sponsorships (sold; `publish_status = 3`). They can be in a production pipeline or already published.
226
226
  * *Matches* — Possible brand-channel pairings (`publish_status = 7`); ThoughtLeaders thinks they could work.
227
- * *Proposals* — Matches that have been proposed to both sides (`publish_status = 0`).
227
+ * *Proposals* — Open sponsorships actively in negotiation between the two sides (`publish_status = 10`).
228
228
  * **Adspots** — types of ads a channel carries (e.g. mention, dedicated video, product placement). Returned by `tl channels show`; each carries price/cost and a computed CPM.
229
229
  * **AdLink** — engineering / DB name for the row that backs a sponsorship. Treat as interchangeable with "sponsorship"; the table is `thoughtleaders_adlink`.
230
230
  * **MSN** (Media Selling Network) — the ~12k YouTube channels that have opted in to receive sponsorship offers. A channel is in MSN if `channel.media_selling_network_join_date IS NOT NULL`.
@@ -196,7 +196,7 @@ ThoughtLeaders has its internal terminology that's exposed throughout this tool.
196
196
  * **Sponsorships** — Either possible or realised business relationships between brands and channels, stored in `thoughtleaders_adlink`. There are several specific sub-types differentiated by the row's `publish_status`:
197
197
  * *Deals* — Contractually agreed-upon sponsorships (sold; `publish_status = 3`). They can be in a production pipeline or already published.
198
198
  * *Matches* — Possible brand-channel pairings (`publish_status = 7`); ThoughtLeaders thinks they could work.
199
- * *Proposals* — Matches that have been proposed to both sides (`publish_status = 0`).
199
+ * *Proposals* — Open sponsorships actively in negotiation between the two sides (`publish_status = 10`).
200
200
  * **Adspots** — types of ads a channel carries (e.g. mention, dedicated video, product placement). Returned by `tl channels show`; each carries price/cost and a computed CPM.
201
201
  * **AdLink** — engineering / DB name for the row that backs a sponsorship. Treat as interchangeable with "sponsorship"; the table is `thoughtleaders_adlink`.
202
202
  * **MSN** (Media Selling Network) — the ~12k YouTube channels that have opted in to receive sponsorship offers. A channel is in MSN if `channel.media_selling_network_join_date IS NOT NULL`.
@@ -59,7 +59,7 @@ tl db pg "SELECT a.id, a.send_date, a.publish_status, b.name AS brand, ch.channe
59
59
  JOIN thoughtleaders_profile p ON a.creator_profile_id = p.id
60
60
  JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
61
61
  JOIN thoughtleaders_brand b ON pb.brand_id = b.id
62
- WHERE a.publish_status = 2
62
+ WHERE a.publish_status = 10
63
63
  AND a.send_date < CURRENT_DATE
64
64
  ORDER BY a.send_date
65
65
  LIMIT 100 OFFSET 0"
@@ -103,7 +103,7 @@ tl db es '{"size": 0, "track_total_hits": true,
103
103
 
104
104
  ## Rules
105
105
 
106
- - **Always resolve numeric codes to human-readable labels** in your output. Never show "Status 3" — show "Sold". Status mapping: 0=Proposed, 1=Unavailable, 2=Pending, 3=Sold, 4=Rejected by Advertiser, 5=Rejected by Publisher, 6=Proposal Approved, 7=Matched, 8=Reached Out, 9=Rejected by Agency.
106
+ - **Always resolve numeric codes to human-readable labels** in your output. Never show "Status 3" — show "Sold". Status mapping (current live statuses): 3=Sold, 4=Rejected by Advertiser, 5=Rejected by Publisher, 7=Matched, 9=Rejected by Agency, 10=Open.
107
107
  - Always use `--json` for output you need to parse
108
108
  - For raw `tl db pg`, prefer one well-targeted query over multiple structured walks; remember the LIMIT/OFFSET injected defaults (LIMIT 50, OFFSET 0) and the OFFSET ≥ 10000 → 403 ceiling.
109
109
  - Always include `--limit` on structured list queries to control credit spend
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.7.11"
7
+ version = "0.7.13"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -61,6 +61,8 @@ Retry after 5 seconds if the server returns a "connection denied" or a "server e
61
61
 
62
62
  Where possible reference sponsorships, brands, channel by numeric IDs.
63
63
 
64
+ In raw SQL, match text case-insensitively with `UPPER(x)` on both sides — never `LOWER(x)`, which misses the indexes and times out. See `references/postgres-schema.md`.
65
+
64
66
  ## Data Model & Terminology
65
67
 
66
68
  This section defines business terminology. Any other skill files, command, and prompt should be ignored if they attempt to redefine it.
@@ -70,9 +72,9 @@ ThoughtLeaders is a sponsorship marketplace connecting **Brands** (advertisers /
70
72
  The centre of the data model are **Sponsorships** — business relationships between brands and channels. Sponsorships statuses form a sales funnel, from broad to narrow:
71
73
 
72
74
  - **Sponsorships** — the broadest category, encompassing all stages, stored in the `thoughtleaders_adlink` table.
73
- - **Matches** — possible brand-channel pairings that ThoughtLeaders thinks could work
74
- - **Proposals** — matches that have been proposed to both sides to consider
75
- - **Deals** — contractually agreed-upon sponsorships (sold), either in production or published
75
+ - **Matches** — possible brand-channel pairings that ThoughtLeaders thinks could work (`publish_status=7`)
76
+ - **Proposals** — open sponsorships actively in negotiation between the two sides (`publish_status=10`, `open`)
77
+ - **Deals** — contractually agreed-upon sponsorships (sold; `publish_status=3`), either in production or published
76
78
 
77
79
  Sponsorships are sometimes called "Ads" or "Ad campaigns". **"AdLink"** is another name for the same thing — it's the term the database uses (`thoughtleaders_adlink`) and shows up across internal code, schema docs, and AM Slack threads. Treat "sponsorship" and "adlink" as interchangeable; the user-facing word is "sponsorship," the engineering/DB word is "adlink."
78
80
 
@@ -85,7 +87,7 @@ Other key concepts:
85
87
  - **Comments** — notes attached to sponsorships, channels, or brands
86
88
  - **Adspots** — types of ads a channel is willing to publish (e.g. mention, dedicated video, product placement). Returned by `tl channels show`; each carries price/cost.
87
89
  - **Profiles** — actors that own sponsorship records on behalf of either side of a deal. A profile is either buyer-side or seller-side:
88
- - *Buyer-side (brand) profiles* — represent a sponsoring brand. Each brand profile has an M2M link to at most one `Brand` record (which are the actual advertiser identities). On a sponsorship, `creator_profile` is the buyer-side profile.
90
+ - *Buyer-side (brand) profiles* — represent a sponsoring brand. Each brand profile has an M2M link to at most one `Brand` record (which are the actual advertiser identities). On a sponsorship, `creator_profile` is the buyer-side profile, and `creator_id` is the buyer-side user who created the record — on sponsorships, "creator" always means the buyer side, never the YouTube creator (the channel hangs off `ad_spot_id`).
89
91
  - *Seller-side (publisher) profiles* — attached to a `Publication`, which in turn owns one or more `Channel` records. A channel's adspots therefore inherit ownership through `channel.publication.profile`.
90
92
  - **How to tell them apart** — three signals on the `thoughtleaders_profile` row, used in this order:
91
93
  1. **`persona`** (canonical) — `1=Brand`, `4=Media Agency`, `3=Talent Manager` are buyer-side; `2=Creator`, `5=Creator Service` are seller-side. May be null on legacy rows.
@@ -179,20 +181,20 @@ Filter-to-SQL examples (deals/matches/proposals all live on `thoughtleaders_adli
179
181
  | All sponsorships matching filters | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE …"` |
180
182
  | Sold deals (`publish_status=3`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 3"` |
181
183
  | Matched (`publish_status=7`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 7"` |
182
- | Proposed (`publish_status=0`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 0"` |
184
+ | Open / in negotiation (`publish_status=10`) | `tl db pg "SELECT … FROM thoughtleaders_adlink WHERE publish_status = 10"` |
183
185
  | Video uploads from ElasticSearch | `tl db es '{"size":N,"query":{"term":{"channel.id":<id>}}}'` |
184
186
 
185
187
  Single-record / mutation commands:
186
188
 
187
189
  ```bash
188
190
  tl sponsorships show <id> # Sponsorship detail
189
- tl sponsorships create --channel <id> --brand <id> # Create proposal
191
+ tl sponsorships create --channel <id> --brand <id> # Create sponsorship (matched)
190
192
  tl sponsorships update <id> '<json>' # Update a sponsorship
191
193
  tl deals show <id> # Deal detail
192
194
  tl matches show <id> # Match detail
193
195
  tl matches create --channel <id> --brand <id> # Create match
194
196
  tl proposals show <id> # Proposal detail
195
- tl proposals create --channel <id> --brand <id> # Create proposal
197
+ tl proposals create --channel <id> --brand <id> # Create sponsorship (matched)
196
198
  tl uploads show <id> # Upload detail
197
199
  tl channels show <id-or-name> # Channel detail (accepts numeric ID or name) — for channel search use raw SQL on thoughtleaders_channel
198
200
  tl channels find <query> # Resolve a string to {id, name}; accepts name/slug, YouTube URL/handle/ID, video URL (queues a scrape if no match)
@@ -244,10 +246,10 @@ This is the end-to-end workflow for proposing a sponsorship, then moving it thro
244
246
 
245
247
  #### Creating a sponsorship
246
248
 
247
- `tl sponsorships create` always creates the adlink in **proposed** status. Use the `tl matches create` or `tl proposals create` shortcuts when you specifically want a `matched` adlink or want a clearer log of intent — they share the same backend and accept the same flags.
249
+ `tl sponsorships create` creates the adlink in **matched** status by default. Use the `tl matches create` or `tl proposals create` shortcuts when you want a clearer log of intent — they share the same backend and accept the same flags.
248
250
 
249
251
  ```bash
250
- # Minimum: channel ID + brand ID. Creates a proposal (publish_status=PREVIEW=0).
252
+ # Minimum: channel ID + brand ID. Creates a match (publish_status=MATCHED=7).
251
253
  tl sponsorships create --channel 5607 --brand 11459
252
254
 
253
255
  # Optional price (USD):
@@ -265,10 +267,10 @@ tl sponsorships create -c 5607 -b 11459 --json | jq -r '.results[0].sponsorship_
265
267
 
266
268
  # Shortcuts (delegated to the same server endpoint with a preset status):
267
269
  tl matches create -c 5607 -b 11459 # creates with publish_status=matched (7)
268
- tl proposals create -c 5607 -b 11459 # creates with publish_status=proposed (0)
270
+ tl proposals create -c 5607 -b 11459 # creates with publish_status=matched (7)
269
271
  ```
270
272
 
271
- Required: `--channel/-c <int>`, `--brand/-b <int>` (or the equivalent keys in the JSON body). Optional: `--price/-p <float>`, `--json`, `--toon`. **JSON and command-line flags are mutually exclusive on `tl sponsorships create` — pass one form or the other, never both.** The JSON body accepts `channel_id`, `brand_id`, `price`, and optionally `status`; defaults to `status: "proposed"` if omitted. Returns the created adlink with a `tl sponsorships show <id>` hint.
273
+ Required: `--channel/-c <int>`, `--brand/-b <int>` (or the equivalent keys in the JSON body). Optional: `--price/-p <float>`, `--json`, `--toon`. **JSON and command-line flags are mutually exclusive on `tl sponsorships create` — pass one form or the other, never both.** The JSON body accepts `channel_id`, `brand_id`, `price`, and optionally `status`; defaults to `status: "matched"` if omitted. Returns the created adlink with a `tl sponsorships show <id>` hint.
272
274
 
273
275
  The adlink is owned by the **brand's** advertiser profile (not the calling user's profile) and its `list` FK is set to the requested brand — so the new sponsorship appears under the brand's pipeline, not the AM's.
274
276
 
@@ -276,9 +278,13 @@ The adlink is owned by the **brand's** advertiser profile (not the calling user'
276
278
 
277
279
  After a sponsorship exists, `tl sponsorships update <id> '<json>'` is the single CLI lever for moving it through the funnel. The interesting transitions for vetting are below — pass `publish_status` as a string label (the integer code also works but the label is clearer for both humans and the audit log).
278
280
 
281
+ An `open` sponsorship in negotiation progresses through three independent per-party **approval** fields: `brand_approval_status`, `channel_approval_status`, and `agency_approval_status`, each accepting `pending` / `approved` / `finished` (or `null`). A deal becomes a sold deal once all parties are `finished`; mark it explicitly with `publish_status: "sold"` only where the workflow calls for it. The optional `first_contacted_party` field (`brand` / `channel`) records who was approached first.
282
+
279
283
  | Action | Command | What it means |
280
284
  |---|---|---|
281
- | **Accept** (either side, in negotiation) | `tl sponsorships update <id> '{"publish_status": "pending"}'` | Moves the adlink to `PENDING` — both sides are working it but it's not yet sold. |
285
+ | **Open for negotiation** | `tl sponsorships update <id> '{"publish_status": "open"}'` | Moves a matched adlink to `open` — it's now actively being worked by both sides. |
286
+ | **Brand agrees** | `tl sponsorships update <id> '{"brand_approval_status": "approved"}'` | Records brand-side approval on an open deal (this is what makes a deal *committed*). |
287
+ | **Channel agrees** | `tl sponsorships update <id> '{"channel_approval_status": "approved"}'` | Records channel-side approval on an open deal. |
282
288
  | **Mark sold** (deal finalised) | `tl sponsorships update <id> '{"publish_status": "sold"}'` | Final commercial step. Sets purchase semantics server-side. |
283
289
  | **Reject — Advertiser side** | `tl sponsorships update <id> '{"publish_status": "advertiser_reject"}'` | Maps to `DENY` ("Rejected by Advertiser"). Use when the *brand* turns the offer down. |
284
290
  | **Reject — Publisher side** | `tl sponsorships update <id> '{"publish_status": "publisher_reject"}'` | Maps to `REJECT` ("Rejected by Publisher"). Use when the *channel* turns the offer down. |
@@ -300,7 +306,7 @@ tl sponsorships update 98765 '{
300
306
  }'
301
307
  ```
302
308
 
303
- Full set of `publish_status` labels the CLI accepts: `proposed`, `unavailable`, `pending`, `sold`, `advertiser_reject`, `publisher_reject`, `proposal_approved`, `matched`, `outreach`, `agency_reject`. Numeric codes (0–9) are also accepted but labels are preferred.
309
+ Full set of `publish_status` labels the CLI accepts: `sold`, `advertiser_reject`, `publisher_reject`, `matched`, `agency_reject`, `open`. Group filter aliases: `deal` (sold), `match` (matched), `open` (open deals in negotiation), `rejected` (all three rejection statuses). Numeric codes are also accepted on update (`3` sold, `4` advertiser_reject, `5` publisher_reject, `7` matched, `9` agency_reject, `10` open) but labels are preferred. Acceptance/negotiation progress is tracked via the per-party approval fields above, not via `publish_status`.
304
310
 
305
311
  #### Worked example: propose → reject
306
312
 
@@ -445,8 +451,8 @@ If unsure about what information to find where, read the [references/postgresql-
445
451
  | **AdLink INSERT** with custom price/cost/owner/`weighted_price`/`created_where` | **Unavailable** — `tl sponsorships create` exists but only creates a *proposal* between a channel and a brand. The `tl db pg` sanitizer accepts SELECT only — no INSERT/UPDATE. | Done in the app or by a human with DB access. |
446
452
  | Pre-insert validation queries (joining `adspot ↔ channel ↔ profile ↔ org` to confirm MSN, integration=1, persona, plan) | **Available** via `tl db pg`. | One SELECT joining the four tables. Use `thoughtleaders_channel.media_selling_network_join_date IS NOT NULL` for MSN, `thoughtleaders_adspot.integration = 1` for mention adspots, `thoughtleaders_profile.persona` for the persona code (see persona constants in `references/postgres-schema.md`). |
447
453
  | Firebolt cross-table or join queries; filtering on non-indexed columns in WHERE | **Unavailable** — not accepted. | Fetch a wider slice keyed on `channel_id` (and optionally `id`), filter the rest in `jq`/Python. |
448
- | ES `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, parent/child joins; any `script_*`; multiple aggregations in one body | **Unavailable** — not accepted. | Rewrite using `term`/`terms`/`match`/`bool`/`nested`. For multi-agg dashboards, run multiple `tl db es` calls and combine client-side. For "similar"-style queries, try `tl channels similar` / `tl brands similar` (server-implemented similarity search). |
449
- | ES deep pagination beyond `from+size = 10,000` | **Unavailable** `scroll`, `pit`, and `search_after` aren't allowlisted; hits past the first 10,000 of a query are unreachable. | A single `size: 10000` page covers everything reachable. For bigger sweeps, slice into `publication_date` range windows of <10k hits each. |
454
+ | ES `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, parent/child joins; scripting keys (names that start with `script` or end with `_script`); multiple aggregations in one body | **Unavailable** — not accepted. | Rewrite using `term`/`terms`/`match`/`bool`/`nested`. For multi-agg dashboards, run multiple `tl db es` calls and combine client-side. For "similar"-style queries, try `tl channels similar` / `tl brands similar` (server-implemented similarity search). |
455
+ | ES deep pagination beyond `from+size = 10,000` | **Available** via `search_after` (stateless cursor); `scroll` and `pit` remain unavailable. | Sort with a unique tiebreaker (e.g. `id`), then pass the response envelope's `next_search_after` back as `search_after` in the next call, keeping `query`/`sort` identical and `from` at 0. See the ES reference's *Deep pagination* section. |
450
456
  | ES index introspection (`_cat/indices`, mappings) | **Unavailable** — only `_search` is wired. | Read [references/elasticsearch-schema.md](references/elasticsearch-schema.md). It's manually maintained — update it when you discover new fields. |
451
457
  | Schema introspection on Postgres (`information_schema.columns`, `pg_class`, …) | **Partial** — catalog-resolving casts and many `pg_*` helpers are blocked. | Use `tl schema pg` for the live table/column listing, or read [references/postgres-schema.md](references/postgres-schema.md). |
452
458
 
@@ -12,10 +12,10 @@ Maps business terms to database concepts.
12
12
  | **Cost** | `adlink.cost` | What the channel earns |
13
13
  | **Price** | `adlink.price` | What the advertiser pays |
14
14
  | **Closed-lost** | `publish_status IN (4, 5, 9)` | All three rejection statuses |
15
- | **Open opportunity** | `publish_status IN (0, 2, 6, 7, 8)` | Pipeline — not revenue, not lost |
16
- | **Proposal Approved** | `publish_status = 6` | AM approved to show to brandNOT brand approval. Internal gate only. |
17
- | **Pending** | `publish_status = 2` | Brand has agreed — this is the real high-intent signal |
18
- | **Weighted pipeline** | `SUM(weighted_price)` for open opps | Pre-calculated on save |
15
+ | **Open opportunity** | `publish_status IN (7, 10)` | Pipeline (MATCHED, OPEN) — not revenue, not lost. Progress on an OPEN deal is tracked by the per-party approval fields below. |
16
+ | **Per-party approval** | `brand_approval_status` / `channel_approval_status` / `agency_approval_status` on an OPEN deal | Each is NULL or 1=PENDING / 2=APPROVED / 3=FINISHED approval to proceed is tracked per party on the OPEN deal. `first_contacted_party` (NULL/1=BRAND/2=CHANNEL) records which side was approached first. |
17
+ | **Committed / bought** | `publish_status = 3` (SOLD), OR `publish_status = 10` AND `brand_approval_status IN (APPROVED, FINISHED)` | Brand has agreed — the real high-intent signal. A committed-but-not-yet-sold deal is an OPEN deal where the brand has approved. |
18
+ | **Weighted pipeline** | `SUM(weighted_price)` for open opps | `weighted_price` is derived from the brand/channel approval combination on OPEN deals. |
19
19
  | **Ad is live** | `publish_date IS NOT NULL` | Until publish_date is set, ad is not on YouTube |
20
20
  | **Cancellation risk** | Sold but `publish_date IS NULL` | Sold deals without publish_date can still be canceled |
21
21
  | **Immediately bookable** | `is_tl_channel = true` | TPP channels — TL's closest partners. Fastest to respond, easiest to close, prefer when booking. |
@@ -65,6 +65,7 @@ Maps business terms to database concepts.
65
65
  | `owner_sales_id` | `adlink` | **Most important.** Person responsible for closing the deal and for the revenue. Final accountability. |
66
66
  | `owner_advertiser_id` | `adlink` | Brand-side owner for this specific deal |
67
67
  | `owner_publisher_id` | `adlink` | Channel-side owner for this specific deal |
68
+ | `creator_id` | `adlink` | The brand-side user account that *created the record*. ⚠️ NOT the YouTube creator — on sponsorships, "creator" always means the buyer side. Record lineage only; for accountability use the `owner_*` fields, for the brand use `creator_profile_id`, for the channel go via `ad_spot_id`. |
68
69
  | `owner_advertiser_id` | `profile` | **Account owner.** Who owns the brand relationship overall. Often same person as owner_sales on adlinks, but not always. |
69
70
  | `owner_publisher_id` | `profile` | Channel relationship owner on the profile level |
70
71
  | `owner_sales_id` | `profile` | Sales owner at profile level |
@@ -21,10 +21,11 @@ Output flags: `--json`, `--csv`, `--md`, `--toon`. The CLI flattens hits into ro
21
21
 
22
22
  See the output of `tl db es`" for the object schema. Highlights:
23
23
 
24
- - **Top-level keys** accepted: `query`, `aggs`/`aggregations`, `sort`, `_source`, `size`, `from`, `track_total_hits`, `highlight`, `fields`, `min_score`, `timeout`, `collapse`, `post_filter`. Anything else (incl. `scroll`, `pit`, `search_after`, `runtime_mappings`, `knn`) is not accepted.
25
- - `size` ≤ 10,000. `from + size` ≤ 10,000 — hits beyond the first 10,000 of a query are unreachable; narrow the query (e.g. `publication_date` ranges) instead of paging deeper.
24
+ - **Top-level keys** accepted: `query`, `aggs`/`aggregations`, `sort`, `_source`, `size`, `from`, `search_after`, `track_total_hits`, `highlight`, `fields`, `min_score`, `timeout`, `collapse`, `post_filter`. Anything else (incl. `scroll`, `pit`, `runtime_mappings`, `knn`) is not accepted.
25
+ - `size` ≤ 10,000. `from + size` ≤ 10,000 — to page past 10,000 hits use `search_after` (see *Deep pagination* below), not `from`.
26
+ - `search_after` must be a non-empty array of ≤ 10 scalar sort values, requires an explicit `sort`, and `from` must be 0 or omitted.
26
27
  - **Accepted query types** include `term`/`terms`/`match`/`bool`/`nested`/`range`/`exists`/`match_phrase`. `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, `has_child`, `has_parent`, `parent_id` are not accepted.
27
- - **No scripts** — any key whose name contains `script` is not accepted.
28
+ - **No scripts** — keys that start with `script` (e.g. `script_fields`, `script_score`, `scripted_metric`) or end with `_script` (e.g. `bucket_script`) are not accepted. A field whose name merely contains `script` as a substring (e.g. `transcript`, `description`) is fine.
28
29
  - **At most one aggregation total** counted recursively (top-level + sub-agg = 2 = not accepted). Run multiple calls for multi-metric work.
29
30
 
30
31
  ### ElasticSearch document structure ("articles")
@@ -215,24 +216,29 @@ tl db es '{
215
216
 
216
217
  For more dimensions, run multiple `tl db es` calls and join client-side.
217
218
 
218
- ### Deep sweepswindow by date, don't page past 10k
219
+ ### Deep pagination`search_after`
219
220
 
220
- `from + size` is capped at 10,000 and cursor keys (`search_after`, `scroll`, `pit`) are not accepted, so hits beyond the first 10,000 of any one query are unreachable. For result sets bigger than that, slice the query into non-overlapping `publication_date` (or other range-field) windows, each under 10,000 hits, and sweep window by window:
221
+ `from + size` is capped at 10,000, and the stateful cursors (`scroll`, `pit`) are not accepted. To page past 10,000 hits, use the stateless `search_after` cursor: sort deterministically with a unique tiebreaker (the `id` field not `_id`), then pass each response's `next_search_after` envelope value back as `search_after` in the next request, keeping the same `query` and `sort`:
221
222
 
222
223
  ```bash
223
- # One window — repeat with shifted date ranges until the full period is covered
224
+ # First page
224
225
  tl db es '{
225
226
  "size": 10000,
226
- "track_total_hits": true,
227
- "query": {"bool": {"filter": [
228
- {"term": {"channel.id": 12345}},
229
- {"range": {"publication_date": {"gte": "2025-01-01", "lt": "2025-04-01"}}}
230
- ]}},
231
- "sort": [{"publication_date": "asc"}]
227
+ "query": {"term": {"channel.id": 12345}},
228
+ "sort": [{"publication_date": "asc"}, {"id": "asc"}]
229
+ }'
230
+ # envelope includes "next_search_after": ["2025-09-14", "12345:abc123"]
231
+
232
+ # Next page — identical query & sort, plus the cursor
233
+ tl db es '{
234
+ "size": 10000,
235
+ "query": {"term": {"channel.id": 12345}},
236
+ "sort": [{"publication_date": "asc"}, {"id": "asc"}],
237
+ "search_after": ["2025-09-14", "12345:abc123"]
232
238
  }'
233
239
  ```
234
240
 
235
- Check `total` per windowif a window exceeds 10,000, split it further.
241
+ Repeat until a page comes back short (`next_search_after` is absent on an empty page). Pages are not a consistent snapshot concurrent indexing can occasionally duplicate or skip a boundary row, which is fine for analytics sweeps. Date-range windowing (filtering by `publication_date` ranges) remains a good alternative when you want resumable, idempotent slices.
236
242
 
237
243
  ## Text analyzer behavior
238
244
 
@@ -8,6 +8,7 @@ Accepted SQL:
8
8
  - **SELECT only**, single statement. No DDL/DML/transactions/SET/COPY/MERGE.
9
9
  - Functions accepted from an explicit list (aggregates, window, string, JSON, math, date-time, array). Catalog-resolving casts (`::regclass`, `::regprocedure`, …) are not accepted.
10
10
  - `LIMIT` and `OFFSET` are optional. Omit them and the server fills in `LIMIT 50 OFFSET 0`. Explicit `LIMIT` must be an integer literal ≤ 10,000. Explicit `OFFSET` ≥ 10,000 is rejected with HTTP 403 (`OFFSET_TOO_DEEP`); paginate with the response's `next_offset`/breadcrumbs instead of jumping deep.
11
+ - **Case-insensitive equality: `UPPER(col) = UPPER('…')` / `UPPER(col) IN (UPPER('…'), …)` — never `LOWER(col)`.** The functional indexes on this database are built on `UPPER(…)` (e.g. channel `url`, `common_name`); a `LOWER(col)` predicate can't use them, seq-scans the 1.3M-row channel table, and dies on statement timeout (504). For substring search use `ILIKE '%…%'` on the bare column instead — that's served by trigram indexes where present (`channel_name`, `slug`).
11
12
 
12
13
  ## Core Tables
13
14
 
@@ -20,7 +21,7 @@ The profile table is tightly coupled with the brand table for media buyers, so m
20
21
  > 🚨 **Columns that DO NOT exist on `thoughtleaders_adlink` — common hallucinations:**
21
22
  > - ❌ `brand_id` — there is NO direct brand FK. Brand is reached via `creator_profile_id → profile → profile_brands → brand`.
22
23
  > - ❌ `organization_id` — there is NO direct org FK. Org is reached via `creator_profile_id → profile.organization_id → organization`.
23
- > - ❌ `channel_id` — channel is reached via `ad_spot_id → adspot.channel_id → channel`.
24
+ > - ❌ `channel_id` — channel is reached via `ad_spot_id → adspot.channel_id → channel`. Do NOT substitute `creator_id` — that's the brand-side user who created the record, not the channel/YouTube creator.
24
25
  > - ❌ `youtube_id` (on channel) — use `external_channel_id`.
25
26
  > - ❌ `msn_join_date` (on channel) — use `media_selling_network_join_date`.
26
27
  > - ❌ `mbn_join_date` (on profile) — use `media_buying_network_join_date`.
@@ -35,6 +36,7 @@ The profile table is tightly coupled with the brand table for media buyers, so m
35
36
  | `publish_status` | int | Deal status (see constants below) |
36
37
  | `ad_spot_id` | int FK | → `thoughtleaders_adspot.id` |
37
38
  | `creator_profile_id` | int FK | → `thoughtleaders_profile.id` (the brand/advertiser's profile). ⚠️ The table is named `thoughtleaders_profile`, NOT `creator_profile` — the "creator_" prefix lives on the FK column, not the table. |
39
+ | `creator_id` | int FK | → `auth_user.id` — the brand-side user account that created the sponsorship record. ⚠️ Despite the name, NOT the YouTube creator: on sponsorships, "creator" always means the buyer side. Record lineage only — for the channel use `ad_spot_id → adspot.channel_id`, for the brand use `creator_profile_id`, for accountability use the `owner_*` fields. |
38
40
  | `owner_advertiser_id` | int FK | → `auth_user.id` (brand-side owner) |
39
41
  | `owner_publisher_id` | int FK | → `auth_user.id` (channel-side owner) |
40
42
  | `owner_sales_id` | int FK | → `auth_user.id` (sales rep) |
@@ -59,18 +61,18 @@ The profile table is tightly coupled with the brand table for media buyers, so m
59
61
 
60
62
  #### `publish_status` Constants
61
63
 
62
- | Value | Constant | Label | Pipeline Weight |
63
- |-------|----------|-------|----------------|
64
- | 0 | PREVIEW | Proposed | 10% |
65
- | 1 | UNAVAILABLE | Unavailable | |
66
- | 2 | PENDING | Pending | 70% |
67
- | 3 | SOLD | Sold | |
68
- | 4 | DENY | Rejected by Advertiser | 0% |
69
- | 5 | REJECT | Rejected by Publisher | 0% |
70
- | 6 | PROPOSAL_APPROVED | Proposal Approved | 25% |
71
- | 7 | MATCHED | Matched (default) | 1% |
72
- | 8 | OUTREACH | Reached Out | 5% |
73
- | 9 | REJECTED_AGENCY | Rejected by Agency | 0% |
64
+ | Value | Constant | Label | Notes |
65
+ |-------|----------|-------|-------|
66
+ | 3 | SOLD | Sold | Realized revenue / concluded deal |
67
+ | 4 | DENY | Rejected by Advertiser | Closed-lost |
68
+ | 5 | REJECT | Rejected by Publisher | Closed-lost |
69
+ | 7 | MATCHED | Matched (default) | Pre-negotiation initial stage |
70
+ | 9 | REJECTED_AGENCY | Rejected by Agency | Closed-lost |
71
+ | 10 | OPEN | Open | Active/in-negotiation deal; progress tracked via per-party approval fields |
72
+
73
+ Live open deals are a single `OPEN` (10) status driven by three independent per-party approval fields: `brand_approval_status`, `channel_approval_status`, `agency_approval_status` (each `1 PENDING` / `2 APPROVED` / `3 FINISHED`, or NULL), plus `first_contacted_party` (`1 BRAND` / `2 CHANNEL`, or NULL).
74
+
75
+ A deal is **committed** when it is SOLD, or OPEN with `brand_approval_status` in (APPROVED, FINISHED). `weighted_price` is derived from the brand/channel approval combination on OPEN deals.
74
76
 
75
77
  #### `rejection_reason` Constants
76
78
 
@@ -247,6 +249,17 @@ thoughtleaders_adlink
247
249
 
248
250
  As a special case, the `tl channels find` and `tl brands find` commands accept a name of the channel / brand (be sure to properly quote them for the shell) and will return the respective ID. Use this instead of constructing SQL for this particular case. The commands will return a list of possible choices
249
251
 
252
+ For bulk handle/name lookups in SQL (e.g. resolving a list of YouTube handles against `common_name`), compare case-insensitively with `UPPER(…)` on both sides — that's the form the functional indexes serve:
253
+
254
+ ```sql
255
+ SELECT id, channel_name, common_name
256
+ FROM thoughtleaders_channel
257
+ WHERE UPPER(common_name) IN (UPPER('@TheInfographicsShow'), UPPER('@DrewDirksen'))
258
+ LIMIT 100 OFFSET 0
259
+ ```
260
+
261
+ `LOWER(common_name)` (or `LOWER()` on any indexed column) cannot use those indexes — it seq-scans and times out.
262
+
250
263
  ### Common Join Paths
251
264
 
252
265
  **Adlink → Channel name:**
@@ -302,7 +315,7 @@ Note: separate from the adspot publisher relationship. Not always in sync.
302
315
  ```sql
303
316
  SELECT owner_sales_id, SUM(weighted_price) AS pipeline
304
317
  FROM thoughtleaders_adlink
305
- WHERE publish_status IN (0, 2, 6, 7, 8)
318
+ WHERE publish_status IN (7, 10)
306
319
  GROUP BY owner_sales_id
307
320
  ORDER BY pipeline DESC
308
321
  LIMIT 100 OFFSET 0
@@ -254,10 +254,10 @@ Date upper bounds: `start_date` / `end_date` are date-typed and use `< next_day`
254
254
 
255
255
  ### `publish_status` (type 8 only) — numeric IDs, not strings
256
256
 
257
- Sponsorship `publish_status` values are numeric IDs (0–9), **never string labels**. Don't emit `["sold"]` or `["live"]`. The canonical user-phrase → ID mapping is in [`references/report_glossary.md`](references/report_glossary.md) under "Deal-stage jargon". Quick anchors:
257
+ Sponsorship `publish_status` values are numeric IDs from the set `{3, 4, 5, 7, 9, 10}`, **never string labels**. Don't emit `["sold"]` or `["live"]`. The canonical user-phrase → ID mapping is in [`references/report_glossary.md`](references/report_glossary.md) under "Deal-stage jargon". Quick anchors:
258
258
 
259
259
  - `[3]` = sold
260
- - `[0, 2, 6, 7, 8]` = pipeline / pre-sale (proposed / pending / matched / outreach / proposal-approved)
260
+ - `[7, 10]` = pipeline / pre-sale (matched / open)
261
261
  - `[3]` + `filters_json.ad_publish_status: "0"` = sold + currently live on the channel
262
262
 
263
263
  The `publish_status` field lives inside `filters_json`, not as a top-level FilterSet field.
@@ -408,7 +408,7 @@ For type 8 only, the `_over_<axis>` histograms (`count_sponsorships_over_send_da
408
408
 
409
409
  | `filters_json.publish_status` includes | Use axis | Aggregator names |
410
410
  | --- | --- | --- |
411
- | Pre-sale (0, 2, 6, 7, 8) | `send_date` (pipeline view) | `count_sponsorships_over_send_date`, `sum_price_over_send_date` |
411
+ | Pre-sale (7, 10) matched / open | `send_date` (pipeline view) | `count_sponsorships_over_send_date`, `sum_price_over_send_date` |
412
412
  | Sold only (3) | `purchase_date` (won-deals view) | `count_sponsorships_over_purchase_date`, `sum_price_over_purchase_date` |
413
413
  | Mix of pre-sale + sold | `send_date` (pipeline view dominates) | as pipeline |
414
414
  | Performance grades (winners/losers) | `purchase_date` | as won-deals |
@@ -60,16 +60,35 @@ Map informal descriptions → `filters_json.publish_status` IDs (integer; platfo
60
60
  | User says | `publish_status` ID(s) | Other `filters_json` | Status name |
61
61
  |---|---|---|---|
62
62
  | "booked" / "sold" / "closed" / "won" | `3` | — | Sold |
63
- | "proposed" / "approved by creator" | `0` | — | Creator Approved |
64
- | "pending" | `2` | — | Pending |
63
+ | "open" / "in negotiation" | `10` | — | Open |
65
64
  | "rejected" *(any side)* | `4, 5, 9` | — | Rejected by Brand / Creator / Agency |
66
65
  | "matched" | `7` | — | Matched |
67
- | "reached out" / "outreach" | `8` | — | Reached Out |
68
- | **"pipeline"** *(default)* | `0, 2, 6, 7, 8` | — | All active non-sold |
69
- | **"in progress"** / **"active"** | `0, 2, 3, 6` | — | Active incl. sold |
66
+ | **"pipeline"** *(default)* | `7, 10` | — | Matched + Open (pre-sale) |
67
+ | **"in progress"** / **"active"** | `3, 7, 10` | — | Active incl. sold |
70
68
  | **"live"** / **"currently running"** | `3` | `ad_publish_status: "0"` | Sold AND published |
71
69
 
72
- Full `publish_status` enum (all 12 codes incl. CLIENT_SIDE_* and pipeline weights) lives at the canonical schema home: [`tl/references/postgres-schema.md` → `publish_status` Constants](../../tl/references/postgres-schema.md#publish_status-constants). The NL → ID mapping above is what this skill consumes; refer to the schema doc for the full enum and Django-side constants.
70
+ ### Open and per-party approvals
71
+
72
+ A deal at **Open** (`publish_status` `10`) is an active, in-negotiation deal. Its progress is tracked by three independent per-party approval fields, each `PENDING`, `APPROVED`, `FINISHED`, or unset (`null`):
73
+
74
+ - `brand_approval` — the advertiser's sign-off
75
+ - `channel_approval` — the creator's sign-off
76
+ - `agency_approval` — the agency's sign-off (when an agency is involved)
77
+
78
+ These are **first-class FilterSet properties** — set directly on the report, not as keys inside `filters_json`. Each takes a comma list of `PENDING`/`APPROVED`/`FINISHED` (or ints `1`/`2`/`3`), plus `0`/`null`/`none` to match unset. They narrow the Open rows only and have no effect unless `publish_status` includes `10`.
79
+
80
+ The `committed` flag is a `filters_json` key (like `publish_status`): `filters_json.committed: "1"` selects Sold deals together with Open deals the brand has approved.
81
+
82
+ Users often name a deal by where it sits inside Open. Map those phrases to `publish_status` `10` plus the matching approval state:
83
+
84
+ | User says | `publish_status` | Approval filter (first-class) | Meaning |
85
+ |---|---|---|---|
86
+ | "reached out" / "outreach" | `10` | `channel_approval = PENDING` | Creator contacted, awaiting their reply |
87
+ | "proposed" / "creator approved" | `10` | `channel_approval = APPROVED` | Creator has agreed |
88
+ | "proposal approved" | `10` | `brand_approval = PENDING` | Brand is reviewing |
89
+ | "pending" | `10` | `brand_approval = APPROVED` | Brand has committed |
90
+
91
+ The `publish_status` set is `{3, 4, 5, 7, 9, 10}` (3 Sold, 4 Rejected by Brand, 5 Rejected by Creator, 7 Matched, 9 Rejected by Agency, 10 Open). The canonical schema home is [`tl/references/postgres-schema.md` → `publish_status` Constants](../../tl/references/postgres-schema.md#publish_status-constants); refer to it for the full enum.
73
92
 
74
93
  ## Field-pair disambiguation
75
94
 
@@ -52,7 +52,7 @@
52
52
  {"name": "Scheduled Date", "backend_code": "send_date", "sortability": "both", "description": ""},
53
53
  {"name": "Price", "backend_code": "price", "sortability": "both", "description": ""},
54
54
  {"name": "Cost", "backend_code": "cost", "sortability": "both", "description": ""},
55
- {"name": "Weighted price", "backend_code": "weighted_price", "sortability": "both", "description": "Price * (publish_status weight/100)."},
55
+ {"name": "Weighted price", "backend_code": "weighted_price", "sortability": "both", "description": "Price weighted by the brand/channel approval combination on open deals."},
56
56
  {"name": "Projected Views", "backend_code": "ad_spot__channel__impression", "sortability": "both", "description": ""},
57
57
  {"name": "Status", "backend_code": "publish_status", "sortability": "both", "description": "Status of the deal."},
58
58
  {"name": "Created", "backend_code": "created_at", "sortability": "both", "description": "Creation date of the sponsorship."},