feat: per-tenant usage metering + self-service usage view - #139
Merged
Conversation
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.
…ypasses EmailService)
… metered interface + per-tenant keys) Co-Authored-By: Claude Sonnet 4.6 <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.
Per-tenant usage metering + self-service usage view
Adds lightweight, per-tenant usage accounting and an in-app view of it.
What it does
usage_counterstable. 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.Settings → Usagepage (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
aggregateUsage/summariseTenantUsage) is unit-tested and isolated from I/O.where tenantId+ an in-memory re-filter) and covered by tests.usage_countersschema ships with the metering commit.🤖 Generated with Claude Code