chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

chore(db): staging ops scripts + newsletter_emails.approved_at migration - #261

Closed
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts
Closed

chore(db): staging ops scripts + newsletter_emails.approved_at migration#261
AndresL230 wants to merge 3 commits into
mainfrom
chore/staging-ops-scripts

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

Tooling used to seed the new staging environment from prod and bootstrap access, plus a schema-drift fix surfaced along the way.

Ops scripts (backend/db/)

Three idempotent, safe-by-default scripts (print the write target and preview unless --yes), sharing a small helper:

ScriptPurpose
copy_courses_to_staging.pyCopy the plaintext courses catalog prod→staging (prod via PostgREST, staging via psycopg). Additive upsert on id — no deletes.
grant_staging_admin.py <email>Approve + grant the admin role, matched by decrypted email. Admin role_id looked up by slug (per-project UUID).
approve_staging_users.py <emails...>Approve accounts (is_approved) by decrypted email; reports emails with no staging account yet as PENDING.
_staging_ops.pyShared: dotenv reader, staging-key setup, decrypt-email→user index, write-target guard.

These deliberately bypass db/connection.py::table() (single-environment) since they span two Supabase projects — the same exception db/migrate.py already makes. Credentials are read from gitignored dotenv files, never argv/process env.

Migration 0019_newsletter_approved_at.sql

newsletter_emails.approved_at exists in prod (added out-of-band) but was never captured as a migration, so any DB built from migrations (staging, CI, fresh) was missing it — which 500s the admin allowlist endpoints (/api/admin/allowlist/approve|revoke). ADD COLUMN IF NOT EXISTS makes it a no-op where it already exists.

Verification

  • 0019 applied to staging; approved_at confirmed present.
  • All three scripts run clean in preview mode (correct target db.ybgq…, zero writes).
  • ruff check passes; ruff format applied.

Follow-up (not in this PR)

  • Record 0019 on prod so its schema_migrations ledger matches reality (no-op column-wise; needs prod's direct DB connection, which isn't in .env).
  • Note: main's Frontend CI is currently red repo-wide (lockfile / eslint baseline) — unrelated to this backend-only change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Database Changes

    • Enhanced newsletter management with approval timestamp tracking.
  • Chores

    • Added operational utilities for user account provisioning and role management in staging environments.
    • Added tooling to synchronize course data between environments.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb08e2bdCommit Preview URL

Branch Preview URL
Jun 24 2026, 04:56 AM

@coderabbitai

coderabbitaiBot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 33 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3eb2913-bcde-47be-8114-ad681c6f1807

📥 Commits

Reviewing files that changed from the base of the PR and between d4d0862fac6b028543982c7c6407c42abc683c86 and b08e2bd.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql
📝 Walkthrough

Walkthrough

Adds a shared _staging_ops.py helper module providing dotenv loading, encryption key initialization, DSN hostname extraction, and a decrypted-email user index. Three new one-off operational scripts (approve_staging_users.py, copy_courses_to_staging.py, grant_staging_admin.py) consume these helpers. Also adds a migration that appends approved_at to newsletter_emails.

Staging Operational Scripts

