fix(admin): licence and plan features serialise as [], never null
Server Deploy / deploy (push) Successful in 5m22s
Server Deploy / deploy (push) Successful in 5m22s
Free has no features, so a nil Go slice served "features": null. The portal's type said string[], called .length on it, and took the page down. The guarantee lives on a named Features type with its own MarshalJSON rather than at each of the six places a licence or plan is serialised, because the seventh is the one that would have been forgotten. Applying it at marshal time also fixes rows already holding null in Mongo, which a write-side fix alone would not. Same class, found while checking the rest: staffListInstances built its expiring filter from a nil []string, marshalling to $in: null, which Mongo rejects — so the quiet week when nothing is expiring is precisely when the staff Operations page's own query would have failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Features is a list of feature flags that marshals as `[]` rather than `null`.
|
||||
//
|
||||
// A nil Go slice becomes JSON null. The Free plan has no features, so every
|
||||
// Free licence served `"features": null`, and the portal — whose type said
|
||||
// string[] — called .length on it and took the page down with it.
|
||||
//
|
||||
// The guarantee lives on the type rather than at each of the six places a
|
||||
// licence or plan is serialised, because the seventh is the one that would have
|
||||
// been forgotten. It also fixes rows already holding null in Mongo, since it
|
||||
// applies at marshal time rather than at write time.
|
||||
type Features []string
|
||||
|
||||
func (f Features) MarshalJSON() ([]byte, error) {
|
||||
if f == nil {
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
return json.Marshal([]string(f))
|
||||
}
|
||||
|
||||
// OrEmpty is the same guarantee for values headed to Mongo rather than to JSON,
|
||||
// so a null never enters the database in the first place.
|
||||
func (f Features) OrEmpty() Features {
|
||||
if f == nil {
|
||||
return Features{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Instance statuses.
|
||||
const (
|
||||
StatusAwaitingLink = "awaiting_link"
|
||||
@@ -107,7 +136,7 @@ type License struct {
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Limits license.Limits `bson:"limits" json:"limits"`
|
||||
Features []string `bson:"features" json:"features"`
|
||||
Features Features `bson:"features" json:"features"`
|
||||
IssuedAt time.Time `bson:"issued_at" json:"issued_at"`
|
||||
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
|
||||
Blob string `bson:"blob" json:"-"`
|
||||
@@ -140,7 +169,7 @@ type Plan struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Limits license.Limits `bson:"limits" json:"limits"`
|
||||
Features []string `bson:"features" json:"features"`
|
||||
Features Features `bson:"features" json:"features"`
|
||||
PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"`
|
||||
PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
|
||||
@@ -27,7 +27,7 @@ func SeedPlans(ctx context.Context) error {
|
||||
"name": p.Name,
|
||||
"deployment": p.Deployment,
|
||||
"limits": p.Limits,
|
||||
"features": p.Features,
|
||||
"features": Features(p.Features).OrEmpty(),
|
||||
"active": true,
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
|
||||
Reference in New Issue
Block a user