feat(admin): bulk KiloClaw trial extend/resurrect#1751
feat(admin): bulk KiloClaw trial extend/resurrect#1751evanjacobson wants to merge 35 commits intomainfrom
Conversation
Code Review SummaryStatus: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (5 files)
Fix these issues in Kilo Cloud Reviewed by gpt-5.4-20260305 · 430,020 tokens |
- extendTrials takes emails, resolves to userIds + subscriptions in two batch queries; skips users with no subscription or active paid plan - canceled subscriptions are fully reset to trialing (mirrors single-user admin reset path including email log clear) - matchUsers now joins kiloclaw_subscriptions and returns subscriptionStatus so the UI can show eligibility before the extend step - no DB transactions; writes are independent per user with try/catch isolation - removes kiloclaw_trial_grants dependency; no-subscription users are skipped
- Remove kiloclaw_trial_grants table from schema, migration, journal, and snapshot — the bulk extend tool skips no-subscription users so the table would never have rows; revert kiloclaw-router.ts to the standard trial duration and remove the GDPR handling from user.ts - Delete empty src/app/admin/contributors/page.tsx (would break Next.js build) - Add WHERE status = 'trialing'/'canceled' guards to both UPDATE calls in extendTrials so a subscription that changes state between the match read and the write is detected and returned as an error rather than silently modified - Fix stale docstring that still mentioned best-effort instance start - Disable Apply button when no eligible (trialing/canceled) users remain
…button label - Restore _journal.json to origin/main state (no migration was added by this PR; previous commits had introduced a trailing comma making it invalid JSON) - ineligibleCount now covers all users who will be skipped (active paid plans AND no-subscription), not just active paid plans - Apply button now shows eligibleCount (trialing + canceled users) instead of total matchedUsers.length so admins see exactly how many will be processed
…inst NULL trial end - Always reset selectedColumn when a new file is loaded so a second upload without Clear doesn't leave a stale column name from the previous file causing 0 emails to be detected - Wrap trial_ends_at in COALESCE before GREATEST so a NULL trial end date (invalid but not DB-constrained) extends from now() rather than producing NULL and overwriting the value with NULL
…scription commit After the subscription UPDATE succeeds, a failure in the subsequent email-log delete or audit-log insert was propagating through the outer catch and reporting success:false to the admin. This caused retry attempts to double-apply the extension (retry hits the trialing path instead of the canceled/resurrect path) and left no audit trail for the first apply. Wrap both secondary writes in their own try/catch so a failure there does not mask the already-committed subscription state change.
…te input Move the standalone /admin/extend-claw-trial page into the /admin/kiloclaw dashboard as a "Bulk Extend" tab. Add a paste-based email input mode (default) alongside the existing CSV upload. Remove the separate sidebar nav entry.
1. src/app/admin/bulk-credits/page.tsx — Converted from a standalone flat page to a tabbed layout with two tabs: - "Bulk Credits" — the existing bulk credit granting functionality (extracted into BulkCreditsTab component) - "KiloClaw Trial Extension" — the KiloclawExtendTrial component imported from its existing location - Page title updated to "Bulk Credits & Trials", breadcrumb updated to match - Tab state synced to URL via ?tab=trial-extension query param (default/no param = bulk-credits tab) 2. src/app/admin/components/KiloclawDashboard.tsx — Removed the "Bulk Extend" tab and its KiloclawExtendTrial import/usage 3. src/app/admin/components/AppSidebar.tsx — Updated sidebar nav item title from "Bulk Credits" to "Bulk Credits & Trials"
…ineligible before extend
…source and pending_conversion on resurrection
…ative payment_source/pending_conversion clear
… user_id/instance_id in all exports
1a6ce20 to
0e326a6
Compare
…itched input widget
…son in table and csv
… export, fix csv encoding, clean up columns
…ed tables, move exports inline
…emove LEAST from sql, remove Days column
…t reflects new trial end
evanjacobson
left a comment
There was a problem hiding this comment.
Leaving file-level notes on the larger new files to orient reviewers.
| @@ -0,0 +1,896 @@ | |||
| 'use client'; | |||
There was a problem hiding this comment.
This is a brand-new component (~900 lines) — the entire file is an addition, so the diff looks huge but there's nothing changing under it. It implements the UI for the KiloClaw Trial Extension tab: CSV upload or paste input, email→user matching (calls extendClawTrial.matchUsers), a preview table with per-row subscription status badges, a "days to extend" field, and a results table after submission. The two main user flows are extending an active trial (pushes trial_ends_at forward) and restarting an expired one. Both are handled by the single extendClawTrial.extendTrials mutation on the backend.
| @@ -0,0 +1,392 @@ | |||
| import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; | |||
There was a problem hiding this comment.
New tRPC router (~390 lines, all additions). Exposes two procedures under admin.extendClawTrial: matchUsers (query) resolves a list of emails to user + subscription records in bulk and applies an at_limit synthetic status when a trial would already extend beyond 1 year from now; extendTrials (mutation) does the actual update — extending trial_ends_at for active trials, or inserting a fresh subscription row for users with no trial yet, both capped at 1 year. It also clears relevant kiloclaw_email_log rows so trial notification emails can retrigger, and writes an admin audit log entry per batch.
| @@ -0,0 +1,151 @@ | |||
| import { describe, expect, it, beforeEach } from '@jest/globals'; | |||
There was a problem hiding this comment.
New test file (~150 lines). Covers the two behaviors most likely to regress: the at_limit ineligibility flag (trial already extends past 1 year from now should be surfaced to the admin before they submit) and the 1-year ceiling on extendTrials (requesting more days than the cap allows should clamp to exactly 1 year out, not silently overflow). Tests use real DB helpers — no mocks.
- Extract isEligible() predicate to replace 4 inline copies - Move extractedEmails computation before handleMatchUsers so handler reuses it - Extract shared downloadCsv to src/lib/admin-csv.ts (used by both bulk-credits and KiloclawExtendTrial) - Move trialDays input from Step 1 to Step 2 alongside the Apply button - Fix at_limit check: change > to >= so users exactly at the 1-year ceiling are blocked (extending would be a no-op) - Add boundary test: trial_ends_at exactly at 365 days must produce at_limit - Add comments explaining raw button tabs (avoids nested Radix Tabs) and useRef toast dedup (React Query v5 workaround)
- Add csvField() RFC 4180 escaping helper to admin-csv.ts and apply it in all three export functions in KiloclawExtendTrial so error messages with commas/quotes can't corrupt downloaded CSVs - Parallelize DB updates in extendTrials via Promise.allSettled, extracting per-user logic into processOneEmail; eliminates up to 1000 sequential round-trips for large batches - Align at_limit ceiling math with SQL interval '1 year': use calendar-year setFullYear arithmetic instead of fixed 365-day ms offset - Compare at UTC-day granularity in the beyondCeiling check so ms-level clock drift cannot let an exact-ceiling row slip through as eligible - Extract parseCsvToTable, extractEmailsFromColumn, guessEmailColumn, and parseEmailList from KiloclawExtendTrial into admin-csv.ts; remove the redundant CsvData type alias (use CsvTableData directly)
Summary
Adds a Bulk KiloClaw Trial Extension tool to the admin panel, accessible under the existing Bulk Credits & Trials page. Admins can paste a list of emails or upload a CSV, preview which users are eligible, and extend or restart KiloClaw trials in bulk with a single click.
How it works
Eligibility
trialing→ eligible. Trial is extended by N days from the later of the current end date or today, so expired trials extend from today rather than compounding from a past date.canceled→ eligible. The subscription is resurrected as a fresh trial: status reset totrialing, plan reset totrial, all Stripe/billing fields cleared. The email suppression log is also cleared so trial notifications fire again.active,past_due,unpaid) → ineligible. We must not touch a user who is actively paying.There is no concern with extending a user's trial more than once.
Product decisions
365-day input cap, 1-year ceiling: The input is capped at 365 days. Additionally, the resulting
trial_ends_atis capped at 1 year from the moment Apply is clicked — meaning a user who is already mid-trial cannot be pushed past 1 year out regardless of how many days are requested. This prevents runaway trial windows.Canceled resurrection — direct DB write, no Stripe API call: When a subscription is
canceled, the Stripe subscription is already terminated (that's what triggered the webhook that set the status). Resurrecting via a direct DB write is safe.stripe_subscription_idis explicitly nulled to prevent any stale Stripecustomer.subscription.deletedwebhook retry from immediately re-canceling the resurrected row.Per-user error isolation: The apply step processes users in a sequential loop on the server. A failure for one user (e.g. their status changed between match and apply) does not abort the others — all users are attempted and a per-user result is returned.
Exports: Successful export includes
instance_id. Ineligible export includesinstance_id,stripe_subscription_id, and a reason string. Failed export includes the error message. Unmatched export is emails only.Verification
pnpm typecheck— passes (one pre-existingcsv-parse/syncerror in an unrelated script)pnpm lint/pnpm oxfmt— passes for all files touched by this branchVisual Changes
N/A — admin-only UI.
Reviewer Notes
canceledresurrection path mirrors the existing single-user admin reset inadmin-router.ts. That path also has the multi-instance exposure (its UPDATE usesWHERE user_id = ?) — worth a follow-up but out of scope here.payment_sourceandpending_conversionare intentionally not cleared on resurrection.stripe_subscription_id = nullis the effective guard for both concerns; the full billing implications of clearing these fields across allpayment_sourcevalues were not fully analyzed.kilo-app/files (confirmed viagit diff origin/main...HEAD).kiloclaw_subscriptionsno longer has a unique constraint onuser_id— users can have multiple instances, each with its own subscription row. All queries useSELECT DISTINCT ON (user_id) ORDER BY user_id, created_at DESCto target only the most recently created subscription per user, and UPDATEs are scoped to the specific rowidto avoid touching other instances. This will need to be revisited at some point after that feature launches.