Layer / File(s)Summary
Shared staging-ops helper module
backend/db/_staging_ops.py
Introduces five shared functions: dotenv_value, set_encryption_key, target_host, confirm_write, and user_email_index (decrypted-email-to-(user_id, is_approved) index). All three scripts below depend on this module.
approve_staging_users script
backend/db/approve_staging_users.py
Accepts one or more email addresses, builds a decrypted-email index via user_email_index, separates matched vs pending targets, and issues UPDATE users SET is_approved=true for only unapproved rows when confirm_write approves the write. Prints per-email status and a count summary.
copy_courses_to_staging script
backend/db/copy_courses_to_staging.py
Paginates prod courses from PostgREST using service-key auth, then upserts all rows into staging via INSERT ... ON CONFLICT (id) DO UPDATE; skips deletes for staging-only rows. Gated by confirm_write.
grant_staging_admin script
backend/db/grant_staging_admin.py
Looks up a staging user by decrypted email, fetches the admin role id from roles.slug, marks the user approved, and inserts a user_roles row idempotently using ON CONFLICT DO NOTHING. Re-queries and prints the resulting approval status and role slugs.
newsletter_emails migration
backend/db/migrations/0019_newsletter_approved_at.sql
Adds approved_at timestamptz (nullable) to newsletter_emails using IF NOT EXISTS to tolerate existing staging/prod drift. Includes inline operational notes for staging vs production application.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, a staging lane!
New scripts to approve and explain,
Encrypt the email, find the host,
Grant the admin, upsert the most.
The rabbit says: --yes to apply,
Or just preview — no need to cry! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: adding staging ops scripts and a database migration for the newsletter_emails.approved_at column.
Description check✅ PassedThe description comprehensively covers what was changed, why it matters, how scripts work, and verification steps. It aligns well with the provided template sections.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/staging-ops-scripts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/_staging_ops.py`:
- Around line 39-49: The confirm_write function currently only prints the target
host and returns the apply flag without validating that the database URL is
actually safe for writing. Add a hard safety guard that validates the database
URL points to a staging or safe environment before the function returns apply as
True. Check the target using the target_host function or similar mechanism to
determine if it is a production database, and either reject the write operation
entirely or require an explicit force flag parameter to bypass the safety check
when attempting to write to non-staging targets.
- Around line 61-67: The index dictionary in the function is silently
overwriting entries when duplicate plaintext emails are encountered, which can
cause downstream operations to target the wrong user ID. Before assigning to the
index dictionary on the line index[plain] = (uid, approved), add a check to
detect if that plaintext email key already exists in the index. If a duplicate
is found, raise an exception with a clear error message indicating the collision
and requiring manual disambiguation, rather than allowing the silent overwrite
to occur.
- Around line 4-6: The comment in _staging_ops.py documents a deliberate bypass
of the standard db/connection.py::table() requirement for cross-project staging
operations, but this exception is not formalized in the coding guidelines.
Update the coding guidelines documentation (the rule about "All Supabase access
must go through backend/db/connection.py::table()") to add a path-scoped
exception that explicitly permits direct Supabase access in the staging
operation scripts (backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2903be73-dde8-4d23-9afc-d0bc63f8c175

📥 Commits

Reviewing files that changed from the base of the PR and between f932cd3 and d4d0862fac6b028543982c7c6407c42abc683c86.

📒 Files selected for processing (5)
  • backend/db/_staging_ops.py
  • backend/db/approve_staging_users.py
  • backend/db/copy_courses_to_staging.py
  • backend/db/grant_staging_admin.py
  • backend/db/migrations/0019_newsletter_approved_at.sql

Comment on lines +4 to +6
Those scripts span two Supabase projects and so deliberately bypass
db/connection.py::table() (which is single-environment) — see each script's module
docstring. The common bits live here to keep them from diverging: dotenv reading, a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Verify the scope of direct database/http clients in backend/db scripts.
fd -e py . backend/db | xargs rg -n "psycopg\.connect|httpx\.Client|from supabase|import supabase"

Repository: SaplingLearn/Sapling

Length of output: 741


🏁 Script executed:

cat -n backend/db/_staging_ops.py | head -50

Repository: SaplingLearn/Sapling

Length of output: 2465


🏁 Script executed:

head -70 backend/db/approve_staging_users.py

Repository: SaplingLearn/Sapling

Length of output: 2878


Codify the exception for multi-project staging operations in the coding guidelines, or refactor to use backend/db/connection.py::table().

Lines 4–6 document a deliberate bypass of db/connection.py::table() for cross-project staging operations. This conflicts with the **/*.py guideline: "All Supabase access must go through backend/db/connection.py::table(). Do not instantiate httpx clients or import supabase directly elsewhere."

While the bypass is justified (these scripts span two Supabase projects), the exception must be codified in the coding guidelines as a path-scoped rule for backend/db/{migrate,approve_staging_users,grant_staging_admin,copy_courses_to_staging}.py rather than left as an undocumented practice. This ensures future maintainers understand when and why direct database access is acceptable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 4 - 6, The comment in
_staging_ops.py documents a deliberate bypass of the standard
db/connection.py::table() requirement for cross-project staging operations, but
this exception is not formalized in the coding guidelines. Update the coding
guidelines documentation (the rule about "All Supabase access must go through
backend/db/connection.py::table()") to add a path-scoped exception that
explicitly permits direct Supabase access in the staging operation scripts
(backend/db/migrate.py, backend/db/approve_staging_users.py,
backend/db/grant_staging_admin.py, and backend/db/copy_courses_to_staging.py).
This codification will ensure future maintainers understand when and why the
bypass is acceptable.

Source: Coding guidelines

Comment on lines +39 to +49
def confirm_write(db_url: str, apply: bool, action: str) -> bool:
"""Safe-by-default guard against pointing a write at the wrong project.

Prints the destination host. Returns True only when --yes (apply=True) was
passed; otherwise prints a preview notice and returns False so the caller can
report what it *would* do without mutating anything.
"""
print(f"Write target: {target_host(db_url)}")
if not apply:
print(f" PREVIEW ONLY — re-run with --yes to {action}. No changes made.")
return apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

confirm_write is preview gating, not actual target validation.

Line 46 only prints the host, and Line 49 returns apply unchanged. With --yes, a mispointed DB URL can still write to the wrong project. Add a hard non-staging guard (or explicit force flag) before any mutation path runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 39 - 49, The confirm_write function
currently only prints the target host and returns the apply flag without
validating that the database URL is actually safe for writing. Add a hard safety
guard that validates the database URL points to a staging or safe environment
before the function returns apply as True. Check the target using the
target_host function or similar mechanism to determine if it is a production
database, and either reject the write operation entirely or require an explicit
force flag parameter to bypass the safety check when attempting to write to
non-staging targets.

Comment on lines +61 to +67
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
index[plain] = (uid, approved)
return index

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Duplicate plaintext emails are silently overwritten in the index.

Line 66 replaces existing entries for the same normalized email. If duplicates exist, downstream approval/admin actions can target the wrong user id. Fail fast on collisions and require manual disambiguation.

Suggested guard
 def user_email_index(cur) -> dict[str, tuple[str, bool]]:
@@
for uid, email_ct, approved in cur.fetchall():
plain = (decrypt_if_present(email_ct) or "").strip().lower()
if plain:
- index[plain] = (uid, approved)+ existing = index.get(plain)+ if existing and existing[0] != uid:+ raise SystemExit(+ f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "+ f"({existing[0]} and {uid}). Resolve before continuing."+ )+ index[plain] = (uid, approved)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
index[plain] = (uid, approved)
returnindex
cur.execute("SELECT id, email, is_approved FROM users")
index: dict[str, tuple[str, bool]] = {}
foruid, email_ct, approvedincur.fetchall():
plain= (decrypt_if_present(email_ct) or"").strip().lower()
ifplain:
existing=index.get(plain)
ifexistingandexisting[0] !=uid:
raiseSystemExit(
f"ERROR: duplicate decrypted email {plain!r} maps to multiple user ids "
f"({existing[0]} and {uid}). Resolve before continuing."
)
index[plain] = (uid, approved)
returnindex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/_staging_ops.py` around lines 61 - 67, The index dictionary in the
function is silently overwriting entries when duplicate plaintext emails are
encountered, which can cause downstream operations to target the wrong user ID.
Before assigning to the index dictionary on the line index[plain] = (uid,
approved), add a check to detect if that plaintext email key already exists in
the index. If a duplicate is found, raise an exception with a clear error
message indicating the collision and requiring manual disambiguation, rather
than allowing the silent overwrite to occur.

