feat(admin): staff instance detail, subscriptions and richer search
Closes the rest of what spec 4's screens need. Account search now also matches a Paddle customer ID and resolves an instance UUID to its owning account -- a support email often contains a UUID and nothing else, and the old search returned nothing for it. GET /api/staff/instances/:id is the "why did this stop working" screen's data: the instance, its account, its whole licence history newest first, and whether the control plane currently holds the blob we think it holds. Injection state is reported only for cloud, because for self-hosted the customer holds the blob and there is nothing for us to have written. Account detail gains subscriptions, customer users and its own audit trail. No secret leaves: the password hash and both verify-token fields are json:"-". The control-plane write surface is unchanged -- still exactly one UpdateOne of three licence fields in inject.go, with reads everywhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
+114
-5
@@ -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()})
|
||||
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user