diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index eab72f8..f4898e9 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -52,6 +52,8 @@ func Routes(cfg config.Config) http.Handler { staff.GET("/accounts/:id", staffGetAccount) staff.GET("/instances", staffListInstances) staff.POST("/instances", staffCreateInstance) + staff.GET("/instances/:id", staffGetInstance) + staff.GET("/subscriptions", staffListSubscriptions) staff.POST("/instances/:id/issue", staffIssue) staff.POST("/instances/:id/relink", staffRelink) staff.GET("/licenses", staffListLicenses) diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 6d78382..656254e 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -21,10 +21,19 @@ import ( func staffListAccounts(c *gin.Context) { filter := bson.M{} if q := c.Query("q"); q != "" { - filter["$or"] = []bson.M{ + or := []bson.M{ {"name": bson.M{"$regex": q, "$options": "i"}}, {"billing_email": bson.M{"$regex": q, "$options": "i"}}, + {"paddle_customer_id": q}, } + // A support email often contains an instance UUID and nothing else, so + // resolve that to its owning account rather than returning nothing. + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(c.Request.Context(), + bson.M{"instance_id": q}).Decode(&inst); err == nil { + or = append(or, bson.M{"account_id": inst.AccountID}) + } + filter["$or"] = or } cur, err := db.Admin("accounts").Find(c.Request.Context(), filter, options.Find().SetLimit(200).SetSort(bson.D{{Key: "created_at", Value: -1}})) @@ -70,12 +79,33 @@ func staffGetAccount(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - cur, _ := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}) instances := []models.Instance{} - if cur != nil { + if cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil { _ = cur.All(ctx, &instances) } - c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances}) + subs := []models.Subscription{} + if cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil { + _ = cur.All(ctx, &subs) + } + users := []models.CustomerUser{} + if cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": acct.AccountID}); err == nil { + _ = cur.All(ctx, &users) + } + entries := []models.AuditEntry{} + if cur, err := db.Admin("admin_audit").Find(ctx, bson.M{"account_id": acct.AccountID}, + options.Find().SetLimit(100).SetSort(bson.D{{Key: "created_at", Value: -1}})); err == nil { + _ = cur.All(ctx, &entries) + } + + // CustomerUser's password hash and both verify-token fields are json:"-", + // so no secret leaves here. + c.JSON(http.StatusOK, gin.H{ + "account": acct, + "instances": instances, + "subscriptions": subs, + "users": users, + "audit": entries, + }) } func staffListInstances(c *gin.Context) { @@ -185,6 +215,81 @@ func staffCreateInstance(c *gin.Context) { c.JSON(http.StatusCreated, inst) } +// staffGetInstance is the "why did this stop working" screen's data: one +// instance, its account, its whole licence history newest first, and whether +// the control plane currently holds what we think it holds. +func staffGetInstance(c *gin.Context) { + ctx := c.Request.Context() + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + var acct models.Account + _ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": inst.AccountID}).Decode(&acct) + + lics := []models.License{} + if cur, err := db.Admin("licenses").Find(ctx, bson.M{"instance_id": inst.InstanceID}, + options.Find().SetSort(bson.D{{Key: "issued_at", Value: -1}})); err == nil { + _ = cur.All(ctx, &lics) + } + + // Injection state is only meaningful for cloud. For self-hosted the + // customer holds the blob and there is nothing for us to have written. + injection := gin.H{"applicable": inst.Deployment == license.DeploymentCloud} + if inst.Deployment == license.DeploymentCloud { + var remote sharedmodels.Instance + err := db.Control("instances").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID}).Decode(&remote) + switch { + case err != nil: + injection["state"] = "missing" + case inst.CurrentLicense == "": + injection["state"] = "none_issued" + default: + var current models.License + if db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(¤t) == nil && + remote.LicenseBlob == current.Blob { + injection["state"] = "current" + } else { + injection["state"] = "stale" + } + } + injection["failed_at"] = inst.InjectFailedAt + } + + c.JSON(http.StatusOK, gin.H{ + "instance": inst, "account": acct, "licenses": lics, "injection": injection, + }) +} + +// staffListSubscriptions backs the past-due queue on the dashboard. +func staffListSubscriptions(c *gin.Context) { + filter := bson.M{} + if v := c.Query("status"); v != "" { + filter["status"] = v + } + if v := c.Query("account_id"); v != "" { + filter["account_id"] = v + } + cur, err := db.Admin("subscriptions").Find(c.Request.Context(), filter, + options.Find().SetLimit(500)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + subs := []models.Subscription{} + if err := cur.All(c.Request.Context(), &subs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, subs) +} + func staffIssue(c *gin.Context) { var body struct { Tier string `json:"tier"` @@ -309,7 +414,11 @@ func staffUpdatePlan(c *gin.Context) { } func staffAudit(c *gin.Context) { - cur, err := db.Admin("admin_audit").Find(c.Request.Context(), bson.M{}, + filter := bson.M{} + if v := c.Query("account_id"); v != "" { + filter["account_id"] = v + } + cur, err := db.Admin("admin_audit").Find(c.Request.Context(), filter, options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}})) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/docs/superpowers/plans/2026-07-25-admin-site.md b/docs/superpowers/plans/2026-07-25-admin-site.md index b47238a..8161cbd 100644 --- a/docs/superpowers/plans/2026-07-25-admin-site.md +++ b/docs/superpowers/plans/2026-07-25-admin-site.md @@ -310,7 +310,7 @@ git commit -m "feat(admin): session probe, self-hosted signup and the relink cap - `GET /api/staff/accounts?q=` also matching `paddle_customer_id` and an instance UUID - `GET /api/staff/accounts/:id` also returning `subscriptions`, `users`, `audit` -- [ ] **Step 1: Search accounts by Paddle ID and instance UUID** +- [x] **Step 1: Search accounts by Paddle ID and instance UUID** In `staffListAccounts`, replace the `if q := c.Query("q"); q != ""` block: @@ -332,7 +332,7 @@ In `staffListAccounts`, replace the `if q := c.Query("q"); q != ""` block: } ``` -- [ ] **Step 2: Fill out account detail** +- [x] **Step 2: Fill out account detail** Replace the body of `staffGetAccount` after the account lookup: @@ -366,7 +366,7 @@ Replace the body of `staffGetAccount` after the account lookup: `CustomerUser.PasswordHash` and both verify-token fields are `json:"-"`, so no secret leaves here. -- [ ] **Step 3: Add staff instance detail** +- [x] **Step 3: Add staff instance detail** Append to `admin/internal/api/staff.go`: @@ -447,7 +447,7 @@ func staffListSubscriptions(c *gin.Context) { } ``` -- [ ] **Step 4: Filter audit by account** +- [x] **Step 4: Filter audit by account** In `staffAudit`, replace `bson.M{}` with a filter: @@ -460,7 +460,7 @@ In `staffAudit`, replace `bson.M{}` with a filter: options.Find().SetLimit(500).SetSort(bson.D{{Key: "created_at", Value: -1}})) ``` -- [ ] **Step 5: Route them** +- [x] **Step 5: Route them** In the `staff` group in `admin/internal/api/routes.go`: @@ -469,7 +469,7 @@ In the `staff` group in `admin/internal/api/routes.go`: staff.GET("/subscriptions", staffListSubscriptions) ``` -- [ ] **Step 6: Build and confirm the write surface is unchanged** +- [x] **Step 6: Build and confirm the write surface is unchanged** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -478,7 +478,7 @@ grep -rn 'Control("' admin/internal/ --include=*.go | grep -v "db.go" Expected: build clean; exactly one `UpdateOne` on `instances` in `inject.go`, and reads only elsewhere. `staffGetInstance` adds a read, never a write. -- [ ] **Step 7: Confirm search by UUID** +- [x] **Step 7: Confirm search by UUID** With the spec-3 verification stack running and an instance adopted: @@ -489,7 +489,7 @@ curl -s localhost:8083/api/staff/instances/6a0fe3f0-49d2-4aa1-967c-a3094b200b5d Expected: the owning account, and an instance payload whose `injection.state` is `current`. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add admin/