mirror of
https://github.com/hanzoai/platform-services.git
synced 2026-10-30 18:23:56 +00:00
227 lines
7.2 KiB
Go
227 lines
7.2 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/types"
|
|
gen "github.com/hanzoai/platform-services/gen"
|
|
)
|
|
|
|
// SpendAlert is the ONLY category whose data lives in Base. It is the native
|
|
// port of the console spendAlertRouter's `cloudSpendAlert` Prisma table: per-org
|
|
// spend thresholds with full CRUD, every row scoped to the caller's org. All
|
|
// other categories proxy upstream; this one owns its rows.
|
|
//
|
|
// Validation matches the router's zod schema exactly, enforced at the boundary:
|
|
// - title: 1..100 chars
|
|
// - threshold: > 0 and <= 1_000_000 (USD)
|
|
// Org ownership is enforced on update/delete (a row from another org is NOT
|
|
// FOUND, never mutated).
|
|
|
|
const spendAlertMaxThreshold = 1_000_000.0
|
|
|
|
// handleSpendAlert dispatches the spendAlert CRUD ops against the Base
|
|
// collection. org is the authoritative scope from the verified capability.
|
|
func (s *Server) handleSpendAlert(_ context.Context, org string, req psRequest) (uint32, []byte) {
|
|
switch req.Op {
|
|
case OpSpendAlertList:
|
|
return s.spendAlertList(org)
|
|
case OpSpendAlertCreate:
|
|
return s.spendAlertCreate(org, req)
|
|
case OpSpendAlertUpdate:
|
|
return s.spendAlertUpdate(org, req)
|
|
case OpSpendAlertDelete:
|
|
return s.spendAlertDelete(org, req)
|
|
default:
|
|
return fail(StatusBadRequest, fmt.Sprintf("spendAlert: unknown op %d", req.Op))
|
|
}
|
|
}
|
|
|
|
// spendAlertList returns the org's alerts, newest first (created desc).
|
|
func (s *Server) spendAlertList(org string) (uint32, []byte) {
|
|
col, err := s.app.FindCollectionByNameOrId(SpendAlertCollection)
|
|
if err != nil {
|
|
return fail(StatusInternal, "spendAlert: collection missing: "+err.Error())
|
|
}
|
|
recs, err := s.app.FindRecordsByFilter(col, "org = {:org}", "-created", 0, 0, map[string]any{"org": org})
|
|
if err != nil {
|
|
return fail(StatusInternal, "spendAlert.list: "+err.Error())
|
|
}
|
|
views := make([]map[string]any, 0, len(recs))
|
|
for _, r := range recs {
|
|
views = append(views, spendAlertJSON(r))
|
|
}
|
|
return respond(StatusOK, views)
|
|
}
|
|
|
|
// spendAlertCreate validates + inserts a new alert for the org.
|
|
func (s *Server) spendAlertCreate(org string, req psRequest) (uint32, []byte) {
|
|
var p struct {
|
|
Title string `json:"title"`
|
|
Threshold float64 `json:"threshold"`
|
|
}
|
|
if !req.bind(&p) {
|
|
return fail(StatusBadRequest, "spendAlert.create: malformed params")
|
|
}
|
|
if status, msg := validateAlert(p.Title, p.Threshold); status != StatusOK {
|
|
return fail(status, msg)
|
|
}
|
|
col, err := s.app.FindCollectionByNameOrId(SpendAlertCollection)
|
|
if err != nil {
|
|
return fail(StatusInternal, "spendAlert: collection missing: "+err.Error())
|
|
}
|
|
rec := core.NewRecord(col)
|
|
rec.Set(faOrg, org)
|
|
rec.Set(faTitle, p.Title)
|
|
rec.Set(faThreshold, p.Threshold)
|
|
if err := s.app.Save(rec); err != nil {
|
|
return fail(StatusInternal, "spendAlert.create: "+err.Error())
|
|
}
|
|
return respond(StatusOK, spendAlertJSON(rec))
|
|
}
|
|
|
|
// spendAlertUpdate mutates an existing alert, asserting org ownership first.
|
|
func (s *Server) spendAlertUpdate(org string, req psRequest) (uint32, []byte) {
|
|
var p struct {
|
|
ID string `json:"id"`
|
|
Title *string `json:"title"`
|
|
Threshold *float64 `json:"threshold"`
|
|
}
|
|
if !req.bind(&p) || p.ID == "" {
|
|
return fail(StatusBadRequest, "spendAlert.update: id required")
|
|
}
|
|
rec, status, msg := s.spendAlertOwned(org, p.ID)
|
|
if status != StatusOK {
|
|
return fail(status, msg)
|
|
}
|
|
if p.Title != nil {
|
|
if st, m := validateTitle(*p.Title); st != StatusOK {
|
|
return fail(st, m)
|
|
}
|
|
rec.Set(faTitle, *p.Title)
|
|
}
|
|
if p.Threshold != nil {
|
|
if st, m := validateThreshold(*p.Threshold); st != StatusOK {
|
|
return fail(st, m)
|
|
}
|
|
rec.Set(faThreshold, *p.Threshold)
|
|
}
|
|
if err := s.app.Save(rec); err != nil {
|
|
return fail(StatusInternal, "spendAlert.update: "+err.Error())
|
|
}
|
|
return respond(StatusOK, spendAlertJSON(rec))
|
|
}
|
|
|
|
// spendAlertDelete removes an alert, asserting org ownership first.
|
|
func (s *Server) spendAlertDelete(org string, req psRequest) (uint32, []byte) {
|
|
var p struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if !req.bind(&p) || p.ID == "" {
|
|
return fail(StatusBadRequest, "spendAlert.delete: id required")
|
|
}
|
|
rec, status, msg := s.spendAlertOwned(org, p.ID)
|
|
if status != StatusOK {
|
|
return fail(status, msg)
|
|
}
|
|
if err := s.app.Delete(rec); err != nil {
|
|
return fail(StatusInternal, "spendAlert.delete: "+err.Error())
|
|
}
|
|
return respond(StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// spendAlertOwned loads an alert by id and verifies it belongs to org. A row
|
|
// owned by another org is reported NOT FOUND (never leaked, never mutated) —
|
|
// exactly the router's behaviour.
|
|
func (s *Server) spendAlertOwned(org, id string) (*core.Record, uint32, string) {
|
|
rec, err := s.app.FindRecordById(SpendAlertCollection, id)
|
|
if err != nil {
|
|
return nil, StatusNotFound, "spendAlert: not found"
|
|
}
|
|
if rec.GetString(faOrg) != org {
|
|
return nil, StatusNotFound, "spendAlert: not found"
|
|
}
|
|
return rec, StatusOK, ""
|
|
}
|
|
|
|
// --- validation (mirrors the router's zod schema) ---
|
|
|
|
func validateAlert(title string, threshold float64) (uint32, string) {
|
|
if status, msg := validateTitle(title); status != StatusOK {
|
|
return status, msg
|
|
}
|
|
return validateThreshold(threshold)
|
|
}
|
|
|
|
func validateTitle(title string) (uint32, string) {
|
|
if n := len([]rune(title)); n < 1 || n > 100 {
|
|
return StatusBadRequest, "spendAlert: title must be 1..100 chars"
|
|
}
|
|
return StatusOK, ""
|
|
}
|
|
|
|
func validateThreshold(threshold float64) (uint32, string) {
|
|
if threshold <= 0 || threshold > spendAlertMaxThreshold {
|
|
return StatusBadRequest, "spendAlert: threshold must be > 0 and <= 1,000,000"
|
|
}
|
|
return StatusOK, ""
|
|
}
|
|
|
|
// --- record <-> view marshaling ---
|
|
|
|
// spendAlertJSON renders a record as the JSON object the console expects.
|
|
func spendAlertJSON(r *core.Record) map[string]any {
|
|
return map[string]any{
|
|
"id": r.Id,
|
|
"orgId": r.GetString(faOrg),
|
|
"title": r.GetString(faTitle),
|
|
"threshold": r.GetFloat(faThreshold),
|
|
"triggeredAt": dtOrNil(r.GetDateTime(faTriggeredAt)),
|
|
"createdAt": dtString(r.GetDateTime(faCreated)),
|
|
"updatedAt": dtString(r.GetDateTime(faUpdated)),
|
|
}
|
|
}
|
|
|
|
// SpendAlertBuf renders a record as a typed SpendAlert ZAP sub-buffer. Exposed
|
|
// for callers (and the SpendAlertList typed view) that want zero-copy access to
|
|
// the stored entity rather than the JSON projection.
|
|
func SpendAlertBuf(r *core.Record) []byte {
|
|
return gen.NewSpendAlert(gen.SpendAlertInput{
|
|
Id: r.Id,
|
|
Org: r.GetString(faOrg),
|
|
Title: r.GetString(faTitle),
|
|
Threshold: r.GetFloat(faThreshold),
|
|
TriggeredAt: dtUnix(r.GetDateTime(faTriggeredAt)),
|
|
Created: dtUnix(r.GetDateTime(faCreated)),
|
|
Updated: dtUnix(r.GetDateTime(faUpdated)),
|
|
})
|
|
}
|
|
|
|
// dtUnix returns the unix-second value of a Base DateTime, or 0 if zero.
|
|
func dtUnix(dt types.DateTime) int64 {
|
|
if dt.IsZero() {
|
|
return 0
|
|
}
|
|
return dt.Time().Unix()
|
|
}
|
|
|
|
// dtString returns an RFC3339 UTC string for a DateTime ("" if zero).
|
|
func dtString(dt types.DateTime) string {
|
|
if dt.IsZero() {
|
|
return ""
|
|
}
|
|
return dt.Time().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// dtOrNil returns an RFC3339 string for a non-zero DateTime, else nil (the
|
|
// router emitted null for a never-triggered alert).
|
|
func dtOrNil(dt types.DateTime) any {
|
|
if dt.IsZero() {
|
|
return nil
|
|
}
|
|
return dt.Time().UTC().Format(time.RFC3339)
|
|
}
|