From 5869fbf5b48416ad5429db5fbfb32f30acadda5e Mon Sep 17 00:00:00 2001 From: Feranmi Oresajo Date: Fri, 25 Sep 2026 21:24:41 +0100 Subject: [PATCH] feat: dashboard backend (v0.47 phase 3a): profile, org detail, members, invites, org analytics Human-JWT routes for the future dashboard frontend. Non-members get 404, non-owners 403 on owner-only routes. Member removal is serialized by the tenant row lock so an org can never lose its last owner. Org analytics reuse the API-key analytics handlers. DEC-228..230. --- .ilana/architecture.md | 8 + .ilana/decisions.md | 3 + .ilana/ledger.md | 3 + .ilana/milestones.md | 5 + .ilana/state.json | 8 +- internal/api/dashboard_handler.go | 330 +++++++++++++++++++++++++ internal/api/dashboard_handler_test.go | 244 ++++++++++++++++++ internal/api/openapi.go | 84 +++++++ internal/api/routes.go | 15 ++ internal/database/dashboard.go | 187 ++++++++++++++ internal/database/dashboard_test.go | 189 ++++++++++++++ 11 files changed, 1072 insertions(+), 4 deletions(-) create mode 100644 internal/api/dashboard_handler.go create mode 100644 internal/api/dashboard_handler_test.go create mode 100644 internal/database/dashboard.go create mode 100644 internal/database/dashboard_test.go diff --git a/.ilana/architecture.md b/.ilana/architecture.md index f0521ae..b7165a3 100644 --- a/.ilana/architecture.md +++ b/.ilana/architecture.md @@ -501,3 +501,11 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u - Enforcement points: `admitSend` (daily UTC-day message count), `domainHandler.handleCreate` (non-deleted domains), `humanauth.InviteToOrganization` (early) + `AcceptOrgInvitationForExistingHuman`/`WithSignup` (authoritative, tenant row `FOR UPDATE`), `broadcastHandler.handleCreate`, `webhookHandler.handleCreate`, retention purge default. - Paystack: `POST /v1/billing/checkout` (owner-only, body `{tenant_id, plan}`, Initialize Transaction in USD cents with metadata `{tenant_id, plan}`, returns `authorization_url`); `GET /v1/billing/subscription?tenant_id=` (any member); `POST /v1/billing/webhook` (public, x-paystack-signature HMAC-SHA512 verified, charge.success applies plan for 30 days, `billing_payments.reference` PK makes it idempotent). `plan-lapse` hourly component downgrades expired paid plans to free/lapsed; no auto-renewal (RSK-044). - Config: `MAILX_PAYSTACK_SECRET_KEY` (enables billing + enforcement), `MAILX_PAYSTACK_PUBLIC_KEY` (frontend only, unused server-side), optional `MAILX_PAYSTACK_CALLBACK_URL`, optional `MAILX_PAYSTACK_BASE_URL` (tests/tooling). + +## Dashboard backend (v0.47 phase 3a; design decisions DEC-228..230) + +- Human-JWT routes (`humanAuthMiddleware`, registered only when humanAuth is configured; `internal/api/dashboard_handler.go`): `GET/PATCH /v1/me` (profile + orgs; name/avatar_url only), `GET /v1/orgs/{id}` (name, logo_url, plan/plan_status/plan_current_period_end, created_at; no slug column exists), `PATCH /v1/orgs/{id}` (owner), `GET /v1/orgs/{id}/members` (any member), `DELETE /v1/orgs/{id}/members/{humanId}` (owner; 204), `GET /v1/orgs/{id}/invites` (owner; pending only), `DELETE /v1/orgs/{id}/invites/{inviteId}` (owner; idempotent 204), `GET /v1/orgs/{id}/analytics/overview|timeseries` (any member; delegates to the API-key analytics handlers via `withTenant`). +- AuthZ: non-member -> 404 `organization_not_found`; member non-owner on owner-only route -> 403 `not_org_owner` (DEC-228). +- Invariant: an org always has >= 1 owner; removals are serialized by the tenant row lock (DEC-229). Owners cannot remove themselves (409 `cannot_remove_self`). +- DB: `internal/database/dashboard.go` (`UpdateHumanProfile`, `UpdateOrganization`, `ListTenantMembers`, `RemoveTenantMember`, `ListPendingOrgInvitations`, `RevokeOrgInvitation`). No migration. +- Limitations: no leave-org / transfer-ownership / role-change flow; no email change; no org slug; `/v1/orgs/*` dashboard routes are not in `TestOpenAPIRoutesMatchRuntime` (its mux has no humanAuth), covered by `dashboard_handler_test.go` instead. diff --git a/.ilana/decisions.md b/.ilana/decisions.md index 4940a85..bc116c4 100644 --- a/.ilana/decisions.md +++ b/.ilana/decisions.md @@ -232,3 +232,6 @@ Process decisions above (DEC-001..DEC-007) belong to the v0.23 FLEET run and sta - DEC-225 [v0.47 phase 2 billing]: Paystack integration is Initialize Transaction + webhook only (no Paystack Subscriptions/plan codes). `POST /v1/billing/webhook` is public and authenticated solely by `x-paystack-signature` = hex HMAC-SHA512(secret key, raw body), verified with `hmac.Equal` before any parsing; failure is 401. A verified `charge.success` is applied only if status=success, currency=USD, amount >= plan price, metadata names a tenant and a paid plan; it sets plan/active/period_end = now+30d. Replays are neutralized by `billing_payments.reference` PRIMARY KEY inserted in the same transaction as the plan update (duplicate -> rollback, 200 `already_applied`). Unknown events and signed-but-unusable payloads get 200 (no Paystack retry); only our storage errors are 5xx. Checkout/subscription take the org as `tenant_id` (body/query) because a human may own several orgs; checkout is owner-only, subscription any member (non-member gets 404, no enumeration). - DEC-226 [org invitations, CI caught what Greptile's trial limit ended before finding]: Greptile's free-trial credit limit was exhausted after PR #23 merged (no more automated review available going forward - manual review is now this project's primary line of defense, alongside CI). CI's own `-race` run on PR #24 caught a genuine, deeper bug in DEC-219/220's own fix: `TestConcurrentResendsNeverInvalidateBothLinks` failed intermittently with 2 survivors instead of 1, reproducing locally at roughly 50% under `go test -count=25`. Root cause: the `(created_at, id)` tuple comparison in `SupersedeOtherPendingOrgInvitations` assumes a smaller tuple means "already committed, therefore visible to a later query" - true only if inserts for the same address are serialized, which they were not. PostgreSQL's `now()` is fixed at a transaction's BEGIN, not its commit, so two genuinely concurrent autocommit INSERTs can commit in a DIFFERENT order than their `created_at` values suggest; a row with a small timestamp can still become visible to a later query AFTER that query's snapshot was already taken, meaning nothing ever supersedes it. Fixed properly this time with two changes together (verified each is independently necessary): (1) `CreateOrgInvitation` now holds `pg_advisory_xact_lock(hashtextextended(tenant_id||':'||normalized_email, 0))` for the duration of its own transaction, fully serializing concurrent inserts for the same address (the lock is scoped to a short transaction that commits well before the slower `SendSystemEmail` network call that follows - never held across it, preserving DEC-218's fix). (2) `created_at` is now set explicitly via `clock_timestamp()` in the INSERT rather than left to the column's `now()` default - critical, because a transaction that waited on the advisory lock would otherwise still capture an EARLIER timestamp (from its own BEGIN) than one that acquired the lock and committed first, silently reintroducing the exact ordering violation the lock exists to prevent. The advisory lock alone was proven insufficient by testing: re-ran the stress test 25x after adding only the lock and it still failed roughly half the time; only after also switching to `clock_timestamp()` did 40/40 stress runs (and the full `-race` suite) pass cleanly. This is the fifth fix-on-a-fix in this feature's review history (DEC-217→218→219→220→226), and the first one Greptile never got to review - a reminder that "manual review = read the diff and reason about it" is not equivalent to "manual review = actually stress-test the concurrency claim under `-count=N` before trusting it," especially now that automated review isn't available as a backstop. Verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean, `TestConcurrentResendsNeverInvalidateBothLinks` run 40x consecutively with `-race` with zero failures, Docker rebuild+boot smoke clean. - DEC-227 [billing plans, CodeRabbit review pass]: Greptile's trial is exhausted (see DEC-226); CodeRabbit is now active on this repo and reviewed PR #24, catching 3 real findings, 2 of them data-integrity-critical. (1) **Data loss on billing enablement or plan lapse** (the most severe): with plan enforcement on, a NULL `tenants.retention_days` falls back to the tenant's PLAN window (Free = 7 days) instead of the flat `DefaultRetentionDays` (90). An operator turning on `MAILX_PAYSTACK_SECRET_KEY` for the first time on an existing deployment would silently shrink every pre-existing tenant's retention window, and the next hourly `retention-purge` run would irreversibly hard-delete any terminal message between 7 and 90 days old; the same happens to a Plus/Pro tenant the instant `plan-lapse` downgrades it (a renewal running even one hour late triggers it). Fixed two ways: migration 000031 now backfills `retention_days = 90` for every tenant that has no explicit value BEFORE the plan columns' semantics can apply (pins pre-existing tenants at today's effective window; a brand-new tenant created after billing is enabled has no messages yet, so its plan's window applying from day one is correct, not a regression) - and `DowngradeLapsedPlans` now pins `retention_days` to the lapsing plan's own window (`COALESCE(retention_days, CASE plan WHEN 'plus' THEN 30 WHEN 'pro' THEN 90 END)`) before clearing `plan`, so a lapse only ever changes billing state, never retention behavior. (2) **Renewal loses remaining paid days** (major): `ApplyPlanPayment` overwrote `plan_current_period_end` to `now + 30 days` regardless of any remaining time on the current period, so an owner renewing 5 days early paid for 30 days but only received 25. Fixed by changing the parameter from an absolute `periodEnd` to a `period time.Duration`, and extending from the LATER of `now()` and the existing `plan_current_period_end` when the tenant is renewing the SAME plan while still active; a plan CHANGE (upgrade/downgrade) or a renewal after the plan had already lapsed still starts a fresh period from now, since carrying over time priced under a different plan has no well-defined meaning. (3) **Signed-but-unusable webhook payloads answered 400 instead of 200** (minor): Paystack can send non-object `metadata` (e.g. `0` or `""`) for a transaction MailX's own checkout never created (a payment page or another integration on the same Paystack account); `Metadata`'s plain-struct JSON decoding failed on those, and `handleWebhook`'s contract requires unrecognized/unusable-but-validly-signed payloads to be acknowledged 200 (Paystack retries forever on anything else). Fixed with a custom `Metadata.UnmarshalJSON` that treats a decode failure as an empty (not erroring) `Metadata` - the handler already ignores an empty `TenantID`. All three fixes covered by new/updated tests (`TestParseEventToleratesNonObjectMetadata`; `TestApplyPlanPaymentAndReplay` extended with same-plan-extends and different-plan-fresh-period cases; `TestDowngradeLapsedPlans` extended to assert the pinned retention window). Verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean, migration 000031 round-trip re-validated with the new backfill statement, Docker rebuild+boot smoke clean. +- DEC-228 [v0.47 phase 3a dashboard backend]: one authorization posture for every `/v1/orgs/{id}/*` dashboard route, implemented once in `dashboardHandler.orgAccess` (`internal/api/dashboard_handler.go`): `IsTenantMember` first (non-member or nonexistent org -> 404 `organization_not_found`, no enumeration, same as `/v1/billing/subscription`), then `IsTenantOwner` for owner-only routes (member non-owner -> 403 `not_org_owner`). Owner-only: PATCH org, DELETE member, GET/DELETE invites (listing reveals non-members' emails). No new membership/ownership queries were written. PATCH bodies: omitted field = unchanged, empty URL = clear; URLs must be absolute http(s) <= 2048 chars (blocks `javascript:` etc.), names trimmed 1..200. Email change is out of scope (needs re-verification). +- DEC-229 [v0.47 phase 3a]: "every org keeps >= 1 owner" is enforced in `database.RemoveTenantMember` under the lockMemberCap pattern: `SELECT 1 FROM tenants WHERE id=$1 FOR UPDATE`, then under that lock re-check the ACTOR is still an owner, the target is a member, and the owner count. Self-removal is refused before the tx (`ErrCannotRemoveSelf`, 409; leave/transfer-ownership flow deferred). Because self-removal is blocked and the actor must be an owner, the only way to reach zero owners is two owners removing each other concurrently; the lock serializes them and the loser gets `ErrNotTenantOwner` (403). `ErrLastOwner` (409) stays as a defensive last line. Proven by `TestRemoveTenantMemberConcurrentMutualRemovalKeepsAnOwner` (20 rounds x 2 racers with a start barrier, asserts exactly 1 ok / 1 denied / 1 owner left); verified the test FAILS with `FOR UPDATE` removed. Invitation revoke reuses the supersede mechanism (`expires_at = now` on a pending row), idempotent for expired/accepted rows, 404 only if the ID does not belong to the org. +- DEC-230 [v0.47 phase 3a]: org analytics routes do not duplicate analytics code: `dashboardHandler.orgAnalytics` runs the membership check, attaches the org via `withTenant` (the single tenant-identity entry point) and calls the SAME `analyticsHandler.handleOverview`/`handleTimeseries` the API-key routes use, so validation, caps and response shape are identical. Org detail omits `slug`: `tenants` has no slug column (create-org already accepts and discards it); adding one needs a migration + uniqueness design, deferred rather than invented. diff --git a/.ilana/ledger.md b/.ilana/ledger.md index d54b519..b6497db 100644 --- a/.ilana/ledger.md +++ b/.ilana/ledger.md @@ -326,3 +326,6 @@ Greptile's free trial hit its credit limit after PR #23, so PR #24 (billing) got ## 2026-09-25 | billing plans: CodeRabbit review pass (first reviewer since Greptile's trial ran out) | GATE PASS Greptile's free-trial credit limit was hit right after PR #23 merged - it can no longer review this repo. CodeRabbit turned out to be configured on the repo too and reviewed PR #24 (billing) in Greptile's place, catching 3 real findings the earlier manual review had missed, 2 of them serious data-integrity bugs: enabling billing on an existing deployment (or a paid plan simply lapsing) could silently shrink a tenant's retention window from 90 days down to Free's 7, and the very next hourly retention-purge run would irreversibly hard-delete anything in that gap - fixed by backfilling retention_days for pre-existing tenants in the migration itself and by pinning the lapsing plan's window explicitly before a lapse clears the plan column, so billing state and retention behavior are now fully decoupled. Also fixed: a plan renewal was overwriting the remaining paid period instead of extending it (an owner renewing early lost days they'd already paid for), and a webhook whose metadata wasn't a JSON object (a real Paystack scenario for transactions MailX didn't create) was answered 400 instead of the required 200, which would make Paystack retry forever. This confirms the plan from the last session: without Greptile, review discipline has to come from us directly, and it's working - CodeRabbit filled the gap this time, but the org-invitation race from the previous entry proves CI's own -race run is just as important a backstop as any external reviewer. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean, migration round-trip re-validated, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-227), state.json (DEC/RSK counters). + +## 2026-09-25 | v0.47 phase 3a: dashboard backend proper | GATE PASS +Built the human-JWT dashboard surface: /v1/me (GET/PATCH), /v1/orgs/{id} (GET/PATCH), members list/remove, pending invites list/revoke, and org-scoped analytics that reuse the API-key analytics handlers via withTenant. Last-owner invariant enforced under the tenant row lock; the concurrent mutual-removal test was checked to fail with the lock removed. No migration; slug omitted (no column). Verified against real Postgres+Redis: gofmt/go vet/go build clean, full go test -race ./... clean, OpenAPI tests pass, Docker rebuild+boot smoke. Ilana updated: decisions.md (DEC-228..230), architecture.md, milestones.md, state.json. diff --git a/.ilana/milestones.md b/.ilana/milestones.md index 0edda0f..e91017e 100644 --- a/.ilana/milestones.md +++ b/.ilana/milestones.md @@ -106,3 +106,8 @@ explicitly deferred to a later phase — v0.47 as a whole is NOT complete. - Routes (only when `MAILX_PAYSTACK_SECRET_KEY` set): POST /v1/billing/checkout, GET /v1/billing/subscription, POST /v1/billing/webhook; `plan-lapse` hourly component. - Enforcement wired into admitSend (daily volume), domain create, invite send + both accept transactions (member cap, row-locked), broadcast create, webhook create, retention purge default. All inert when billing is not configured (DEC-221..225). - Tests: `internal/billing/billing_test.go`, `internal/database/plans_test.go` (incl. concurrent-accept race), `internal/api/billing_handler_test.go`, `internal/humanauth` member-cap test. Full `go test -race ./...` clean against real Postgres+Redis (0 skips in database/api/humanauth); migration down/up round-trip on a disposable DB; Docker rebuild+boot clean. + +## v0.47 phase 3a — Dashboard backend proper (profile, org detail, members, invites, org analytics) — COMPLETE (scope per DEC-228..230) +- 10 human-JWT routes in `internal/api/dashboard_handler.go`, DB layer `internal/database/dashboard.go`, OpenAPI documented; no migration. +- Tests: `internal/database/dashboard_test.go` (rules, concurrent mutual-removal race, invite list/revoke idempotency, profile/org update), `internal/api/dashboard_handler_test.go` (every endpoint: happy path, non-member 404, non-owner 403, validation). +- Deferred: leave org / transfer ownership / role changes, email change, org slug column. Plan auto-renewal and OAuth/MFA are separate parallel work, not part of 3a. diff --git a/.ilana/state.json b/.ilana/state.json index 70c417c..dc3ed28 100644 --- a/.ilana/state.json +++ b/.ilana/state.json @@ -18,7 +18,7 @@ "DEF": 27, "CR": 24, "RSK": 45, - "DEC": 227, + "DEC": 230, "MET": 72, "ETH": 1 }, @@ -34,10 +34,10 @@ "G8": 1 }, "mailx": { - "last_completed_milestone": "v0.47 phase 2 (billing & plans MVP)", - "ilana_current_through": "v0.47 phase 2", + "last_completed_milestone": "v0.47 phase 3a (dashboard backend: profile/org/members/invites/org analytics)", + "ilana_current_through": "v0.47 phase 3a", "current_milestone": "v0.47 Human Accounts & Organizations", - "current_milestone_status": "v0.47 phase 1 (human auth, orgs, password reset, invitations) and phase 2 (Free/Plus/Pro plans + Paystack one-off checkout, enforcement gated on MAILX_PAYSTACK_SECRET_KEY) complete; auto-renewal (RSK-044), OAuth, MFA deferred", + "current_milestone_status": "v0.47 phase 1 (human auth, orgs, password reset, invitations) and phase 2 (Free/Plus/Pro plans + Paystack one-off checkout, enforcement gated on MAILX_PAYSTACK_SECRET_KEY) complete; auto-renewal (RSK-044), OAuth, MFA deferred; phase 3a dashboard backend (DEC-228..230) complete; leave-org/transfer-ownership, email change, org slug deferred", "next_milestone": "v0.47 phase 3 (plan auto-renewal, dashboard backend) or v0.48 (TBD)" } } diff --git a/internal/api/dashboard_handler.go b/internal/api/dashboard_handler.go new file mode 100644 index 0000000..2a77f94 --- /dev/null +++ b/internal/api/dashboard_handler.go @@ -0,0 +1,330 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Ferousco-dev/mailx/internal/database" +) + +// dashboardHandler serves the human-JWT dashboard surface (v0.47 phase 3a): +// /v1/me and the per-org detail/member/invite/analytics routes. Every route +// sits behind humanAuthMiddleware. Authorization posture (DEC-228): a +// non-member always gets 404 organization_not_found (no enumeration); a +// member who is not an owner gets 403 not_org_owner on owner-only routes. +type dashboardHandler struct { + db *database.DB + now func() time.Time +} + +const ( + maxDisplayNameLen = 200 + maxURLLen = 2048 +) + +// orgAccess resolves the caller and the {id} org, enforcing membership (and +// ownership when ownerOnly). It writes the error response and returns ok=false +// on any failure. +func (h *dashboardHandler) orgAccess(w http.ResponseWriter, r *http.Request, ownerOnly bool) (humanID, tenantID string, ok bool) { + humanID, ok = humanIDFromContext(r.Context()) + if !ok { + writeError(w, r, newError(ErrAuthentication, "invalid_access_token", "missing or invalid access token")) + return "", "", false + } + tenantID = r.PathValue("id") + member, err := h.db.IsTenantMember(r.Context(), tenantID, humanID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check organization membership")) + return "", "", false + } + if !member { + writeError(w, r, newError(ErrNotFoundType, "organization_not_found", "organization not found")) + return "", "", false + } + if ownerOnly { + owner, err := h.db.IsTenantOwner(r.Context(), tenantID, humanID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check organization ownership")) + return "", "", false + } + if !owner { + writeError(w, r, newError(ErrForbidden, "not_org_owner", "only an organization owner can do this")) + return "", "", false + } + } + return humanID, tenantID, true +} + +// patchBody is the shared {name?, ?} PATCH shape. A present +// empty URL clears it; an absent field is left unchanged. +type patchBody struct { + Name *string `json:"name"` + AvatarURL *string `json:"avatar_url"` + LogoURL *string `json:"logo_url"` +} + +func decodePatch(w http.ResponseWriter, r *http.Request) (patchBody, *apiError) { + var p patchBody + if !acceptsJSONContentType(r.Header.Get("Content-Type")) { + return p, newError(ErrUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json") + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&p); err != nil { + return p, newError(ErrInvalidRequest, "invalid_json", "request body is not valid JSON") + } + if p.Name != nil { + n := strings.TrimSpace(*p.Name) + if n == "" || len(n) > maxDisplayNameLen { + return p, newError(ErrValidation, "invalid_name", "name must be 1-200 characters") + } + p.Name = &n + } + for _, u := range []*string{p.AvatarURL, p.LogoURL} { + if u != nil && *u != "" && !validHTTPURL(*u) { + return p, newError(ErrValidation, "invalid_url", "URL must be an absolute http(s) URL of at most 2048 characters") + } + } + return p, nil +} + +func validHTTPURL(s string) bool { + if len(s) > maxURLLen { + return false + } + u, err := url.Parse(s) + return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" +} + +func rfc3339Ptr(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s +} + +type meResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL *string `json:"avatar_url"` + LastLoginAt *string `json:"last_login_at"` + CreatedAt string `json:"created_at"` + Organizations []orgResource `json:"organizations"` +} + +func (h *dashboardHandler) writeMe(w http.ResponseWriter, r *http.Request, humanID string) { + hu, err := h.db.GetHuman(r.Context(), humanID) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + writeError(w, r, newError(ErrAuthentication, "invalid_access_token", "missing or invalid access token")) + return + } + writeError(w, r, newError(ErrInternal, "internal_error", "failed to load profile")) + return + } + tenants, err := h.db.ListOrganizationsForHuman(r.Context(), humanID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to list organizations")) + return + } + orgs := make([]orgResource, 0, len(tenants)) + for _, t := range tenants { + orgs = append(orgs, orgResourceFrom(t)) + } + writeJSON(w, http.StatusOK, meResponse{ + ID: hu.ID, Name: hu.Name, Email: hu.Email, AvatarURL: hu.AvatarURL, + LastLoginAt: rfc3339Ptr(hu.LastLoginAt), CreatedAt: hu.CreatedAt.UTC().Format(time.RFC3339), + Organizations: orgs, + }) +} + +func (h *dashboardHandler) handleGetMe(w http.ResponseWriter, r *http.Request) { + humanID, ok := humanIDFromContext(r.Context()) + if !ok { + writeError(w, r, newError(ErrAuthentication, "invalid_access_token", "missing or invalid access token")) + return + } + h.writeMe(w, r, humanID) +} + +func (h *dashboardHandler) handlePatchMe(w http.ResponseWriter, r *http.Request) { + humanID, ok := humanIDFromContext(r.Context()) + if !ok { + writeError(w, r, newError(ErrAuthentication, "invalid_access_token", "missing or invalid access token")) + return + } + p, aerr := decodePatch(w, r) + if aerr != nil { + writeError(w, r, aerr) + return + } + if p.LogoURL != nil { + writeError(w, r, newError(ErrValidation, "invalid_field", "only name and avatar_url can be updated")) + return + } + if err := h.db.UpdateHumanProfile(r.Context(), humanID, p.Name, p.AvatarURL); err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to update profile")) + return + } + h.writeMe(w, r, humanID) +} + +type orgDetailResponse struct { + ID string `json:"id"` + Name string `json:"name"` + LogoURL *string `json:"logo_url"` + Plan string `json:"plan"` + PlanStatus string `json:"plan_status"` + PlanCurrentPeriodEnd *string `json:"plan_current_period_end"` + CreatedAt string `json:"created_at"` +} + +func (h *dashboardHandler) writeOrg(w http.ResponseWriter, r *http.Request, tenantID string) { + t, err := h.db.GetTenant(r.Context(), tenantID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to load organization")) + return + } + tp, err := h.db.GetTenantPlan(r.Context(), tenantID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to load plan")) + return + } + writeJSON(w, http.StatusOK, orgDetailResponse{ + ID: t.ID, Name: t.Name, LogoURL: t.LogoURL, Plan: tp.Plan, PlanStatus: tp.Status, + PlanCurrentPeriodEnd: rfc3339Ptr(tp.CurrentPeriodEnd), CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339), + }) +} + +func (h *dashboardHandler) handleGetOrg(w http.ResponseWriter, r *http.Request) { + if _, tenantID, ok := h.orgAccess(w, r, false); ok { + h.writeOrg(w, r, tenantID) + } +} + +func (h *dashboardHandler) handlePatchOrg(w http.ResponseWriter, r *http.Request) { + _, tenantID, ok := h.orgAccess(w, r, true) + if !ok { + return + } + p, aerr := decodePatch(w, r) + if aerr != nil { + writeError(w, r, aerr) + return + } + if p.AvatarURL != nil { + writeError(w, r, newError(ErrValidation, "invalid_field", "only name and logo_url can be updated")) + return + } + if err := h.db.UpdateOrganization(r.Context(), tenantID, p.Name, p.LogoURL); err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to update organization")) + return + } + h.writeOrg(w, r, tenantID) +} + +type memberResource struct { + HumanID string `json:"human_id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL *string `json:"avatar_url"` + Role string `json:"role"` + JoinedAt string `json:"joined_at"` +} + +func (h *dashboardHandler) handleListMembers(w http.ResponseWriter, r *http.Request) { + _, tenantID, ok := h.orgAccess(w, r, false) + if !ok { + return + } + ms, err := h.db.ListTenantMembers(r.Context(), tenantID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to list members")) + return + } + out := make([]memberResource, 0, len(ms)) + for _, m := range ms { + out = append(out, memberResource{HumanID: m.HumanID, Name: m.Name, Email: m.Email, AvatarURL: m.AvatarURL, Role: m.Role, JoinedAt: m.JoinedAt.UTC().Format(time.RFC3339)}) + } + writeJSON(w, http.StatusOK, map[string]any{"data": out}) +} + +func (h *dashboardHandler) handleRemoveMember(w http.ResponseWriter, r *http.Request) { + humanID, tenantID, ok := h.orgAccess(w, r, true) + if !ok { + return + } + err := h.db.RemoveTenantMember(r.Context(), tenantID, humanID, r.PathValue("humanId")) + switch { + case err == nil: + w.WriteHeader(http.StatusNoContent) + case errors.Is(err, database.ErrCannotRemoveSelf): + writeError(w, r, newError(ErrConflictType, "cannot_remove_self", "owners cannot remove themselves; leaving or transferring ownership is not supported yet")) + case errors.Is(err, database.ErrLastOwner): + writeError(w, r, newError(ErrConflictType, "last_owner", "an organization must keep at least one owner")) + case errors.Is(err, database.ErrNotTenantOwner): + // Lost ownership between the pre-check and the locked re-check. + writeError(w, r, newError(ErrForbidden, "not_org_owner", "only an organization owner can do this")) + case errors.Is(err, database.ErrNotFound): + writeError(w, r, newError(ErrNotFoundType, "member_not_found", "member not found")) + default: + writeError(w, r, newError(ErrInternal, "internal_error", "failed to remove member")) + } +} + +type inviteResource struct { + ID string `json:"id"` + Email string `json:"email"` + InvitedBy string `json:"invited_by"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +func (h *dashboardHandler) handleListInvites(w http.ResponseWriter, r *http.Request) { + _, tenantID, ok := h.orgAccess(w, r, true) + if !ok { + return + } + invs, err := h.db.ListPendingOrgInvitations(r.Context(), tenantID, h.now()) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to list invitations")) + return + } + out := make([]inviteResource, 0, len(invs)) + for _, i := range invs { + out = append(out, inviteResource{ID: i.ID, Email: i.RawEmail, InvitedBy: i.InvitedBy, ExpiresAt: i.ExpiresAt.UTC().Format(time.RFC3339), CreatedAt: i.CreatedAt.UTC().Format(time.RFC3339)}) + } + writeJSON(w, http.StatusOK, map[string]any{"data": out}) +} + +func (h *dashboardHandler) handleRevokeInvite(w http.ResponseWriter, r *http.Request) { + _, tenantID, ok := h.orgAccess(w, r, true) + if !ok { + return + } + err := h.db.RevokeOrgInvitation(r.Context(), tenantID, r.PathValue("inviteId"), h.now()) + switch { + case err == nil: + w.WriteHeader(http.StatusNoContent) + case errors.Is(err, database.ErrNotFound): + writeError(w, r, newError(ErrNotFoundType, "invitation_not_found", "invitation not found")) + default: + writeError(w, r, newError(ErrInternal, "internal_error", "failed to revoke invitation")) + } +} + +// orgAnalytics adapts an API-key analytics handler to the human-JWT org +// route: after the membership check it attaches the org as the request's +// tenant (withTenant, the single tenant-identity entry point) and delegates, +// so the analytics query/response code is shared, not duplicated (DEC-230). +func (h *dashboardHandler) orgAnalytics(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, tenantID, ok := h.orgAccess(w, r, false); ok { + next(w, r.WithContext(withTenant(r.Context(), tenantID))) + } + } +} diff --git a/internal/api/dashboard_handler_test.go b/internal/api/dashboard_handler_test.go new file mode 100644 index 0000000..b358497 --- /dev/null +++ b/internal/api/dashboard_handler_test.go @@ -0,0 +1,244 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Ferousco-dev/mailx/internal/auth" + "github.com/Ferousco-dev/mailx/internal/database" + "github.com/Ferousco-dev/mailx/internal/humanauth" + "github.com/Ferousco-dev/mailx/internal/storage" +) + +type dashAPI struct { + mux http.Handler + db *database.DB + svc *humanauth.Service +} + +func newDashAPI(t *testing.T) dashAPI { + t.Helper() + db := newTestDB(t) + store, err := storage.NewFileStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + svc, err := humanauth.NewService(db, []byte("test-secret-at-least-32-bytes-long!!")) + if err != nil { + t.Fatal(err) + } + mux := newMux(newEmailHandler(db, store), auth.NewService(db, nil), func() error { return nil }, routeServices{humanAuth: svc}) + return dashAPI{mux: mux, db: db, svc: svc} +} + +func (d dashAPI) do(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + d.mux.ServeHTTP(rec, req) + return rec +} + +// dashFixture: an org with an owner, a plain member, and an outsider. +type dashFixture struct { + owner, member, outsider humanauth.Session + orgID string +} + +func newDashFixture(t *testing.T, d dashAPI) dashFixture { + t.Helper() + ctx := context.Background() + owner, err := d.svc.SignUp(ctx, "Ada", "ada@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + org, err := d.svc.CreateOrganization(ctx, owner.Human.ID, "Acme", "") + if err != nil { + t.Fatal(err) + } + member, err := d.svc.SignUp(ctx, "Bob", "bob@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + inv, err := d.db.CreateOrgInvitation(ctx, org.ID, owner.Human.ID, "bob@example.com", "h-bob", time.Now().Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if err := d.db.AcceptOrgInvitationForExistingHuman(ctx, inv.ID, org.ID, member.Human.ID, time.Now()); err != nil { + t.Fatal(err) + } + outsider, err := d.svc.SignUp(ctx, "Eve", "eve@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + return dashFixture{owner: owner, member: member, outsider: outsider, orgID: org.ID} +} + +func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("bad json %q: %v", rec.Body, err) + } + return m +} + +func TestDashboardMe(t *testing.T) { + d := newDashAPI(t) + f := newDashFixture(t, d) + if rec := d.do(t, "GET", "/v1/me", "", nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("no token: %d", rec.Code) + } + rec := d.do(t, "GET", "/v1/me", f.member.AccessToken, nil) + m := decodeJSON(t, rec) + orgs, _ := m["organizations"].([]any) + if rec.Code != 200 || m["email"] != "bob@example.com" || len(orgs) != 1 { + t.Fatalf("GET /v1/me: %d %s", rec.Code, rec.Body) + } + rec = d.do(t, "PATCH", "/v1/me", f.member.AccessToken, map[string]string{"name": " Robert ", "avatar_url": "https://img.example/b.png"}) + m = decodeJSON(t, rec) + if rec.Code != 200 || m["name"] != "Robert" || m["avatar_url"] != "https://img.example/b.png" || m["email"] != "bob@example.com" { + t.Fatalf("PATCH /v1/me: %d %s", rec.Code, rec.Body) + } + for _, bad := range []map[string]string{{"name": " "}, {"avatar_url": "javascript:alert(1)"}, {"logo_url": "https://x.example"}} { + if rec := d.do(t, "PATCH", "/v1/me", f.member.AccessToken, bad); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("PATCH %v: %d %s", bad, rec.Code, rec.Body) + } + } +} + +func TestDashboardOrgDetailAndPatch(t *testing.T) { + d := newDashAPI(t) + f := newDashFixture(t, d) + path := "/v1/orgs/" + f.orgID + rec := d.do(t, "GET", path, f.member.AccessToken, nil) + if m := decodeJSON(t, rec); rec.Code != 200 || m["name"] != "Acme" || m["plan"] != "free" || m["plan_status"] != "active" { + t.Fatalf("member GET: %d %s", rec.Code, rec.Body) + } + for _, p := range []string{path, "/v1/orgs/does-not-exist"} { + if rec := d.do(t, "GET", p, f.outsider.AccessToken, nil); rec.Code != 404 || !strings.Contains(rec.Body.String(), "organization_not_found") { + t.Fatalf("outsider GET %s: %d %s", p, rec.Code, rec.Body) + } + } + body := map[string]string{"name": "Acme Inc", "logo_url": "https://img.example/l.png"} + if rec := d.do(t, "PATCH", path, f.member.AccessToken, body); rec.Code != 403 || !strings.Contains(rec.Body.String(), "not_org_owner") { + t.Fatalf("member PATCH: %d %s", rec.Code, rec.Body) + } + if rec := d.do(t, "PATCH", path, f.outsider.AccessToken, body); rec.Code != 404 { + t.Fatalf("outsider PATCH: %d", rec.Code) + } + rec = d.do(t, "PATCH", path, f.owner.AccessToken, body) + if m := decodeJSON(t, rec); rec.Code != 200 || m["name"] != "Acme Inc" || m["logo_url"] != "https://img.example/l.png" { + t.Fatalf("owner PATCH: %d %s", rec.Code, rec.Body) + } +} + +func TestDashboardMembers(t *testing.T) { + d := newDashAPI(t) + f := newDashFixture(t, d) + base := "/v1/orgs/" + f.orgID + "/members" + rec := d.do(t, "GET", base, f.member.AccessToken, nil) + data, _ := decodeJSON(t, rec)["data"].([]any) + if rec.Code != 200 || len(data) != 2 { + t.Fatalf("member list: %d %s", rec.Code, rec.Body) + } + if rec := d.do(t, "GET", base, f.outsider.AccessToken, nil); rec.Code != 404 { + t.Fatalf("outsider list: %d", rec.Code) + } + cases := []struct { + name, token, target string + code int + errCode string + }{ + {"outsider", f.outsider.AccessToken, f.member.Human.ID, 404, "organization_not_found"}, + {"non-owner", f.member.AccessToken, f.owner.Human.ID, 403, "not_org_owner"}, + {"self", f.owner.AccessToken, f.owner.Human.ID, 409, "cannot_remove_self"}, + {"not a member", f.owner.AccessToken, f.outsider.Human.ID, 404, "member_not_found"}, + {"owner removes member", f.owner.AccessToken, f.member.Human.ID, 204, ""}, + } + for _, tc := range cases { + rec := d.do(t, "DELETE", base+"/"+tc.target, tc.token, nil) + if rec.Code != tc.code || !strings.Contains(rec.Body.String(), tc.errCode) { + t.Fatalf("%s: %d %s", tc.name, rec.Code, rec.Body) + } + } + if ok, _ := d.db.IsTenantMember(context.Background(), f.orgID, f.member.Human.ID); ok { + t.Fatal("member not removed") + } +} + +func TestDashboardInvites(t *testing.T) { + d := newDashAPI(t) + f := newDashFixture(t, d) + ctx := context.Background() + inv, err := d.db.CreateOrgInvitation(ctx, f.orgID, f.owner.Human.ID, "Carol@Example.com", "h-carol", time.Now().Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + base := "/v1/orgs/" + f.orgID + "/invites" + if rec := d.do(t, "GET", base, f.member.AccessToken, nil); rec.Code != 403 { + t.Fatalf("member list invites: %d", rec.Code) + } + if rec := d.do(t, "GET", base, f.outsider.AccessToken, nil); rec.Code != 404 { + t.Fatalf("outsider list invites: %d", rec.Code) + } + rec := d.do(t, "GET", base, f.owner.AccessToken, nil) + data, _ := decodeJSON(t, rec)["data"].([]any) + if rec.Code != 200 || len(data) != 1 || data[0].(map[string]any)["email"] != "Carol@Example.com" { + t.Fatalf("owner list invites: %d %s", rec.Code, rec.Body) + } + if rec := d.do(t, "DELETE", base+"/"+inv.ID, f.member.AccessToken, nil); rec.Code != 403 { + t.Fatalf("member revoke: %d", rec.Code) + } + for i := 0; i < 2; i++ { + if rec := d.do(t, "DELETE", base+"/"+inv.ID, f.owner.AccessToken, nil); rec.Code != 204 { + t.Fatalf("owner revoke #%d: %d %s", i, rec.Code, rec.Body) + } + } + if rec := d.do(t, "DELETE", base+"/nope", f.owner.AccessToken, nil); rec.Code != 404 { + t.Fatalf("unknown invite: %d", rec.Code) + } + rec = d.do(t, "GET", base, f.owner.AccessToken, nil) + if data, _ := decodeJSON(t, rec)["data"].([]any); len(data) != 0 { + t.Fatalf("still listed after revoke: %s", rec.Body) + } +} + +func TestDashboardAnalytics(t *testing.T) { + d := newDashAPI(t) + f := newDashFixture(t, d) + to := time.Now().UTC().Add(time.Hour).Format(time.RFC3339) + from := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) + q := "?from=" + from + "&to=" + to + ov := "/v1/orgs/" + f.orgID + "/analytics/overview" + q + ts := "/v1/orgs/" + f.orgID + "/analytics/timeseries" + q + "&interval=hour" + if rec := d.do(t, "GET", ov, f.member.AccessToken, nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"counts"`) { + t.Fatalf("overview: %d %s", rec.Code, rec.Body) + } + if rec := d.do(t, "GET", ts, f.member.AccessToken, nil); rec.Code != 200 { + t.Fatalf("timeseries: %d %s", rec.Code, rec.Body) + } + for _, p := range []string{ov, ts} { + if rec := d.do(t, "GET", p, f.outsider.AccessToken, nil); rec.Code != 404 { + t.Fatalf("outsider %s: %d", p, rec.Code) + } + } + // Shared validation path: the API-key handler's range check applies. + if rec := d.do(t, "GET", "/v1/orgs/"+f.orgID+"/analytics/overview", f.member.AccessToken, nil); rec.Code != 422 { + t.Fatalf("missing range: %d", rec.Code) + } +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go index e1f3a59..f4369c7 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -151,6 +151,90 @@ const openAPISpec = `{ "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"message": {"type": "string"}}}}}}, "403": {"description": "Caller is not an owner of this organization", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + }, + "get": { + "summary": "List pending invitations", + "description": "Owner only (it reveals invitees' email addresses). Returns unaccepted, unexpired invitations, newest first.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}], + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "email": {"type": "string"}, "invited_by": {"type": "string", "description": "Inviting human ID."}, "expires_at": {"type": "string", "format": "date-time"}, "created_at": {"type": "string", "format": "date-time"}}}}}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "403": {"description": "Caller is a member but not an owner of this organization", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/me": { + "get": { + "summary": "Get the caller's profile and organizations", + "description": "Requires a human access token (HumanAuth).", + "security": [{"HumanAuth": []}], + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}, "avatar_url": {"type": "string", "nullable": true}, "last_login_at": {"type": "string", "format": "date-time", "nullable": true}, "created_at": {"type": "string", "format": "date-time"}, "organizations": {"type": "array", "items": {"$ref": "#/components/schemas/Organization"}}}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + }, + "patch": { + "summary": "Update the caller's name and/or avatar_url", + "description": "Requires a human access token (HumanAuth). Omitted fields are unchanged. Email cannot be changed here.", + "security": [{"HumanAuth": []}], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200}, "avatar_url": {"type": "string", "description": "Absolute http(s) URL, max 2048 chars; empty string clears it."}}}}}}, + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}, "avatar_url": {"type": "string", "nullable": true}, "last_login_at": {"type": "string", "format": "date-time", "nullable": true}, "created_at": {"type": "string", "format": "date-time"}, "organizations": {"type": "array", "items": {"$ref": "#/components/schemas/Organization"}}}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "422": {"description": "Invalid name or URL", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/orgs/{id}": { + "get": { + "summary": "Get an organization", + "description": "Requires a human access token (HumanAuth) of any member; a non-member gets 404 organization_not_found, the same as a nonexistent organization.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}], + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "logo_url": {"type": "string", "nullable": true}, "plan": {"type": "string", "enum": ["free", "plus", "pro"]}, "plan_status": {"type": "string", "enum": ["active", "lapsed"]}, "plan_current_period_end": {"type": "string", "format": "date-time", "nullable": true}, "created_at": {"type": "string", "format": "date-time"}}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + }, + "patch": { + "summary": "Update an organization's name and/or logo_url", + "description": "Owner only (403 not_org_owner for other members, 404 for non-members). Omitted fields are unchanged.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string", "minLength": 1, "maxLength": 200}, "logo_url": {"type": "string", "description": "Absolute http(s) URL, max 2048 chars; empty string clears it."}}}}}}, + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "logo_url": {"type": "string", "nullable": true}, "plan": {"type": "string", "enum": ["free", "plus", "pro"]}, "plan_status": {"type": "string", "enum": ["active", "lapsed"]}, "plan_current_period_end": {"type": "string", "format": "date-time", "nullable": true}, "created_at": {"type": "string", "format": "date-time"}}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "403": {"description": "Caller is a member but not an owner of this organization", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "422": {"description": "Invalid name or URL", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/orgs/{id}/members": { + "get": { + "summary": "List an organization's members", + "description": "Any member may call it; non-members get 404.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}], + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"data": {"type": "array", "items": {"type": "object", "properties": {"human_id": {"type": "string"}, "name": {"type": "string"}, "email": {"type": "string"}, "avatar_url": {"type": "string", "nullable": true}, "role": {"type": "string"}, "joined_at": {"type": "string", "format": "date-time"}}}}}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/orgs/{id}/members/{humanId}": { + "delete": { + "summary": "Remove a member", + "description": "Owner only. An owner cannot remove themselves (409 cannot_remove_self), and an organization always keeps at least one owner (409 last_owner). Removals for one organization are serialized, so concurrent removals can never leave it ownerless.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}, {"name": "humanId", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": { "204": {"description": "Removed"}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "403": {"description": "Caller is a member but not an owner of this organization", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization or member not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "409": {"description": "Self-removal or last owner", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/orgs/{id}/invites/{inviteId}": { + "delete": { + "summary": "Revoke a pending invitation", + "description": "Owner only. Expires the invitation immediately. Idempotent: revoking an already expired or accepted invitation succeeds and changes nothing.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}, {"name": "inviteId", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": { "204": {"description": "Revoked (or already inactive)"}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "403": {"description": "Caller is a member but not an owner of this organization", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization or invitation not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/orgs/{id}/analytics/overview": { + "get": { + "summary": "Organization analytics overview (dashboard)", + "description": "Same semantics and response as GET /analytics/overview, but authenticated by a human access token of any member of the organization instead of an API key. Non-members get 404.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}], + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AnalyticsOverview"}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "422": {"description": "Invalid range", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } + } + }, + "/orgs/{id}/analytics/timeseries": { + "get": { + "summary": "Organization analytics timeseries (dashboard)", + "description": "Same semantics and response as GET /analytics/timeseries, but authenticated by a human access token of any member of the organization instead of an API key. Non-members get 404.", + "security": [{"HumanAuth": []}], + "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."}, {"name": "from", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "to", "in": "query", "required": true, "schema": {"type": "string", "format": "date-time"}}, {"name": "interval", "in": "query", "required": true, "schema": {"type": "string", "enum": ["hour", "day"]}}], + "responses": { "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/AnalyticsBucket"}}}}}, "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, "422": {"description": "Invalid range or interval", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} } } }, "/orgs/invites/accept": { diff --git a/internal/api/routes.go b/internal/api/routes.go index d7a6a2b..24139dd 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -198,6 +198,21 @@ func newMux(h *emailHandler, authSvc authService, readiness func() error, extras // (orgInviteAcceptIPLimitMiddleware) since this route is public and // the no-account-yet path runs a full bcrypt hash per call. mux.Handle("POST /v1/orgs/invites/accept", chain(http.HandlerFunc(ha.handleAcceptInvite), orgInviteAcceptIPLimitMiddleware(abuse))) + + // Dashboard backend (v0.47 phase 3a, DEC-228..230): profile, org + // detail, members, pending invites, and org-scoped analytics that + // delegate to the same analyticsHandler the API-key routes use. + dh := &dashboardHandler{db: h.db, now: func() time.Time { return time.Now().UTC() }} + mux.Handle("GET /v1/me", orgsAuthenticated(http.HandlerFunc(dh.handleGetMe))) + mux.Handle("PATCH /v1/me", orgsAuthenticated(http.HandlerFunc(dh.handlePatchMe))) + mux.Handle("GET /v1/orgs/{id}", orgsAuthenticated(http.HandlerFunc(dh.handleGetOrg))) + mux.Handle("PATCH /v1/orgs/{id}", orgsAuthenticated(http.HandlerFunc(dh.handlePatchOrg))) + mux.Handle("GET /v1/orgs/{id}/members", orgsAuthenticated(http.HandlerFunc(dh.handleListMembers))) + mux.Handle("DELETE /v1/orgs/{id}/members/{humanId}", orgsAuthenticated(http.HandlerFunc(dh.handleRemoveMember))) + mux.Handle("GET /v1/orgs/{id}/invites", orgsAuthenticated(http.HandlerFunc(dh.handleListInvites))) + mux.Handle("DELETE /v1/orgs/{id}/invites/{inviteId}", orgsAuthenticated(http.HandlerFunc(dh.handleRevokeInvite))) + mux.Handle("GET /v1/orgs/{id}/analytics/overview", orgsAuthenticated(dh.orgAnalytics(analytics.handleOverview))) + mux.Handle("GET /v1/orgs/{id}/analytics/timeseries", orgsAuthenticated(dh.orgAnalytics(analytics.handleTimeseries))) } if len(extras) > 0 && extras[0].billing != nil { lg := extras[0].log diff --git a/internal/database/dashboard.go b/internal/database/dashboard.go new file mode 100644 index 0000000..4adeac4 --- /dev/null +++ b/internal/database/dashboard.go @@ -0,0 +1,187 @@ +package database + +import ( + "context" + "errors" + "fmt" + "time" +) + +// Dashboard (v0.47 phase 3a) org/member/profile data access. Membership and +// ownership reads reuse IsTenantMember / IsTenantOwner; this file only holds +// the writes and list queries the dashboard needs (DEC-228..230). + +var ( + // ErrNotTenantOwner: the acting human is not (or, under the tenant lock, + // is no longer) an owner of the tenant. + ErrNotTenantOwner = errors.New("database: not a tenant owner") + // ErrCannotRemoveSelf: an owner tried to remove their own membership; + // leaving/transferring ownership is a separate, not-yet-built flow. + ErrCannotRemoveSelf = errors.New("database: cannot remove yourself") + // ErrLastOwner: the removal would leave the tenant with zero owners. + ErrLastOwner = errors.New("database: cannot remove the last owner") +) + +// UpdateHumanProfile sets name and/or avatar_url (nil = leave unchanged; an +// empty avatarURL clears it). Email is deliberately not updatable here. +func (db *DB) UpdateHumanProfile(ctx context.Context, humanID string, name, avatarURL *string) error { + tag, err := db.pool.Exec(ctx, ` + UPDATE humans SET + name = COALESCE($2, name), + avatar_url = CASE WHEN $3::text IS NULL THEN avatar_url ELSE NULLIF($3::text, '') END, + updated_at = now() + WHERE id = $1`, humanID, name, avatarURL) + if err != nil { + return fmt.Errorf("database: update human profile: %w", normalizeErr(err)) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// UpdateOrganization sets name and/or logo_url with the same nil/empty +// semantics as UpdateHumanProfile. Authorization is the caller's job. +func (db *DB) UpdateOrganization(ctx context.Context, tenantID string, name, logoURL *string) error { + tag, err := db.pool.Exec(ctx, ` + UPDATE tenants SET + name = COALESCE($2, name), + logo_url = CASE WHEN $3::text IS NULL THEN logo_url ELSE NULLIF($3::text, '') END + WHERE id = $1`, tenantID, name, logoURL) + if err != nil { + return fmt.Errorf("database: update organization: %w", normalizeErr(err)) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// OrgMember is one member of a tenant joined with the human's profile. +type OrgMember struct { + HumanID string + Name string + Email string + AvatarURL *string + Role string + JoinedAt time.Time +} + +// ListTenantMembers returns every member of tenantID, oldest first. +func (db *DB) ListTenantMembers(ctx context.Context, tenantID string) ([]OrgMember, error) { + rows, err := db.pool.Query(ctx, ` + SELECT h.id, h.name, h.email, h.avatar_url, m.role, m.created_at + FROM tenant_members m JOIN humans h ON h.id = m.human_id + WHERE m.tenant_id = $1 + ORDER BY m.created_at, h.id`, tenantID) + if err != nil { + return nil, fmt.Errorf("database: list members: %w", normalizeErr(err)) + } + defer rows.Close() + out := []OrgMember{} + for rows.Next() { + var m OrgMember + if err := rows.Scan(&m.HumanID, &m.Name, &m.Email, &m.AvatarURL, &m.Role, &m.JoinedAt); err != nil { + return nil, normalizeErr(err) + } + out = append(out, m) + } + return out, normalizeErr(rows.Err()) +} + +// RemoveTenantMember removes targetID from tenantID on behalf of actorID. +// It locks the tenant row (SELECT ... FOR UPDATE, the lockMemberCap +// pattern) so every removal for one tenant serializes, then re-checks +// under that lock that the actor is still an owner and that at least one +// owner remains afterwards. Two owners removing each other concurrently +// therefore cannot both succeed: the second one finds its actor gone +// (ErrNotTenantOwner). ErrNotFound if target is not a member. +func (db *DB) RemoveTenantMember(ctx context.Context, tenantID, actorID, targetID string) error { + if actorID == targetID { + return ErrCannotRemoveSelf + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("database: begin remove member: %w", normalizeErr(err)) + } + defer func() { _ = tx.Rollback(ctx) }() + + var one int + if err := tx.QueryRow(ctx, `SELECT 1 FROM tenants WHERE id = $1 FOR UPDATE`, tenantID).Scan(&one); err != nil { + return fmt.Errorf("database: lock tenant for member removal: %w", normalizeErr(err)) + } + var actorOwner bool + var targetRole *string + var owners int + if err := tx.QueryRow(ctx, ` + SELECT + coalesce(bool_or(human_id = $2 AND role = 'owner'), false), + max(role) FILTER (WHERE human_id = $3), + count(*) FILTER (WHERE role = 'owner') + FROM tenant_members WHERE tenant_id = $1`, tenantID, actorID, targetID, + ).Scan(&actorOwner, &targetRole, &owners); err != nil { + return fmt.Errorf("database: read members for removal: %w", normalizeErr(err)) + } + if !actorOwner { + return ErrNotTenantOwner + } + if targetRole == nil { + return ErrNotFound + } + if *targetRole == "owner" && owners <= 1 { + return ErrLastOwner // unreachable while actor is a distinct owner; kept as the invariant's last line + } + if _, err := tx.Exec(ctx, `DELETE FROM tenant_members WHERE tenant_id = $1 AND human_id = $2`, tenantID, targetID); err != nil { + return fmt.Errorf("database: delete member: %w", normalizeErr(err)) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("database: commit remove member: %w", normalizeErr(err)) + } + return nil +} + +// ListPendingOrgInvitations returns tenantID's unaccepted, unexpired +// invitations as of now, newest first. +func (db *DB) ListPendingOrgInvitations(ctx context.Context, tenantID string, now time.Time) ([]OrgInvitation, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, tenant_id, invited_by, normalized_email, raw_email, token_hash, expires_at, accepted_at, created_at + FROM org_invitations + WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > $2 + ORDER BY created_at DESC, id DESC`, tenantID, now) + if err != nil { + return nil, fmt.Errorf("database: list pending invitations: %w", normalizeErr(err)) + } + defer rows.Close() + out := []OrgInvitation{} + for rows.Next() { + var inv OrgInvitation + if err := rows.Scan(&inv.ID, &inv.TenantID, &inv.InvitedBy, &inv.NormalizedEmail, &inv.RawEmail, &inv.TokenHash, &inv.ExpiresAt, &inv.AcceptedAt, &inv.CreatedAt); err != nil { + return nil, normalizeErr(err) + } + out = append(out, inv) + } + return out, normalizeErr(rows.Err()) +} + +// RevokeOrgInvitation invalidates a pending invitation by setting +// expires_at = now, the same mechanism SupersedeOtherPendingOrgInvitations +// uses. Idempotent: an already expired/accepted invitation is a no-op. +// ErrNotFound only if no invitation with that ID belongs to tenantID. +func (db *DB) RevokeOrgInvitation(ctx context.Context, tenantID, invitationID string, now time.Time) error { + var exists bool + err := db.pool.QueryRow(ctx, ` + WITH upd AS ( + UPDATE org_invitations SET expires_at = $3 + WHERE id = $2 AND tenant_id = $1 AND accepted_at IS NULL AND expires_at > $3 + ) + SELECT EXISTS (SELECT 1 FROM org_invitations WHERE id = $2 AND tenant_id = $1)`, + tenantID, invitationID, now, + ).Scan(&exists) + if err != nil { + return fmt.Errorf("database: revoke invitation: %w", normalizeErr(err)) + } + if !exists { + return ErrNotFound + } + return nil +} diff --git a/internal/database/dashboard_test.go b/internal/database/dashboard_test.go new file mode 100644 index 0000000..a27b449 --- /dev/null +++ b/internal/database/dashboard_test.go @@ -0,0 +1,189 @@ +package database + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +func addMember(t *testing.T, db *DB, tenantID, email, role string) Human { + t.Helper() + h, err := db.CreateHuman(context.Background(), "M", email, "x") + if err != nil { + t.Fatal(err) + } + if _, err := db.pool.Exec(context.Background(), + `INSERT INTO tenant_members (tenant_id, human_id, role) VALUES ($1, $2, $3)`, tenantID, h.ID, role); err != nil { + t.Fatal(err) + } + return h +} + +func countOwners(t *testing.T, db *DB, tenantID string) int { + t.Helper() + var n int + if err := db.pool.QueryRow(context.Background(), + `SELECT count(*) FROM tenant_members WHERE tenant_id = $1 AND role = 'owner'`, tenantID).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func TestRemoveTenantMemberRules(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + owner, tn := newOwnedOrg(t, db, "owner@example.com") + m1 := addMember(t, db, tn.ID, "m1@example.com", "member") + m2 := addMember(t, db, tn.ID, "m2@example.com", "member") + outsider := addMember(t, db, newTestTenant(t, db).ID, "x@example.com", "owner") + + cases := []struct { + name string + actor, target string + want error + }{ + {"self", owner.ID, owner.ID, ErrCannotRemoveSelf}, + {"non-owner actor", m1.ID, m2.ID, ErrNotTenantOwner}, + {"target not a member", owner.ID, outsider.ID, ErrNotFound}, + {"owner removes member", owner.ID, m1.ID, nil}, + {"already removed", owner.ID, m1.ID, ErrNotFound}, + } + for _, tc := range cases { + if err := db.RemoveTenantMember(ctx, tn.ID, tc.actor, tc.target); !errors.Is(err, tc.want) { + t.Fatalf("%s: err=%v want %v", tc.name, err, tc.want) + } + } + if ok, _ := db.IsTenantMember(ctx, tn.ID, m1.ID); ok { + t.Fatal("m1 still a member") + } + if ok, _ := db.IsTenantMember(ctx, tn.ID, m2.ID); !ok { + t.Fatal("m2 wrongly removed") + } + if n := countOwners(t, db, tn.ID); n != 1 { + t.Fatalf("owners=%d want 1", n) + } +} + +// Two owners removing each other at the same instant must never leave the +// org ownerless: the tenant row lock serializes them and the loser finds +// its own ownership gone. Repeated so the race is actually exercised. +func TestRemoveTenantMemberConcurrentMutualRemovalKeepsAnOwner(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + for round := 0; round < 20; round++ { + a, tn := newOwnedOrg(t, db, fmt.Sprintf("a%d@example.com", round)) + b := addMember(t, db, tn.ID, fmt.Sprintf("b%d@example.com", round), "owner") + pairs := [][2]string{{a.ID, b.ID}, {b.ID, a.ID}} + var wg sync.WaitGroup + results := make(chan error, len(pairs)) + start := make(chan struct{}) + for _, p := range pairs { + wg.Add(1) + go func(actor, target string) { + defer wg.Done() + <-start + results <- db.RemoveTenantMember(ctx, tn.ID, actor, target) + }(p[0], p[1]) + } + close(start) + wg.Wait() + close(results) + ok, denied := 0, 0 + for err := range results { + switch { + case err == nil: + ok++ + case errors.Is(err, ErrNotTenantOwner): + denied++ + default: + t.Fatalf("round %d: unexpected error %v", round, err) + } + } + if owners := countOwners(t, db, tn.ID); ok != 1 || denied != 1 || owners != 1 { + t.Fatalf("round %d: ok=%d denied=%d owners=%d; want 1/1/1", round, ok, denied, owners) + } + } +} + +func TestOrgInvitationListAndRevoke(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + owner, tn := newOwnedOrg(t, db, "owner@example.com") + _, other := newOwnedOrg(t, db, "other@example.com") + now := time.Now().UTC() + pending, err := db.CreateOrgInvitation(ctx, tn.ID, owner.ID, "p@example.com", "h-p", now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if _, err := db.CreateOrgInvitation(ctx, tn.ID, owner.ID, "old@example.com", "h-old", now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + accepted, err := db.CreateOrgInvitation(ctx, tn.ID, owner.ID, "acc@example.com", "h-acc", now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if _, err := db.AcceptOrgInvitationWithSignup(ctx, accepted.ID, tn.ID, "A", "acc@example.com", "x", now); err != nil { + t.Fatal(err) + } + + list, err := db.ListPendingOrgInvitations(ctx, tn.ID, now) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].ID != pending.ID { + t.Fatalf("pending list = %+v, want only %s", list, pending.ID) + } + if err := db.RevokeOrgInvitation(ctx, other.ID, pending.ID, now); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-tenant revoke: %v", err) + } + if err := db.RevokeOrgInvitation(ctx, tn.ID, "nope", now); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown revoke: %v", err) + } + for i := 0; i < 2; i++ { // second call is the idempotent no-op + if err := db.RevokeOrgInvitation(ctx, tn.ID, pending.ID, now); err != nil { + t.Fatalf("revoke %d: %v", i, err) + } + } + if err := db.RevokeOrgInvitation(ctx, tn.ID, accepted.ID, now); err != nil { + t.Fatalf("revoke accepted: %v", err) + } + if list, _ := db.ListPendingOrgInvitations(ctx, tn.ID, now); len(list) != 0 { + t.Fatalf("still pending after revoke: %+v", list) + } + acc, err := db.GetOrgInvitationByHash(ctx, "h-acc") + if err != nil || acc.AcceptedAt == nil || !acc.ExpiresAt.After(now) { + t.Fatalf("accepted invite was modified: %+v %v", acc, err) + } +} + +func TestUpdateProfileAndOrganization(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + h, tn := newOwnedOrg(t, db, "o@example.com") + name, url, empty := "New Name", "https://x.example/a.png", "" + if err := db.UpdateHumanProfile(ctx, h.ID, &name, &url); err != nil { + t.Fatal(err) + } + got, _ := db.GetHuman(ctx, h.ID) + if got.Name != name || got.AvatarURL == nil || *got.AvatarURL != url || got.Email != "o@example.com" { + t.Fatalf("profile = %+v", got) + } + if err := db.UpdateHumanProfile(ctx, h.ID, nil, &empty); err != nil { + t.Fatal(err) + } + if got, _ := db.GetHuman(ctx, h.ID); got.Name != name || got.AvatarURL != nil { + t.Fatalf("clear avatar: %+v", got) + } + if err := db.UpdateOrganization(ctx, tn.ID, nil, &url); err != nil { + t.Fatal(err) + } + if got, _ := db.GetTenant(ctx, tn.ID); got.Name != tn.Name || got.LogoURL == nil || *got.LogoURL != url { + t.Fatalf("org = %+v", got) + } + if err := db.UpdateOrganization(ctx, "missing", &name, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing org: %v", err) + } +}