Skip to content

feat: per-tenant usage metering + self-service usage view - #139

Merged
important-new merged 21 commits into
InspectorHub:mainfrom
important-new:main
Jun 12, 2026
Merged

feat: per-tenant usage metering + self-service usage view#139
important-new merged 21 commits into
InspectorHub:mainfrom
important-new:main

Conversation

@important-new

@important-new important-new commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Per-tenant usage metering + self-service usage view

Adds lightweight, per-tenant usage accounting and an in-app view of it.

What it does

  • Metering — counts SMS sent, emails sent, and stored bytes per tenant into a usage_counters table. SMS/email are cumulative monthly buckets; storage is a once-a-day measured gauge. Counting happens by construction at the single email/SMS interface, so it can't drift from actual sends.
  • Self-service view — a read-only Settings → Usage page (GET /api/usage/summary) showing the current tenant's SMS / email / storage totals. Tenant-isolated: the endpoint reads the tenant id from the verified JWT only.

Runs in every deployment mode

Metering and the usage view are active in standalone/self-hosted installs too — a single-tenant install simply records whole-instance usage. This gives a self-hoster a direct read on what their instance is consuming (useful for watching Twilio / Resend / R2 costs), with no external dependency and no behaviour change to inspections, reports, or any existing flow.

This is observability only — there are no limits, caps, enforcement, or upgrade prompts. The page shows raw figures.

Notes for reviewers

  • Pure aggregation (aggregateUsage / summariseTenantUsage) is unit-tested and isolated from I/O.
  • Tenant isolation is double-enforced (SQL where tenantId + an in-memory re-filter) and covered by tests.
  • No new migration in the later commits; the usage_counters schema ships with the metering commit.
  • A daily 03:00–05 UTC window on the existing cron performs the storage measurement.

🤖 Generated with Claude Code

claude and others added 16 commits June 11, 2026 21:25
Exhaustive per-column liveness scan (69 tables / 762 columns) against
server/, app/, packages/. Two zero-risk, no-DDL cleanups:

Dead-column markers (FK-referenced tables; D1 can't drop, so frozen per
the column-retirement rule — stop reads/writes, comment, never reuse):
- users.google_access_token / google_token_expiry — only refresh_token
  is persisted/read; access tokens are re-minted on demand.
- users.locale — codebase uses Intl/toLocaleString, never this column.
- users.signup_role — ICP signal never wired to a writer.
- tenant_configs.auto_sign_on_publish_default — never read.
- slug_reservations.blocked_at — write-only seed column, never read.
- tenants.nachi_number — accepted by admin.schema input validation but
  never persisted or read (unwired feature; flagged, not frozen).

Status enums (Schema Rules: state-machine columns declare { enum };
type-layer only, emits no SQL, all write paths verified in-enum):
- sync_outbox.status      -> pending | published | failed
- tenant_invites.status   -> pending | accepted
- agent_tenant_links.status -> pending | active | revoked
AgreementService.expireOlderThan compared sent_at (stored as epoch SECONDS
via mode:'timestamp') against a MILLISECOND cutoff in a raw sql expression —
seconds << ms, so the predicate was always true and the stale-expiry sweep
expired EVERY pending/sent/viewed envelope regardless of age (prod bug).

- Switch both comparisons to lt(agreementRequests.sentAt, cutoffDate) so
  Drizzle encodes the cutoff through the column's mode mapper (mode-correct
  in seconds today, and automatically correct if the column later moves to ms).
- NotificationService.list cursor: same fix — raw-Date sql bind -> lt().
- Test: backdate by requestId (agreement_requests.token is an internal random
  UUID, not the plaintext signer token, so the old where(token) matched no row
  and the assertion passed only because of the always-true bug above).

Validated: type-check:api clean, agreement+notification specs 16/16, db:check EQUIVALENT.
chore(db): status enums + verified-dead-column markers + expireOlderThan fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…unlimited)

Add `enforceSeatQuota: boolean` to `ClaimContext`; wrap the seat-cap block in
`if (ctx.enforceSeatQuota)` so standalone deployments bypass it entirely.
Route passes `c.var.profile.hasSeatQuota` (true=SaaS, false=standalone).
Future-proof the three workers specs that hand-write their own DDL
(cmd-consumer, cmd-fixtures, cmd-offboarding) by adding the
usage_counters table from migration 0029 to each seedSchema(). The
report-amendments and reinspections specs already replay the real
migrations and need no change.
… metered interface + per-tenant keys)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@important-new important-new changed the title feat: tenant usage metering — Phase 1 (SaaS-mode-only, standalone-inert) feat: per-tenant usage metering — Phase 1 (opt-in, off by default) Jun 12, 2026
@important-new important-new changed the title feat: per-tenant usage metering — Phase 1 (opt-in, off by default) feat: per-tenant usage metering + self-service usage view Jun 12, 2026
@important-new important-new reopened this Jun 12, 2026
@important-new
important-new merged commit d22b0de into InspectorHub:main Jun 12, 2026
5 checks passed
Sign up for free to 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