admin/customers: case-insensitive email search + multi-tier filter + lower(email) index#55
Merged
Merged
Conversation
…lower(email) index Wires the dashboard's filter pills (anon/free/hobby/pro/team) to the admin customers list so the founder can actually narrow the view. Three behavior changes: - q=<substr> is case-insensitive both ways: q=FOUNDER matches founder@x.com (handler lowercases both sides; lower(email) index added in migration 023 to keep the lookup cheap at scale). - tier=hobby,pro is now valid — comma-joined values OR together via WHERE plan_tier IN (...). Single-tier shape (tier=pro) is unchanged and still uses = $N so PR #48's planner stats stay valid. - Bogus tier values (typos, stale UI builds) return an empty list, not 400. Multi-tier with mixed valid/invalid keeps only the valid ones. UI-stable contract for the filter-pills UI. Adds 5 new test cases covering substring match, case-insensitivity, multi-tier OR, all-unknown empty-list, and pagination correctness across q+limit+offset (regression guard against the $N counter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805
added a commit
that referenced
this pull request
Jun 5, 2026
…limit (Task #55) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mastermanas805
added a commit
that referenced
this pull request
Jun 5, 2026
) * feat(plans): flag-gated per-service resource-count caps (Task #55) Closes the strict-≥80%-margin hole where only queue_count was capped: a tenant could create MANY postgres/vector/redis/mongodb/storage resources each at the per-resource size cap and blow the saturated-COGS bound (Redis binding at $6.50/GB). Adds a per-tier active-resource COUNT cap per service, enforced like the existing queue_count A6 block — but FLAG-GATED, default OFF. Flag: RESOURCE_COUNT_CAPS_ENABLED (config.go, default false). When off, the new enforceResourceCountCap helper returns immediately and runs NO count query — zero behavior change, proven inert by TestResourceCountCap_FlagOffIsInert + TestEnforceResourceCountCap_FlagOffInert_Whitebox. Operator enables after a usage audit so no current tenant is retroactively over a cap. Enforcement sites (mirror queue.go's A6 block; 402 + agent_action + metric): db.go (postgres), vector.go, cache.go (redis), nosql.go (mongodb), storage.go. Shared helper: internal/handlers/resource_count_cap.go (one call site per handler, not a copy-pasted block). Count cap fails CLOSED on a count-query error when enabled (a cheap indexed COUNT; must not silently bypass a cost cap). Per-tier numbers (api/plans.yaml; mirrors common defaultYAML, depends on common#47 which is merged): anon/free=1 each; hobby=2; hobby_plus=3; pro pg/vec/mongo/storage=5 redis=3; growth=6 redis=3; team pg=5 vec=8 redis=4 mongo=6 storage=6. redis_count is the most conservative line everywhere (binding COGS). Derived so count×size×unit-COGS ≤ tier 20%-of-price budget per service. Surfaces (rule 22): /api/v1/capabilities resource_count_limit map; /api/v1/billing/usage count+count_limit on storage services; openapi.go schemas; content/llms.txt + instanode-web public/llms.txt (separate PRs). Metric (rule 25): instant_resource_count_limit_blocked_total{service,team_tier} (metrics.go). Alert + Prom rule + dashboard tile + catalog row in infra PR. Tests: registry-iterating flag-on guard (rule 18, TestResourceCountCap_FlagOnAtLimitRejects) so service N+1 can't ship uncapped; under-limit pass; whitebox edge branches (unlimited, count-error, nil cfg) → enforceResourceCountCap 100%; capabilities surface guard; config flag test; strict_margin guard extended to the new *_count fields. make gate green (the one unrelated pre-existing failure, models.TestLinkGitHubID, reproduces with this change stashed and touches no files in this diff). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(openapi): regenerate snapshot for resource_count_limit + count_limit (Task #55) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wires the dashboard's filter pills + search box to
GET /api/v1/admin/customersso the founder can actually narrow the customer view. Three behavior changes on top of Track A (PR #48):q=FOUNDERmatchesfounder@x.com. Handler lowercases both sides; new functional indexidx_users_email_lower(migration 023) keeps the lookup cheap as the users table grows.?tier=hobby,proORs both intoWHERE plan_tier IN ('hobby','pro'). Single-tier shape (?tier=pro) is unchanged and still emits= $Nso PR admin: Customers endpoints (list/detail/tier-change/issue-promo) gated on ADMIN_EMAILS #48's planner stats remain valid.?tier=platinumnow returns an empty list (200) rather than 400. Mixed valid+invalid (?tier=pro,platinum) keeps the valid ones. Filter pills degrade gracefully on stale UI builds / typos instead of error-bannering.Why now
Track A shipped the endpoint + UI scaffolding (filter pills + search box) but the pills didn't actually filter and the search box was case-sensitive prefix-only. This is the wire-up.
Performance note
Migration 023 adds
CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users (lower(email))— NOTCONCURRENTLY. The migration runner (internal/db/postgres.go:RunMigrations) executes each.sqlfile throughdb.Exec, andCREATE INDEX CONCURRENTLYcan't run inside a transaction block; lib/pq's multi-statement batching makes this flaky in practice. At instanode's current users-table size (single-digit-thousand rows max) a blockingCREATE INDEXcompletes in sub-second — lock window is imperceptible. When we cross ~100k users we should revisit and split this migration to run outside the standard runner. Rationale documented in the migration file's leading comment.Migration is also numbered 023 as requested in the spec, not 022. The current highest is 021; the gap is intentional (mirrors existing gaps like 003/007).
Test plan
make test-unitgreen across all packages🤖 Generated with Claude Code