Add three idempotent, safe-by-default (preview unless --yes) ops scripts used to
seed staging from prod and bootstrap access, plus a shared helper:
- copy_courses_to_staging.py — copy the plaintext `courses` catalog prod→staging
(prod via PostgREST, staging via psycopg; additive upsert on id, no deletes).
- grant_staging_admin.py — approve + grant the admin role, matched by decrypted
email; admin role_id looked up by slug (per-project UUID).
- approve_staging_users.py — approve accounts (is_approved) by decrypted email;
reports emails with no staging account yet as PENDING.
- _staging_ops.py — shared dotenv reader, staging-key setup, decrypt-email→user
index, and a write-target guard.
These deliberately bypass db/connection.py::table() (single-environment) since they
span two Supabase projects, mirroring db/migrate.py's direct-connection exception.
Credentials are read from gitignored dotenv files, never argv/process env.
Also add migration 0019: newsletter_emails.approved_at. The admin allowlist
endpoints read/write this column, but it was added to prod out-of-band and never
captured as a migration, so staging/CI/fresh DBs were missing it (500s the
endpoint). ADD COLUMN IF NOT EXISTS is a no-op where it already exists (prod).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the len(batch) < PAGE termination, which under-fetches silently
when prod's db-max-rows is below PAGE (the first response is short and the
loop stops early). Request pages with the Range header plus Prefer:
count=exact and loop until offset reaches the total reported in
Content-Range.
created_at is effectively immutable; excluding it from the ON CONFLICT
DO UPDATE SET clause prevents re-runs from replacing staging's existing
value with prod's. It stays in the INSERT column list for first seed.
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The newsletter_emails.approved_at migration is absorbed into the redesign's 0026 (tracked by #267), and the standalone 0019_newsletter… file collides with the redesign's 0019. Closing as superseded by #279. NOTE: if any of the staging ops scripts here are still wanted, salvage them into a fresh PR — reopen if so.

@AndresL230
AndresL230 deleted the chore/staging-ops-scripts branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AndresL230@Jose-Gael-Cruz-Lopez