diff --git a/CONTEXT.md b/CONTEXT.md index a67a90c8..7cac283c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -660,7 +660,7 @@ _Avoid_: free workspace, trial period (for the state), Free plan (bare, for this The platform's fixed grace timeline that starts the moment a Workspace Subscription expires: the workspace is suspended immediately, the warning escalates as the countdown runs, and the workspace's resources are permanently deleted when it ends. Both roads into expiry — failed renewal payment and cancelled-then-lapsed — join the same countdown. The Billing Area surfaces it as a destructive warning carrying the stage's next deadline — the suspension date while a cancelled subscription's paid period still runs, the deletion date once expiry has passed; renewing (or resuming, before expiry) exits the countdown. -_Avoid_: grace period (as the user-facing name), debt period, deletion schedule. +_Avoid_: grace period (as the user-facing name), debt period, deletion schedule, paused (for the suspended workspace). ### Pay-As-You-Go (PAYG) @@ -714,16 +714,28 @@ _Avoid_: user currency preference, build-time currency. ### Notification Center -The user's single inbox for Notifications, opened from the App Sidebar's Notifications entry (below the Projects row). User-scoped and global: it aggregates messages across every Project rather than belonging to one. It is not the Deployment Task Dock and does not manage running tasks; it holds messages, not work. +The user's single inbox for Notifications, opened from the App Sidebar's Notifications entry (below the Projects row). Global across every Project in the current workspace rather than belonging to one: every Workspace Actor sees the same messages, and only read state is personal. It is not the Deployment Task Dock and does not manage running tasks; it holds messages, not work. _Avoid_: task center, activity feed, message center, alerts panel. ### Notification -One message addressed to the current user in the Notification Center: a system event (a deployment outcome, a database event), a billing or quota event, or a product announcement. Persistent and individually read/unread, which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. +One message addressed to the current user in the Notification Center: a billing, subscription, or quota event, or a platform announcement, each carrying a Notification Severity. Persistent and individually read/unread — read state is per message and per user, never a workspace-shared fact — which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. A Notification originates from the platform or from Brain itself; the two read identically in the Notification Center, though a platform-origin message may be withdrawn or revived by the platform when its underlying condition changes. _Avoid_: alert, toast (for persistent items), event (for the user-facing message). +### Notification Severity + +How much a Notification's message matters to the reader, on three levels — **critical** (something is already suspended or faces deletion), **warning** (a threshold was crossed or a deadline approaches; action prevents the next stage), **info** (a receipt, a hint, or an announcement; nothing to fix). Derived from what the message is about, never chosen per message, and shown without visual escalation: a critical item is marked, not shouted. + +_Avoid_: priority, importance (the platform CR field), level, tone. + +### Status Hint + +The one banner at the top of the content area that explains a billing state while it holds — payment-due (under the Deletion Countdown), Account Debt, a full workspace quota, or an Active Free Trial about to end — and offers the way out. It is a state, not a message: it appears and vanishes with the condition, writes nothing to the Notification Center, and only the most severe holding state shows. The destructive states cannot be dismissed; a dismissed quota or trial hint stays hidden until its state ends and re-enters. + +_Avoid_: alert bar, global notification, sticky toast, warning strip (as the concept's name). + ## Design System ### Component Registry @@ -760,7 +772,7 @@ _Avoid_: indicator capsule, FAB. ### Dev Mock -A dev/demo-only mode in which one feature's API answers are served from fixtures according to the selected Mock Scenario. Its state lives outside the dev tweaks panel — the panel is only its remote control, never the source of truth — which separates it from a tweak, an override value the panel owns. While a Dev Mock is enabled, the pages it covers show fixture data, not real state. +A dev/demo-only mode in which a feature's API answers are served from fixtures according to the selected Mock Scenario. One Dev Mock answers for every surface that derives from the same facts — the billing mock also serves the billing-born Notifications and, through them, the Status Hint — so those surfaces can never disagree; surfaces with unrelated facts (platform-origin Notifications, a Deployment Task Timeline, a Conversation) get Dev Mocks of their own, and independent Dev Mocks compose. Its state lives outside the dev tweaks panel — the panel is only its remote control, never the source of truth — which separates it from a tweak, an override value the panel owns. While a Dev Mock is enabled, the pages it covers show fixture data, not real state. _Avoid_: mock group, mock tweak, mock override. diff --git a/apps/api/main.go b/apps/api/main.go index 4218e710..0ef1d4d4 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -22,6 +22,7 @@ import ( "sealos/api/route/db" "sealos/api/route/health" "sealos/api/route/k8s" + "sealos/api/route/notification" "sealos/api/route/telemetry" ) @@ -70,6 +71,7 @@ func main() { k8s.RegisterExecWebSocket(router) ap.Register(api) db.Register(api) + notification.Register(api) telemetry.Register(api) fmt.Printf("Server listening on :%s\n", port) @@ -346,9 +348,10 @@ func addLogsQueryExamples(_ *huma.OpenAPI, op *huma.Operation) { // those paths and internally appends the slash so chi can match. func appendSlashForGroupRoots(next http.Handler) http.Handler { roots := map[string]bool{ - "/api/ap/v1alpha1": true, - "/api/db/v1alpha1": true, - "/api/k8s/v1alpha1": true, + "/api/ap/v1alpha1": true, + "/api/db/v1alpha1": true, + "/api/k8s/v1alpha1": true, + notification.BasePath: true, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if roots[r.URL.Path] { diff --git a/apps/api/route/notification/routes.go b/apps/api/route/notification/routes.go new file mode 100644 index 00000000..fc88c8ca --- /dev/null +++ b/apps/api/route/notification/routes.go @@ -0,0 +1,104 @@ +// Package notification exposes the Notification Center's platform stream: +// a read proxy over the upstream Notification CRs of the caller's namespace, +// authenticated with the caller's own kubeconfig bearer token (no standing +// credentials on the Brain side), plus the desktop-compatible mark-read patch. +package notification + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "sealos/api/middleware" + notificationsvc "sealos/api/service/notification" +) + +// BasePath is the group root; `main.go` accepts it with or without the slash. +const BasePath = "/api/notification/v1alpha1" + +// Service seams, swapped by route tests for a fake cluster. +var ( + listNotifications = notificationsvc.List + markNotificationRead = notificationsvc.MarkRead +) + +// Register adds the Notification API routes to the Huma API. +func Register(api huma.API) { + grp := huma.NewGroup(api, BasePath) + registerList(grp) + registerMarkRead(grp) +} + +func registerList(grp huma.API) { + type listInput struct { + middleware.AuthInput + Namespace string `query:"namespace" doc:"Namespace (default from kubeconfig)"` + } + type listOutput struct { + Body notificationsvc.ListResult + } + + huma.Register(grp, huma.Operation{ + OperationID: "notification-list", + Method: http.MethodGet, + Path: "/", + Summary: "List platform Notifications", + Description: "List the upstream Notification CRs (`notifications.notification.sealos.io/v1`) of the resolved namespace, newest first. The platform writes and withdraws these; Brain only reads them. `isRead` mirrors the CR's `isRead` label — the same state the Sealos desktop's own inbox shows.", + Tags: []string{"Notification"}, + }, func(ctx context.Context, input *listInput) (*listOutput, error) { + _, cfg, err := middleware.RestConfigFromAuth(input.Authorization) + if err != nil { + return nil, huma.Error400BadRequest("invalid kubeconfig", err) + } + result, err := listNotifications(ctx, cfg, input.Namespace) + if err != nil { + return nil, mapK8sError("failed to list notifications", err) + } + return &listOutput{Body: *result}, nil + }) +} + +func registerMarkRead(grp huma.API) { + type markReadInput struct { + middleware.AuthInput + Name string `path:"name" doc:"Notification CR metadata.name"` + Namespace string `query:"namespace" doc:"Namespace (default from kubeconfig)"` + } + type markReadOutput struct { + Body notificationsvc.ReadResult + } + + huma.Register(grp, huma.Operation{ + OperationID: "notification-mark-read", + Method: http.MethodPatch, + Path: "/{name}/read", + Summary: "Mark a platform Notification read", + Description: "Merge-patch `metadata.labels.isRead: \"true\"` on one Notification CR — the write the Sealos desktop performs, so the desktop bell follows. Callers whose kubeconfig lacks patch permission (workspace Developers) receive 403; the Notification Center treats that as a best-effort skip because its own per-user receipt already records the read.", + Tags: []string{"Notification"}, + }, func(ctx context.Context, input *markReadInput) (*markReadOutput, error) { + _, cfg, err := middleware.RestConfigFromAuth(input.Authorization) + if err != nil { + return nil, huma.Error400BadRequest("invalid kubeconfig", err) + } + result, err := markNotificationRead(ctx, cfg, input.Namespace, input.Name) + if err != nil { + return nil, mapK8sError("failed to mark notification read", err) + } + return &markReadOutput{Body: *result}, nil + }) +} + +func mapK8sError(message string, err error) error { + switch { + case apierrors.IsNotFound(err): + return huma.Error404NotFound("notification not found", err) + case apierrors.IsForbidden(err): + return huma.Error403Forbidden("notification access forbidden", err) + case apierrors.IsUnauthorized(err): + return huma.Error401Unauthorized("invalid kubeconfig", err) + default: + return huma.Error500InternalServerError(message, err) + } +} diff --git a/apps/api/route/notification/routes_test.go b/apps/api/route/notification/routes_test.go new file mode 100644 index 00000000..7061b65f --- /dev/null +++ b/apps/api/route/notification/routes_test.go @@ -0,0 +1,266 @@ +package notification + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + k8stesting "k8s.io/client-go/testing" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + notificationsvc "sealos/api/service/notification" +) + +func newTestAPI() (chi.Router, huma.API) { + router := chi.NewRouter() + api := humachi.New(router, huma.DefaultConfig("test", "0.0.0")) + Register(api) + return router, api +} + +// A kubeconfig the auth middleware accepts: inline bearer token, one context +// with a namespace. Off-cluster transport needs NODE_ENV=development. +const testKubeconfig = `apiVersion: v1 +clusters: +- cluster: + server: https://example.test + name: c +contexts: +- context: + cluster: c + namespace: ns-a + user: u + name: ctx +current-context: ctx +users: +- name: u + user: + token: test-token +` + +func testAuthorization() string { + return "Bearer " + url.QueryEscape(testKubeconfig) +} + +func fakeCluster(t *testing.T, objects ...runtime.Object) dynamic.Interface { + t.Helper() + t.Setenv("NODE_ENV", "development") + client := fake.NewSimpleDynamicClientWithCustomListKinds( + runtime.NewScheme(), + map[schema.GroupVersionResource]string{notificationsvc.GVR: "NotificationList"}, + objects..., + ) + previousList, previousRead := listNotifications, markNotificationRead + listNotifications = func(ctx context.Context, _ *clientcmdapi.Config, namespace string) (*notificationsvc.ListResult, error) { + return notificationsvc.ListWithClient(ctx, client, namespace, time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)) + } + markNotificationRead = func(ctx context.Context, _ *clientcmdapi.Config, namespace string, name string) (*notificationsvc.ReadResult, error) { + return notificationsvc.MarkReadWithClient(ctx, client, namespace, name) + } + t.Cleanup(func() { + listNotifications, markNotificationRead = previousList, previousRead + }) + return client +} + +func testNotification(name string, isRead string, timestamp int64) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "notification.sealos.io/v1", + "kind": "Notification", + "metadata": map[string]interface{}{ + "name": name, + "namespace": "ns-a", + "labels": map[string]interface{}{"isRead": isRead}, + }, + "spec": map[string]interface{}{ + "title": "Balance exhausted", + "message": "Services are suspended.", + "from": "Debt-System", + "timestamp": timestamp, + }, + }} +} + +func TestRegisterExposesListAndMarkRead(t *testing.T) { + _, api := newTestAPI() + + list := api.OpenAPI().Paths["/api/notification/v1alpha1/"] + if list == nil || list.Get == nil { + t.Fatalf("expected GET /api/notification/v1alpha1/ to be registered") + } + if list.Get.OperationID != "notification-list" { + t.Fatalf("unexpected list operation ID: %q", list.Get.OperationID) + } + + read := api.OpenAPI().Paths["/api/notification/v1alpha1/{name}/read"] + if read == nil || read.Patch == nil { + t.Fatalf("expected PATCH /api/notification/v1alpha1/{name}/read to be registered") + } + if read.Patch.OperationID != "notification-mark-read" { + t.Fatalf("unexpected mark-read operation ID: %q", read.Patch.OperationID) + } +} + +func TestListRequiresAuthorizationAtHTTPBoundary(t *testing.T) { + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodGet, "/api/notification/v1alpha1/?namespace=ns-a", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected a missing Authorization header to be rejected, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListRejectsInvalidKubeconfigBeforeTouchingTheCluster(t *testing.T) { + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodGet, "/api/notification/v1alpha1/?namespace=ns-a", nil) + req.Header.Set("Authorization", "Bearer not-a-kubeconfig") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected an invalid kubeconfig to answer 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListServesTheNamespaceNotificationsNewestFirst(t *testing.T) { + fakeCluster(t, + testNotification("debt-choice-debtperiod", "false", 1756200000), + testNotification("workspace-debt-debt", "true", 1756300000), + ) + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodGet, "/api/notification/v1alpha1/?namespace=ns-a", nil) + req.Header.Set("Authorization", testAuthorization()) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var body notificationsvc.ListResult + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if body.Namespace != "ns-a" || len(body.Items) != 2 { + t.Fatalf("unexpected body: %+v", body) + } + if body.Items[0].Name != "workspace-debt-debt" || !body.Items[0].IsRead { + t.Fatalf("expected the newest, read CR first, got %+v", body.Items[0]) + } + if body.Items[1].Name != "debt-choice-debtperiod" || body.Items[1].IsRead || body.Items[1].Title != "Balance exhausted" { + t.Fatalf("expected the unread debt CR with upstream copy as-is, got %+v", body.Items[1]) + } +} + +func TestMarkReadPatchesTheLabelThroughTheRoute(t *testing.T) { + client := fakeCluster(t, testNotification("debt-choice-debtperiod", "false", 1756200000)) + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodPatch, "/api/notification/v1alpha1/debt-choice-debtperiod/read?namespace=ns-a", nil) + req.Header.Set("Authorization", testAuthorization()) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var body notificationsvc.ReadResult + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if !body.IsRead || body.Name != "debt-choice-debtperiod" { + t.Fatalf("unexpected body: %+v", body) + } + obj, err := client.Resource(notificationsvc.GVR).Namespace("ns-a").Get(context.Background(), "debt-choice-debtperiod", metav1.GetOptions{}) + if err != nil { + t.Fatalf("re-reading the CR failed: %v", err) + } + if obj.GetLabels()[notificationsvc.ReadLabel] != "true" { + t.Fatalf("expected the isRead label to be true, got %q", obj.GetLabels()[notificationsvc.ReadLabel]) + } +} + +func TestMarkReadMapsClusterForbiddenTo403(t *testing.T) { + client := fakeCluster(t, testNotification("debt-choice-debtperiod", "false", 1756200000)) + // A Developer's kubeconfig can read but not patch: the apiserver answers + // Forbidden, which the route must pass through so the client can skip. + client.(*fake.FakeDynamicClient).PrependReactor("patch", "notifications", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(notificationsvc.GVR.GroupResource(), "debt-choice-debtperiod", errors.New("developer role")) + }) + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodPatch, "/api/notification/v1alpha1/debt-choice-debtperiod/read?namespace=ns-a", nil) + req.Header.Set("Authorization", testAuthorization()) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestMarkReadMapsMissingCRTo404(t *testing.T) { + fakeCluster(t) + router, _ := newTestAPI() + + req := httptest.NewRequest(http.MethodPatch, "/api/notification/v1alpha1/missing/read?namespace=ns-a", nil) + req.Header.Set("Authorization", testAuthorization()) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestK8sErrorStatusMapping(t *testing.T) { + resource := notificationsvc.GVR.GroupResource() + tests := []struct { + name string + err error + want int + }{ + {name: "not found", err: apierrors.NewNotFound(resource, "x"), want: http.StatusNotFound}, + {name: "forbidden", err: apierrors.NewForbidden(resource, "x", errors.New("rbac")), want: http.StatusForbidden}, + {name: "unauthorized", err: apierrors.NewUnauthorized("bad token"), want: http.StatusUnauthorized}, + {name: "anything else", err: errors.New("boom"), want: http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mapK8sError("failed", tt.err) + statusErr, ok := err.(huma.StatusError) + if !ok { + t.Fatalf("expected Huma status error, got %T", err) + } + if statusErr.GetStatus() != tt.want { + t.Fatalf("expected status %d, got %d", tt.want, statusErr.GetStatus()) + } + }) + } +} diff --git a/apps/api/service/notification/notification.go b/apps/api/service/notification/notification.go new file mode 100644 index 00000000..d43af868 --- /dev/null +++ b/apps/api/service/notification/notification.go @@ -0,0 +1,244 @@ +// Package notification reads the platform's Notification CRs +// (`notifications.notification.sealos.io/v1`) for the Notification Center. +// +// The platform is the single source of truth for these messages: the account +// and workspace-subscription controllers write fixed-name CRs (for example +// `debt-choice-debtperiod`, `workspace-debt-debt`) into every user namespace, +// overwrite them in place when a state escalates (flipping `isRead` back to +// "false"), and mark them read again on recovery. Brain never copies them — +// it lists them with the caller's own kubeconfig and patches the same +// `isRead` label the Sealos desktop uses, so both surfaces agree. +package notification + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "sealos/api/middleware" +) + +// GVR is the upstream Notification CRD resource. +var GVR = schema.GroupVersionResource{ + Group: "notification.sealos.io", + Version: "v1", + Resource: "notifications", +} + +// ReadLabel is the CR label the platform and the desktop use for read state. +const ReadLabel = "isRead" + +// Item is one platform Notification, flattened for the UI. Copy is the CR's +// default language as written by upstream; `i18ns` is deliberately dropped. +type Item struct { + Name string `json:"name" doc:"CR metadata.name; fixed per upstream scenario and overwritten in place"` + Namespace string `json:"namespace"` + UID string `json:"uid,omitempty"` + IsRead bool `json:"isRead" doc:"metadata.labels.isRead == \"true\""` + Title string `json:"title"` + Message string `json:"message"` + From string `json:"from,omitempty"` + Importance string `json:"importance,omitempty" doc:"High, Medium, or Low"` + DesktopPopup bool `json:"desktopPopup"` + Timestamp int64 `json:"timestamp" doc:"spec.timestamp (Unix seconds); creationTimestamp when upstream omitted it"` + Version int64 `json:"version" doc:"The id's version component: spec.timestamp, or metadata.generation when upstream omitted it. An in-place overwrite changes it; a label patch never does."` + CreationTimestamp string `json:"creationTimestamp,omitempty"` +} + +// ListResult is the list response body. +type ListResult struct { + Namespace string `json:"namespace"` + Items []Item `json:"items"` +} + +// ReadResult is the mark-read response body. +type ReadResult struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + IsRead bool `json:"isRead"` +} + +type dynamicClientFunc func(*clientcmdapi.Config, string) (dynamic.Interface, string, error) + +var dynamicClientFactory dynamicClientFunc = defaultDynamicClientFactory + +func defaultDynamicClientFactory(cfg *clientcmdapi.Config, ns string) (dynamic.Interface, string, error) { + resolved, err := middleware.ResolveContext(cfg, middleware.ResolveOptions{Namespace: ns}) + if err != nil { + return nil, "", err + } + if strings.TrimSpace(resolved.Namespace) == "" { + return nil, "", fmt.Errorf("namespace is required") + } + client, err := dynamic.NewForConfig(resolved.RestConfig) + if err != nil { + return nil, "", err + } + return client, resolved.Namespace, nil +} + +// List returns the namespace's live Notification CRs using the caller's kubeconfig. +func List(ctx context.Context, cfg *clientcmdapi.Config, namespace string) (*ListResult, error) { + client, ns, err := dynamicClientFactory(cfg, namespace) + if err != nil { + return nil, err + } + return ListWithClient(ctx, client, ns, time.Now()) +} + +// MarkRead flips the CR's `isRead` label to "true" using the caller's kubeconfig. +func MarkRead(ctx context.Context, cfg *clientcmdapi.Config, namespace string, name string) (*ReadResult, error) { + client, ns, err := dynamicClientFactory(cfg, namespace) + if err != nil { + return nil, err + } + return MarkReadWithClient(ctx, client, ns, name) +} + +// ListWithClient lists the namespace's Notification CRs, drops entries outside +// their optional `startTime`/`endTime` window (the desktop's rule), and orders +// them newest first. +func ListWithClient(ctx context.Context, client dynamic.Interface, namespace string, now time.Time) (*ListResult, error) { + namespace = strings.TrimSpace(namespace) + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + list, err := client.Resource(GVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + items := make([]Item, 0, len(list.Items)) + for i := range list.Items { + item, visible := itemFromUnstructured(&list.Items[i], now) + if visible { + items = append(items, item) + } + } + sort.SliceStable(items, func(a, b int) bool { + if items[a].Timestamp != items[b].Timestamp { + return items[a].Timestamp > items[b].Timestamp + } + return items[a].Name < items[b].Name + }) + return &ListResult{Namespace: namespace, Items: items}, nil +} + +// MarkReadWithClient merge-patches `metadata.labels.isRead: "true"` on one CR — +// the same write the Sealos desktop performs, so the desktop bell follows. +func MarkReadWithClient(ctx context.Context, client dynamic.Interface, namespace string, name string) (*ReadResult, error) { + namespace = strings.TrimSpace(namespace) + name = strings.TrimSpace(name) + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + if name == "" { + return nil, fmt.Errorf("name is required") + } + patch, err := json.Marshal(map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{ReadLabel: "true"}, + }, + }) + if err != nil { + return nil, err + } + obj, err := client.Resource(GVR).Namespace(namespace).Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + return nil, err + } + return &ReadResult{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + IsRead: obj.GetLabels()[ReadLabel] == "true", + }, nil +} + +func itemFromUnstructured(obj *unstructured.Unstructured, now time.Time) (Item, bool) { + spec, _, _ := unstructured.NestedMap(obj.Object, "spec") + if startedAt, ok := parseMetaTime(spec["startTime"]); ok && now.Before(startedAt) { + return Item{}, false + } + if endedAt, ok := parseMetaTime(spec["endTime"]); ok && !now.Before(endedAt) { + return Item{}, false + } + item := Item{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + UID: string(obj.GetUID()), + IsRead: obj.GetLabels()[ReadLabel] == "true", + Title: stringField(spec, "title"), + Message: stringField(spec, "message"), + From: stringField(spec, "from"), + Importance: stringField(spec, "importance"), + DesktopPopup: boolField(spec, "desktopPopup"), + Timestamp: int64Field(spec, "timestamp"), + } + // The id is versioned so an upstream overwrite of a fixed-name CR reads + // as a new message: spec.timestamp when the writer set one, else the + // object's generation — creationTimestamp would survive the overwrite and + // let an old receipt hide the revived message. + item.Version = item.Timestamp + if item.Version == 0 { + item.Version = obj.GetGeneration() + } + created := obj.GetCreationTimestamp() + if !created.IsZero() { + item.CreationTimestamp = created.UTC().Format(time.RFC3339) + if item.Timestamp == 0 { + item.Timestamp = created.Unix() + } + if item.Version == 0 { + item.Version = created.Unix() + } + } + return item, true +} + +func parseMetaTime(value interface{}) (time.Time, bool) { + raw, ok := value.(string) + if !ok || strings.TrimSpace(raw) == "" { + return time.Time{}, false + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return parsed, true +} + +func stringField(m map[string]interface{}, key string) string { + value, _ := m[key].(string) + return value +} + +func boolField(m map[string]interface{}, key string) bool { + value, _ := m[key].(bool) + return value +} + +func int64Field(m map[string]interface{}, key string) int64 { + switch value := m[key].(type) { + case int64: + return value + case int: + return int64(value) + case float64: + return int64(value) + case json.Number: + parsed, err := value.Int64() + if err == nil { + return parsed + } + } + return 0 +} diff --git a/apps/api/service/notification/notification_test.go b/apps/api/service/notification/notification_test.go new file mode 100644 index 00000000..559ba3be --- /dev/null +++ b/apps/api/service/notification/notification_test.go @@ -0,0 +1,212 @@ +package notification + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" +) + +func testClient(objects ...runtime.Object) dynamic.Interface { + return fake.NewSimpleDynamicClientWithCustomListKinds( + runtime.NewScheme(), + map[schema.GroupVersionResource]string{GVR: "NotificationList"}, + objects..., + ) +} + +func testNotification(name, namespace string, isRead string, spec map[string]interface{}) *unstructured.Unstructured { + metadata := map[string]interface{}{ + "name": name, + "namespace": namespace, + "uid": "uid-" + name, + "creationTimestamp": "2026-08-20T10:00:00Z", + } + if isRead != "" { + metadata["labels"] = map[string]interface{}{"isRead": isRead} + } + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "notification.sealos.io/v1", + "kind": "Notification", + "metadata": metadata, + "spec": spec, + }} +} + +func testNow() time.Time { + return time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) +} + +func TestListWithClientFlattensAndOrdersNewestFirst(t *testing.T) { + client := testClient( + testNotification("debt-choice-debtperiod", "ns-a", "false", map[string]interface{}{ + "title": "Balance exhausted", + "message": "Your services will be suspended.", + "from": "Debt-System", + "importance": "High", + "desktopPopup": true, + "timestamp": int64(1756200000), + "i18ns": map[string]interface{}{"zh": map[string]interface{}{"title": "余额耗尽"}}, + }), + testNotification("workspace-debt-debt", "ns-a", "true", map[string]interface{}{ + "title": "Workspace suspended", + "message": "Renew to resume.", + "from": "Workspace-Subscription-System", + "timestamp": int64(1756300000), + }), + testNotification("other-namespace", "ns-b", "false", map[string]interface{}{ + "title": "Not mine", + "message": "", + "timestamp": int64(1756400000), + }), + ) + + got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if got.Namespace != "ns-a" { + t.Fatalf("expected namespace ns-a, got %q", got.Namespace) + } + if len(got.Items) != 2 { + t.Fatalf("expected 2 items in ns-a, got %d: %+v", len(got.Items), got.Items) + } + first, second := got.Items[0], got.Items[1] + if first.Name != "workspace-debt-debt" || second.Name != "debt-choice-debtperiod" { + t.Fatalf("expected newest first, got %q then %q", first.Name, second.Name) + } + if !first.IsRead || second.IsRead { + t.Fatalf("expected isRead to follow the label: first=%v second=%v", first.IsRead, second.IsRead) + } + if second.Title != "Balance exhausted" || second.Message != "Your services will be suspended." { + t.Fatalf("unexpected copy: %+v", second) + } + if second.From != "Debt-System" || second.Importance != "High" || !second.DesktopPopup { + t.Fatalf("unexpected metadata: %+v", second) + } + if second.Timestamp != 1756200000 { + t.Fatalf("expected spec.timestamp to pass through, got %d", second.Timestamp) + } + if second.CreationTimestamp != "2026-08-20T10:00:00Z" { + t.Fatalf("expected creationTimestamp to pass through, got %q", second.CreationTimestamp) + } + if second.Version != 1756200000 { + t.Fatalf("expected the id version to be spec.timestamp, got %d", second.Version) + } +} + +func TestListWithClientFallsBackToCreationTimestampAndTreatsMissingLabelAsUnread(t *testing.T) { + announcement := testNotification("announcement", "ns-a", "", map[string]interface{}{ + "title": "Welcome", + "message": "Hello", + }) + announcement.SetGeneration(3) + client := testClient(announcement) + + got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if len(got.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(got.Items)) + } + item := got.Items[0] + if item.IsRead { + t.Fatalf("expected a CR without the isRead label to be unread") + } + wantCreated := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC).Unix() + if item.Timestamp != wantCreated { + t.Fatalf("expected timestamp to fall back to creationTimestamp %d, got %d", wantCreated, item.Timestamp) + } + // The display time may fall back to creation, but the id must not: an + // in-place overwrite keeps creationTimestamp and bumps generation. + if item.Version != 3 { + t.Fatalf("expected the id version to fall back to metadata.generation 3, got %d", item.Version) + } +} + +func TestListWithClientHonoursStartAndEndWindows(t *testing.T) { + client := testClient( + testNotification("not-yet", "ns-a", "false", map[string]interface{}{ + "title": "Scheduled", + "message": "", + "timestamp": int64(1), + "startTime": "2026-09-01T00:00:00Z", + }), + testNotification("expired", "ns-a", "false", map[string]interface{}{ + "title": "Old", + "message": "", + "timestamp": int64(2), + "endTime": "2026-08-01T00:00:00Z", + }), + testNotification("live", "ns-a", "false", map[string]interface{}{ + "title": "Live", + "message": "", + "timestamp": int64(3), + "startTime": "2026-08-01T00:00:00Z", + "endTime": "2026-09-01T00:00:00Z", + }), + ) + + got, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if len(got.Items) != 1 || got.Items[0].Name != "live" { + t.Fatalf("expected only the live notification, got %+v", got.Items) + } +} + +func TestListWithClientRequiresNamespace(t *testing.T) { + if _, err := ListWithClient(context.Background(), testClient(), " ", testNow()); err == nil { + t.Fatalf("expected an error for a blank namespace") + } +} + +func TestMarkReadWithClientPatchesTheReadLabel(t *testing.T) { + client := testClient( + testNotification("debt-choice-debtperiod", "ns-a", "false", map[string]interface{}{ + "title": "Balance exhausted", + "message": "", + "timestamp": int64(1756200000), + }), + ) + + got, err := MarkReadWithClient(context.Background(), client, "ns-a", "debt-choice-debtperiod") + if err != nil { + t.Fatalf("MarkReadWithClient returned error: %v", err) + } + if !got.IsRead || got.Name != "debt-choice-debtperiod" || got.Namespace != "ns-a" { + t.Fatalf("unexpected read result: %+v", got) + } + + listed, err := ListWithClient(context.Background(), client, "ns-a", testNow()) + if err != nil { + t.Fatalf("ListWithClient returned error: %v", err) + } + if len(listed.Items) != 1 || !listed.Items[0].IsRead { + t.Fatalf("expected the CR to read back as read, got %+v", listed.Items) + } + if listed.Items[0].Title != "Balance exhausted" { + t.Fatalf("expected the merge patch to leave spec untouched, got %+v", listed.Items[0]) + } +} + +func TestMarkReadWithClientSurfacesNotFound(t *testing.T) { + _, err := MarkReadWithClient(context.Background(), testClient(), "ns-a", "missing") + if err == nil || !apierrors.IsNotFound(err) { + t.Fatalf("expected a NotFound error, got %v", err) + } +} + +func TestMarkReadWithClientRequiresName(t *testing.T) { + if _, err := MarkReadWithClient(context.Background(), testClient(), "ns-a", " "); err == nil { + t.Fatalf("expected an error for a blank name") + } +} diff --git a/apps/ui/drizzle.config.ts b/apps/ui/drizzle.config.ts index 0f95e632..84d93fba 100644 --- a/apps/ui/drizzle.config.ts +++ b/apps/ui/drizzle.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "./src/features/deploy/task/schema.ts", "./src/features/onboarding/schema.ts", "./src/features/marketing/schema.ts", + "./src/features/notifications/schema.ts", "./src/lib/project-persistence/schema.ts", ], dbCredentials: { url: process.env.DATABASE_URL ?? "" }, diff --git a/apps/ui/drizzle/0015_notification_center.sql b/apps/ui/drizzle/0015_notification_center.sql new file mode 100644 index 00000000..0ff625f7 --- /dev/null +++ b/apps/ui/drizzle/0015_notification_center.sql @@ -0,0 +1,28 @@ +CREATE SCHEMA "sealai_notification"; +--> statement-breakpoint +CREATE TABLE "sealai_notification"."notification_messages" ( + "id" text PRIMARY KEY NOT NULL, + "namespace" text NOT NULL, + "user_uid" text, + "kind" text NOT NULL, + "project_uid" text, + "payload" jsonb NOT NULL, + "dedupe_key" text NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sealai_notification"."notification_read_receipts" ( + "user_uid" text NOT NULL, + "message_key" text NOT NULL, + "message_id" text, + "read_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notification_read_receipts_user_uid_message_key_pk" PRIMARY KEY("user_uid","message_key") +); +--> statement-breakpoint +ALTER TABLE "sealai_notification"."notification_read_receipts" ADD CONSTRAINT "notification_read_receipts_message_id_notification_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "sealai_notification"."notification_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "notification_messages_live_dedupe_key_idx" ON "sealai_notification"."notification_messages" USING btree ("dedupe_key") WHERE "sealai_notification"."notification_messages"."released_at" IS NULL;--> statement-breakpoint +CREATE INDEX "notification_messages_namespace_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("namespace","created_at");--> statement-breakpoint +CREATE INDEX "notification_messages_user_uid_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("user_uid","created_at");--> statement-breakpoint +CREATE INDEX "notification_messages_created_at_idx" ON "sealai_notification"."notification_messages" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "notification_read_receipts_message_id_idx" ON "sealai_notification"."notification_read_receipts" USING btree ("message_id"); \ No newline at end of file diff --git a/apps/ui/drizzle/meta/0015_snapshot.json b/apps/ui/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..56acc997 --- /dev/null +++ b/apps/ui/drizzle/meta/0015_snapshot.json @@ -0,0 +1,2848 @@ +{ + "id": "0dbbaddf-7e79-4f0e-83bf-b3eb789b1de9", + "prevId": "67332fdf-15e9-467a-aecf-25706210e908", + "version": "7", + "dialect": "postgresql", + "tables": { + "sealai_assistant.assistant_chat_messages": { + "name": "assistant_chat_messages", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_chat_messages_chat_id_idx": { + "name": "assistant_chat_messages_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_chat_messages_chat_id_created_idx": { + "name": "assistant_chat_messages_chat_id_created_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assistant_chat_messages_chat_id_assistant_chats_id_fk": { + "name": "assistant_chat_messages_chat_id_assistant_chats_id_fk", + "tableFrom": "assistant_chat_messages", + "tableTo": "assistant_chats", + "schemaTo": "sealai_assistant", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_chats": { + "name": "assistant_chats", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Chat'" + }, + "title_ai_generated": { + "name": "title_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_chats_updated_at_idx": { + "name": "assistant_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_chats_namespace_actor_updated_at_idx": { + "name": "assistant_chats_namespace_actor_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_actor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_devbox_runtimes": { + "name": "assistant_devbox_runtimes", + "schema": "sealai_assistant", + "columns": { + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_name": { + "name": "runtime_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pause_due_at": { + "name": "pause_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delete_due_at": { + "name": "delete_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_lease_owner": { + "name": "cleanup_lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_lease_expires_at": { + "name": "cleanup_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_devbox_runtimes_pause_due_idx": { + "name": "assistant_devbox_runtimes_pause_due_idx", + "columns": [ + { + "expression": "pause_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"paused_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "assistant_devbox_runtimes_delete_due_idx": { + "name": "assistant_devbox_runtimes_delete_due_idx", + "columns": [ + { + "expression": "delete_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_assistant\".\"assistant_devbox_runtimes\".\"delete_due_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.assistant_entitlements": { + "name": "assistant_entitlements", + "schema": "sealai_assistant", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "free_turns_used": { + "name": "free_turns_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assistant_entitlements_updated_at_idx": { + "name": "assistant_entitlements_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_app_install_sessions": { + "name": "github_app_install_sessions", + "schema": "sealai_assistant", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_identity_version": { + "name": "owner_identity_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_app_install_sessions_expires_at_idx": { + "name": "github_app_install_sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_app_install_sessions_namespace_idx": { + "name": "github_app_install_sessions_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_connections": { + "name": "github_connections", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github_app'" + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'User'" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_connections_updated_at_idx": { + "name": "github_connections_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_namespace_updated_at_idx": { + "name": "github_connections_namespace_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_namespace_unique_idx": { + "name": "github_connections_namespace_unique_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_connections_installation_idx": { + "name": "github_connections_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.github_oauth_connections": { + "name": "github_oauth_connections", + "schema": "sealai_assistant", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_actor": { + "name": "workspace_actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_identity_version": { + "name": "owner_identity_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bearer'" + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_oauth_connections_updated_at_idx": { + "name": "github_oauth_connections_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_oauth_connections_github_login_idx": { + "name": "github_oauth_connections_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_oauth_connections_current_owner_unique_idx": { + "name": "github_oauth_connections_current_owner_unique_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_actor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_assistant\".\"github_oauth_connections\".\"owner_identity_version\" = 2", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.identity_fingerprints": { + "name": "identity_fingerprints", + "schema": "sealai_assistant", + "columns": { + "cr_name": { + "name": "cr_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "minted_at": { + "name": "minted_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_assistant.identity_uid_canonicalizations": { + "name": "identity_uid_canonicalizations", + "schema": "sealai_assistant", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "canonical_user_uid": { + "name": "canonical_user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_uid_canonicalizations_canonical_idx": { + "name": "identity_uid_canonicalizations_canonical_idx", + "columns": [ + { + "expression": "canonical_user_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_agent_calls": { + "name": "deploy_task_agent_calls", + "schema": "sealai_deployment", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "call_id": { + "name": "call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deploy_task_agent_calls_pending_idx": { + "name": "deploy_task_agent_calls_pending_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_agent_calls_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_agent_calls_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_agent_calls", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deploy_task_agent_calls_pk": { + "name": "deploy_task_agent_calls_pk", + "columns": ["task_id", "call_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deploy_task_agent_calls_lease_epoch_nonnegative": { + "name": "deploy_task_agent_calls_lease_epoch_nonnegative", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"lease_epoch\" >= 0" + }, + "deploy_task_agent_calls_tool_name_valid": { + "name": "deploy_task_agent_calls_tool_name_valid", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"tool_name\" IN ('template_ready', 'deployment_completed')" + }, + "deploy_task_agent_calls_state_valid": { + "name": "deploy_task_agent_calls_state_valid", + "value": "\"sealai_deployment\".\"deploy_task_agent_calls\".\"state\" IN ('pending', 'running', 'succeeded', 'failed')" + }, + "deploy_task_agent_calls_request_hash_valid": { + "name": "deploy_task_agent_calls_request_hash_valid", + "value": "char_length(\"sealai_deployment\".\"deploy_task_agent_calls\".\"request_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_events": { + "name": "deploy_task_events", + "schema": "sealai_deployment", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "nextval('sealai_deployment.deploy_task_events_seq'::regclass)" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_task_events_task_created_at_idx": { + "name": "deploy_task_events_task_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_events_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_events_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_events", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deploy_task_events_pk": { + "name": "deploy_task_events_pk", + "columns": ["task_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_task_messages": { + "name": "deploy_task_messages", + "schema": "sealai_deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_task_messages_task_id_idx": { + "name": "deploy_task_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_task_messages_task_created_idx": { + "name": "deploy_task_messages_task_created_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deploy_task_messages_task_id_deploy_tasks_id_fk": { + "name": "deploy_task_messages_task_id_deploy_tasks_id_fk", + "tableFrom": "deploy_task_messages", + "tableTo": "deploy_tasks", + "schemaTo": "sealai_deployment", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_deployment.deploy_tasks": { + "name": "deploy_tasks", + "schema": "sealai_deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creating_actor": { + "name": "creating_actor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "marketing_attribution": { + "name": "marketing_attribution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_binding": { + "name": "credential_binding", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_connection_id": { + "name": "github_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "runner": { + "name": "runner", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'api'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_provider": { + "name": "runtime_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_name": { + "name": "runtime_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_state": { + "name": "runtime_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_paused_at": { + "name": "runtime_paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runtime_cleanup_lease_owner": { + "name": "runtime_cleanup_lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_cleanup_lease_expires_at": { + "name": "runtime_cleanup_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gateway_url": { + "name": "gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_session_id": { + "name": "gateway_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_thread_id": { + "name": "gateway_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_turn_id": { + "name": "gateway_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_state_snapshot": { + "name": "gateway_state_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "agent_turn_count": { + "name": "agent_turn_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_protocol": { + "name": "agent_protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mcp-v1'" + }, + "agent_control_token_hash": { + "name": "agent_control_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_control_token_revoked_at": { + "name": "agent_control_token_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_template_digest": { + "name": "agent_template_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_input_schema_digest": { + "name": "agent_input_schema_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_checkpoint_id": { + "name": "agent_checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_input_revision": { + "name": "agent_input_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "agent_completion_receipt": { + "name": "agent_completion_receipt", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_details": { + "name": "failure_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifact_summary": { + "name": "artifact_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "canvas_projection": { + "name": "canvas_projection", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "timeline_snapshot": { + "name": "timeline_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocking_inputs": { + "name": "blocking_inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_url": { + "name": "result_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_claimed_at": { + "name": "lease_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retried_from_task_id": { + "name": "retried_from_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deploy_tasks_agent_control_token_hash_idx": { + "name": "deploy_tasks_agent_control_token_hash_idx", + "columns": [ + { + "expression": "agent_control_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_namespace_updated_at_idx": { + "name": "deploy_tasks_namespace_updated_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_project_uid_updated_at_idx": { + "name": "deploy_tasks_project_uid_updated_at_idx", + "columns": [ + { + "expression": "project_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_status_updated_at_idx": { + "name": "deploy_tasks_status_updated_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_leased_expiry_idx": { + "name": "deploy_tasks_leased_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('running', 'applying')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_queued_created_idx": { + "name": "deploy_tasks_queued_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_terminal_completed_idx": { + "name": "deploy_tasks_terminal_completed_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('completed', 'failed', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_runtime_paused_idx": { + "name": "deploy_tasks_runtime_paused_idx", + "columns": [ + { + "expression": "runtime_paused_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"runtime_provider\" = 'devbox' AND lower(coalesce(\"sealai_deployment\".\"deploy_tasks\".\"runtime_state\", '')) IN ('paused', 'archived')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "deploy_tasks_one_active_clone_idx": { + "name": "deploy_tasks_one_active_clone_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retried_from_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_deployment\".\"deploy_tasks\".\"retried_from_task_id\" IS NOT NULL AND \"sealai_deployment\".\"deploy_tasks\".\"status\" IN ('queued', 'running', 'blocked', 'applying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deploy_tasks_agent_turn_count_nonnegative": { + "name": "deploy_tasks_agent_turn_count_nonnegative", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_turn_count\" >= 0" + }, + "deploy_tasks_agent_input_revision_nonnegative": { + "name": "deploy_tasks_agent_input_revision_nonnegative", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_input_revision\" >= 0" + }, + "deploy_tasks_agent_protocol_valid": { + "name": "deploy_tasks_agent_protocol_valid", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_protocol\" IN ('mcp-v1')" + }, + "deploy_tasks_agent_control_token_hash_valid": { + "name": "deploy_tasks_agent_control_token_hash_valid", + "value": "\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\" IS NULL OR char_length(\"sealai_deployment\".\"deploy_tasks\".\"agent_control_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "sealai_onboarding.onboarding_profiles": { + "name": "onboarding_profiles", + "schema": "sealai_onboarding", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at_step": { + "name": "dismissed_at_step", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role_type": { + "name": "role_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_other_text": { + "name": "role_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_context": { + "name": "usage_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_other_text": { + "name": "usage_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority_tags": { + "name": "priority_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority_display_order": { + "name": "priority_display_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority_other_text": { + "name": "priority_other_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_goal_text": { + "name": "open_goal_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_marketing.attribution_subjects": { + "name": "attribution_subjects", + "schema": "sealai_marketing", + "columns": { + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_touch": { + "name": "first_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_touch": { + "name": "last_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gbraid": { + "name": "gbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wbraid": { + "name": "wbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_personalization": { + "name": "ad_personalization", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "ad_user_data_consent": { + "name": "ad_user_data_consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "click_id_candidates": { + "name": "click_id_candidates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consent_provenance": { + "name": "consent_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "attribution_subjects_pk": { + "name": "attribution_subjects_pk", + "columns": ["subject_type", "subject_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_marketing.lifecycle_events": { + "name": "lifecycle_events", + "schema": "sealai_marketing", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "first_touch": { + "name": "first_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_touch": { + "name": "last_touch", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "gclid": { + "name": "gclid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gbraid": { + "name": "gbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wbraid": { + "name": "wbraid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ad_personalization": { + "name": "ad_personalization", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "ad_user_data_consent": { + "name": "ad_user_data_consent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unspecified'" + }, + "click_id_candidates": { + "name": "click_id_candidates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "consent_provenance": { + "name": "consent_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hashed_user_data": { + "name": "hashed_user_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "numeric(24, 6)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lifecycle_events_action_transaction_idx": { + "name": "lifecycle_events_action_transaction_idx", + "columns": [ + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_marketing\".\"lifecycle_events\".\"transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_notification.notification_messages": { + "name": "notification_messages", + "schema": "sealai_notification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_messages_live_dedupe_key_idx": { + "name": "notification_messages_live_dedupe_key_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sealai_notification\".\"notification_messages\".\"released_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_namespace_created_at_idx": { + "name": "notification_messages_namespace_created_at_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_user_uid_created_at_idx": { + "name": "notification_messages_user_uid_created_at_idx", + "columns": [ + { + "expression": "user_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_messages_created_at_idx": { + "name": "notification_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_notification.notification_read_receipts": { + "name": "notification_read_receipts", + "schema": "sealai_notification", + "columns": { + "user_uid": { + "name": "user_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_key": { + "name": "message_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_read_receipts_message_id_idx": { + "name": "notification_read_receipts_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_read_receipts_message_id_notification_messages_id_fk": { + "name": "notification_read_receipts_message_id_notification_messages_id_fk", + "tableFrom": "notification_read_receipts", + "tableTo": "notification_messages", + "schemaTo": "sealai_notification", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_read_receipts_user_uid_message_key_pk": { + "name": "notification_read_receipts_user_uid_message_key_pk", + "columns": ["user_uid", "message_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_canvas_layouts": { + "name": "project_canvas_layouts", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_uid": { + "name": "project_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name_snapshot": { + "name": "project_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "nodes": { + "name": "nodes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_canvas_layouts_updated_at_idx": { + "name": "project_canvas_layouts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_canvas_layouts_pk": { + "name": "project_canvas_layouts_pk", + "columns": ["namespace", "project_uid"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_delete_operations": { + "name": "project_delete_operations", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preview_id": { + "name": "preview_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_delete_operations_expires_at_idx": { + "name": "project_delete_operations_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_delete_operations_pk": { + "name": "project_delete_operations_pk", + "columns": ["namespace", "project_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_delete_previews": { + "name": "project_delete_previews", + "schema": "sealai_project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_summary": { + "name": "resource_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_delete_previews_expires_at_idx": { + "name": "project_delete_previews_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_delete_previews_target_idx": { + "name": "project_delete_previews_target_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_delete_previews_pk": { + "name": "project_delete_previews_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_management_audit_events": { + "name": "project_management_audit_events", + "schema": "sealai_project", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_uid": { + "name": "actor_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_summary": { + "name": "resource_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_management_audit_events_target_idx": { + "name": "project_management_audit_events_target_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_management_audit_events_pk": { + "name": "project_management_audit_events_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.project_navigation_preferences": { + "name": "project_navigation_preferences", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_project_ids": { + "name": "pinned_project_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_navigation_preferences_updated_at_idx": { + "name": "project_navigation_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "project_navigation_preferences_pk": { + "name": "project_navigation_preferences_pk", + "columns": ["namespace"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "sealai_project.projects": { + "name": "projects", + "schema": "sealai_project", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_namespace_lower_display_name_idx": { + "name": "projects_namespace_lower_display_name_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"display_name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_updated_at_idx": { + "name": "projects_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "projects_pk": { + "name": "projects_pk", + "columns": ["namespace", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "sealai_assistant": "sealai_assistant", + "sealai_deployment": "sealai_deployment", + "sealai_onboarding": "sealai_onboarding", + "sealai_marketing": "sealai_marketing", + "sealai_notification": "sealai_notification", + "sealai_project": "sealai_project" + }, + "sequences": { + "sealai_deployment.deploy_task_events_seq": { + "name": "deploy_task_events_seq", + "schema": "sealai_deployment", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/ui/drizzle/meta/_journal.json b/apps/ui/drizzle/meta/_journal.json index 1b7042ff..8f8d3fff 100644 --- a/apps/ui/drizzle/meta/_journal.json +++ b/apps/ui/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1786602557421, "tag": "0014_marketing_lifecycle_attribution", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1787883888064, + "tag": "0015_notification_center", + "breakpoints": true } ] } diff --git a/apps/ui/scripts/reset-local-db.mjs b/apps/ui/scripts/reset-local-db.mjs index 9acd7148..d80fff37 100644 --- a/apps/ui/scripts/reset-local-db.mjs +++ b/apps/ui/scripts/reset-local-db.mjs @@ -28,6 +28,8 @@ try { DROP SCHEMA IF EXISTS sealai_assistant, sealai_deployment, + sealai_marketing, + sealai_notification, sealai_onboarding, sealai_project, drizzle diff --git a/apps/ui/src/app/api/chat/route.ts b/apps/ui/src/app/api/chat/route.ts index c813f0a5..144fb820 100644 --- a/apps/ui/src/app/api/chat/route.ts +++ b/apps/ui/src/app/api/chat/route.ts @@ -69,6 +69,7 @@ import { import { withSelectedResourceContext } from "@/features/chat/runtime/selected-resource-context"; import { buildChatToolset } from "@/features/chat/runtime/tools"; import { withWorkspaceResourceContext } from "@/features/chat/runtime/workspace-resource-context"; +import { observeWorkspaceQuotaQuietly } from "@/features/notifications/producers"; import { appTokenFromRequest } from "@/lib/app-token"; import { IdentityBindingSupersededError } from "@/lib/identity-fingerprint-core"; import { decodeKubeconfig } from "@/lib/kubeconfig"; @@ -929,6 +930,14 @@ export async function POST(req: Request) { } const actor: VerifiedAssistantConversationActor = verifiedPersonalResourceActor(authorization); + // The chat turn is a natural observation point for the quota-exhausted + // producer: the snapshot already crossed the server boundary here. + if (workspaceResourceQuota.success) { + observeWorkspaceQuotaQuietly({ + namespace: authorization.namespace, + snapshot: workspaceResourceQuota.data, + }); + } const kubeconfig = decodeKubeconfig(parsed.data.encodedKubeconfig); if (kubeconfig == null) { return jsonError( diff --git a/apps/ui/src/app/api/deploy-task-projections/stream/route.ts b/apps/ui/src/app/api/deploy-task-projections/stream/route.ts index 23d12a8b..8f4c020c 100644 --- a/apps/ui/src/app/api/deploy-task-projections/stream/route.ts +++ b/apps/ui/src/app/api/deploy-task-projections/stream/route.ts @@ -2,6 +2,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import type { DeploymentTaskProjectionStreamEvent } from "@/features/deploy/task/projection"; import { type DeploymentTaskEventsSubscription, @@ -22,7 +23,7 @@ function encodeSse(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } -export async function GET(request: Request) { +async function handleGet(request: Request) { const url = new URL(request.url); const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -157,3 +158,5 @@ export async function GET(request: Request) { }, }); } + +export const GET = withDeployTaskDevMock("projections-stream", handleGet); diff --git a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts index 76f374f0..3143d9b7 100644 --- a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts +++ b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/route.ts @@ -4,6 +4,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import { getDeployTaskTimelineSnapshot } from "@/features/deploy/task/service"; export const dynamic = "force-dynamic"; @@ -17,7 +18,7 @@ function jsonError(message: string, status: number) { return NextResponse.json({ error: message }, { status }); } -export async function GET(request: Request, context: RouteContext) { +async function handleGet(request: Request, context: RouteContext) { const { taskId } = await context.params; const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -42,3 +43,7 @@ export async function GET(request: Request, context: RouteContext) { } return NextResponse.json(snapshot); } + +export const GET = withDeployTaskDevMock("timeline", handleGet, (context) => + context.params.then((params) => params.taskId) +); diff --git a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts index 15d6fed9..9a02749c 100644 --- a/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts +++ b/apps/ui/src/app/api/deploy-tasks/[taskId]/timeline/stream/route.ts @@ -2,6 +2,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import { getDeployTaskTimelineSnapshot } from "@/features/deploy/task/service"; import { type DeploymentTaskTimelineSubscription, @@ -26,7 +27,7 @@ function encodeSse(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } -export async function GET(request: Request, context: RouteContext) { +async function handleGet(request: Request, context: RouteContext) { const { taskId } = await context.params; const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -163,3 +164,9 @@ export async function GET(request: Request, context: RouteContext) { }, }); } + +export const GET = withDeployTaskDevMock( + "timeline-stream", + handleGet, + (context) => context.params.then((params) => params.taskId) +); diff --git a/apps/ui/src/app/api/deploy-tasks/route.ts b/apps/ui/src/app/api/deploy-tasks/route.ts index ff26308a..d1258854 100644 --- a/apps/ui/src/app/api/deploy-tasks/route.ts +++ b/apps/ui/src/app/api/deploy-tasks/route.ts @@ -10,6 +10,7 @@ import { deployTaskRequestParams, resolveDeployTaskRequestNamespace, } from "@/features/deploy/task/api-auth"; +import { withDeployTaskDevMock } from "@/features/deploy/task/dev-mock-route"; import { createDeployTaskAction } from "@/features/deploy/task/engine/actions"; import { getDeployTaskEngineContext } from "@/features/deploy/task/engine/server"; import { @@ -165,7 +166,7 @@ async function resolveCredentialBinding(input: { }; } -export async function GET(request: Request) { +async function handleGet(request: Request) { const url = new URL(request.url); const params = deployTaskRequestParams(request); const namespaceResolved = await resolveDeployTaskRequestNamespace({ @@ -221,6 +222,8 @@ export async function GET(request: Request) { return NextResponse.json({ projections }); } +export const GET = withDeployTaskDevMock("list", handleGet); + export async function POST(request: Request) { const body = await request.json().catch(() => null); const parsed = requestSchema.safeParse(body); diff --git a/apps/ui/src/app/api/notifications/gift-observation/route.ts b/apps/ui/src/app/api/notifications/gift-observation/route.ts new file mode 100644 index 00000000..14f9c63b --- /dev/null +++ b/apps/ui/src/app/api/notifications/gift-observation/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = notificationRouteHandlers.observeGift; diff --git a/apps/ui/src/app/api/notifications/quota-observation/route.ts b/apps/ui/src/app/api/notifications/quota-observation/route.ts new file mode 100644 index 00000000..6b134d7f --- /dev/null +++ b/apps/ui/src/app/api/notifications/quota-observation/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = notificationRouteHandlers.observeQuota; diff --git a/apps/ui/src/app/api/notifications/read/route.ts b/apps/ui/src/app/api/notifications/read/route.ts new file mode 100644 index 00000000..15d9ba34 --- /dev/null +++ b/apps/ui/src/app/api/notifications/read/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = notificationRouteHandlers.markRead; diff --git a/apps/ui/src/app/api/notifications/route.ts b/apps/ui/src/app/api/notifications/route.ts new file mode 100644 index 00000000..c92f2f7d --- /dev/null +++ b/apps/ui/src/app/api/notifications/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const GET = notificationRouteHandlers.feed; diff --git a/apps/ui/src/app/api/notifications/subscription-change/route.ts b/apps/ui/src/app/api/notifications/subscription-change/route.ts new file mode 100644 index 00000000..163c968e --- /dev/null +++ b/apps/ui/src/app/api/notifications/subscription-change/route.ts @@ -0,0 +1,6 @@ +import { notificationRouteHandlers } from "@/features/notifications/route-handlers"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export const POST = notificationRouteHandlers.observeSubscriptionChange; diff --git a/apps/ui/src/app/billing/layout.tsx b/apps/ui/src/app/billing/layout.tsx index 58505af8..6ba362ff 100644 --- a/apps/ui/src/app/billing/layout.tsx +++ b/apps/ui/src/app/billing/layout.tsx @@ -9,6 +9,7 @@ import { AppSidebarCookieBridge } from "@/features/shell/app-sidebar-cookie-brid import AuthBootstrap, { SealosSdkBootstrap, } from "@/features/shell/auth-bootstrap"; +import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; /** Desktop iframe auth is resolved on the client through the Sealos SDK. */ export const dynamic = "force-dynamic"; @@ -26,6 +27,7 @@ export default function BillingLayout({ + {children} diff --git a/apps/ui/src/app/project/layout.tsx b/apps/ui/src/app/project/layout.tsx index 9089fb86..014f6ec1 100644 --- a/apps/ui/src/app/project/layout.tsx +++ b/apps/ui/src/app/project/layout.tsx @@ -1,3 +1,5 @@ +import { ChatDevMockGate } from "@/features/chat/dev-mock-gate"; +import { DeployTaskDevMockGate } from "@/features/deploy/task/dev-mock-gate"; import { OnboardingGate } from "@/features/onboarding/onboarding-gate"; import { ProjectsExplorerDevMockGate } from "@/features/projects/explorer/projects-dev-mock-gate"; import { @@ -11,6 +13,7 @@ import AuthBootstrap, { SealosSdkBootstrap, } from "@/features/shell/auth-bootstrap"; import ProjectWorkspaceLayout from "@/features/shell/project-workspace-layout"; +import { StatusHintBanner } from "@/features/status-hint/status-hint-banner"; /** Desktop iframe auth is resolved on the client through the Sealos SDK. */ export const dynamic = "force-dynamic"; @@ -27,9 +30,12 @@ export default function ProjectLayout({ + + + {children} diff --git a/apps/ui/src/features/billing/account-balance.ts b/apps/ui/src/features/billing/account-balance.ts index e052f87f..35248a2a 100644 --- a/apps/ui/src/features/billing/account-balance.ts +++ b/apps/ui/src/features/billing/account-balance.ts @@ -18,14 +18,15 @@ export interface AccountBalance { microUnits: number; } -export async function loadAccountBalance( - credentials: { - appToken: string; - currency: BillingCurrency; - kubeconfig: string; - }, +/** + * The cash term of the available Account Balance: Balance − DeductionBalance, + * before credits. Currency-free, for surfaces that judge the amount rather + * than display it (the status hint's Account Debt evaluation). + */ +export async function loadAccountBalanceMicroUnits( + credentials: { appToken: string; kubeconfig: string }, fetch: BillingFetch = globalThis.fetch -): Promise { +): Promise { const requestBillingJson = createBillingJsonRequester({ credentials, fallbackErrorMessage: "Could not load Account Balance.", @@ -36,9 +37,22 @@ export async function loadAccountBalance( if (!parsed.success) { throw new Error("Account Balance response is invalid."); } + return parsed.data.account.Balance - parsed.data.account.DeductionBalance; +} + +export async function loadAccountBalance( + credentials: { + appToken: string; + currency: BillingCurrency; + kubeconfig: string; + }, + fetch: BillingFetch = globalThis.fetch +): Promise { return { currency: credentials.currency, - microUnits: - parsed.data.account.Balance - parsed.data.account.DeductionBalance, + microUnits: await loadAccountBalanceMicroUnits( + { appToken: credentials.appToken, kubeconfig: credentials.kubeconfig }, + fetch + ), }; } diff --git a/apps/ui/src/features/billing/account-top-up.test.ts b/apps/ui/src/features/billing/account-top-up.test.ts new file mode 100644 index 00000000..390a34bc --- /dev/null +++ b/apps/ui/src/features/billing/account-top-up.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isPaidTopUpPayment, loadHasToppedUp } from "./account-top-up"; + +const CREDENTIALS = { appToken: "t", kubeconfig: "k" }; + +function fetchAnswering(payload: unknown) { + let request: { body: unknown; url: string } | null = null; + const fetch = (input: RequestInfo | URL, init?: RequestInit) => { + request = { body: JSON.parse(String(init?.body)), url: input.toString() }; + return Promise.resolve(Response.json(payload)); + }; + return { fetch, request: () => request }; +} + +test("a paid recharge anywhere in the account's history means the account topped up", async () => { + const answering = fetchAnswering({ + payments: [ + { ID: "p1", Status: "PAID", Type: "SUBSCRIPTION" }, + { ID: "p2", Status: "PAID", Type: "ACCOUNT_RECHARGE" }, + ], + }); + assert.equal( + await loadHasToppedUp( + CREDENTIALS, + answering.fetch, + () => new Date("2026-08-27T12:00:00Z") + ), + true + ); + const request = answering.request(); + assert.equal(request?.url, "/api/billing/payments"); + assert.deepEqual(request?.body, { + endTime: "2026-08-27T12:00:00.000Z", + startTime: "2020-01-01T00:00:00.000Z", + }); +}); + +test("subscription payments, failed recharges, and an empty history are not top-ups", async () => { + assert.equal( + await loadHasToppedUp( + CREDENTIALS, + fetchAnswering({ + payments: [ + { Status: "PAID", Type: "SUBSCRIPTION" }, + { Status: "FAILED", Type: "RECHARGE" }, + ], + }).fetch + ), + false + ); + assert.equal( + await loadHasToppedUp( + CREDENTIALS, + fetchAnswering({ payments: null }).fetch + ), + false + ); + assert.equal(isPaidTopUpPayment({ Type: "RECHARGE" }), true); +}); diff --git a/apps/ui/src/features/billing/account-top-up.ts b/apps/ui/src/features/billing/account-top-up.ts new file mode 100644 index 00000000..e628aa2f --- /dev/null +++ b/apps/ui/src/features/billing/account-top-up.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { + type BillingFetch, + createBillingJsonRequester, +} from "./billing-data-client"; + +/** + * Whether the account ever recorded a paid top-up — the fact that ends the + * Notification Center's gift-only filter forever (design spec §6 D1). Read + * from the account's payment history over its whole life: history only + * grows, so one top-up stays true by construction. Rows are read loosely — + * only the type and status matter here. + */ + +const paymentsResponseSchema = z.object({ + payments: z + .array( + z.object({ + Status: z.string().optional(), + Type: z.string().default(""), + }) + ) + .nullish() + .transform((value) => value ?? []), +}); + +/** Payment history begins after the platform did; anything earlier is empty. */ +const ALL_TIME_START = "2020-01-01T00:00:00.000Z"; + +export function isPaidTopUpPayment(payment: { + Status?: string; + Type: string; +}): boolean { + return ( + payment.Type.toUpperCase().includes("RECHARGE") && + (payment.Status == null || payment.Status.toUpperCase() === "PAID") + ); +} + +export async function loadHasToppedUp( + credentials: { appToken: string; kubeconfig: string }, + fetch: BillingFetch = globalThis.fetch, + now: () => Date = () => new Date() +): Promise { + const requestBillingJson = createBillingJsonRequester({ + credentials, + fallbackErrorMessage: "Could not load payment history.", + fetch, + }); + const payload = await requestBillingJson("/api/billing/payments", { + endTime: now().toISOString(), + startTime: ALL_TIME_START, + }); + const parsed = paymentsResponseSchema.safeParse(payload); + if (!parsed.success) { + throw new Error("Payment history response is invalid."); + } + return parsed.data.payments.some(isPaidTopUpPayment); +} diff --git a/apps/ui/src/features/billing/billing-amount.ts b/apps/ui/src/features/billing/billing-amount.ts index fe605cad..fefb3ad1 100644 --- a/apps/ui/src/features/billing/billing-amount.ts +++ b/apps/ui/src/features/billing/billing-amount.ts @@ -1,6 +1,7 @@ import type { BillingCurrency } from "@/features/billing/config-core"; -const MICRO_UNITS_PER_CURRENCY_UNIT = 1_000_000; +/** Upstream money is carried in micro-units: 1,000,000 per dollar (or yuan). */ +export const MICRO_UNITS_PER_CURRENCY_UNIT = 1_000_000; const USD_FORMATTER = new Intl.NumberFormat("en-US", { currency: "USD", currencyDisplay: "narrowSymbol", diff --git a/apps/ui/src/features/billing/billing-dev-mock.tsx b/apps/ui/src/features/billing/billing-dev-mock.tsx index b88db57b..ebbbc97c 100644 --- a/apps/ui/src/features/billing/billing-dev-mock.tsx +++ b/apps/ui/src/features/billing/billing-dev-mock.tsx @@ -1,86 +1,41 @@ "use client"; -import { - type DevTweaksMockSource, - useDevTweaksMock, -} from "@workspace/dev-tweaks"; +import { useDevTweaksMock } from "@workspace/dev-tweaks"; import { mutate } from "swr"; +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + import { - BILLING_DEV_MOCK_COOKIE, BILLING_DEV_SCENARIOS, + billingDevMockCookie, DEFAULT_BILLING_DEV_SCENARIO, - formatBillingDevMockCookie, - isBillingDevScenario, - parseBillingDevMockCookie, } from "./dev-mock-cookie"; +import { isBillingDevMockSwrKey } from "./dev-mock-swr-keys"; /** * Billing's Dev Mock: registered with the dev tweaks panel while any - * /billing screen is mounted. The session cookie stays the single source of + * /billing screen or the App Sidebar's Notification Center is mounted. The session cookie stays the single source of * truth — the source below is the only writer on the client, the fixture * dispatcher transitions it on the server. */ export const BILLING_DEV_MOCK_KEY = "billing-mock"; -function readCookieState() { - const pair = document.cookie - .split(";") - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith(`${BILLING_DEV_MOCK_COOKIE}=`)); - return parseBillingDevMockCookie( - pair?.slice(BILLING_DEV_MOCK_COOKIE.length + 1) - ); -} - /** - * Cookie-backed mock source. `set` also drops the whole SWR cache: the - * scenario shapes every /api/billing/* answer, so billing keys must refetch — - * everything else is untouched data-wise. + * Cookie-backed mock source; a toggle revalidates the SWR keys the mock + * owns (see `dev-mock-swr-keys.ts`) — nothing else on the page is touched. */ -const billingDevMockSource: DevTweaksMockSource = { - load: () => { - const parsed = readCookieState(); - return parsed.kind === "set" ? parsed.state : null; - }, - set: (state) => { - const scenario = isBillingDevScenario(state.scenario) - ? state.scenario - : DEFAULT_BILLING_DEV_SCENARIO; - const value = formatBillingDevMockCookie({ - enabled: state.enabled, - scenario, - }); - // biome-ignore lint/suspicious/noDocumentCookie: the synchronous write must land before the SWR refetches fire; the async Cookie Store API cannot guarantee that. - document.cookie = `${BILLING_DEV_MOCK_COOKIE}=${value}; path=/; samesite=lax`; - mutate(() => true).catch(() => undefined); - }, - // The fixture dispatcher rewrites the cookie behind the panel's back (its - // Set-Cookie scenario transitions); the Cookie Store API pushes those, the - // focus/interval pair is the fallback. The mock store dedupes unchanged - // loads. - watch: (onChange) => { - const cookieStore = (window as { cookieStore?: EventTarget }).cookieStore; - cookieStore?.addEventListener("change", onChange); - window.addEventListener("focus", onChange); - const watchTimer = - cookieStore == null ? window.setInterval(onChange, 3000) : undefined; - return () => { - cookieStore?.removeEventListener("change", onChange); - window.removeEventListener("focus", onChange); - if (watchTimer != null) { - window.clearInterval(watchTimer); - } - }; +const billingDevMockSource = createDevMockCookieSource(billingDevMockCookie, { + revalidate: () => { + mutate(isBillingDevMockSwrKey).catch(() => undefined); }, -}; +}); /** Registers the mock while any /billing screen is mounted; renders nothing. */ export function BillingDevMockTweaks() { useDevTweaksMock(BILLING_DEV_MOCK_KEY, { defaultScenario: DEFAULT_BILLING_DEV_SCENARIO, - note: "Serves /api/billing/* from fixtures", + note: "Serves /api/billing/* and the Notification Center from fixtures", scenarios: BILLING_DEV_SCENARIOS, source: billingDevMockSource, title: "Billing mock", diff --git a/apps/ui/src/features/billing/billing-plan-change-dialog.interaction.test.tsx b/apps/ui/src/features/billing/billing-plan-change-dialog.interaction.test.tsx index 417061b4..c8d647cf 100644 --- a/apps/ui/src/features/billing/billing-plan-change-dialog.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan-change-dialog.interaction.test.tsx @@ -1199,9 +1199,11 @@ test("upgrade waiting ignores other payments, polls, times out, reopens, and can events.push("poll"); return Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-old", planName: "Team", status: "completed", + startAt: null, }); }, loadUpgradeQuote: () => @@ -1315,8 +1317,10 @@ test("upgrade waiting stops immediately for the matching failed payment", async transactionChecks += 1; return Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", + startAt: null, status: transactionChecks === 1 ? "failed" : "pending", }); }, @@ -1418,8 +1422,10 @@ test("cancel failure resolves a concurrently completed payment", async () => { transactionChecks += 1; return Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", + startAt: null, status: transactionChecks === 1 ? "pending" : "completed", }); }, @@ -1640,9 +1646,11 @@ test("payment waiting keeps the quote surface and disables its dismissals", asyn loadTransaction: () => Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", status: "pending", + startAt: null, }), loadUpgradeQuote: () => Promise.resolve({ @@ -1757,9 +1765,11 @@ test("a completed payment confirms in place before the checkout hands off", asyn loadTransaction: () => Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", status: "completed", + startAt: null, }), loadUpgradeQuote: () => Promise.resolve({ diff --git a/apps/ui/src/features/billing/billing-plan-data.test.ts b/apps/ui/src/features/billing/billing-plan-data.test.ts index a5fcfdb3..764d034c 100644 --- a/apps/ui/src/features/billing/billing-plan-data.test.ts +++ b/apps/ui/src/features/billing/billing-plan-data.test.ts @@ -1037,8 +1037,10 @@ test("loads the latest subscription transaction status for payment waiting", asy assert.deepEqual(transaction, { id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", + startAt: null, status: "completed", }); assert.equal(request?.url, "/api/billing/subscription/last-transaction"); @@ -1172,6 +1174,10 @@ test("loads the sidebar subscription summary with only region-addressed reads", isPayg: false, lifecycle: "active", planName: "Pro", + recoveryVoice: "renew", + role: "OWNER", + warningDeadlineAt: null, + warningStage: null, }); assert.deepEqual( requests.map((request) => request.url), @@ -1195,6 +1201,10 @@ test("the sidebar summary reports an Active Free Trial and its period end", asyn isPayg: false, lifecycle: "active", planName: "Free", + recoveryVoice: "resubscribe", + role: "OWNER", + warningDeadlineAt: null, + warningStage: null, }); }); @@ -1218,5 +1228,23 @@ test("the sidebar summary presents a deleted subscription as PAYG", async () => isPayg: true, lifecycle: "active", planName: "PAYG", + recoveryVoice: "renew", + role: "OWNER", + warningDeadlineAt: null, + warningStage: null, + }); +}); + +test("the sidebar summary carries the Deletion Countdown's stage and derived deadline", async () => { + // ADR-0063: the status hint states the deletion date exactly as the Plan + // view does — expiry plus the fixed grace, derived client-side. + const { summary } = loadSummaryWithSubscription({ + CurrentPeriodEndAt: "2026-08-20T00:00:00Z", + Status: "DEBT", }); + const result = await summary; + assert.equal(result.lifecycle, "payment-due"); + assert.equal(result.warningStage, "expired"); + assert.equal(result.warningDeadlineAt, "2026-09-03T00:00:00.000Z"); + assert.equal(result.recoveryVoice, "renew"); }); diff --git a/apps/ui/src/features/billing/billing-plan-data.ts b/apps/ui/src/features/billing/billing-plan-data.ts index 10dca8c0..c2b08145 100644 --- a/apps/ui/src/features/billing/billing-plan-data.ts +++ b/apps/ui/src/features/billing/billing-plan-data.ts @@ -712,6 +712,9 @@ export async function loadBillingPlanSnapshot( }; } +/** The caller's membership role in the workspace as the subscription record names it. */ +export type WorkspaceSubscriptionRole = "DEVELOPER" | "MANAGER" | "OWNER"; + /** * The Workspace Subscription facts the App Sidebar account section needs — * a two-request read (region, then the region-addressed subscription route) @@ -723,6 +726,15 @@ export interface WorkspaceSubscriptionSummary { isPayg: boolean; lifecycle: SubscriptionLifecycle; planName: string; + recoveryVoice: RecoveryVoice; + /** Null when the record names no role (PAYG workspaces). */ + role: WorkspaceSubscriptionRole | null; + /** + * The Deletion Countdown's next deadline, derived client-side exactly as + * the Plan view derives it (ADR-0063). Set only while `warningStage` is. + */ + warningDeadlineAt: string | null; + warningStage: SubscriptionWarningStage | null; } export async function loadWorkspaceSubscriptionSummary( @@ -750,6 +762,19 @@ export async function loadWorkspaceSubscriptionSummary( ).subscription ); + // Pending upgrades are invisible to this read (no transaction request); + // they surface as the quiet "active" state, which is what the account + // row's second line wants anyway. + const lifecycle = subscriptionLifecycle({ + cancelAtPeriodEnd: subscription.CancelAtPeriodEnd, + isPayg: subscription.type === "PAYG", + planName: subscription.PlanName, + status: subscription.Status, + }); + const warningStage = subscriptionWarningStage({ + lifecycle, + status: subscription.Status, + }); return { currentPeriodEndAt: subscription.CurrentPeriodEndAt, isActiveFreeTrial: isActiveFreeTrialSubscription({ @@ -758,16 +783,16 @@ export async function loadWorkspaceSubscriptionSummary( type: subscription.type ?? "", }), isPayg: subscription.type === "PAYG", - // Pending upgrades are invisible to this read (no transaction request); - // they surface as the quiet "active" state, which is what the account - // row's second line wants anyway. - lifecycle: subscriptionLifecycle({ - cancelAtPeriodEnd: subscription.CancelAtPeriodEnd, - isPayg: subscription.type === "PAYG", - planName: subscription.PlanName, - status: subscription.Status, - }), + lifecycle, planName: subscription.PlanName, + recoveryVoice: recoveryVoice(subscription.PlanName), + role: subscription.role ?? null, + warningDeadlineAt: subscriptionWarningDeadline({ + currentPeriodEndAt: subscription.CurrentPeriodEndAt, + expireAt: subscription.ExpireAt ?? null, + stage: warningStage, + }), + warningStage, }; } @@ -938,8 +963,12 @@ export async function cancelSubscriptionInvoice( export interface SubscriptionTransactionStatus { id: string; + /** Lowercased upstream operator: created, upgraded, downgraded, renewed, canceled, … */ + operator: string; payId: string; planName: string; + /** When a scheduled change lands (a downgrade at period end); null otherwise. */ + startAt: string | null; status: string; } @@ -966,8 +995,10 @@ export async function loadSubscriptionTransactionStatus( } return { id: transaction.ID ?? "", + operator: transaction.Operator?.trim().toLowerCase() ?? "", payId: transaction.PayID ?? "", planName: transaction.NewPlanName ?? "", + startAt: transaction.StartAt?.trim() || null, status: transaction.Status?.trim().toLowerCase() ?? "", }; } diff --git a/apps/ui/src/features/billing/billing-plan-surface.tsx b/apps/ui/src/features/billing/billing-plan-surface.tsx index 1496b200..7c4a72ee 100644 --- a/apps/ui/src/features/billing/billing-plan-surface.tsx +++ b/apps/ui/src/features/billing/billing-plan-surface.tsx @@ -51,6 +51,7 @@ import { type SubscriptionWarningStage, subscriptionLifecycleAllowsBillingActions, } from "@/features/billing/billing-plan-data"; +import { BILLING_SURFACE_TONES } from "@/features/billing/billing-surface-tones"; import type { BillingCurrency } from "@/features/billing/config-core"; import { useSealosDesktopUrl } from "@/lib/sealos-desktop-url"; @@ -280,9 +281,9 @@ const ACCOUNT_DEBT_WARNING_COPY: Partial< // destructive (red). The semantic color stays on the icon and title only — // the description keeps its muted default. const SUBSCRIPTION_WARNING_TONES: Record = { - cancelling: "bg-amber-400/10 text-amber-600 dark:text-amber-400", - "deletion-imminent": "bg-red-500/10 text-destructive", - expired: "bg-red-500/10 text-destructive", + cancelling: BILLING_SURFACE_TONES.warning, + "deletion-imminent": BILLING_SURFACE_TONES.destructive, + expired: BILLING_SURFACE_TONES.destructive, }; function SubscriptionWarningBanner({ diff --git a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx index 17c93e3f..abfac50a 100644 --- a/apps/ui/src/features/billing/billing-plan.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-plan.interaction.test.tsx @@ -347,9 +347,11 @@ test("a settled poll closes the checkout and picker into congratulations", async loadTransaction: () => Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", status: "completed", + startAt: null, }), loadUpgradeQuote: () => Promise.resolve({ diff --git a/apps/ui/src/features/billing/billing-plan.tsx b/apps/ui/src/features/billing/billing-plan.tsx index 6a99f50a..f0b68291 100644 --- a/apps/ui/src/features/billing/billing-plan.tsx +++ b/apps/ui/src/features/billing/billing-plan.tsx @@ -59,6 +59,7 @@ import { type FreeChatTurnsUsage, fetchFreeChatTurnsUsage, } from "@/features/chat/persistence/client"; +import { observeSubscriptionChangeQuietly } from "@/features/notifications/subscription-change-observer"; import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; import { errorDescription, toastErrorDetail } from "@/lib/toast-utils"; @@ -471,6 +472,19 @@ export function BillingPlan({ workspace: current.workspace, }); await refreshSnapshot(); + if (operator === "canceled") { + // The receipt's observation point for cancellations (catalog B5). + observeSubscriptionChangeQuietly({ + appToken, + cancelled: { + currentPeriodEndAt: current.currentPeriodEndAt, + planName: current.planName, + }, + kubeconfig, + regionDomain: current.regionDomain, + workspace: current.workspace, + }).catch(() => undefined); + } toast.success( operator === "canceled" ? "Subscription cancellation scheduled." @@ -592,6 +606,18 @@ export function BillingPlan({ kubeconfig, workspace: settleWorkspace, }); + // …and for the subscription-change receipt (catalog B5): the settled + // transaction names what happened. Only the current workspace's inbox + // is verifiable from here, so another workspace's change goes + // unreceipted rather than landing in the wrong inbox. + if (settleWorkspace === workspace) { + observeSubscriptionChangeQuietly({ + appToken, + kubeconfig, + regionDomain: nextSnapshot.current.regionDomain, + workspace, + }).catch(() => undefined); + } return nextSnapshot; }, [appToken, currency, kubeconfig, refreshSnapshot, workspace] diff --git a/apps/ui/src/features/billing/billing-pricing.interaction.test.tsx b/apps/ui/src/features/billing/billing-pricing.interaction.test.tsx index bafb83f7..c07cb19d 100644 --- a/apps/ui/src/features/billing/billing-pricing.interaction.test.tsx +++ b/apps/ui/src/features/billing/billing-pricing.interaction.test.tsx @@ -210,9 +210,11 @@ test("a payment started from Pricing ends in the shared congratulations", async loadTransaction: () => Promise.resolve({ id: "transaction-1", + operator: "upgraded", payId: "payment-1", planName: "Team", status: "completed", + startAt: null, }), loadUpgradeQuote: () => Promise.resolve({ diff --git a/apps/ui/src/features/billing/billing-surface-tones.ts b/apps/ui/src/features/billing/billing-surface-tones.ts new file mode 100644 index 00000000..c05843cf --- /dev/null +++ b/apps/ui/src/features/billing/billing-surface-tones.ts @@ -0,0 +1,13 @@ +/** + * The one tint recipe billing state surfaces share — the Plan view's + * subscription warning and the status hint banner alike: a soft background + * wash with the semantic color on icon and title only, so the description + * keeps its muted default. + */ +export type BillingSurfaceTone = "destructive" | "info" | "warning"; + +export const BILLING_SURFACE_TONES: Record = { + destructive: "bg-red-500/10 text-destructive", + info: "bg-blue-400/10 text-blue-600 dark:text-blue-400", + warning: "bg-amber-400/10 text-amber-600 dark:text-amber-400", +}; diff --git a/apps/ui/src/features/billing/billing-usage-data.ts b/apps/ui/src/features/billing/billing-usage-data.ts index 34b44748..66d1f7f1 100644 --- a/apps/ui/src/features/billing/billing-usage-data.ts +++ b/apps/ui/src/features/billing/billing-usage-data.ts @@ -12,6 +12,7 @@ export type BillingQuotaType = | "gpu" | "memory" | "nodeport" + | "pod" | "storage" | "traffic"; @@ -56,6 +57,7 @@ const QUOTA_RESOURCES: ReadonlyArray<{ { keys: ["limits.cpu"], label: "CPU", type: "cpu" }, { keys: ["limits.memory"], label: "Memory", type: "memory" }, { keys: ["requests.storage"], label: "Storage", type: "storage" }, + { keys: ["pods", "count/pods"], label: "Pods", type: "pod" }, { keys: ["services.nodeports"], label: "Ports", type: "nodeport" }, { keys: ["traffic"], label: "Traffic", type: "traffic" }, { @@ -122,6 +124,30 @@ function quotaRows( }); } +/** + * One workspace's quota rows from the proxied resource-quota read — the + * Usage view's table without its workspace picker, for surfaces that only + * judge fullness (the status hint's quota-full evaluation). + */ +export async function loadWorkspaceQuotaUsage( + credentials: BillingCredentials & { workspace: string }, + fetch: BillingFetch = globalThis.fetch +): Promise { + const requestBillingJson = createBillingJsonRequester({ + credentials: { + appToken: credentials.appToken, + kubeconfig: credentials.kubeconfig, + }, + fallbackErrorMessage: "Could not load workspace usage.", + fetch, + }); + const quotaPayload = await requestBillingJson( + "/api/billing/workspace-quota", + { workspace: credentials.workspace } + ); + return quotaRows(quotaResponseSchema.parse(quotaPayload)); +} + export async function loadBillingUsage( input: BillingCredentials & { workspace: string }, dependencies: BillingUsageDependencies = {} @@ -163,14 +189,11 @@ export async function loadBillingUsage( return { rows: [], selectedWorkspace, workspaces }; } - const quotaPayload = await requestBillingJson( - "/api/billing/workspace-quota", - { - workspace: selectedWorkspace, - } - ); return { - rows: quotaRows(quotaResponseSchema.parse(quotaPayload)), + rows: await loadWorkspaceQuotaUsage( + { ...credentials, workspace: selectedWorkspace }, + fetch + ), selectedWorkspace, workspaces, }; diff --git a/apps/ui/src/features/billing/billing-usage.tsx b/apps/ui/src/features/billing/billing-usage.tsx index f3311c02..8c3d9904 100644 --- a/apps/ui/src/features/billing/billing-usage.tsx +++ b/apps/ui/src/features/billing/billing-usage.tsx @@ -12,6 +12,7 @@ import { import { cn } from "@workspace/ui/lib/utils"; import { useAtomValue } from "jotai"; import { + Boxes, CircuitBoard, Cpu, HardDrive, @@ -45,6 +46,7 @@ const QUOTA_ICONS = { gpu: CircuitBoard, memory: MemoryStick, nodeport: HdmiPort, + pod: Boxes, storage: HardDrive, traffic: Network, } satisfies Record; diff --git a/apps/ui/src/features/billing/dev-mock-cookie.ts b/apps/ui/src/features/billing/dev-mock-cookie.ts index 6b3e8328..8c24e52a 100644 --- a/apps/ui/src/features/billing/dev-mock-cookie.ts +++ b/apps/ui/src/features/billing/dev-mock-cookie.ts @@ -1,20 +1,22 @@ +import { + type DevMockState, + defineDevMockCookie, + type ParsedDevMockCookie, +} from "@/features/dev-mock/cookie"; + /** - * Cookie protocol shared by the billing dev-mock panel (client) and the - * fixture dispatcher (server). The session cookie is the single source of - * truth for mock state — there is no env var and no localStorage copy. - * - * Value grammar: `` while the mock is on, `off:` while it - * is off (the scenario part keeps the last selection so the panel can restore - * it). Anything else is invalid and makes the server answer 500 so typos fail - * loud instead of silently serving real data. + * The billing Dev Mock's cookie (grammar in `features/dev-mock/cookie.ts`). + * One scenario shapes every `/api/billing/*` answer, the billing-born + * Notifications, and through them the Status Hint and Chat Billing Mode. */ -export const BILLING_DEV_MOCK_COOKIE = "sealai-billing-dev-mock"; - export const BILLING_DEV_SCENARIOS = [ "payg", "payg-debt", + "payg-debt-deletion", + "payg-debt-final", "free", + "free-expiring", "free-expired", "paused", "active", @@ -24,6 +26,7 @@ export const BILLING_DEV_SCENARIOS = [ "payment-due-deletion", "payment-due-final", "pending-upgrade", + "quota-full", "deleted", "status-unknown", "mixed-workspaces", @@ -33,49 +36,31 @@ export type BillingDevScenario = (typeof BILLING_DEV_SCENARIOS)[number]; export const DEFAULT_BILLING_DEV_SCENARIO: BillingDevScenario = "active"; -export interface BillingDevMockState { - enabled: boolean; - scenario: BillingDevScenario; -} +export const billingDevMockCookie = defineDevMockCookie({ + defaultScenario: DEFAULT_BILLING_DEV_SCENARIO, + name: "sealai-billing-dev-mock", + scenarios: BILLING_DEV_SCENARIOS, +}); + +export const BILLING_DEV_MOCK_COOKIE = billingDevMockCookie.name; -const OFF_PREFIX = "off:"; +export type BillingDevMockState = DevMockState; + +export type ParsedBillingDevMockCookie = + ParsedDevMockCookie; export function isBillingDevScenario( value: string ): value is BillingDevScenario { - return (BILLING_DEV_SCENARIOS as readonly string[]).includes(value); + return billingDevMockCookie.is(value); } export function formatBillingDevMockCookie(state: BillingDevMockState): string { - return state.enabled ? state.scenario : `${OFF_PREFIX}${state.scenario}`; + return billingDevMockCookie.format(state); } -export type ParsedBillingDevMockCookie = - | { kind: "invalid"; raw: string } - | { kind: "set"; state: BillingDevMockState } - | { kind: "unset" }; - export function parseBillingDevMockCookie( raw: string | null | undefined ): ParsedBillingDevMockCookie { - const value = raw?.trim() ?? ""; - if (value === "") { - return { kind: "unset" }; - } - if (value.startsWith(OFF_PREFIX)) { - const scenario = value.slice(OFF_PREFIX.length); - return { - kind: "set", - state: { - enabled: false, - scenario: isBillingDevScenario(scenario) - ? scenario - : DEFAULT_BILLING_DEV_SCENARIO, - }, - }; - } - if (isBillingDevScenario(value)) { - return { kind: "set", state: { enabled: true, scenario: value } }; - } - return { kind: "invalid", raw: value }; + return billingDevMockCookie.parse(raw); } diff --git a/apps/ui/src/features/billing/dev-mock-swr-keys.test.ts b/apps/ui/src/features/billing/dev-mock-swr-keys.test.ts new file mode 100644 index 00000000..f2967a4a --- /dev/null +++ b/apps/ui/src/features/billing/dev-mock-swr-keys.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { isBillingDevMockSwrKey } from "./dev-mock-swr-keys"; + +describe("isBillingDevMockSwrKey", () => { + test("matches every key family the billing fixtures shape", () => { + for (const key of [ + ["billing-plan-snapshot", "ns-a", "token", "kc"], + ["billing-account-credits", "kc", "token"], + ["billing-ai-credits", "kc", "token", "ns-a"], + ["app-sidebar-subscription", "ns-a", "kc", "token"], + ["notifications-feed", "ns-a", "kc", "token"], + ["notifications-topped-up", "kc", "token"], + ["status-hint-balance", "kc", "token"], + ["status-hint-quota", "ns-a", "kc", "token"], + ["chat-free-turns", "ns-a"], + ]) { + expect(isBillingDevMockSwrKey(key)).toBe(true); + } + }); + + test("leaves cluster-bound and unrelated keys alone", () => { + for (const key of [ + ["/api/notification", "ns-a", "kc"], + ["workload-logs", "ns-a", "app"], + "/api/k8s/pods", + ["projects-explorer"], + null, + undefined, + 42, + [], + ]) { + expect(isBillingDevMockSwrKey(key)).toBe(false); + } + }); +}); diff --git a/apps/ui/src/features/billing/dev-mock-swr-keys.ts b/apps/ui/src/features/billing/dev-mock-swr-keys.ts new file mode 100644 index 00000000..b594920e --- /dev/null +++ b/apps/ui/src/features/billing/dev-mock-swr-keys.ts @@ -0,0 +1,26 @@ +/** + * Which SWR keys the billing Dev Mock owns. Switching a Mock Scenario + * revalidates exactly these — every surface whose answers the fixtures + * shape (Billing Area, the sidebar subscription badge, the Notification + * Center's Brain feed and account facts, the Status Hint, the chat + * free-turn count) — and nothing else. A blanket `mutate(() => true)` used + * to refire every key on the page; with the cluster unreachable those + * requests hang, saturate the browser's per-host connection pool, and + * queue the mock's own refetches behind them. + */ +export const BILLING_DEV_MOCK_SWR_KEY_PREFIXES = [ + "billing-", + "app-sidebar-subscription", + "notifications-", + "status-hint-", + "chat-free-turns", +] as const; + +/** SWR `mutate` filter: true for keys the billing Dev Mock's fixtures shape. */ +export function isBillingDevMockSwrKey(key: unknown): boolean { + const head = Array.isArray(key) ? key[0] : key; + return ( + typeof head === "string" && + BILLING_DEV_MOCK_SWR_KEY_PREFIXES.some((prefix) => head.startsWith(prefix)) + ); +} diff --git a/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts b/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts index 8c082cfe..b41898b3 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/dev-fixtures.test.ts @@ -2,10 +2,12 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { loadAccountBalance } from "../../account-balance"; import { loadAccountCredits } from "../../account-credits"; +import { loadHasToppedUp } from "../../account-top-up"; import { loadAiCredits } from "../../billing-ai-credits"; import { loadBillingCosts } from "../../billing-costs-data"; import type { BillingFetch } from "../../billing-data-client"; import { + checkSubscriptionDowngrade, loadBillingPlanSnapshot, loadSubscriptionUpgradeQuote, } from "../../billing-plan-data"; @@ -72,10 +74,13 @@ const DATE_RANGE = { const CREDITLESS_SCENARIOS = new Set([ "free", + "free-expiring", "free-expired", "paused", "payg", "payg-debt", + "payg-debt-deletion", + "payg-debt-final", ]); function loadPlanForScenario(scenario: string) { @@ -231,6 +236,40 @@ test("payg-debt derives a PAYG workspace inside the debt pipeline", async () => assert.ok(balance.microUnits < 0, "the account balance sits in debt"); }); +test("payg-debt-deletion and payg-debt-final walk the account debt ladder's later rungs", async () => { + for (const scenario of ["payg-debt-deletion", "payg-debt-final"]) { + const plan = await loadPlanForScenario(scenario); + assert.equal(plan.current.isPayg, true, scenario); + assert.equal(plan.current.lifecycle, "payment-due", scenario); + assert.equal(plan.current.warningStage, "deletion-imminent", scenario); + assert.equal(plan.current.warningDeadlineAt, null, scenario); + } +}); + +test("quota-full is an active subscription whose storage sits at 100%", async () => { + const plan = await loadPlanForScenario("quota-full"); + assert.equal(plan.current.lifecycle, "active"); + const check = await checkSubscriptionDowngrade( + { + ...CREDENTIALS, + limits: { storage: "20Gi" }, + regionDomain: "mock.sealos.run", + }, + { fetch: mockFetchFor("quota-full") } + ); + assert.equal(check.allowed, true, "at the limit, not past it"); +}); + +test("only the gift newcomer has never topped up", async () => { + for (const scenario of BILLING_DEV_SCENARIOS) { + assert.equal( + await loadHasToppedUp(CREDENTIALS, mockFetchFor(scenario)), + scenario !== "free", + `${scenario}: top-up history` + ); + } +}); + test("free derives an active trial that never reads as cancelling", async () => { const plan = await loadPlanForScenario("free"); assert.equal(plan.current.planName, "Free"); diff --git a/apps/ui/src/features/billing/server/dev-fixtures/index.ts b/apps/ui/src/features/billing/server/dev-fixtures/index.ts index d2a1f0cb..44e81d71 100644 --- a/apps/ui/src/features/billing/server/dev-fixtures/index.ts +++ b/apps/ui/src/features/billing/server/dev-fixtures/index.ts @@ -1,10 +1,11 @@ import { - BILLING_DEV_MOCK_COOKIE, - BILLING_DEV_SCENARIOS, type BillingDevScenario, - formatBillingDevMockCookie, - parseBillingDevMockCookie, + billingDevMockCookie, } from "@/features/billing/dev-mock-cookie"; +import { + type DevMockResolution, + resolveDevMock, +} from "@/features/dev-mock/server/resolve"; import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; /** @@ -15,7 +16,9 @@ import { namespaceFromKubeconfigText } from "@/lib/kubeconfig-namespace-core"; * (`dev-mock-cookie.ts` documents the grammar); while it names a scenario, * every `/api/billing/*` proxy answers from these fixtures instead of the * account service, so the real /billing pages can be exercised in any - * subscription state without credentials. + * subscription state without credentials. The same cookie drives the + * Notification Center's fixtures (`./notifications.ts`), so one scenario + * shapes the Plan view and the inbox together. * * Reads come from `FIXTURES`, pure functions of (body, scenario, workspace). * Writes are scenario transitions: a successful mutation answers with @@ -52,30 +55,47 @@ const MOCK_WORKSPACES = { /** Scenarios whose account sits in debt (deductions outrun the balance). */ const DEBT_SCENARIOS = new Set([ "payg-debt", + "payg-debt-deletion", + "payg-debt-final", "payment-due", "payment-due-deletion", "payment-due-final", ]); +/** Pay-as-you-go modes: no subscription row at all. */ +export const PAYG_SCENARIOS = new Set([ + "payg", + "payg-debt", + "payg-debt-deletion", + "payg-debt-final", +]); + /** Scenarios with no saved card: PAYG modes and never-paid Free plans. */ const CARDLESS_SCENARIOS = new Set([ "active-balance", "free", + "free-expiring", "free-expired", "paused", - "payg", - "payg-debt", + ...PAYG_SCENARIOS, ]); /** Scenarios with no subscription transaction history. */ const TRANSACTIONLESS_SCENARIOS = new Set([ "free", + "free-expiring", "free-expired", "paused", - "payg", - "payg-debt", + ...PAYG_SCENARIOS, ]); +/** + * Accounts that never topped up: the $1 gift newcomer. Their payment history + * carries no recharge, which is what keeps the Notification Center's + * gift-only filter on for them. + */ +export const NEVER_TOPPED_UP_SCENARIOS = new Set(["free"]); + function daysFromNow(days: number): string { return new Date(Date.now() + days * DAY_IN_MILLISECONDS).toISOString(); } @@ -109,6 +129,14 @@ function subscriptionPayload( // arrives without any period timestamps to derive dates from. return { Status: "DEBT", type: "PAYG" }; } + // The account debt ladder's later rungs (design spec §2.3): the deletion + // period, then final deletion — still PAYG, still no timestamps. + if (scenario === "payg-debt-deletion") { + return { Status: "DEBT_PRE_DELETION", type: "PAYG" }; + } + if (scenario === "payg-debt-final") { + return { Status: "DEBT_FINAL_DELETION", type: "PAYG" }; + } // The upstream keeps ExpireAt >= CurrentPeriodEndAt and its Stripe path // sets them equal, so the fixtures mirror that instead of null. const periodEnd = daysFromNow(12); @@ -131,8 +159,11 @@ function subscriptionPayload( return { ...base, CancelAtPeriodEnd: true }; // The platform creates Free subscriptions with CancelAtPeriodEnd already // true; a trial runs a period, a paused (no-trial) Free has none. - case "free": { - const trialEnd = daysFromNow(10); + case "free": + case "free-expiring": { + // free-expiring sits inside the status hint's three-day notice window + // (catalog C2); free is a mid-trial newcomer. + const trialEnd = daysFromNow(scenario === "free-expiring" ? 3 : 10); return { ...base, CancelAtPeriodEnd: true, @@ -217,7 +248,7 @@ function subscriptionPayload( } function subscriptionListPayload(context: FixtureContext): unknown[] { - if (context.scenario === "payg" || context.scenario === "payg-debt") { + if (PAYG_SCENARIOS.has(context.scenario)) { return []; } const current = subscriptionPayload(context.scenario, context.workspace); @@ -438,6 +469,9 @@ function paymentsPayload(context: FixtureContext): unknown[] { Type: "SUBSCRIPTION", Workspace: context.workspace, }); + if (NEVER_TOPPED_UP_SCENARIOS.has(context.scenario)) { + return []; + } return [ subscriptionPayment("pay-mock-1", "renewed", daysFromNow(-2)), { @@ -706,10 +740,10 @@ const FIXTURES: Record unknown> = { // plan) so downgrade checks compare realistic numbers. PAYG modes have no // ai_quota key at all; Free plans carry one with the plan's zero allowance. "/account/v1alpha1/workspace/get-resource-quota": (context) => { - const isPaygMode = - context.scenario === "payg" || context.scenario === "payg-debt"; + const isPaygMode = PAYG_SCENARIOS.has(context.scenario); const isFreePlan = context.scenario === "free" || + context.scenario === "free-expiring" || context.scenario === "free-expired" || context.scenario === "paused"; return { @@ -727,7 +761,9 @@ const FIXTURES: Record unknown> = { "limits.cpu": "1500m", "limits.memory": "3Gi", "limits.nvidia.com/gpu": "0", - "requests.storage": "12Gi", + // quota-full: storage at 100% (catalog A1/A2). + "requests.storage": + context.scenario === "quota-full" ? "20Gi" : "12Gi", "services.nodeports": "2", traffic: "26843545600", ...(isPaygMode ? {} : { ai_quota: isFreePlan ? 0 : 1_200_000 }), @@ -844,6 +880,8 @@ const PAY_TRANSITIONS: Record< "free-expired": "active", payg: "active", "payg-debt": "active", + "payg-debt-deletion": "active", + "payg-debt-final": "active", "payment-due": "active", "payment-due-deletion": "active", "payment-due-final": "active", @@ -853,77 +891,66 @@ const PAY_TRANSITIONS: Record< active: "active", "active-balance": "active", free: "active", + "free-expiring": "active", "mixed-workspaces": "active", paused: "active", + "quota-full": "active", }, }; -function mockCookieValue(request: Request): string | undefined { - const header = request.headers.get("cookie") ?? ""; - for (const pair of header.split(";")) { - const separator = pair.indexOf("="); - if (separator === -1) { - continue; - } - if (pair.slice(0, separator).trim() === BILLING_DEV_MOCK_COOKIE) { - return decodeURIComponent(pair.slice(separator + 1).trim()); - } - } - return undefined; -} - function transitionHeaders( nextScenario: BillingDevScenario ): Record { - const value = formatBillingDevMockCookie({ - enabled: true, - scenario: nextScenario, - }); return { - "set-cookie": `${BILLING_DEV_MOCK_COOKIE}=${value}; Path=/; SameSite=Lax`, + "set-cookie": billingDevMockCookie.setCookieHeader({ + enabled: true, + scenario: nextScenario, + }), }; } +export type BillingDevMockResolution = DevMockResolution; + +/** + * Reads the billing mock cookie off a request (see `resolveDevMock`). Shared + * by every fixture dispatcher the billing scenario drives — billing, + * billing-born notifications, the chat free-trial judgment. + */ +export function resolveBillingDevMock( + request: Request +): BillingDevMockResolution { + return resolveDevMock(billingDevMockCookie, request, "billing"); +} + +/** The workspace fixtures address when the request names none. */ +export function billingDevMockWorkspace(requested: unknown): string { + return typeof requested === "string" && requested.trim() !== "" + ? requested + : defaultWorkspace(); +} + export async function billingDevMockResponse( pathname: string, request: Request ): Promise { - if ( - process.env.NODE_ENV === "production" && - process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" - ) { - return null; - } - const parsed = parseBillingDevMockCookie(mockCookieValue(request)); - if ( - parsed.kind === "unset" || - (parsed.kind === "set" && !parsed.state.enabled) - ) { + const resolution = resolveBillingDevMock(request); + if (resolution.kind === "off") { return null; } - if (parsed.kind === "invalid") { - return Response.json( - { - error: `Unknown billing mock scenario "${parsed.raw}". Valid scenarios: ${BILLING_DEV_SCENARIOS.join(", ")}. Toggle the mock from the dev tweaks pane (⌃⌥T).`, - }, - { status: 500 } - ); + if (resolution.kind === "invalid") { + return resolution.response; } - const scenario = parsed.state.scenario; + const scenario = resolution.scenario; const payload: unknown = await request.json().catch(() => null); const body = typeof payload === "object" && payload != null ? (payload as Record) : {}; - const requestedWorkspace = - typeof body.workspace === "string" && body.workspace.trim() !== "" - ? body.workspace - : defaultWorkspace(); const context: FixtureContext = { body, scenario, - workspace: requestedWorkspace, + workspace: billingDevMockWorkspace(body.workspace), }; const write = WRITE_FIXTURES[pathname]; diff --git a/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts b/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts new file mode 100644 index 00000000..57b2d397 --- /dev/null +++ b/apps/ui/src/features/billing/server/dev-fixtures/notification-fixtures.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { CR_OVERRIDES } from "@/features/notifications/cr-overrides"; +import { + isGiftOnlyNewcomer, + mergeNotificationFeed, +} from "@/features/notifications/feed-model"; +import { + NOTIFICATION_MESSAGE_KINDS, + notificationFeedResponseSchema, +} from "@/features/notifications/types"; + +import { loadAccountCredits } from "../../account-credits"; +import { loadHasToppedUp } from "../../account-top-up"; +import { + BILLING_DEV_MOCK_COOKIE, + BILLING_DEV_SCENARIOS, + type BillingDevScenario, +} from "../../dev-mock-cookie"; +import { notificationDevMockResponse } from "./notifications"; +import { scenarioTestFetch } from "./scenario-test-fetch"; + +/** + * Pins the Notification Center's dev fixtures to the real pipeline: every + * billing scenario answers a feed the wire schema accepts, the merged-feed + * seam turns it into rows, and the account fixtures decide the gift-only + * filter through the same loaders the browser uses. Together the scenarios + * cover every catalog row — the eight fixed platform names and every + * Brain-produced kind. + */ + +const CREDENTIALS = { appToken: "test-token", kubeconfig: "test-kubeconfig" }; + +function mockRequest( + pathname: string, + scenario: string, + init?: RequestInit +): Request { + const request = new Request( + new URL(`${pathname}?namespace=ns-test`, "http://localhost"), + init + ); + request.headers.set("cookie", `${BILLING_DEV_MOCK_COOKIE}=${scenario}`); + return request; +} + +async function feedFor(scenario: string) { + const response = await notificationDevMockResponse( + "feed", + mockRequest("/api/notifications", scenario) + ); + assert.ok(response, `${scenario}: the mock answers the feed`); + return notificationFeedResponseSchema.parse(await response.json()); +} + +async function mergedFor(scenario: BillingDevScenario) { + const feed = await feedFor(scenario); + const fetch = scenarioTestFetch(scenario); + const [credits, hasToppedUp] = await Promise.all([ + loadAccountCredits(CREDENTIALS, fetch), + loadHasToppedUp(CREDENTIALS, fetch), + ]); + return mergeNotificationFeed({ + crItems: feed.platformItems ?? [], + dbMessages: feed.messages, + giftOnly: isGiftOnlyNewcomer({ ...credits, hasToppedUp }), + receipts: feed.receipts, + }); +} + +test("every scenario answers a feed the pipeline accepts, and together they cover the catalog", async () => { + const seenNames = new Set(); + const seenKinds = new Set(); + for (const scenario of BILLING_DEV_SCENARIOS) { + const feed = await feedFor(scenario); + for (const item of feed.platformItems ?? []) { + seenNames.add(item.name); + } + for (const message of feed.messages) { + seenKinds.add(message.kind); + } + const merged = await mergedFor(scenario); + assert.ok( + merged.every((item) => item.title !== "" && item.timestamp > 0), + `${scenario}: every row renders` + ); + } + for (const name of Object.keys(CR_OVERRIDES)) { + assert.ok(seenNames.has(name), `some scenario carries ${name}`); + } + for (const kind of NOTIFICATION_MESSAGE_KINDS) { + assert.ok(seenKinds.has(kind), `some scenario carries a ${kind} entry`); + } +}); + +test("free: a gift-only newcomer sees the welcome hint, never the low/critical tiers", async () => { + const feed = await feedFor("free"); + assert.ok( + feed.platformItems?.some( + (item) => item.name === "debt-choice-criticalbalanceperiod" + ), + "upstream did write the critical-balance CR" + ); + const merged = await mergedFor("free"); + assert.deepEqual( + merged.map((item) => item.title), + ["You have a $1 welcome gift"] + ); + assert.equal(merged[0]?.unread, true); +}); + +test("payg-debt-final: the whole account debt ladder, overridden, with only the final tier unread", async () => { + const merged = await mergedFor("payg-debt-final"); + const ladder = merged.filter((item) => item.source === "cr"); + assert.deepEqual( + ladder.map((item) => [item.title, item.unread]), + [ + ["Account resources face final deletion", true], + ["Account resources scheduled for deletion", false], + ["Account balance in debt", false], + ["Account balance almost empty", false], + ["Account balance is low", false], + ] + ); + assert.ok( + ladder.every((item) => item.cta?.label === "Top up balance"), + "account money recovers by top-up" + ); +}); + +test("active: an unknown platform name falls back to its own text beside the upgrade receipt", async () => { + const merged = await mergedFor("active"); + const announcement = merged.find( + (item) => item.source === "cr" && item.severity === "info" + ); + assert.equal(announcement?.cta, undefined); + assert.ok(announcement?.body, "the original body survives"); + assert.ok( + merged.some((item) => item.title === "Subscription upgraded"), + "the settled mock checkout has its receipt" + ); + assert.ok( + merged.some((item) => item.title === "Account balance is low"), + "a topped-up account keeps its low-balance history" + ); +}); + +test("marking read in mock mode sticks for the session and never reaches the store", async () => { + const before = await feedFor("payment-due"); + const target = before.platformItems?.[0]; + assert.ok(target); + const id = `cr:${target.name}:${target.timestamp}`; + const marked = await notificationDevMockResponse( + "read", + mockRequest("/api/notifications/read", "payment-due", { + body: JSON.stringify({ ids: [id] }), + method: "POST", + }) + ); + assert.equal(marked?.status, 200); + const after = await feedFor("payment-due"); + assert.ok(after.receipts.includes(id)); + + const observed = await notificationDevMockResponse( + "observation", + mockRequest("/api/notifications/gift-observation", "payment-due", { + body: JSON.stringify({ giftMicroUnits: 1 }), + method: "POST", + }) + ); + assert.equal(observed?.status, 200); +}); + +test("an absent or disabled cookie falls through to the real handlers", async () => { + const absent = await notificationDevMockResponse( + "feed", + new Request("http://localhost/api/notifications?namespace=ns-test") + ); + assert.equal(absent, null); + const disabled = await notificationDevMockResponse( + "feed", + mockRequest("/api/notifications", "off:active") + ); + assert.equal(disabled, null); +}); diff --git a/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts b/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts new file mode 100644 index 00000000..b75f8568 --- /dev/null +++ b/apps/ui/src/features/billing/server/dev-fixtures/notifications.ts @@ -0,0 +1,408 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; +import type { BillingDevScenario } from "@/features/billing/dev-mock-cookie"; +import { + markNotificationReadRequestSchema, + type NotificationFeedResponse, + type NotificationMessage, + type NotificationPayload, +} from "@/features/notifications/types"; +import { DAY_MS, HOUR_MS } from "@/lib/time"; + +import { billingDevMockWorkspace, resolveBillingDevMock } from "./index"; + +/** + * Notification Center dev fixtures: while the billing Dev Mock names a + * scenario, Brain's notification routes answer from here so the inbox can + * be exercised without a cluster or store. The fixtures feed the real + * pipeline — fixture platform CRs ride the feed as `platformItems` and the + * client merges, overrides, and filters them exactly as live ones; Brain + * rows are ordinary `db:` messages. Each scenario mirrors its billing + * fixtures (a debt account carries the debt ladder, the gift newcomer the + * welcome hint, the settled checkout its receipt), so one scenario shapes + * the Plan view and the inbox together. Read state is session-only memory + * per scenario; observations answer without writing. + */ + +/** Anchored at module load so fixture ids stay stable across polls. */ +const FIXTURE_NOW_MS = Date.now(); + +const DEBT_SYSTEM = "Debt-System"; +const SUBSCRIPTION_SYSTEM = "Workspace-Subscription-System"; + +interface PlatformFixture { + ago: number; + from: string; + isRead: boolean; + message: string; + name: string; + title: string; +} + +interface BrainFixture { + ago: number; + payload: NotificationPayload; +} + +const ACCOUNT_LADDER = [ + "debt-choice-lowbalanceperiod", + "debt-choice-criticalbalanceperiod", + "debt-choice-debtperiod", + "debt-choice-debtdeletionperiod", + "debt-choice-finaldeletionperiod", +] as const; + +const WORKSPACE_LADDER = [ + "workspace-debt-debt", + "workspace-debt-debtpredeletion", + "workspace-debt-debtfinaldeletion", +] as const; + +type FixedCRName = + | (typeof ACCOUNT_LADDER)[number] + | (typeof WORKSPACE_LADDER)[number]; + +/** Upstream copy as the platform's controllers actually write it. */ +const PLATFORM_COPY: Record< + FixedCRName, + Pick +> = { + "debt-choice-criticalbalanceperiod": { + from: DEBT_SYSTEM, + message: + "Your account balance is critically low (under $5). Services will be suspended when it reaches $0.", + title: "Critical balance", + }, + "debt-choice-debtdeletionperiod": { + from: DEBT_SYSTEM, + message: + "Your account is still in arrears. Resources will be released soon.", + title: "Resource release period", + }, + "debt-choice-debtperiod": { + from: DEBT_SYSTEM, + message: "Your account balance is exhausted; services have been suspended.", + title: "Debt period", + }, + "debt-choice-finaldeletionperiod": { + from: DEBT_SYSTEM, + message: "Radical resource release: all resources may be deleted.", + title: "Final deletion period", + }, + "debt-choice-lowbalanceperiod": { + from: DEBT_SYSTEM, + message: "Your account balance is below $10. Please recharge in time.", + title: "Low balance", + }, + "workspace-debt-debt": { + from: SUBSCRIPTION_SYSTEM, + message: + "The workspace subscription has expired and the workspace is suspended.", + title: "Workspace debt", + }, + "workspace-debt-debtfinaldeletion": { + from: SUBSCRIPTION_SYSTEM, + message: "Radical resource release: workspace resources may be deleted.", + title: "Workspace final deletion", + }, + "workspace-debt-debtpredeletion": { + from: SUBSCRIPTION_SYSTEM, + message: + "The workspace subscription is still unpaid. Resources enter the deletion period.", + title: "Workspace pre-deletion", + }, +}; + +/** A debt ladder's rungs, oldest first; at most the named rung is unread. */ +function ladder( + names: readonly FixedCRName[], + options: { agoDays: readonly number[]; unread?: FixedCRName } +): PlatformFixture[] { + return names.map((name, index) => ({ + ...PLATFORM_COPY[name], + ago: (options.agoDays[index] ?? 0) * DAY_MS, + isRead: name !== options.unread, + name, + })); +} + +const ANNOUNCEMENT: PlatformFixture = { + ago: 2 * DAY_MS, + from: "Admin", + isRead: false, + message: "The database terminal is available from every database page.", + name: "release-notes-2026-08", + title: "New: database terminal", +}; + +const UPGRADE_RECEIPT: BrainFixture = { + ago: DAY_MS, + payload: { + change: "upgraded", + kind: "subscription-change", + planName: "Hobby", + }, +}; + +function periodEnd(days: number): string { + return new Date(FIXTURE_NOW_MS + days * DAY_MS).toISOString(); +} + +/** The gift was granted with the trial; it runs a month, like the trial. */ +const GIFT_HINT: BrainFixture = { + ago: 3 * HOUR_MS, + payload: { + expiresAt: periodEnd(10), + giftMicroUnits: 1_000_000, + kind: "credit-hint", + }, +}; + +interface ScenarioFixture { + brain: readonly BrainFixture[]; + platform: readonly PlatformFixture[]; +} + +const EMPTY: ScenarioFixture = { brain: [], platform: [] }; + +/** One inbox per billing scenario; every catalog row appears somewhere. */ +function scenarioFixture(scenario: BillingDevScenario): ScenarioFixture { + switch (scenario) { + case "active": + case "mixed-workspaces": + return { + brain: [UPGRADE_RECEIPT], + platform: [ + ANNOUNCEMENT, + // A topped-up account keeps its history; the tier is long read. + ...ladder(["debt-choice-lowbalanceperiod"], { agoDays: [20] }), + ], + }; + case "active-balance": + return { + brain: [ + { + ago: 2 * HOUR_MS, + payload: { + change: "downgraded", + effectiveAt: periodEnd(12), + kind: "subscription-change", + planName: "Starter", + }, + }, + ], + platform: [], + }; + case "cancelling": + return { + brain: [ + { + ago: HOUR_MS, + payload: { + change: "cancelled", + effectiveAt: periodEnd(12), + kind: "subscription-change", + planName: "Hobby", + }, + }, + ], + platform: [], + }; + case "free": + // The gift newcomer: upstream already tagged the sub-$5 balance, the + // display layer's gift-only filter hides both tiers. + return { + brain: [GIFT_HINT], + platform: ladder(ACCOUNT_LADDER.slice(0, 2), { + agoDays: [1, 1], + unread: "debt-choice-criticalbalanceperiod", + }), + }; + // The trial's advance reminders (C1) are upstream's to write; until they + // ship, an expiring trial's inbox is quiet and the status hint carries + // the notice alone. + case "free-expiring": + return { brain: [], platform: [] }; + case "free-expired": + return { + brain: [{ ...GIFT_HINT, ago: 20 * DAY_MS }], + platform: ladder(WORKSPACE_LADDER.slice(0, 1), { + agoDays: [2], + unread: "workspace-debt-debt", + }), + }; + case "payg-debt": + return { + brain: [], + platform: ladder(ACCOUNT_LADDER.slice(0, 3), { + agoDays: [9, 6, 1], + unread: "debt-choice-debtperiod", + }), + }; + case "payg-debt-deletion": + return { + brain: [], + platform: ladder(ACCOUNT_LADDER.slice(0, 4), { + agoDays: [16, 13, 8, 1], + unread: "debt-choice-debtdeletionperiod", + }), + }; + case "payg-debt-final": + return { + brain: [], + platform: ladder(ACCOUNT_LADDER, { + agoDays: [23, 20, 15, 8, 1], + unread: "debt-choice-finaldeletionperiod", + }), + }; + case "payment-due": + return { + brain: [], + platform: ladder(WORKSPACE_LADDER.slice(0, 1), { + agoDays: [2], + unread: "workspace-debt-debt", + }), + }; + case "payment-due-deletion": + return { + brain: [], + platform: ladder(WORKSPACE_LADDER.slice(0, 2), { + agoDays: [8, 1], + unread: "workspace-debt-debtpredeletion", + }), + }; + case "payment-due-final": + return { + brain: [], + platform: ladder(WORKSPACE_LADDER, { + agoDays: [16, 9, 2], + unread: "workspace-debt-debtfinaldeletion", + }), + }; + case "quota-full": + return { + brain: [ + { + ago: HOUR_MS, + payload: { + kind: "quota-exhausted", + limit: 20_480, + resource: "storage", + used: 20_480, + }, + }, + ], + platform: [], + }; + default: + return EMPTY; + } +} + +function platformItem( + fixture: PlatformFixture, + workspace: string +): NotificationCRItem { + return { + desktopPopup: true, + from: fixture.from, + importance: "High", + isRead: fixture.isRead, + message: fixture.message, + name: fixture.name, + namespace: workspace, + timestamp: Math.floor((FIXTURE_NOW_MS - fixture.ago) / 1000), + title: fixture.title, + version: Math.floor((FIXTURE_NOW_MS - fixture.ago) / 1000), + }; +} + +function brainMessage( + fixture: BrainFixture, + scenario: string, + index: number +): NotificationMessage { + return { + createdAt: FIXTURE_NOW_MS - fixture.ago, + id: `mock-${scenario}-${index + 1}`, + kind: fixture.payload.kind, + payload: fixture.payload, + projectUid: null, + }; +} + +// Session-only read state, one inbox per scenario; nothing persists. +const receiptsByScenario = new Map>(); + +function receiptsFor(scenario: string): Set { + let receipts = receiptsByScenario.get(scenario); + if (receipts == null) { + receipts = new Set(); + receiptsByScenario.set(scenario, receipts); + } + return receipts; +} + +export function notificationDevMockFeed( + scenario: BillingDevScenario, + workspace: string +): NotificationFeedResponse { + const fixture = scenarioFixture(scenario); + return { + messages: fixture.brain.map((entry, index) => + brainMessage(entry, scenario, index) + ), + platformItems: fixture.platform.map((entry) => + platformItem(entry, workspace) + ), + receipts: [...receiptsFor(scenario)], + }; +} + +export type NotificationDevMockHandler = "feed" | "observation" | "read"; + +/** + * Answers a notification route from fixtures while the billing Dev Mock is + * on; null hands the request to the real handler. Observations answer as + * "nothing new" so the producers never touch the store in mock mode. + */ +export async function notificationDevMockResponse( + handler: NotificationDevMockHandler, + request: Request +): Promise { + const resolution = resolveBillingDevMock(request); + if (resolution.kind === "off") { + return null; + } + if (resolution.kind === "invalid") { + return resolution.response; + } + const { scenario } = resolution; + switch (handler) { + case "feed": { + const workspace = billingDevMockWorkspace( + new URL(request.url).searchParams.get("namespace") + ); + return Response.json(notificationDevMockFeed(scenario, workspace)); + } + case "read": { + const parsed = markNotificationReadRequestSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return Response.json( + { error: "Invalid mark-read request." }, + { status: 400 } + ); + } + const receipts = receiptsFor(scenario); + for (const id of parsed.data.ids) { + receipts.add(id); + } + return Response.json({ read: parsed.data.ids }); + } + case "observation": + return Response.json({ produced: false, released: [] }); + default: + return handler satisfies never; + } +} diff --git a/apps/ui/src/features/chat/dev-fixtures.test.ts b/apps/ui/src/features/chat/dev-fixtures.test.ts new file mode 100644 index 00000000..d4b4c24a --- /dev/null +++ b/apps/ui/src/features/chat/dev-fixtures.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { chatDevMockResponse } from "./dev-fixtures"; +import { CHAT_DEV_SCENARIOS, chatDevMockCookie } from "./dev-mock-cookie"; +import { readSelectedResourceContext } from "./persistence/types"; + +function request(path: string, scenario: string): Request { + const req = new Request(`http://localhost${path}`); + req.headers.set("cookie", `${chatDevMockCookie.name}=${scenario}`); + return req; +} + +const realSession = () => + Promise.resolve( + Response.json({ + chatId: "real", + freeTier: { billing: "blocked", limit: 10, remaining: 0 }, + messages: [], + threads: [], + }) + ); +const realDown = () => + Promise.resolve(Response.json({ error: "down" }, { status: 503 })); + +interface SessionBody { + chatId: string; + freeTier: { billing: string }; + messages: { id: string; parts: unknown[]; role: string }[]; + threads: { id: string }[]; +} + +describe("conversation dev fixtures", () => { + for (const scenario of CHAT_DEV_SCENARIOS) { + test(`${scenario} bootstraps a session whose thread is in the list`, async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", scenario), + realSession + ); + const body = (await response.json()) as SessionBody; + expect(body.chatId).toBe(`mock-chat-${scenario}`); + if (scenario !== "empty") { + expect(body.threads.map((thread) => thread.id)).toContain(body.chatId); + } + const messages = await chatDevMockResponse( + "messages", + request( + `/api/chat/messages?chatId=${body.chatId}&namespace=ns-test`, + scenario + ), + realDown + ); + const list = (await messages.json()) as Pick; + expect(list.messages).toEqual(body.messages); + }); + } + + test("keeps the real Chat Billing Mode when the real handler answers", async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "short"), + realSession + ); + const body = (await response.json()) as SessionBody; + expect(body.freeTier.billing).toBe("blocked"); + expect(body.messages).toHaveLength(4); + }); + + test("falls back to a neutral posture when the real handler cannot", async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "long"), + realDown + ); + const body = (await response.json()) as SessionBody; + expect(body.freeTier.billing).toBe("user"); + }); + + test("long carries tool cards in every state and a pinned context", async () => { + const response = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "long"), + realDown + ); + const body = (await response.json()) as SessionBody; + const states = new Set( + body.messages.flatMap((message) => + message.parts.flatMap((part) => { + const candidate = part as { state?: string; type: string }; + return candidate.type.startsWith("tool-") && candidate.state != null + ? [candidate.state] + : []; + }) + ) + ); + expect([...states].sort()).toEqual( + ["approval-requested", "output-available", "output-error"].sort() + ); + const pinned = body.messages + .map((message) => + readSelectedResourceContext( + message as Parameters[0] + ) + ) + .find((context) => context != null); + expect(pinned?.name).toBe("web-app"); + }); + + test("an unknown thread and an off cookie fall through", async () => { + const unknown = await chatDevMockResponse( + "messages", + request( + "/api/chat/messages?chatId=real-thread&namespace=ns-test", + "long" + ), + realDown + ); + expect(unknown.status).toBe(503); + const off = await chatDevMockResponse( + "session", + request("/api/chat/session?namespace=ns-test", "off:long"), + realSession + ); + expect(((await off.json()) as SessionBody).chatId).toBe("real"); + }); +}); diff --git a/apps/ui/src/features/chat/dev-fixtures.ts b/apps/ui/src/features/chat/dev-fixtures.ts new file mode 100644 index 00000000..c02c76ea --- /dev/null +++ b/apps/ui/src/features/chat/dev-fixtures.ts @@ -0,0 +1,362 @@ +import type { UIMessage } from "ai"; + +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { HOUR_MS, MINUTE_MS } from "@/lib/time"; + +import { type ChatDevScenario, chatDevMockCookie } from "./dev-mock-cookie"; +import type { + AssistantSessionPayload, + AssistantThreadDTO, + FreeTierState, +} from "./persistence/types"; +import { SELECTED_RESOURCE_CONTEXT_PART_TYPE } from "./persistence/types"; + +/** + * Conversation dev fixtures: three threads' worth of transcript so the chat + * pane can be designed against a real-looking history — tool cards in every + * state, a pinned chat context, code blocks, a long scroll. The session and + * message routes answer from here; the layer below (the real handler) + * still decides the Chat Billing Mode when it can answer, so the billing + * mock keeps driving it. Nothing here is ever written to Postgres. + */ + +/** Anchored at module load so thread timestamps stay stable across reloads. */ +const FIXTURE_NOW_MS = Date.now(); + +const THREAD_IDS = { + empty: "mock-chat-empty", + long: "mock-chat-long", + short: "mock-chat-short", +} as const; + +function at(agoMs: number): string { + return new Date(FIXTURE_NOW_MS - agoMs).toISOString(); +} + +function user( + id: string, + text: string, + extraParts: UIMessage["parts"] = [] +): UIMessage { + return { id, parts: [...extraParts, { text, type: "text" }], role: "user" }; +} + +function assistant(id: string, parts: UIMessage["parts"]): UIMessage { + return { id, parts, role: "assistant" }; +} + +const SHORT_TRANSCRIPT: UIMessage[] = [ + user("m1", "How do I expose my app on a custom domain?"), + assistant("m2", [ + { type: "step-start" }, + { + text: "You can attach a custom domain in three steps:\n\n1. Add the domain under **Networking → Domains**.\n2. Point a `CNAME` record at the generated target.\n3. Wait for the TLS certificate to be issued.\n\nOnce the certificate is **Ready**, traffic resolves automatically.", + type: "text", + }, + ]), + user("m3", "What if the certificate stays pending?"), + assistant("m4", [ + { type: "step-start" }, + { + text: "A pending certificate almost always means the `CNAME` has not propagated yet. Verify it with:\n\n```bash\ndig +short CNAME app.example.com\n```\n\nIf the target does not match, fix the DNS record and re-check in a few minutes.", + type: "text", + }, + ]), +]; + +const LONG_TRANSCRIPT: UIMessage[] = [ + user( + "l1", + "Deploy acme/web-app from GitHub into this project. It needs a Postgres database." + ), + assistant("l2", [ + { type: "step-start" }, + { + text: "I'll look at the repository first, then plan the resources.", + type: "text", + }, + { + input: { kind: "AP", name: "web-app", namespace: "ns-mock" }, + output: { found: false }, + state: "output-available", + toolCallId: "call-read-1", + type: "tool-readProductResource", + }, + { type: "step-start" }, + { + text: "There's no `web-app` workload yet, so this will be a fresh deployment. The repo is a Next.js app with a `DATABASE_URL` dependency. Here's the plan:\n\n| Resource | Kind | Notes |\n|---|---|---|\n| `web-app` | AP | Node 20, 1 replica, port 3000 |\n| `pg-main` | DB | PostgreSQL 16, 1 Gi |\n| `web-app` | Public Access | HTTPS on the platform domain |\n\nShall I create the database first?", + type: "text", + }, + ]), + user("l3", "Yes, go ahead."), + assistant("l4", [ + { type: "step-start" }, + { + approval: { approved: true, id: "approval-db-1" }, + input: { + intention: "create the PostgreSQL cluster web-app depends on", + kind: "DB", + name: "pg-main", + operation: "create", + spec: { storageGi: 1, type: "postgresql", version: "16" }, + }, + output: { name: "pg-main", status: "Creating" }, + state: "output-available", + toolCallId: "call-write-db", + type: "tool-writeProductResource", + }, + { type: "step-start" }, + { + text: "`pg-main` is being created. I'll start a deployment task for the app while it comes up.", + type: "text", + }, + { + input: { + branch: "main", + repo: "acme/web-app", + target: "existingProject", + }, + output: { status: "running", taskId: "mock-task-running" }, + state: "output-available", + toolCallId: "call-deploy-1", + type: "tool-startDeploymentTask", + }, + { type: "step-start" }, + { + text: "The deployment task is running — you can follow it in the timeline. I'll check back once the image is built.", + type: "text", + }, + ]), + user("l5", "The build failed. What went wrong?", [ + { + data: { kind: "AP", name: "web-app", namespace: "ns-mock" }, + type: SELECTED_RESOURCE_CONTEXT_PART_TYPE, + }, + ]), + assistant("l6", [ + { type: "step-start" }, + { + input: { taskId: "mock-task-running" }, + output: { + error: "Image build failed: `npm run build` exited with status 1.", + reason: "image-build-failed", + status: "failed", + }, + state: "output-available", + toolCallId: "call-task-1", + type: "tool-readDeploymentTask", + }, + { + input: { container: "build", lines: 40, name: "web-app" }, + errorText: "Build logs are no longer available for this task.", + state: "output-error", + toolCallId: "call-logs-1", + type: "tool-readWorkloadLogs", + }, + { type: "step-start" }, + { + text: "The build step failed with `npm run build` exiting 1, and the build logs have already been rotated. Two common causes for this repo shape:\n\n- a **TypeScript error** that `next build` treats as fatal;\n- a missing `NEXT_PUBLIC_*` variable the build reads at compile time.\n\nI can redeploy with `CI=false` to surface the exact error, or you can run `npm run build` locally first. Which do you prefer?", + type: "text", + }, + ]), + user( + "l7", + "Redeploy, and set NEXT_PUBLIC_API_URL to https://api.example.com." + ), + assistant("l8", [ + { type: "step-start" }, + { + approval: { approved: true, id: "approval-env-1" }, + input: { + intention: "set the public API URL the build reads", + kind: "AP", + name: "web-app", + operation: "patch", + patch: { env: { NEXT_PUBLIC_API_URL: "https://api.example.com" } }, + }, + output: { applied: true }, + state: "output-available", + toolCallId: "call-write-env", + type: "tool-writeProductResource", + }, + { + input: { retriedFromTaskId: "mock-task-running" }, + output: { status: "running", taskId: "mock-task-succeeded" }, + state: "output-available", + toolCallId: "call-deploy-2", + type: "tool-startDeploymentTask", + }, + { type: "step-start" }, + { + text: "Environment updated and the redeploy is running. This time the build has the variable it needs.", + type: "text", + }, + ]), + user("l9", "Is it up?"), + assistant("l10", [ + { type: "step-start" }, + { + input: { taskId: "mock-task-succeeded" }, + output: { + resultUrl: "https://web-app.mock.sealos.run", + status: "completed", + }, + state: "output-available", + toolCallId: "call-task-2", + type: "tool-readDeploymentTask", + }, + { + input: { namespace: "ns-mock" }, + output: { scheduled: true }, + state: "output-available", + toolCallId: "call-refresh-1", + type: "tool-refreshFrontendSwrCaches", + }, + { type: "step-start" }, + { + text: "Yes — `web-app` is live at https://web-app.mock.sealos.run with 1/1 replicas ready, connected to `pg-main`. The canvas has been refreshed to show both resources.", + type: "text", + }, + ]), + user("l11", "Scale it to 3 replicas and show me the resulting spec."), + assistant("l12", [ + { type: "step-start" }, + { + approval: { id: "approval-scale-1" }, + input: { + intention: "scale web-app after showing the patch", + kind: "AP", + name: "web-app", + operation: "patch", + patch: { spec: { replicas: 3 } }, + }, + state: "approval-requested", + toolCallId: "call-scale-1", + type: "tool-writeProductResource", + }, + { type: "step-start" }, + { + text: "Here is the patch I'd apply:\n\n```yaml\nspec:\n replicas: 3\n```\n\nApprove it and I'll scale `web-app` right away.", + type: "text", + }, + ]), +]; + +const TRANSCRIPTS: Record = { + [THREAD_IDS.empty]: [], + [THREAD_IDS.long]: LONG_TRANSCRIPT, + [THREAD_IDS.short]: SHORT_TRANSCRIPT, +}; + +function threads(namespace: string): AssistantThreadDTO[] { + return [ + { + id: THREAD_IDS.long, + namespace, + title: "Deploy acme/web-app from GitHub", + updatedAt: at(12 * MINUTE_MS), + }, + { + id: THREAD_IDS.short, + namespace, + title: "Custom domain setup", + updatedAt: at(3 * HOUR_MS), + }, + { + id: THREAD_IDS.empty, + namespace, + title: "New chat", + updatedAt: at(26 * HOUR_MS), + }, + ]; +} + +/** Every scenario shows the same three threads; the scenario picks which one is open. */ +function scenarioSession( + scenario: ChatDevScenario, + namespace: string, + freeTier: FreeTierState +): AssistantSessionPayload { + const chatId = THREAD_IDS[scenario]; + return { + chatId, + freeTier, + messages: TRANSCRIPTS[chatId] ?? [], + threads: scenario === "empty" ? [] : threads(namespace), + }; +} + +/** No allowance to speak of; the real handler's posture replaces it when it answers. */ +const NEUTRAL_FREE_TIER: FreeTierState = { + billing: "user", + limit: 0, + remaining: 0, +}; + +export type ChatDevMockHandler = "messages" | "session" | "threads"; + +export type ChatDevMockNext = (request: Request) => Promise; + +function namespaceOf(request: Request): string { + return ( + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock" + ); +} + +async function freeTierBelow( + request: Request, + next: ChatDevMockNext +): Promise { + const below = await next(request).catch(() => null); + if (below == null || !below.ok) { + return NEUTRAL_FREE_TIER; + } + const payload: unknown = await below.json().catch(() => null); + const freeTier = + typeof payload === "object" && payload != null + ? (payload as { freeTier?: unknown }).freeTier + : null; + return typeof freeTier === "object" && freeTier != null + ? (freeTier as FreeTierState) + : NEUTRAL_FREE_TIER; +} + +/** + * Answers a conversation persistence route from fixtures while the mock is + * on; `next` is the real handler. The session keeps the real handler's + * Chat Billing Mode when it can answer (so the billing mock still drives + * it) and falls back to a neutral posture otherwise. Off hands the request + * down untouched. + */ +export async function chatDevMockResponse( + handler: ChatDevMockHandler, + request: Request, + next: ChatDevMockNext +): Promise { + const resolution = resolveDevMock(chatDevMockCookie, request, "chat"); + if (resolution.kind === "off") { + return next(request); + } + if (resolution.kind === "invalid") { + return resolution.response; + } + const { scenario } = resolution; + const namespace = namespaceOf(request); + switch (handler) { + case "session": + return Response.json( + scenarioSession(scenario, namespace, await freeTierBelow(request, next)) + ); + case "threads": + return Response.json({ + threads: scenario === "empty" ? [] : threads(namespace), + }); + case "messages": { + const chatId = new URL(request.url).searchParams.get("chatId") ?? ""; + const messages = TRANSCRIPTS[chatId]; + return messages == null ? next(request) : Response.json({ messages }); + } + default: + return handler satisfies never; + } +} diff --git a/apps/ui/src/features/chat/dev-mock-cookie.ts b/apps/ui/src/features/chat/dev-mock-cookie.ts new file mode 100644 index 00000000..f5d09736 --- /dev/null +++ b/apps/ui/src/features/chat/dev-mock-cookie.ts @@ -0,0 +1,20 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Conversation Dev Mock's cookie: the assistant transcript (session + * bootstrap, thread list, thread messages) served from fixtures. Sending a + * message still goes to the real `POST /api/chat`; Chat Billing Mode still + * follows the billing mock. + */ + +export const CHAT_DEV_SCENARIOS = ["empty", "short", "long"] as const; + +export type ChatDevScenario = (typeof CHAT_DEV_SCENARIOS)[number]; + +export const DEFAULT_CHAT_DEV_SCENARIO: ChatDevScenario = "long"; + +export const chatDevMockCookie = defineDevMockCookie({ + defaultScenario: DEFAULT_CHAT_DEV_SCENARIO, + name: "sealai-chat-dev-mock", + scenarios: CHAT_DEV_SCENARIOS, +}); diff --git a/apps/ui/src/features/chat/dev-mock-gate.tsx b/apps/ui/src/features/chat/dev-mock-gate.tsx new file mode 100644 index 00000000..92aaefee --- /dev/null +++ b/apps/ui/src/features/chat/dev-mock-gate.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Same inlined gate as dev-tweaks.tsx: a real production build statically +// drops the dynamic import and tree-shakes the mock module away; +// `NEXT_PUBLIC_DEV_TWEAKS=1` keeps it for demo deployments. +const ChatDevMockTweaks = + process.env.NODE_ENV === "development" || + process.env.NEXT_PUBLIC_DEV_TWEAKS === "1" + ? dynamic(() => import("./dev-mock").then((mod) => mod.ChatDevMockTweaks)) + : null; + +/** Mounts the chat mock's dev-tweaks registration on /project. */ +export function ChatDevMockGate() { + if (!ChatDevMockTweaks) { + return null; + } + return ; +} diff --git a/apps/ui/src/features/chat/dev-mock.tsx b/apps/ui/src/features/chat/dev-mock.tsx new file mode 100644 index 00000000..3ca666d7 --- /dev/null +++ b/apps/ui/src/features/chat/dev-mock.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; + +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + CHAT_DEV_SCENARIOS, + chatDevMockCookie, + DEFAULT_CHAT_DEV_SCENARIO, +} from "./dev-mock-cookie"; + +export const CHAT_DEV_MOCK_KEY = "chat-mock"; + +// The chat pane bootstraps its session once per credential set, outside +// SWR; a reload is the one honest way to re-bootstrap it from (or off) the +// fixtures. +const chatDevMockSource = createDevMockCookieSource(chatDevMockCookie, { + revalidate: () => window.location.reload(), +}); + +/** Registers the mock while a Project Canvas is mounted; renders nothing. */ +export function ChatDevMockTweaks() { + useDevTweaksMock(CHAT_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_CHAT_DEV_SCENARIO, + note: "Serves the assistant transcript from fixtures; sending still hits the model; toggling reloads the page", + scenarios: CHAT_DEV_SCENARIOS, + source: chatDevMockSource, + title: "Chat mock", + }); + return null; +} diff --git a/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts b/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts index 1be3957e..f3ce3af7 100644 --- a/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts +++ b/apps/ui/src/features/chat/runtime/conversation-route-handlers.ts @@ -1,5 +1,6 @@ import "server-only"; +import type { ChatDevMockHandler } from "../dev-fixtures"; import { getFreeTierSnapshot, resolveFreeTierPosture, @@ -12,18 +13,49 @@ import { } from "../persistence/service"; import { createAssistantConversationHandlers } from "./conversation-handlers"; -export const assistantConversationRouteHandlers = - createAssistantConversationHandlers({ - adoptLegacyConversations: adoptLegacyAssistantConversationsForActor, - bootstrap: bootstrapAssistantSession, - freeTurnsUsage: getFreeTierSnapshot, - list: listThreadsForOwner, - read: (owner, chatId) => loadMessagesForOwner(chatId, owner), - resolveFreeTier: ({ actor, cookieHeader }) => - resolveFreeTierPosture({ - accountUserId: actor.accountUserId ?? null, - cookieHeader, - namespace: actor.owner.namespace, - userUid: actor.owner.userUid, - }), - }); +type ConversationRouteHandler = (request: Request) => Promise; + +/** + * Lets the Conversation Dev Mock answer the persistence routes first in dev + * and demo builds (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image); a real + * production build statically drops the dynamic import, so fixtures never + * reach production bundles — the same gate as the /api/billing routes. + */ +function withChatDevMock( + handler: ChatDevMockHandler, + real: ConversationRouteHandler +): ConversationRouteHandler { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return real; + } + return async (request) => { + const { chatDevMockResponse } = await import("../dev-fixtures"); + return chatDevMockResponse(handler, request, real); + }; +} + +const handlers = createAssistantConversationHandlers({ + adoptLegacyConversations: adoptLegacyAssistantConversationsForActor, + bootstrap: bootstrapAssistantSession, + freeTurnsUsage: getFreeTierSnapshot, + list: listThreadsForOwner, + read: (owner, chatId) => loadMessagesForOwner(chatId, owner), + resolveFreeTier: ({ actor, cookieHeader }) => + resolveFreeTierPosture({ + accountUserId: actor.accountUserId ?? null, + cookieHeader, + namespace: actor.owner.namespace, + userUid: actor.owner.userUid, + }), +}); + +/** Production wiring for the conversation routes, dev mock first. */ +export const assistantConversationRouteHandlers = { + ...handlers, + messagesGet: withChatDevMock("messages", handlers.messagesGet), + session: withChatDevMock("session", handlers.session), + threads: withChatDevMock("threads", handlers.threads), +}; diff --git a/apps/ui/src/features/deploy/task/dev-fixtures.test.ts b/apps/ui/src/features/deploy/task/dev-fixtures.test.ts new file mode 100644 index 00000000..7b368961 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-fixtures.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { + deployTaskDevMockProjection, + deployTaskDevMockResponse, + deployTaskDevMockTask, +} from "./dev-fixtures"; +import { + DEPLOY_TASK_DEV_SCENARIOS, + deployTaskDevMockCookie, + deployTaskDevMockTaskId, +} from "./dev-mock-cookie"; +import { deploymentTaskProjectionIsVisible } from "./projection"; + +const NOW_MS = Date.parse("2026-08-28T10:00:00.000Z"); + +function request(path: string, scenario: string): Request { + const req = new Request(`http://localhost${path}`); + req.headers.set("cookie", `${deployTaskDevMockCookie.name}=${scenario}`); + return req; +} + +describe("deployment task dev fixtures", () => { + for (const scenario of DEPLOY_TASK_DEV_SCENARIOS) { + test(`${scenario} is a coherent task, timeline and projection`, () => { + const snapshot = deployTaskDevMockTask(scenario, { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: "project-1", + }); + expect(snapshot.task.id).toBe(deployTaskDevMockTaskId(scenario)); + expect(snapshot.timeline.taskId).toBe(snapshot.task.id); + expect(snapshot.timeline.status).toBe(snapshot.task.status); + expect(snapshot.timeline.steps.length).toBeGreaterThan(0); + expect(snapshot.timeline.steps.map((step) => step.order)).toEqual([ + ...snapshot.timeline.steps.keys(), + ]); + const projection = deployTaskDevMockProjection(scenario, { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: "project-1", + }); + expect(projection.id).toBe(snapshot.task.id); + expect(projection.projectId).toBe("project-1"); + expect(projection.status).toBe(snapshot.task.status); + }); + } + + test("an active task places itself on the canvas", () => { + const projection = deployTaskDevMockProjection("running", { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: "project-1", + }); + expect( + deploymentTaskProjectionIsVisible(projection, new Date(NOW_MS)) + ).toBe(true); + }); + + test("blocked carries the inputs the pane asks for", () => { + const { task } = deployTaskDevMockTask("blocked", { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: null, + }); + expect(task.blockingInputs.length).toBeGreaterThan(0); + }); + + test("failed names a scrubbed failure reason", () => { + const { task, timeline } = deployTaskDevMockTask("failed", { + namespace: "ns-test", + nowMs: NOW_MS, + projectId: null, + }); + expect(task.failureDetails?.reason).toBe("image-build-failed"); + expect(timeline.steps.at(-1)?.status).toBe("failed"); + }); + + test("the timeline route answers only for the fixture task", async () => { + const mocked = deployTaskDevMockResponse( + "timeline", + request("/api/deploy-tasks/x/timeline?namespace=ns-test", "succeeded"), + deployTaskDevMockTaskId("succeeded") + ); + expect(mocked?.status).toBe(200); + const body = (await mocked?.json()) as { task: { status: string } }; + expect(body.task.status).toBe("completed"); + expect( + deployTaskDevMockResponse( + "timeline", + request( + "/api/deploy-tasks/real/timeline?namespace=ns-test", + "succeeded" + ), + "real-task" + ) + ).toBeNull(); + }); + + test("the list answers with the fixture projection for the project", async () => { + const mocked = deployTaskDevMockResponse( + "list", + request("/api/deploy-tasks?namespace=ns-test&projectId=p1", "failed"), + null + ); + const body = (await mocked?.json()) as { projections: { id: string }[] }; + expect(body.projections.map((item) => item.id)).toEqual([ + "mock-task-failed", + ]); + }); + + test("the stream sends one snapshot frame and stays open", async () => { + const controller = new AbortController(); + const req = new Request( + "http://localhost/api/deploy-tasks/mock-task-running/timeline/stream?namespace=ns-test", + { signal: controller.signal } + ); + req.headers.set("cookie", `${deployTaskDevMockCookie.name}=running`); + const mocked = deployTaskDevMockResponse( + "timeline-stream", + req, + "mock-task-running" + ); + expect(mocked?.headers.get("content-type")).toContain("text/event-stream"); + const reader = mocked?.body?.getReader(); + const first = await reader?.read(); + const text = new TextDecoder().decode(first?.value); + expect(text.startsWith("event: snapshot\n")).toBe(true); + expect(JSON.parse(text.split("data: ")[1] ?? "{}").type).toBe("snapshot"); + controller.abort(); + const last = await reader?.read(); + expect(last?.done).toBe(true); + }); + + test("off and invalid behave like every Dev Mock", () => { + expect( + deployTaskDevMockResponse( + "list", + request("/api/deploy-tasks?projectId=p1", "off:running"), + null + ) + ).toBeNull(); + const invalid = deployTaskDevMockResponse( + "list", + request("/api/deploy-tasks?projectId=p1", "nope"), + null + ); + expect(invalid?.status).toBe(500); + }); +}); diff --git a/apps/ui/src/features/deploy/task/dev-fixtures.ts b/apps/ui/src/features/deploy/task/dev-fixtures.ts new file mode 100644 index 00000000..8f719e85 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-fixtures.ts @@ -0,0 +1,684 @@ +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { MINUTE_MS } from "@/lib/time"; + +import { + type DeployTaskDevScenario, + deployTaskDevMockCookie, + deployTaskDevMockTaskId, +} from "./dev-mock-cookie"; +import type { DeploymentTaskProjection } from "./projection"; +import type { DeployTaskStatus } from "./schema"; +import { + DEPLOYMENT_TASK_TERMINAL_FAILURE_EVENT_KEY, + type DeploymentResultResourceCard, + type DeploymentTaskTimelineSnapshot, + type DeploymentTimelineEvent, + type DeploymentTimelineStep, + type DeploymentTimelineStepStatus, +} from "./timeline"; +import type { + DeploymentTaskTimelineSnapshotDTO, + DeploymentTaskTimelineStreamEvent, + DeployTaskDTO, + DeployTaskEventDTO, +} from "./types"; + +/** + * Deployment Task Timeline dev fixtures: one AI-runner task per scenario, + * deploying `acme/web-app` into the open Project. The timeline route and its + * SSE stream answer for the fixture task id; the task list and projection + * stream answer with the fixture alone so the Deployment Task Dock shows + * its chip. Timestamps are relative to the request so completion windows + * (canvas placeholder, dock notice) behave as they would live. Static: the + * stream sends the snapshot once and then only heartbeats. + */ + +const SECOND_MS = 1000; +const PROJECT_NAME = "web-app"; +const REPO = "acme/web-app"; + +interface FixtureClock { + at(offsetMs: number): string; + nowMs: number; +} + +function clock(nowMs: number): FixtureClock { + return { at: (offsetMs) => new Date(nowMs + offsetMs).toISOString(), nowMs }; +} + +function event( + time: FixtureClock, + offsetMs: number, + id: string, + message: string, + extra: Partial = {} +): DeploymentTimelineEvent { + return { + createdAt: time.at(offsetMs), + id, + message, + source: "runner", + ...extra, + }; +} + +function step( + id: string, + label: string, + order: number, + status: DeploymentTimelineStepStatus, + events: DeploymentTimelineEvent[], + resultCards?: DeploymentResultResourceCard[] +): DeploymentTimelineStep { + return { + events, + id, + label, + order, + ...(resultCards == null ? {} : { resultCards }), + status, + }; +} + +function resultCards( + time: FixtureClock, + namespace: string, + phase: "creating" | "ready" +): DeploymentResultResourceCard[] { + const ready = phase === "ready"; + return [ + { + events: [ + event( + time, + ready ? -40 * SECOND_MS : -20 * SECOND_MS, + "evt-ap", + ready + ? "AP workload has 1/1 ready replicas." + : "AP workload is starting; 0/1 replicas ready.", + { + source: "resource-observer", + ...(ready ? { severity: "success" } : {}), + } + ), + ], + id: `AP:${namespace}:${PROJECT_NAME}`, + latestStatusText: ready ? "1/1 replicas ready" : "0/1 replicas ready", + required: true, + resultRef: { kind: "AP", name: PROJECT_NAME, namespace }, + status: ready ? "running" : "creating", + title: PROJECT_NAME, + }, + { + events: [ + event(time, -50 * SECOND_MS, "evt-db", "Database cluster is ready.", { + severity: "success", + source: "resource-observer", + }), + ], + id: `DB:${namespace}:pg-main`, + latestStatusText: "Ready", + required: true, + resultRef: { kind: "DB", name: "pg-main", namespace }, + status: "running", + title: "pg-main", + }, + { + events: ready + ? [ + event( + time, + -30 * SECOND_MS, + "evt-pa", + "Public Address is accessible.", + { + severity: "success", + source: "health-check", + } + ), + ] + : [], + id: `PublicAccess:${namespace}:${PROJECT_NAME}:pa_web`, + ...(ready ? { latestStatusText: "accessible" } : {}), + required: false, + resultRef: { + apName: PROJECT_NAME, + id: "pa_web", + kind: "PublicAccess", + namespace, + }, + status: ready ? "running" : "pending", + title: "Public access", + }, + ]; +} + +interface ScenarioShape { + cancelRequestedAt: string | null; + completedAt: string | null; + error: string | null; + failureDetails: DeployTaskDTO["failureDetails"]; + phase: DeployTaskDTO["phase"]; + resultUrl: string | null; + status: DeployTaskStatus; + steps: DeploymentTimelineStep[]; +} + +function scenarioShape( + scenario: DeployTaskDevScenario, + time: FixtureClock, + namespace: string +): ScenarioShape { + const prepare = step( + "prepare-workspace", + "Prepare workspace", + 0, + "completed", + [event(time, -4 * MINUTE_MS, "evt-1", "Runtime workspace is ready.")] + ); + const analyze = step("analyze-source", "Analyze repository", 1, "completed", [ + event(time, -3.5 * MINUTE_MS, "evt-2", `Cloned ${REPO}@main.`), + event( + time, + -3 * MINUTE_MS, + "evt-3", + "Detected a Next.js app with a PostgreSQL dependency." + ), + ]); + const generate = step( + "generate-deployment", + "Generate deployment", + 2, + "completed", + [ + event( + time, + -2 * MINUTE_MS, + "evt-4", + "Planned 1 AP, 1 DB and 1 Public Address." + ), + ] + ); + switch (scenario) { + case "running": + return { + cancelRequestedAt: null, + completedAt: null, + error: null, + failureDetails: null, + phase: "apply", + resultUrl: null, + status: "applying", + steps: [ + prepare, + analyze, + generate, + step( + "create-resources", + "Create resources", + 3, + "running", + [ + event( + time, + -MINUTE_MS, + "evt-5", + "Applying deployment artifacts." + ), + ], + resultCards(time, namespace, "creating") + ), + ], + }; + case "blocked": + return { + cancelRequestedAt: null, + completedAt: null, + error: null, + failureDetails: null, + phase: "configure", + resultUrl: null, + status: "blocked", + steps: [ + prepare, + analyze, + step("generate-deployment", "Generate deployment", 2, "blocked", [ + event( + time, + -2 * MINUTE_MS, + "evt-4", + "Two values are needed before the deployment can continue.", + { severity: "warning" } + ), + ]), + step("create-resources", "Create resources", 3, "pending", []), + ], + }; + case "failed": + return { + cancelRequestedAt: null, + completedAt: time.at(-MINUTE_MS), + error: "Image build failed: `npm run build` exited with status 1.", + failureDetails: { + failureMessage: + "Image build failed: `npm run build` exited with status 1.", + reason: "image-build-failed", + stage: "apply", + }, + phase: "apply", + resultUrl: null, + status: "failed", + steps: [ + prepare, + analyze, + generate, + step("create-resources", "Create resources", 3, "failed", [ + event( + time, + -90 * SECOND_MS, + "evt-5", + "Building the container image." + ), + event( + time, + -MINUTE_MS, + "evt-6", + "Image build failed: `npm run build` exited with status 1.", + { + dedupeKey: DEPLOYMENT_TASK_TERMINAL_FAILURE_EVENT_KEY, + reason: "image-build-failed", + severity: "error", + } + ), + ]), + ], + }; + case "succeeded": + return { + cancelRequestedAt: null, + completedAt: time.at(-30 * SECOND_MS), + error: null, + failureDetails: null, + phase: "completed", + resultUrl: "https://web-app.mock.sealos.run", + status: "completed", + steps: [ + prepare, + analyze, + generate, + step( + "create-resources", + "Create resources", + 3, + "completed", + [ + event( + time, + -MINUTE_MS, + "evt-5", + "Applying deployment artifacts." + ), + event( + time, + -30 * SECOND_MS, + "evt-6", + "All resources are ready.", + { + severity: "success", + } + ), + ], + resultCards(time, namespace, "ready") + ), + ], + }; + case "cancelled": + return { + cancelRequestedAt: time.at(-70 * SECOND_MS), + completedAt: time.at(-MINUTE_MS), + error: null, + failureDetails: { reason: "cancelled" }, + phase: "generate-artifacts", + resultUrl: null, + status: "cancelled", + steps: [ + prepare, + analyze, + step("generate-deployment", "Generate deployment", 2, "skipped", [ + event(time, -MINUTE_MS, "evt-4", "Cancelled by the user.", { + severity: "warning", + }), + ]), + step("create-resources", "Create resources", 3, "skipped", []), + ], + }; + default: + return scenario satisfies never; + } +} + +const RESOURCE_SLOTS = (namespace: string) => ({ + resources: [ + { + apiVersion: "apps/v1", + kind: "Deployment", + name: PROJECT_NAME, + namespace, + }, + { + apiVersion: "apps.kubeblocks.io/v1alpha1", + kind: "Cluster", + name: "pg-main", + namespace, + }, + ], + slots: [ + { + anchor: true, + expectedRef: { kind: "AP" as const, name: PROJECT_NAME, namespace }, + id: "slot-ap", + }, + { + expectedRef: { kind: "DB" as const, name: "pg-main", namespace }, + id: "slot-db", + }, + ], +}); + +export function deployTaskDevMockTask( + scenario: DeployTaskDevScenario, + input: { namespace: string; nowMs: number; projectId: string | null } +): DeploymentTaskTimelineSnapshotDTO { + const time = clock(input.nowMs); + const shape = scenarioShape(scenario, time, input.namespace); + const taskId = deployTaskDevMockTaskId(scenario); + const facts = RESOURCE_SLOTS(input.namespace); + const timeline: DeploymentTaskTimelineSnapshot = { + revision: shape.steps.length, + status: shape.status, + steps: shape.steps, + taskId, + updatedAt: time.at(-30 * SECOND_MS), + }; + const task: DeployTaskDTO = { + artifactSummary: { + resources: facts.resources, + }, + blockingInputs: + scenario === "blocked" + ? [ + { + id: "DATABASE_PASSWORD", + key: "DATABASE_PASSWORD", + label: "Database password", + required: true, + sensitive: true, + type: "secret", + }, + { + defaultValue: "3", + description: "Replicas for the web workload", + id: "REPLICAS", + key: "REPLICAS", + label: "Replicas", + options: ["1", "2", "3"], + required: true, + type: "text", + valueType: "choice", + }, + ] + : [], + cancelRequestedAt: shape.cancelRequestedAt, + canvasProjection: { + edges: [{ sourceSlotId: "slot-ap", targetSlotId: "slot-db" }], + slots: facts.slots, + ...(scenario === "succeeded" + ? { + resultMappings: facts.slots.map((slot) => ({ + actualRef: slot.expectedRef, + slotId: slot.id, + })), + } + : {}), + }, + completedAt: shape.completedAt, + createdAt: time.at(-5 * MINUTE_MS), + createdFrom: "ui", + error: shape.error, + failureDetails: shape.failureDetails, + gatewaySessionId: null, + gatewayStateSnapshot: null, + gatewayTurnId: null, + gatewayUrl: null, + id: taskId, + namespace: input.namespace, + phase: shape.phase, + previewUrl: null, + projectId: input.projectId, + projectName: PROJECT_NAME, + resultUrl: shape.resultUrl, + retriedFromTaskId: null, + runner: { kind: "ai", runtimeProvider: "devbox" }, + runtimeName: "devbox-web-app", + runtimeProvider: "devbox", + runtimeState: shape.completedAt == null ? "Running" : "Stopped", + source: { + branch: "main", + kind: "github", + repo: { + fullName: REPO, + name: PROJECT_NAME, + url: `https://github.com/${REPO}`, + }, + }, + startedAt: time.at(-4.5 * MINUTE_MS), + status: shape.status, + target: + input.projectId == null + ? { displayName: PROJECT_NAME, kind: "newProject" } + : { + kind: "existingProject", + projectId: input.projectId, + projectName: PROJECT_NAME, + }, + timelineSnapshot: timeline, + updatedAt: time.at(-30 * SECOND_MS), + }; + const events: DeployTaskEventDTO[] = shape.steps.flatMap((entry) => + entry.events.map((item, index) => ({ + createdAt: item.createdAt, + kind: `deployment_task.${entry.status}`, + message: item.message, + payload: item.reason == null ? {} : { reason: item.reason }, + phase: shape.phase, + seq: entry.order * 10 + index, + taskId, + })) + ); + return { events, task, timeline }; +} + +export function deployTaskDevMockProjection( + scenario: DeployTaskDevScenario, + input: { namespace: string; nowMs: number; projectId: string } +): DeploymentTaskProjection { + const { task } = deployTaskDevMockTask(scenario, input); + return { + artifactSummary: task.artifactSummary, + cancelRequestedAt: task.cancelRequestedAt ?? null, + canvasProjection: task.canvasProjection, + completedAt: task.completedAt, + display: { + resultSummary: PROJECT_NAME, + sourceKind: "github", + sourceSummary: `${REPO}@main`, + }, + id: task.id, + namespace: task.namespace, + phase: task.phase, + projectId: input.projectId, + ...(task.canvasProjection.resultMappings == null + ? {} + : { resultMappings: task.canvasProjection.resultMappings }), + retriedFromTaskId: null, + status: task.status, + updatedAt: task.updatedAt, + }; +} + +export type DeployTaskDevMockRoute = + | "list" + | "projections-stream" + | "timeline" + | "timeline-stream"; + +const HEARTBEAT_MS = 10_000; + +function encodeSse(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +/** One snapshot frame, then heartbeats until the client goes away. */ +function staticSseResponse( + request: Request, + event: string, + data: unknown +): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + let timer: ReturnType | undefined; + const close = () => { + if (timer != null) { + clearInterval(timer); + timer = undefined; + } + try { + controller.close(); + } catch { + // Already closed by the client. + } + }; + if (request.signal.aborted) { + close(); + return; + } + request.signal.addEventListener("abort", close, { once: true }); + controller.enqueue(encoder.encode(encodeSse(event, data))); + timer = setInterval(() => { + try { + controller.enqueue(encoder.encode(": ping\n\n")); + } catch { + close(); + } + }, HEARTBEAT_MS); + }, + }); + return new Response(stream, { + headers: { + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", + "X-Accel-Buffering": "no", + }, + }); +} + +function namespaceOf(request: Request): string { + return ( + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock" + ); +} + +/** + * Answers a deploy-task route from fixtures while the mock is on; null hands + * the request to the real handler. The timeline routes answer only for the + * fixture task so real tasks stay reachable; the list and projection + * stream answer with the fixture alone. + */ +export function deployTaskDevMockResponse( + route: DeployTaskDevMockRoute, + request: Request, + taskId: string | null +): Response | null { + const resolution = resolveDevMock( + deployTaskDevMockCookie, + request, + "deploy task" + ); + if (resolution.kind === "off") { + return null; + } + if (resolution.kind === "invalid") { + return resolution.response; + } + const { scenario } = resolution; + const url = new URL(request.url); + const namespace = namespaceOf(request); + const nowMs = Date.now(); + switch (route) { + case "timeline": + case "timeline-stream": { + if (taskId !== deployTaskDevMockTaskId(scenario)) { + return null; + } + const snapshot = deployTaskDevMockTask(scenario, { + namespace, + nowMs, + projectId: null, + }); + if (route === "timeline") { + return Response.json(snapshot); + } + const frame: DeploymentTaskTimelineStreamEvent = { + snapshot, + type: "snapshot", + }; + return staticSseResponse(request, "snapshot", frame); + } + case "list": { + const projectId = url.searchParams.get("projectId")?.trim() || null; + if (url.searchParams.get("view") === "tasks") { + const { task } = deployTaskDevMockTask(scenario, { + namespace, + nowMs, + projectId, + }); + return Response.json({ nextCursor: null, tasks: [task] }); + } + if (projectId == null) { + return Response.json({ projections: [] }); + } + return Response.json({ + projections: [ + deployTaskDevMockProjection(scenario, { + namespace, + nowMs, + projectId, + }), + ], + }); + } + case "projections-stream": { + const projectId = url.searchParams.get("projectId")?.trim(); + if (!projectId) { + return Response.json( + { error: "Project ID is required." }, + { status: 400 } + ); + } + return staticSseResponse(request, "snapshot", { + projections: [ + deployTaskDevMockProjection(scenario, { + namespace, + nowMs, + projectId, + }), + ], + type: "snapshot", + }); + } + default: + return route satisfies never; + } +} diff --git a/apps/ui/src/features/deploy/task/dev-mock-cookie.ts b/apps/ui/src/features/deploy/task/dev-mock-cookie.ts new file mode 100644 index 00000000..a0aa2fa0 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock-cookie.ts @@ -0,0 +1,34 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Deployment Task Timeline Dev Mock's cookie: one fixture Deployment + * Task per scenario, served as a static timeline snapshot (no event + * replay) plus the canvas projection that lets its chip appear. + */ + +export const DEPLOY_TASK_DEV_SCENARIOS = [ + "running", + "blocked", + "failed", + "succeeded", + "cancelled", +] as const; + +export type DeployTaskDevScenario = (typeof DEPLOY_TASK_DEV_SCENARIOS)[number]; + +export const DEFAULT_DEPLOY_TASK_DEV_SCENARIO: DeployTaskDevScenario = + "running"; + +export const deployTaskDevMockCookie = + defineDevMockCookie({ + defaultScenario: DEFAULT_DEPLOY_TASK_DEV_SCENARIO, + name: "sealai-deploy-task-dev-mock", + scenarios: DEPLOY_TASK_DEV_SCENARIOS, + }); + +/** The fixture task's id; the timeline route answers from fixtures only for it. */ +export function deployTaskDevMockTaskId( + scenario: DeployTaskDevScenario +): string { + return `mock-task-${scenario}`; +} diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock-gate.tsx b/apps/ui/src/features/deploy/task/dev-mock-gate.tsx similarity index 51% rename from apps/ui/src/features/shell/app-sidebar-notifications-dev-mock-gate.tsx rename to apps/ui/src/features/deploy/task/dev-mock-gate.tsx index 365a1f53..41b87f3c 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock-gate.tsx +++ b/apps/ui/src/features/deploy/task/dev-mock-gate.tsx @@ -5,20 +5,18 @@ import dynamic from "next/dynamic"; // Same inlined gate as dev-tweaks.tsx: a real production build statically // drops the dynamic import and tree-shakes the mock module away; // `NEXT_PUBLIC_DEV_TWEAKS=1` keeps it for demo deployments. -const AppSidebarNotificationsDevMock = +const DeployTaskDevMockTweaks = process.env.NODE_ENV === "development" || process.env.NEXT_PUBLIC_DEV_TWEAKS === "1" ? dynamic(() => - import("./app-sidebar-notifications-dev-mock").then( - (mod) => mod.AppSidebarNotificationsDevMock - ) + import("./dev-mock").then((mod) => mod.DeployTaskDevMockTweaks) ) : null; -/** Mounts the notifications mock's dev-tweaks registration with the sidebar. */ -export function AppSidebarNotificationsDevMockGate() { - if (!AppSidebarNotificationsDevMock) { +/** Mounts the deployment task mock's dev-tweaks registration on /project. */ +export function DeployTaskDevMockGate() { + if (!DeployTaskDevMockTweaks) { return null; } - return ; + return ; } diff --git a/apps/ui/src/features/deploy/task/dev-mock-route.ts b/apps/ui/src/features/deploy/task/dev-mock-route.ts new file mode 100644 index 00000000..c037bc91 --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock-route.ts @@ -0,0 +1,41 @@ +import "server-only"; + +import type { DeployTaskDevMockRoute } from "./dev-fixtures"; + +type RouteContextArgs = C extends undefined + ? [context?: undefined] + : [context: C]; + +type RouteHandler = ( + request: Request, + ...args: RouteContextArgs +) => Promise; + +/** + * Lets the Deployment Task Timeline Dev Mock answer first in dev and demo + * builds (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image); a real production + * build statically drops the dynamic import, so fixtures never reach + * production bundles — the same gate as the /api/billing routes. + */ +export function withDeployTaskDevMock( + route: DeployTaskDevMockRoute, + handler: RouteHandler, + taskIdOf: (...args: RouteContextArgs) => Promise = () => + Promise.resolve(null) +): RouteHandler { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return handler; + } + return async (request, ...args) => { + const { deployTaskDevMockResponse } = await import("./dev-fixtures"); + const mocked = deployTaskDevMockResponse( + route, + request, + await taskIdOf(...args) + ); + return mocked ?? handler(request, ...args); + }; +} diff --git a/apps/ui/src/features/deploy/task/dev-mock.tsx b/apps/ui/src/features/deploy/task/dev-mock.tsx new file mode 100644 index 00000000..401449cb --- /dev/null +++ b/apps/ui/src/features/deploy/task/dev-mock.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; + +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + DEFAULT_DEPLOY_TASK_DEV_SCENARIO, + DEPLOY_TASK_DEV_SCENARIOS, + deployTaskDevMockCookie, +} from "./dev-mock-cookie"; + +export const DEPLOY_TASK_DEV_MOCK_KEY = "deploy-task-mock"; + +// The projection store and the timeline pane hold SSE connections keyed by +// credentials, not SWR keys; a reload is the one honest way to reconnect +// them to (or from) the fixtures. +const deployTaskDevMockSource = createDevMockCookieSource( + deployTaskDevMockCookie, + { revalidate: () => window.location.reload() } +); + +/** Registers the mock while a Project Canvas is mounted; renders nothing. */ +export function DeployTaskDevMockTweaks() { + useDevTweaksMock(DEPLOY_TASK_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_DEPLOY_TASK_DEV_SCENARIO, + note: "Serves one Deployment Task and its timeline from fixtures (static snapshot); toggling reloads the page", + scenarios: DEPLOY_TASK_DEV_SCENARIOS, + source: deployTaskDevMockSource, + title: "Deployment task mock", + }); + return null; +} diff --git a/apps/ui/src/features/dev-mock/cookie.ts b/apps/ui/src/features/dev-mock/cookie.ts new file mode 100644 index 00000000..f52ae42f --- /dev/null +++ b/apps/ui/src/features/dev-mock/cookie.ts @@ -0,0 +1,97 @@ +/** + * The cookie protocol every route-handler Dev Mock speaks, shared by its + * dev-tweaks registration (client) and its fixture dispatcher (server). A + * Dev Mock's session cookie is the single source of truth for its state — + * no env var, no localStorage copy — and each Dev Mock owns one cookie, so + * independent mocks compose: the billing mock and the Notification Center + * mock can be on at once, each answering only its own surfaces. + * + * Value grammar: `` while the mock is on, `off:` while it + * is off (the scenario part keeps the last selection so the panel can + * restore it). Anything else is invalid and makes the server answer 500 so + * typos fail loud instead of silently serving real data. + */ + +const OFF_PREFIX = "off:"; + +export interface DevMockState { + enabled: boolean; + scenario: S; +} + +export type ParsedDevMockCookie = + | { kind: "invalid"; raw: string } + | { kind: "set"; state: DevMockState } + | { kind: "unset" }; + +export interface DevMockCookieDef { + defaultScenario: S; + /** The session cookie's name. */ + name: string; + scenarios: readonly S[]; +} + +export interface DevMockCookie extends DevMockCookieDef { + /** The `document.cookie` assignment the client writes on a toggle. */ + documentCookie(state: DevMockState): string; + format(state: DevMockState): string; + /** The raw cookie value off a request's `cookie` header, if present. */ + fromRequest(request: Request): string | undefined; + is(value: string): value is S; + parse(raw: string | null | undefined): ParsedDevMockCookie; + /** The cookie the server sends when it advances the scenario itself. */ + setCookieHeader(state: DevMockState): string; +} + +function cookieValue(header: string | null, name: string): string | undefined { + for (const pair of (header ?? "").split(";")) { + const separator = pair.indexOf("="); + if (separator === -1) { + continue; + } + if (pair.slice(0, separator).trim() === name) { + return decodeURIComponent(pair.slice(separator + 1).trim()); + } + } + return undefined; +} + +export function defineDevMockCookie( + def: DevMockCookieDef +): DevMockCookie { + const is = (value: string): value is S => + (def.scenarios as readonly string[]).includes(value); + const format = (state: DevMockState): string => + state.enabled ? state.scenario : `${OFF_PREFIX}${state.scenario}`; + return { + ...def, + documentCookie: (state) => + `${def.name}=${format(state)}; path=/; samesite=lax`, + format, + fromRequest: (request) => + cookieValue(request.headers.get("cookie"), def.name), + is, + parse: (raw) => { + const value = raw?.trim() ?? ""; + if (value === "") { + return { kind: "unset" }; + } + if (value.startsWith(OFF_PREFIX)) { + const scenario = value.slice(OFF_PREFIX.length); + return { + kind: "set", + state: { + enabled: false, + scenario: is(scenario) ? scenario : def.defaultScenario, + }, + }; + } + if (is(value)) { + return { kind: "set", state: { enabled: true, scenario: value } }; + } + return { kind: "invalid", raw: value }; + }, + setCookieHeader: (state) => + `${def.name}=${format(state)}; Path=/; SameSite=Lax`, + }; +} diff --git a/apps/ui/src/features/dev-mock/server/resolve.ts b/apps/ui/src/features/dev-mock/server/resolve.ts new file mode 100644 index 00000000..8ef242ef --- /dev/null +++ b/apps/ui/src/features/dev-mock/server/resolve.ts @@ -0,0 +1,46 @@ +import type { DevMockCookie } from "../cookie"; + +export type DevMockResolution = + | { kind: "invalid"; response: Response } + | { kind: "off" } + | { kind: "set"; scenario: S }; + +/** + * Reads a Dev Mock's cookie off a request: off (no cookie, or disabled, or a + * real production build), invalid (a 500 that names the valid scenarios so + * typos fail loud), or the scenario to serve. Every fixture dispatcher + * resolves through here; the dynamic-import gate that keeps fixtures out of + * production bundles stays inlined at each route (a helper call would not + * be statically dropped), this is the runtime backstop behind it. + */ +export function resolveDevMock( + cookie: DevMockCookie, + request: Request, + label: string +): DevMockResolution { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return { kind: "off" }; + } + const parsed = cookie.parse(cookie.fromRequest(request)); + if ( + parsed.kind === "unset" || + (parsed.kind === "set" && !parsed.state.enabled) + ) { + return { kind: "off" }; + } + if (parsed.kind === "invalid") { + return { + kind: "invalid", + response: Response.json( + { + error: `Unknown ${label} mock scenario "${parsed.raw}". Valid scenarios: ${cookie.scenarios.join(", ")}. Toggle the mock from the dev tweaks pane (⌃⌥T).`, + }, + { status: 500 } + ), + }; + } + return { kind: "set", scenario: parsed.state.scenario }; +} diff --git a/apps/ui/src/features/dev-mock/source.ts b/apps/ui/src/features/dev-mock/source.ts new file mode 100644 index 00000000..ab961815 --- /dev/null +++ b/apps/ui/src/features/dev-mock/source.ts @@ -0,0 +1,56 @@ +"use client"; + +import type { DevTweaksMockSource } from "@workspace/dev-tweaks"; + +import type { DevMockCookie } from "./cookie"; + +/** + * The dev-tweaks panel's remote control for a cookie-backed Dev Mock. The + * panel never owns the state: `load` re-reads the cookie, `set` writes it + * and then asks the feature to revalidate the SWR keys its fixtures shape, + * `watch` picks up rewrites the server makes behind the panel's back (a + * fixture's `Set-Cookie` scenario transition) — the Cookie Store API pushes + * those, the focus/interval pair is the fallback. + */ +export function createDevMockCookieSource( + cookie: DevMockCookie, + options: { revalidate: () => void } +): DevTweaksMockSource { + return { + load: () => { + const parsed = cookie.parse( + cookie.fromRequest( + new Request("http://mock.local", { + headers: { cookie: document.cookie }, + }) + ) + ); + return parsed.kind === "set" ? parsed.state : null; + }, + set: (state) => { + const scenario = cookie.is(state.scenario) + ? state.scenario + : cookie.defaultScenario; + // biome-ignore lint/suspicious/noDocumentCookie: the synchronous write must land before the SWR refetches fire; the async Cookie Store API cannot guarantee that. + document.cookie = cookie.documentCookie({ + enabled: state.enabled, + scenario, + }); + options.revalidate(); + }, + watch: (onChange) => { + const cookieStore = (window as { cookieStore?: EventTarget }).cookieStore; + cookieStore?.addEventListener("change", onChange); + window.addEventListener("focus", onChange); + const watchTimer = + cookieStore == null ? window.setInterval(onChange, 3000) : undefined; + return () => { + cookieStore?.removeEventListener("change", onChange); + window.removeEventListener("focus", onChange); + if (watchTimer != null) { + window.clearInterval(watchTimer); + } + }; + }, + }; +} diff --git a/apps/ui/src/features/notifications/client.test.ts b/apps/ui/src/features/notifications/client.test.ts new file mode 100644 index 00000000..3b0ab85f --- /dev/null +++ b/apps/ui/src/features/notifications/client.test.ts @@ -0,0 +1,61 @@ +import { test } from "bun:test"; +import assert from "node:assert/strict"; + +import { postNotificationReadReceipts, readReceiptBatches } from "./client"; + +const CREDENTIALS = { appToken: "t", kubeconfig: "k", namespace: "ns-a" }; +const MARK_READ_FAILED_RE = /Mark-read request failed \(503\)/; + +function ids(count: number): string[] { + return Array.from({ length: count }, (_, index) => `cr:name-${index}:1`); +} + +test("readReceiptBatches splits ids at the route's limit and keeps order", () => { + assert.deepEqual(readReceiptBatches([], 3), []); + assert.deepEqual(readReceiptBatches(["a", "b", "c"], 3), [["a", "b", "c"]]); + assert.deepEqual(readReceiptBatches(["a", "b", "c", "d"], 3), [ + ["a", "b", "c"], + ["d"], + ]); + assert.deepEqual( + readReceiptBatches(ids(401)).map((b) => b.length), + [200, 200, 1] + ); +}); + +test("postNotificationReadReceipts sends one request per batch, none for no ids", async () => { + const bodies: string[][] = []; + const fetchImpl = ((_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { ids: string[] }; + bodies.push(body.ids); + return Promise.resolve( + new Response(JSON.stringify({ read: body.ids }), { status: 200 }) + ); + }) as typeof fetch; + + await postNotificationReadReceipts(CREDENTIALS, [], fetchImpl); + assert.equal(bodies.length, 0); + + await postNotificationReadReceipts(CREDENTIALS, ids(450), fetchImpl); + assert.deepEqual( + bodies.map((b) => b.length), + [200, 200, 50] + ); + assert.deepEqual(bodies.flat(), ids(450)); +}); + +test("postNotificationReadReceipts rejects on a failed batch", async () => { + let calls = 0; + const fetchImpl = ((_url: unknown, _init?: RequestInit) => { + calls += 1; + return Promise.resolve( + new Response("", { status: calls === 2 ? 503 : 200 }) + ); + }) as typeof fetch; + + await assert.rejects( + postNotificationReadReceipts(CREDENTIALS, ids(250), fetchImpl), + MARK_READ_FAILED_RE + ); + assert.equal(calls, 2); +}); diff --git a/apps/ui/src/features/notifications/client.ts b/apps/ui/src/features/notifications/client.ts new file mode 100644 index 00000000..9154e561 --- /dev/null +++ b/apps/ui/src/features/notifications/client.ts @@ -0,0 +1,141 @@ +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; +import { personalResourceAuthHeaders } from "@/lib/personal-resource-headers"; + +import { + type GiftObservationRequest, + NOTIFICATION_READ_BATCH_LIMIT, + type NotificationFeedResponse, + notificationFeedResponseSchema, + type SubscriptionChangeObservationRequest, +} from "./types"; + +/** Credentials every Brain-side notification request carries (ADR-0059). */ +export interface NotificationClientCredentials { + appToken: string; + kubeconfig: string; + namespace: string; +} + +function notificationsUrl(pathname: string, namespace: string): string { + const search = new URLSearchParams({ namespace }); + return `/api/notifications${pathname}?${search.toString()}`; +} + +/** The `db:` stream and the user's receipts for the current workspace. */ +export async function fetchNotificationFeed( + credentials: NotificationClientCredentials, + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + const response = await fetchImpl( + notificationsUrl("", credentials.namespace), + { headers: personalResourceAuthHeaders(credentials) } + ); + if (!response.ok) { + throw new Error(`Notification feed request failed (${response.status})`); + } + return notificationFeedResponseSchema.parse(await response.json()); +} + +/** Splits ids into request-sized batches so "mark all" never exceeds the route's limit. */ +export function readReceiptBatches( + ids: readonly string[], + limit: number = NOTIFICATION_READ_BATCH_LIMIT +): string[][] { + const batches: string[][] = []; + for (let start = 0; start < ids.length; start += limit) { + batches.push(ids.slice(start, start + limit)); + } + return batches; +} + +/** + * Records per-user receipts for the given source-prefixed ids, one request + * per batch in order. Rejects on the first failed batch; earlier batches + * stay recorded (receipts are idempotent, so a retry re-sends them all). + */ +export async function postNotificationReadReceipts( + credentials: NotificationClientCredentials, + ids: readonly string[], + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + for (const batch of readReceiptBatches(ids)) { + await postNotificationJson( + "/read", + credentials, + { ids: batch }, + "Mark-read", + fetchImpl + ); + } +} + +async function postNotificationJson( + pathname: string, + credentials: NotificationClientCredentials, + body: unknown, + label: string, + fetchImpl: typeof fetch +): Promise { + const response = await fetchImpl( + notificationsUrl(pathname, credentials.namespace), + { + body: JSON.stringify(body), + headers: { + ...personalResourceAuthHeaders(credentials), + "content-type": "application/json", + }, + method: "POST", + } + ); + if (!response.ok) { + throw new Error(`${label} request failed (${response.status})`); + } +} + +/** + * Reports a workspace quota snapshot so the quota-exhausted producer can + * observe it. Best-effort: the sidebar's quota read must never fail on it. + */ +export function reportWorkspaceQuotaObservation( + credentials: NotificationClientCredentials, + snapshot: WorkspaceResourceQuotaSnapshot, + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + return postNotificationJson( + "/quota-observation", + credentials, + { quota: snapshot }, + "Quota observation", + fetchImpl + ); +} + +/** Reports visible gift credit so the gift-hint producer can observe it. */ +export function reportGiftCreditObservation( + credentials: NotificationClientCredentials, + observation: GiftObservationRequest, + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + return postNotificationJson( + "/gift-observation", + credentials, + observation, + "Gift observation", + fetchImpl + ); +} + +/** Reports a settled subscription change so its receipt can be written. */ +export function reportSubscriptionChangeObservation( + credentials: NotificationClientCredentials, + observation: SubscriptionChangeObservationRequest, + fetchImpl: typeof fetch = globalThis.fetch +): Promise { + return postNotificationJson( + "/subscription-change", + credentials, + observation, + "Subscription-change observation", + fetchImpl + ); +} diff --git a/apps/ui/src/features/notifications/cr-overrides.ts b/apps/ui/src/features/notifications/cr-overrides.ts new file mode 100644 index 00000000..597b532b --- /dev/null +++ b/apps/ui/src/features/notifications/cr-overrides.ts @@ -0,0 +1,79 @@ +import type { NotificationCTA } from "@/features/shell/app-sidebar-notifications-model"; + +/** + * The display-layer override table for the platform's fixed-name debt-ladder + * CRs (design spec §10 rule 2). Upstream English carries broken phrasing + * ("Radical resource release") that must never reach users, so the eight + * known names map to Brain-voiced title, body, and CTA. Pure display + * substitution: the CR is the source of truth and is never touched; unknown + * names fall back to the original text. English only — `i18ns.zh` is never + * read. + */ + +export interface CROverride { + body: string; + cta: NotificationCTA; + title: string; +} + +/** Account money recovers by top-up: the Billing Plan page holds the balance. */ +const TOP_UP_BALANCE: NotificationCTA = { + href: "/billing", + label: "Top up balance", +}; + +/** A lapsed Workspace Subscription recovers by renewing on the Plan page. */ +const RENEW_PLAN: NotificationCTA = { href: "/billing", label: "Renew plan" }; + +export const CR_OVERRIDES: Readonly> = { + "debt-choice-criticalbalanceperiod": { + body: "Your balance is under $5. Pay-as-you-go workspaces are suspended at $0.", + cta: TOP_UP_BALANCE, + title: "Account balance almost empty", + }, + "debt-choice-debtdeletionperiod": { + body: "Your balance is still in debt. Resources will be deleted soon unless you top up.", + cta: TOP_UP_BALANCE, + title: "Account resources scheduled for deletion", + }, + "debt-choice-debtperiod": { + body: "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.", + cta: TOP_UP_BALANCE, + title: "Account balance in debt", + }, + "debt-choice-finaldeletionperiod": { + body: "Your balance is still in debt. All resources under this account can be deleted at any time.", + cta: TOP_UP_BALANCE, + title: "Account resources face final deletion", + }, + "debt-choice-lowbalanceperiod": { + body: "Your balance is under $10. Top up to keep pay-as-you-go workspaces running.", + cta: TOP_UP_BALANCE, + title: "Account balance is low", + }, + "workspace-debt-debt": { + body: "The subscription has expired and this workspace is suspended. Renew to restore it.", + cta: RENEW_PLAN, + title: "Workspace suspended — payment due", + }, + "workspace-debt-debtfinaldeletion": { + body: "Resources can be permanently deleted at any time. This cannot be undone.", + cta: RENEW_PLAN, + title: "Workspace faces final deletion", + }, + "workspace-debt-debtpredeletion": { + body: "The subscription is still unpaid. Resources will be permanently deleted soon.", + cta: RENEW_PLAN, + title: "Workspace deletion approaching", + }, +}; + +/** + * The low-balance warning tiers (catalog D1) the gift-only filter hides for + * never-topped-up newcomers. The debt ladder (D2: `debtperiod` onward) is + * never filtered — a suspended workspace must always be visible. + */ +export const GIFT_FILTERED_CR_NAMES: ReadonlySet = new Set([ + "debt-choice-lowbalanceperiod", + "debt-choice-criticalbalanceperiod", +]); diff --git a/apps/ui/src/features/notifications/db-types.ts b/apps/ui/src/features/notifications/db-types.ts new file mode 100644 index 00000000..03f12dc6 --- /dev/null +++ b/apps/ui/src/features/notifications/db-types.ts @@ -0,0 +1,23 @@ +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; + +import type { identityFingerprints } from "@/features/chat/persistence/schema"; + +import type { notificationMessages, notificationReadReceipts } from "./schema"; + +// biome-ignore lint/style/useConsistentTypeDefinitions: interfaces lack the implicit index signature PgDatabase's schema generic requires +export type NotificationDbSchema = { + identityFingerprints: typeof identityFingerprints; + notificationMessages: typeof notificationMessages; + notificationReadReceipts: typeof notificationReadReceipts; +}; + +/** + * Driver-agnostic database type shared by production (node-postgres) and the + * PGlite tests; the store constrains itself to the query-builder surface + * every drizzle pg driver provides. `identityFingerprints` rides along + * because receipt writes re-check the binding in-transaction (ADR-0059). + */ +export type NotificationPgDatabase = PgDatabase< + PgQueryResultHKT, + NotificationDbSchema +>; diff --git a/apps/ui/src/features/notifications/db.ts b/apps/ui/src/features/notifications/db.ts new file mode 100644 index 00000000..bde1376b --- /dev/null +++ b/apps/ui/src/features/notifications/db.ts @@ -0,0 +1,28 @@ +import "server-only"; + +import { drizzle } from "drizzle-orm/node-postgres"; + +import { identityFingerprints } from "@/features/chat/persistence/schema"; +import { getAppPostgresPool } from "@/lib/app-postgres/db"; + +import type { NotificationDbSchema, NotificationPgDatabase } from "./db-types"; +import { notificationMessages, notificationReadReceipts } from "./schema"; + +const notificationSchema: NotificationDbSchema = { + identityFingerprints, + notificationMessages, + notificationReadReceipts, +}; + +let notificationDbInstance: NotificationPgDatabase | undefined; + +/** + * Lazily creates the Drizzle client on first use so `next build` does not need + * `DATABASE_URL` (static analysis / route collection must not open the pool). + */ +export function getNotificationDb(): NotificationPgDatabase { + notificationDbInstance ??= drizzle(getAppPostgresPool(), { + schema: notificationSchema, + }) as NotificationPgDatabase; + return notificationDbInstance; +} diff --git a/apps/ui/src/features/notifications/dev-fixtures.test.ts b/apps/ui/src/features/notifications/dev-fixtures.test.ts new file mode 100644 index 00000000..70c44ca9 --- /dev/null +++ b/apps/ui/src/features/notifications/dev-fixtures.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; + +import { billingDevMockCookie } from "@/features/billing/dev-mock-cookie"; +import { notificationDevMockResponse } from "@/features/billing/server/dev-fixtures/notifications"; +import { + platformNotificationDevMockItems, + withPlatformNotificationDevMock, +} from "./dev-fixtures"; +import { + NOTIFICATIONS_DEV_SCENARIOS, + notificationsDevMockCookie, +} from "./dev-mock-cookie"; +import { mergeNotificationFeed } from "./feed-model"; +import { crNotificationId } from "./notification-ids"; +import { + type NotificationFeedResponse, + notificationFeedResponseSchema, +} from "./types"; + +const REAL_FEED: NotificationFeedResponse = { + messages: [], + receipts: ["db:real-1"], +}; + +function request( + path: string, + cookies: Record, + init?: RequestInit +): Request { + const req = new Request(`http://localhost${path}?namespace=ns-test`, init); + req.headers.set( + "cookie", + Object.entries(cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; ") + ); + return req; +} + +const realFeed = () => Promise.resolve(Response.json(REAL_FEED)); +const realRead = async (req: Request) => + Response.json({ read: ((await req.json()) as { ids: string[] }).ids }); + +describe("platform notification dev mock", () => { + test("off hands the request down untouched", async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: "off:mixed", + }), + realFeed + ); + expect(await response.json()).toEqual(REAL_FEED); + }); + + test("an unknown scenario fails loud", async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: "nope", + }), + realFeed + ); + expect(response.status).toBe(500); + }); + + for (const scenario of NOTIFICATIONS_DEV_SCENARIOS) { + test(`${scenario} layers fixture platform items over the real feed`, async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: scenario, + }), + realFeed + ); + const feed = notificationFeedResponseSchema.parse(await response.json()); + const expected = platformNotificationDevMockItems(scenario, "ns-test"); + expect(expected.length).toBeGreaterThan(0); + expect(feed.platformItems).toEqual(expected); + expect(feed.receipts).toContain("db:real-1"); + const merged = mergeNotificationFeed({ + crItems: feed.platformItems ?? [], + dbMessages: feed.messages, + receipts: feed.receipts, + }); + expect(merged).toHaveLength(expected.length); + expect(new Set(merged.map((item) => item.id)).size).toBe(expected.length); + }); + } + + test("mixed is a month of mostly-read traffic, newest first", () => { + const items = platformNotificationDevMockItems("mixed", "ns-test"); + expect(items.length).toBeGreaterThanOrEqual(15); + expect(items.filter((item) => !item.isRead).length).toBeLessThan(5); + for (let index = 1; index < items.length; index += 1) { + expect(items[index - 1]?.timestamp).toBeGreaterThanOrEqual( + items[index]?.timestamp ?? 0 + ); + } + }); + + test("stacks on the billing mock's feed", async () => { + const cookies = { + [billingDevMockCookie.name]: "payg-debt", + [notificationsDevMockCookie.name]: "announcement", + }; + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", cookies), + async (req) => + (await notificationDevMockResponse("feed", req)) ?? realFeed() + ); + const feed = notificationFeedResponseSchema.parse(await response.json()); + const names = (feed.platformItems ?? []).map((item) => item.name); + expect(names).toContain("debt-choice-debtperiod"); + expect(names).toContain("announce-db-terminal"); + }); + + test("falls back to fixtures alone when the layer below fails", async () => { + const response = await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", { + [notificationsDevMockCookie.name]: "db-event", + }), + () => Promise.resolve(Response.json({ error: "down" }, { status: 503 })) + ); + const feed = notificationFeedResponseSchema.parse(await response.json()); + expect(feed.messages).toEqual([]); + expect(feed.platformItems).toHaveLength(1); + }); + + test("keeps fixture read receipts in memory and forwards the rest", async () => { + const item = platformNotificationDevMockItems("deployment", "ns-test")[0]; + if (item == null) { + throw new Error("deployment fixture is empty"); + } + const id = crNotificationId(item.name, item.version); + const cookies = { [notificationsDevMockCookie.name]: "deployment" }; + const forwarded: string[][] = []; + const next = async (req: Request) => { + forwarded.push(((await req.json()) as { ids: string[] }).ids); + return realRead( + new Request(req, { body: JSON.stringify({ ids: forwarded.at(-1) }) }) + ); + }; + const response = await withPlatformNotificationDevMock( + "read", + request("/api/notifications/read", cookies, { + body: JSON.stringify({ ids: [id, "db:real-1"] }), + headers: { "content-type": "application/json" }, + method: "POST", + }), + next + ); + expect(await response.json()).toEqual({ read: [id, "db:real-1"] }); + expect(forwarded).toEqual([["db:real-1"]]); + + const feed = notificationFeedResponseSchema.parse( + await ( + await withPlatformNotificationDevMock( + "feed", + request("/api/notifications", cookies), + realFeed + ) + ).json() + ); + expect(feed.receipts).toContain(id); + }); +}); diff --git a/apps/ui/src/features/notifications/dev-fixtures.ts b/apps/ui/src/features/notifications/dev-fixtures.ts new file mode 100644 index 00000000..e6e062ac --- /dev/null +++ b/apps/ui/src/features/notifications/dev-fixtures.ts @@ -0,0 +1,406 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { resolveDevMock } from "@/features/dev-mock/server/resolve"; +import { DAY_MS, HOUR_MS } from "@/lib/time"; + +import { + type NotificationsDevScenario, + notificationsDevMockCookie, +} from "./dev-mock-cookie"; +import { crNotificationId } from "./notification-ids"; +import { + markNotificationReadRequestSchema, + type NotificationFeedResponse, + notificationFeedResponseSchema, +} from "./types"; + +/** + * Platform-origin Notification fixtures (the `cr:` stream): announcements, + * deployment outcomes, and database events as the platform's controllers + * would write them. They ride the feed as `platformItems`, so the client + * takes them instead of polling the cluster and merges, sorts, and marks + * them read exactly as live CRs. The billing-born ladder stays the billing + * mock's; this layer only ever appends. Read state is session-only memory + * per scenario. + */ + +/** Anchored at module load so fixture ids stay stable across polls. */ +const FIXTURE_NOW_MS = Date.now(); + +const ADMIN = "Admin"; +const DEPLOYMENT_SYSTEM = "Deployment-System"; +const DATABASE_SYSTEM = "Database-System"; + +interface PlatformFixture { + ago: number; + from: string; + isRead: boolean; + message: string; + name: string; + title: string; +} + +const ANNOUNCEMENTS: readonly Omit[] = [ + { + from: ADMIN, + message: + "Open a terminal on any database from its page — no client install needed.", + name: "announce-db-terminal", + title: "New: database terminal", + }, + { + from: ADMIN, + message: + "Deployments now show a step-by-step timeline with the failing step called out.", + name: "announce-deploy-timeline", + title: "New: deployment timeline", + }, + { + from: ADMIN, + message: + "Scheduled maintenance on Sunday 02:00–04:00 UTC. Running workloads are not affected.", + name: "announce-maintenance-window", + title: "Scheduled maintenance", + }, + { + from: ADMIN, + message: "Custom domains can now be verified with a single CNAME record.", + name: "announce-custom-domains", + title: "Simpler custom domains", + }, + { + from: ADMIN, + message: + "Workspace members can be invited by email from the workspace settings.", + name: "announce-invites", + title: "Invite by email", + }, + { + from: ADMIN, + message: "Node pools were upgraded; new pods start on the faster nodes.", + name: "announce-node-upgrade", + title: "Faster nodes", + }, +]; + +const DEPLOYMENTS: readonly Omit[] = [ + { + from: DEPLOYMENT_SYSTEM, + message: "web-app is live on 2 replicas from main@4f1c2a9.", + name: "deploy-web-app-succeeded", + title: "web-app deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: + "api-server failed at the build step: `go build` exited with status 2.", + name: "deploy-api-server-failed", + title: "api-server deployment failed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "landing is live from main@b77e0d1.", + name: "deploy-landing-succeeded", + title: "landing deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: + "image-worker is waiting for a storage volume to bind; it will continue on its own.", + name: "deploy-image-worker-blocked", + title: "image-worker deployment waiting", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "docs-site is live from main@91aa3c0.", + name: "deploy-docs-site-succeeded", + title: "docs-site deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: + "auth-service rolled back to the previous image after a failed health check.", + name: "deploy-auth-service-rolled-back", + title: "auth-service rolled back", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "analytics is live on 1 replica from main@0c4d7e2.", + name: "deploy-analytics-succeeded", + title: "analytics deployed", + }, + { + from: DEPLOYMENT_SYSTEM, + message: "cron-runner deployment was cancelled before the build finished.", + name: "deploy-cron-runner-cancelled", + title: "cron-runner deployment cancelled", + }, +]; + +const DB_EVENTS: readonly Omit[] = [ + { + from: DATABASE_SYSTEM, + message: "The nightly backup of pg-main completed (1.2 GB).", + name: "db-pg-main-backup-completed", + title: "pg-main backup completed", + }, + { + from: DATABASE_SYSTEM, + message: + "redis-cache restarted after exceeding its memory limit; data in memory was lost.", + name: "db-redis-cache-restarted", + title: "redis-cache restarted", + }, + { + from: DATABASE_SYSTEM, + message: "pg-main storage is at 85% of its volume. Consider resizing.", + name: "db-pg-main-storage-warning", + title: "pg-main storage almost full", + }, + { + from: DATABASE_SYSTEM, + message: "search-index was restored from the backup taken 3 days ago.", + name: "db-search-index-restored", + title: "search-index restored", + }, +]; + +function spread( + entries: readonly Omit[], + options: { firstAgo: number; step: number; unread: number } +): PlatformFixture[] { + return entries.map((entry, index) => ({ + ...entry, + ago: options.firstAgo + index * options.step, + isRead: index >= options.unread, + })); +} + +/** One inbox per scenario, newest first. */ +function scenarioFixture( + scenario: NotificationsDevScenario +): PlatformFixture[] { + switch (scenario) { + case "announcement": + return spread(ANNOUNCEMENTS.slice(0, 1), { + firstAgo: 2 * DAY_MS, + step: 0, + unread: 1, + }); + case "deployment": + return spread(DEPLOYMENTS.slice(0, 2), { + firstAgo: HOUR_MS, + step: 2 * HOUR_MS, + unread: 2, + }); + case "db-event": + return spread(DB_EVENTS.slice(0, 1), { + firstAgo: 5 * HOUR_MS, + step: 0, + unread: 1, + }); + case "mixed": { + // Thirty days of traffic, interleaved by time, only the newest unread. + const items = [ + ...spread(DEPLOYMENTS, { + firstAgo: HOUR_MS, + step: 3.5 * DAY_MS, + unread: 2, + }), + ...spread(DB_EVENTS, { + firstAgo: 5 * HOUR_MS, + step: 7 * DAY_MS, + unread: 1, + }), + ...spread(ANNOUNCEMENTS, { + firstAgo: 2 * DAY_MS, + step: 5 * DAY_MS, + unread: 0, + }), + ]; + return items.sort((a, b) => a.ago - b.ago); + } + default: + return scenario satisfies never; + } +} + +function platformItem( + fixture: PlatformFixture, + workspace: string +): NotificationCRItem { + const seconds = Math.floor((FIXTURE_NOW_MS - fixture.ago) / 1000); + return { + desktopPopup: true, + from: fixture.from, + importance: fixture.from === ADMIN ? "Medium" : "High", + isRead: fixture.isRead, + message: fixture.message, + name: fixture.name, + namespace: workspace, + timestamp: seconds, + title: fixture.title, + version: seconds, + }; +} + +export function platformNotificationDevMockItems( + scenario: NotificationsDevScenario, + workspace: string +): NotificationCRItem[] { + return scenarioFixture(scenario).map((entry) => + platformItem(entry, workspace) + ); +} + +// Session-only read state, one inbox per scenario; nothing persists. +const receiptsByScenario = new Map>(); + +function receiptsFor(scenario: string): Set { + let receipts = receiptsByScenario.get(scenario); + if (receipts == null) { + receipts = new Set(); + receiptsByScenario.set(scenario, receipts); + } + return receipts; +} + +function fixtureIds( + scenario: NotificationsDevScenario, + workspace: string +): Set { + return new Set( + platformNotificationDevMockItems(scenario, workspace).map((item) => + crNotificationId(item.name, item.version) + ) + ); +} + +export type PlatformNotificationDevMockHandler = + | "feed" + | "observation" + | "read"; + +/** The next layer down: the billing mock's fixtures or the real handler. */ +export type NotificationDevMockNext = (request: Request) => Promise; + +/** The feed the mock answers alone when the layer below is unavailable. */ +function standaloneFeed( + scenario: NotificationsDevScenario, + workspace: string +): NotificationFeedResponse { + return { + messages: [], + platformItems: platformNotificationDevMockItems(scenario, workspace), + receipts: [...receiptsFor(scenario)], + }; +} + +async function feedResponse( + scenario: NotificationsDevScenario, + request: Request, + next: NotificationDevMockNext +): Promise { + const workspace = + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock"; + const below = await next(request); + if (!below.ok) { + return Response.json(standaloneFeed(scenario, workspace)); + } + const parsed = notificationFeedResponseSchema.safeParse( + await below.json().catch(() => null) + ); + if (!parsed.success) { + return Response.json(standaloneFeed(scenario, workspace)); + } + const body: NotificationFeedResponse = { + messages: parsed.data.messages, + platformItems: [ + ...(parsed.data.platformItems ?? []), + ...platformNotificationDevMockItems(scenario, workspace), + ], + receipts: [...new Set([...parsed.data.receipts, ...receiptsFor(scenario)])], + }; + return Response.json(body); +} + +async function readResponse( + scenario: NotificationsDevScenario, + request: Request, + next: NotificationDevMockNext +): Promise { + const parsed = markNotificationReadRequestSchema.safeParse( + await request + .clone() + .json() + .catch(() => null) + ); + if (!parsed.success) { + return next(request); + } + const workspace = + new URL(request.url).searchParams.get("namespace")?.trim() || "ns-mock"; + const owned = fixtureIds(scenario, workspace); + const mine = parsed.data.ids.filter((id) => owned.has(id)); + const rest = parsed.data.ids.filter((id) => !owned.has(id)); + const receipts = receiptsFor(scenario); + for (const id of mine) { + receipts.add(id); + } + if (rest.length === 0) { + return Response.json({ read: mine }); + } + if (mine.length === 0) { + return next(request); + } + const forwarded = new Request(request, { + body: JSON.stringify({ ids: rest }), + }); + const below = await next(forwarded); + if (!below.ok) { + return below; + } + const payload: unknown = await below.json().catch(() => null); + const read = + typeof payload === "object" && + payload != null && + Array.isArray((payload as { read?: unknown }).read) + ? ((payload as { read: string[] }).read ?? []) + : rest; + return Response.json({ read: [...mine, ...read] }); +} + +/** + * Layers the platform fixtures over whatever answers below — the billing + * mock's feed or the real handler: the feed gains the fixture `cr:` items, + * a mark-read for fixture ids is kept in memory and the rest forwarded, + * observations pass straight through. Off hands the request down untouched. + */ +export function withPlatformNotificationDevMock( + handler: PlatformNotificationDevMockHandler, + request: Request, + next: NotificationDevMockNext +): Promise { + const resolution = resolveDevMock( + notificationsDevMockCookie, + request, + "Notification Center" + ); + if (resolution.kind === "off") { + return next(request); + } + if (resolution.kind === "invalid") { + return Promise.resolve(resolution.response); + } + const { scenario } = resolution; + switch (handler) { + case "feed": + return feedResponse(scenario, request, next); + case "read": + return readResponse(scenario, request, next); + case "observation": + return next(request); + default: + return handler satisfies never; + } +} diff --git a/apps/ui/src/features/notifications/dev-mock-cookie.ts b/apps/ui/src/features/notifications/dev-mock-cookie.ts new file mode 100644 index 00000000..04f3b8ec --- /dev/null +++ b/apps/ui/src/features/notifications/dev-mock-cookie.ts @@ -0,0 +1,29 @@ +import { defineDevMockCookie } from "@/features/dev-mock/cookie"; + +/** + * The Notification Center Dev Mock's cookie: platform-origin Notifications + * (announcements, deployment outcomes, database events — the `cr:` stream) + * served from fixtures. Independent of the billing mock, whose scenario + * keeps driving the billing-born messages; both can be on at once and the + * inbox merges them. + */ + +export const NOTIFICATIONS_DEV_SCENARIOS = [ + "announcement", + "deployment", + "db-event", + "mixed", +] as const; + +export type NotificationsDevScenario = + (typeof NOTIFICATIONS_DEV_SCENARIOS)[number]; + +export const DEFAULT_NOTIFICATIONS_DEV_SCENARIO: NotificationsDevScenario = + "mixed"; + +export const notificationsDevMockCookie = + defineDevMockCookie({ + defaultScenario: DEFAULT_NOTIFICATIONS_DEV_SCENARIO, + name: "sealai-notifications-dev-mock", + scenarios: NOTIFICATIONS_DEV_SCENARIOS, + }); diff --git a/apps/ui/src/features/notifications/dev-mock-gate.tsx b/apps/ui/src/features/notifications/dev-mock-gate.tsx new file mode 100644 index 00000000..02bf79d2 --- /dev/null +++ b/apps/ui/src/features/notifications/dev-mock-gate.tsx @@ -0,0 +1,22 @@ +"use client"; + +import dynamic from "next/dynamic"; + +// Same inlined gate as dev-tweaks.tsx: a real production build statically +// drops the dynamic import and tree-shakes the mock module away; +// `NEXT_PUBLIC_DEV_TWEAKS=1` keeps it for demo deployments. +const NotificationsDevMockTweaks = + process.env.NODE_ENV === "development" || + process.env.NEXT_PUBLIC_DEV_TWEAKS === "1" + ? dynamic(() => + import("./dev-mock").then((mod) => mod.NotificationsDevMockTweaks) + ) + : null; + +/** Mounts the Notification Center mock's dev-tweaks registration. */ +export function NotificationsDevMockGate() { + if (!NotificationsDevMockTweaks) { + return null; + } + return ; +} diff --git a/apps/ui/src/features/notifications/dev-mock.tsx b/apps/ui/src/features/notifications/dev-mock.tsx new file mode 100644 index 00000000..06587f61 --- /dev/null +++ b/apps/ui/src/features/notifications/dev-mock.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useDevTweaksMock } from "@workspace/dev-tweaks"; +import { mutate } from "swr"; + +import { createDevMockCookieSource } from "@/features/dev-mock/source"; + +import { + DEFAULT_NOTIFICATIONS_DEV_SCENARIO, + NOTIFICATIONS_DEV_SCENARIOS, + notificationsDevMockCookie, +} from "./dev-mock-cookie"; + +export const NOTIFICATIONS_DEV_MOCK_KEY = "notifications-mock"; + +/** Only the Brain feed carries the fixture platform items. */ +function isNotificationFeedKey(key: unknown): boolean { + return Array.isArray(key) && key[0] === "notifications-feed"; +} + +const notificationsDevMockSource = createDevMockCookieSource( + notificationsDevMockCookie, + { + revalidate: () => { + mutate(isNotificationFeedKey).catch(() => undefined); + }, + } +); + +/** + * Registers the Notification Center's Dev Mock while the inbox is mounted; + * renders nothing. Stacks with the billing mock: this one adds the + * platform-origin messages, that one keeps the billing-born ones. + */ +export function NotificationsDevMockTweaks() { + useDevTweaksMock(NOTIFICATIONS_DEV_MOCK_KEY, { + defaultScenario: DEFAULT_NOTIFICATIONS_DEV_SCENARIO, + note: "Adds platform-origin Notifications from fixtures; stacks with the billing mock", + scenarios: NOTIFICATIONS_DEV_SCENARIOS, + source: notificationsDevMockSource, + title: "Notification Center mock", + }); + return null; +} diff --git a/apps/ui/src/features/notifications/feed-model.test.ts b/apps/ui/src/features/notifications/feed-model.test.ts new file mode 100644 index 00000000..55e41689 --- /dev/null +++ b/apps/ui/src/features/notifications/feed-model.test.ts @@ -0,0 +1,448 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { + isGiftOnlyNewcomer, + mergeNotificationFeed, + notificationSeverityForCR, + renderNotificationMessage, +} from "./feed-model"; +import { crNotificationId, notificationSource } from "./notification-ids"; +import { formatNotificationDate } from "./notification-time"; +import type { NotificationMessage } from "./types"; + +const T0 = 1_756_200_000; // Unix seconds +const QUOTA_BODY_RE = /New deployments can't start/; + +function cr(overrides: Partial): NotificationCRItem { + // As upstream writes them: the version is the spec timestamp. + const timestamp = overrides.timestamp ?? T0; + return { + desktopPopup: true, + from: "Debt-System", + importance: "High", + isRead: false, + message: "Your account balance is exhausted; services are suspended.", + name: "debt-choice-debtperiod", + namespace: "ns-a", + timestamp, + title: "Balance exhausted", + version: timestamp, + ...overrides, + }; +} + +function dbMessage( + overrides: Partial +): NotificationMessage { + return { + createdAt: (T0 + 600) * 1000, + id: "m1", + kind: "quota-exhausted", + payload: { + kind: "quota-exhausted", + limit: 20_480, + resource: "storage", + used: 20_480, + }, + projectUid: null, + ...overrides, + }; +} + +test("both streams merge into one list, newest first, with source-prefixed ids", () => { + const feed = mergeNotificationFeed({ + crItems: [ + cr({}), + cr({ + name: "workspace-debt-debt", + from: "Workspace-Subscription-System", + timestamp: T0 + 1200, + }), + ], + dbMessages: [dbMessage({})], + receipts: [], + }); + + assert.deepEqual( + feed.map((item) => item.id), + [ + `cr:workspace-debt-debt:${T0 + 1200}`, + "db:m1", + `cr:debt-choice-debtperiod:${T0}`, + ] + ); + assert.deepEqual( + feed.map((item) => item.timestamp), + [(T0 + 1200) * 1000, (T0 + 600) * 1000, T0 * 1000] + ); + assert.deepEqual( + feed.map((item) => item.source), + ["cr", "db", "cr"] + ); +}); + +test("platform copy outside the override table is shown as-is and severity follows name and sender", () => { + const [item] = mergeNotificationFeed({ + crItems: [cr({ name: "debt-choice-newtier" })], + dbMessages: [], + receipts: [], + }); + assert.equal(item?.title, "Balance exhausted"); + assert.equal( + item?.body, + "Your account balance is exhausted; services are suspended." + ); + assert.equal(item?.severity, "critical"); + assert.equal(item?.crName, "debt-choice-newtier"); + + assert.equal( + notificationSeverityForCR({ from: "Debt-System", name: "x" }), + "warning" + ); + assert.equal( + notificationSeverityForCR({ name: "workspace-debt-debt-pre-deletion" }), + "critical" + ); + assert.equal( + notificationSeverityForCR({ + from: "Debt-System", + name: "debt-choice-lowbalanceperiod", + }), + "warning" + ); + assert.equal( + notificationSeverityForCR({ from: "Admin", name: "release-notes" }), + "info" + ); +}); + +test("Brain entries render from their payload", () => { + const rendered = renderNotificationMessage(dbMessage({})); + assert.equal(rendered.severity, "warning"); + assert.equal(rendered.title, "Storage quota is full"); + assert.match(rendered.body, QUOTA_BODY_RE); + assert.equal( + renderNotificationMessage( + dbMessage({ + payload: { + kind: "quota-exhausted", + limit: 4, + resource: "cpu", + used: 4, + }, + }) + ).title, + "CPU quota is full" + ); +}); + +test("unread for platform messages is label unread AND no receipt", () => { + const unreadNoReceipt = cr({ isRead: false }); + const readByLabel = cr({ isRead: true, name: "workspace-debt-debt" }); + const unreadWithReceipt = cr({ + isRead: false, + name: "announce", + from: "Admin", + }); + + const feed = mergeNotificationFeed({ + crItems: [unreadNoReceipt, readByLabel, unreadWithReceipt], + dbMessages: [], + receipts: [crNotificationId("announce", T0)], + }); + const byName = new Map(feed.map((item) => [item.crName, item.unread])); + + assert.equal(byName.get("debt-choice-debtperiod"), true); + assert.equal( + byName.get("workspace-debt-debt"), + false, + "upstream auto-read wins" + ); + assert.equal(byName.get("announce"), false, "a Brain receipt wins"); +}); + +test("a revived fixed-name CR is a new id no old receipt covers", () => { + const oldId = crNotificationId("debt-choice-debtperiod", T0); + const [revived] = mergeNotificationFeed({ + crItems: [cr({ timestamp: T0 + 86_400 })], + dbMessages: [], + receipts: [oldId], + }); + + assert.notEqual(revived?.id, oldId); + assert.equal(revived?.unread, true); +}); + +test("Brain entries are unread until a receipt exists", () => { + const unread = mergeNotificationFeed({ + crItems: [], + dbMessages: [dbMessage({})], + receipts: [], + }); + const read = mergeNotificationFeed({ + crItems: [], + dbMessages: [dbMessage({})], + receipts: ["db:m1"], + }); + assert.equal(unread[0]?.unread, true); + assert.equal(read[0]?.unread, false); +}); + +test("ids carry their source", () => { + assert.equal(notificationSource("cr:x:1"), "cr"); + assert.equal(notificationSource("db:m1"), "db"); + assert.equal(notificationSource("n1"), null); +}); + +test("the 8 fixed platform names render Brain-voiced copy and a CTA; the CR text is untouched", () => { + const source = cr({ + message: "Radical resource release", + name: "workspace-debt-debtfinaldeletion", + from: "Workspace-Subscription-System", + title: "Workspace resource final deletion", + }); + const [item] = mergeNotificationFeed({ + crItems: [source], + dbMessages: [], + receipts: [], + }); + + assert.equal(item?.title, "Workspace faces final deletion"); + assert.equal( + item?.body, + "Resources can be permanently deleted at any time. This cannot be undone." + ); + assert.deepEqual(item?.cta, { href: "/billing", label: "Renew plan" }); + assert.equal(item?.severity, "critical"); + assert.equal(item?.crName, "workspace-debt-debtfinaldeletion"); + assert.equal(source.message, "Radical resource release", "display-only"); + + const expected: Record = { + "debt-choice-criticalbalanceperiod": [ + "Account balance almost empty", + "Top up balance", + ], + "debt-choice-debtdeletionperiod": [ + "Account resources scheduled for deletion", + "Top up balance", + ], + "debt-choice-debtperiod": ["Account balance in debt", "Top up balance"], + "debt-choice-finaldeletionperiod": [ + "Account resources face final deletion", + "Top up balance", + ], + "debt-choice-lowbalanceperiod": [ + "Account balance is low", + "Top up balance", + ], + "workspace-debt-debt": ["Workspace suspended — payment due", "Renew plan"], + "workspace-debt-debtfinaldeletion": [ + "Workspace faces final deletion", + "Renew plan", + ], + "workspace-debt-debtpredeletion": [ + "Workspace deletion approaching", + "Renew plan", + ], + }; + for (const [name, [title, cta]] of Object.entries(expected)) { + const [entry] = mergeNotificationFeed({ + crItems: [cr({ name })], + dbMessages: [], + receipts: [], + }); + assert.equal(entry?.title, title, name); + assert.equal(entry?.cta?.label, cta, name); + } +}); + +test("unknown platform names fall back to the original text with no CTA", () => { + const [item] = mergeNotificationFeed({ + crItems: [ + cr({ + from: "Admin", + message: "Maintenance tonight at 02:00 UTC.", + name: "release-2026-08", + title: "Scheduled maintenance", + }), + ], + dbMessages: [], + receipts: [], + }); + assert.equal(item?.title, "Scheduled maintenance"); + assert.equal(item?.body, "Maintenance tonight at 02:00 UTC."); + assert.equal(item?.cta, undefined); +}); + +test("gift-only newcomers do not see the low/critical balance tiers; the debt ladder always shows", () => { + const crItems = [ + cr({ name: "debt-choice-lowbalanceperiod", timestamp: T0 + 1 }), + cr({ name: "debt-choice-criticalbalanceperiod", timestamp: T0 + 2 }), + cr({ name: "debt-choice-debtperiod", timestamp: T0 + 3 }), + cr({ name: "workspace-debt-debt", timestamp: T0 + 4 }), + ]; + const filtered = mergeNotificationFeed({ + crItems, + dbMessages: [], + giftOnly: true, + receipts: [], + }); + assert.deepEqual( + filtered.map((item) => item.crName), + ["workspace-debt-debt", "debt-choice-debtperiod"] + ); + + const unfiltered = mergeNotificationFeed({ + crItems, + dbMessages: [], + giftOnly: false, + receipts: [], + }); + assert.equal(unfiltered.length, 4); + assert.equal( + mergeNotificationFeed({ crItems, dbMessages: [], receipts: [] }).length, + 4, + "unknown account state never hides a warning" + ); +}); + +test("isGiftOnlyNewcomer: never topped up and nothing but gift credit; one top-up ends it forever", () => { + assert.equal( + isGiftOnlyNewcomer({ + giftMicroUnits: 720_000, + hasToppedUp: false, + usableMicroUnits: 720_000, + }), + true + ); + assert.equal( + isGiftOnlyNewcomer({ + giftMicroUnits: 720_000, + hasToppedUp: true, + usableMicroUnits: 720_000, + }), + false, + "a top-up disables the filter" + ); + assert.equal( + isGiftOnlyNewcomer({ + giftMicroUnits: 0, + hasToppedUp: false, + usableMicroUnits: 1_800_000, + }), + false, + "a plan grant is not gift credit" + ); + assert.equal( + isGiftOnlyNewcomer({ + giftMicroUnits: 0, + hasToppedUp: false, + usableMicroUnits: 0, + }), + true, + "a spent gift on a never-topped-up account is still nothing but gift" + ); +}); + +test("the gift hint renders the reassuring welcome copy with no CTA", () => { + const rendered = renderNotificationMessage( + dbMessage({ + id: "m2", + kind: "credit-hint", + payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, + }) + ); + assert.equal(rendered.severity, "info"); + assert.equal(rendered.title, "You have a $1 welcome gift"); + assert.equal( + rendered.body, + "It covers your first deployments and expires a month after it was granted." + ); + assert.equal(rendered.cta, undefined); + + const dated = renderNotificationMessage( + dbMessage({ + id: "m3", + kind: "credit-hint", + payload: { + expiresAt: "2026-09-03T00:00:00.000Z", + giftMicroUnits: 1_000_000, + kind: "credit-hint", + }, + }) + ); + assert.equal( + dated.body, + `It covers your first deployments and expires on ${formatNotificationDate("2026-09-03T00:00:00.000Z")}.` + ); +}); + +test("subscription-change receipts are one factual sentence per change, no CTA", () => { + const upgraded = renderNotificationMessage( + dbMessage({ + id: "m4", + kind: "subscription-change", + payload: { + change: "upgraded", + kind: "subscription-change", + planName: "Pro", + }, + }) + ); + assert.equal(upgraded.severity, "info"); + assert.equal(upgraded.title, "Subscription upgraded"); + assert.equal(upgraded.body, "This workspace is now on Pro."); + assert.equal(upgraded.cta, undefined); + + const effectiveAt = "2026-09-03T12:00:00.000Z"; + const date = formatNotificationDate(effectiveAt); + const downgraded = renderNotificationMessage( + dbMessage({ + id: "m5", + kind: "subscription-change", + payload: { + change: "downgraded", + effectiveAt, + kind: "subscription-change", + planName: "Hobby", + }, + }) + ); + assert.equal(downgraded.title, "Subscription downgraded"); + assert.equal(downgraded.body, `This workspace moves to Hobby on ${date}.`); + + const cancelled = renderNotificationMessage( + dbMessage({ + id: "m6", + kind: "subscription-change", + payload: { + change: "cancelled", + effectiveAt, + kind: "subscription-change", + planName: "Hobby", + }, + }) + ); + assert.equal(cancelled.title, "Subscription cancelled"); + assert.equal( + cancelled.body, + `This workspace's Hobby subscription ends on ${date}.` + ); + assert.equal( + renderNotificationMessage( + dbMessage({ + id: "m7", + kind: "subscription-change", + payload: { + change: "cancelled", + kind: "subscription-change", + planName: "Hobby", + }, + }) + ).body, + "This workspace's Hobby subscription ends at the end of the current period." + ); +}); diff --git a/apps/ui/src/features/notifications/feed-model.ts b/apps/ui/src/features/notifications/feed-model.ts new file mode 100644 index 00000000..e1a1c147 --- /dev/null +++ b/apps/ui/src/features/notifications/feed-model.ts @@ -0,0 +1,271 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { MICRO_UNITS_PER_CURRENCY_UNIT } from "@/features/billing/billing-amount"; +import type { + AppNotification, + NotificationCTA, + NotificationSeverity, +} from "@/features/shell/app-sidebar-notifications-model"; + +import { CR_OVERRIDES, GIFT_FILTERED_CR_NAMES } from "./cr-overrides"; +import { crNotificationId, dbNotificationId } from "./notification-ids"; +import { formatNotificationDate } from "./notification-time"; +import type { + NotificationMessage, + NotificationPayload, + QuotaExhaustedResource, + SubscriptionChange, +} from "./types"; + +/** + * The merged-feed seam: platform CRs (`cr:`) and Brain-produced entries + * (`db:`) become one Notification list sorted by real time, with unread + * computed from each source's own state and the user's receipts. Pure, so + * fixture CRs and rows pin contents, order, and unread here. + */ + +/** Upstream `spec.from` values written by the platform's billing controllers. */ +const BILLING_SENDERS: ReadonlySet = new Set([ + "Debt-System", + "Workspace-Subscription-System", +]); + +/** The low-balance warning tiers: a threshold crossed, nothing suspended yet. */ +const WARNING_TIER_CR_NAMES: ReadonlySet = new Set([ + "debt-choice-lowbalanceperiod", + "debt-choice-criticalbalanceperiod", +]); + +/** + * A platform message's Severity from its fixed name and sender: the debt + * ladder from `debtperiod` on and every `workspace-debt-*` stage mean + * something is already suspended or faces deletion (critical); the + * low-balance tiers and any other billing-controller message warn; anything + * else is an announcement (info). + */ +export function notificationSeverityForCR( + item: Pick +): NotificationSeverity { + if (item.name.startsWith("workspace-debt-")) { + return "critical"; + } + if (WARNING_TIER_CR_NAMES.has(item.name)) { + return "warning"; + } + if (item.name.startsWith("debt-choice-")) { + return "critical"; + } + if (item.from != null && BILLING_SENDERS.has(item.from)) { + return "warning"; + } + return "info"; +} + +const QUOTA_RESOURCE_LABELS: Record = { + cpu: "CPU", + memory: "Memory", + nodeport: "Port", + pod: "Pod", + storage: "Storage", +}; + +interface RenderedNotification { + body: string; + cta?: NotificationCTA; + severity: NotificationSeverity; + title: string; +} + +/** + * The gift's nominal whole-dollar amount (design spec §10 rule 5): the gift + * is granted in whole dollars and only burns down, so rounding the remainder + * up recovers the grant a newcomer was told about. + */ +function wholeDollars(microUnits: number): string { + return `$${Math.max(1, Math.ceil(microUnits / MICRO_UNITS_PER_CURRENCY_UNIT))}`; +} + +const SUBSCRIPTION_CHANGE_TITLES: Record = { + cancelled: "Subscription cancelled", + downgraded: "Subscription downgraded", + upgraded: "Subscription upgraded", +}; + +/** One factual sentence per change type (catalog B5); receipts carry no CTA. */ +function subscriptionChangeBody(payload: { + change: SubscriptionChange; + effectiveAt?: string; + planName: string; +}): string { + const date = + payload.effectiveAt == null + ? "" + : formatNotificationDate(payload.effectiveAt); + switch (payload.change) { + case "upgraded": + return `This workspace is now on ${payload.planName}.`; + case "downgraded": + return date === "" + ? `This workspace moves to ${payload.planName} at the end of the current period.` + : `This workspace moves to ${payload.planName} on ${date}.`; + case "cancelled": + return date === "" + ? `This workspace's ${payload.planName} subscription ends at the end of the current period.` + : `This workspace's ${payload.planName} subscription ends on ${date}.`; + default: + return payload.change satisfies never; + } +} + +/** + * Brain-produced entries render from their payload here, client-side; the + * store holds parameters, never strings. Keyed by kind so the compiler + * demands a renderer for every producer that lands. Copy from the design + * spec's inbox table: A1 names the exhausted resource and what it blocks, + * D4 reassures the newcomer, B5 is a fact-only receipt. + */ +const RENDERERS: { + [K in NotificationPayload["kind"]]: ( + payload: Extract + ) => RenderedNotification; +} = { + "credit-hint": (payload) => { + const date = + payload.expiresAt == null + ? "" + : formatNotificationDate(payload.expiresAt); + return { + body: + date === "" + ? "It covers your first deployments and expires a month after it was granted." + : `It covers your first deployments and expires on ${date}.`, + severity: "info", + title: `You have a ${wholeDollars(payload.giftMicroUnits)} welcome gift`, + }; + }, + "quota-exhausted": (payload) => { + const label = QUOTA_RESOURCE_LABELS[payload.resource]; + return { + body: `${label} is at 100%. New deployments can't start.`, + cta: { href: "/billing/usage", label: "View usage" }, + severity: "warning", + title: `${label} quota is full`, + }; + }, + "subscription-change": (payload) => ({ + body: subscriptionChangeBody(payload), + severity: "info", + title: SUBSCRIPTION_CHANGE_TITLES[payload.change], + }), +}; + +export function renderNotificationMessage( + message: NotificationMessage +): RenderedNotification { + const { payload } = message; + // The map is exhaustive by construction; the union cannot be correlated + // with its own key without the cast. + const render = RENDERERS[payload.kind] as ( + value: NotificationPayload + ) => RenderedNotification; + return render(payload); +} + +export interface MergeNotificationFeedInput { + crItems: readonly NotificationCRItem[]; + dbMessages: readonly NotificationMessage[]; + /** + * The gift-only filter (catalog D1): true hides the low/critical balance + * tiers for a never-topped-up newcomer holding nothing but gift credit. + * Absent or false while the account state is unknown — an unknown state + * never hides a warning. + */ + giftOnly?: boolean; + /** Source-prefixed ids the user has read (server receipts). */ + receipts: Iterable; +} + +/** The account facts the gift-only filter is decided from. */ +export interface GiftOnlyAccountState { + /** Remaining new-user gift, in micro-units. */ + giftMicroUnits: number; + /** Whether the account ever recorded a paid top-up. */ + hasToppedUp: boolean; + /** Every usable credit — gift, plan grant, and anything else. */ + usableMicroUnits: number; +} + +/** + * A never-topped-up account whose available balance is entirely gift credit + * (design spec §6 D1): no top-up ever, and no credit beyond the gift — a + * plan grant is not gift. Cash is not consulted: with no top-up on record, + * any cash balance can only be the platform's legacy gift seed. One top-up + * disables the filter forever, which the payment history makes permanent by + * construction. + */ +export function isGiftOnlyNewcomer(state: GiftOnlyAccountState): boolean { + return !state.hasToppedUp && state.usableMicroUnits <= state.giftMicroUnits; +} + +export function platformNotification( + item: NotificationCRItem, + receipts: ReadonlySet +): AppNotification { + const id = crNotificationId(item.name, item.version); + const override = CR_OVERRIDES[item.name]; + return { + body: override?.body ?? item.message, + crName: item.name, + ...(override == null ? {} : { cta: override.cta }), + id, + severity: notificationSeverityForCR(item), + source: "cr", + timestamp: item.timestamp * 1000, + title: override?.title ?? item.title, + // A platform message is unread iff the label says unread AND the user + // holds no receipt; upstream auto-read on recovery stacks on top. + unread: !(item.isRead || receipts.has(id)), + }; +} + +export function brainNotification( + message: NotificationMessage, + receipts: ReadonlySet +): AppNotification { + const id = dbNotificationId(message.id); + const rendered = renderNotificationMessage(message); + return { + body: rendered.body, + ...(rendered.cta == null ? {} : { cta: rendered.cta }), + id, + severity: rendered.severity, + source: "db", + timestamp: message.createdAt, + title: rendered.title, + unread: !receipts.has(id), + }; +} + +function compareIds(a: string, b: string): number { + if (a < b) { + return -1; + } + return a > b ? 1 : 0; +} + +/** One list, both streams, newest first; ids break ties so order is stable. */ +export function mergeNotificationFeed( + input: MergeNotificationFeedInput +): AppNotification[] { + const receipts = new Set(input.receipts); + const crItems = + input.giftOnly === true + ? input.crItems.filter((item) => !GIFT_FILTERED_CR_NAMES.has(item.name)) + : input.crItems; + const items = [ + ...crItems.map((item) => platformNotification(item, receipts)), + ...input.dbMessages.map((message) => brainNotification(message, receipts)), + ]; + items.sort((a, b) => b.timestamp - a.timestamp || compareIds(a.id, b.id)); + return items; +} diff --git a/apps/ui/src/features/notifications/http-handlers.test.ts b/apps/ui/src/features/notifications/http-handlers.test.ts new file mode 100644 index 00000000..d8d138e2 --- /dev/null +++ b/apps/ui/src/features/notifications/http-handlers.test.ts @@ -0,0 +1,306 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { SignJWT } from "jose"; + +import { + assistantChatMessages, + assistantChats, + assistantDevboxRuntimes, + assistantEntitlements, + githubAppInstallSessions, + githubConnections, + githubOauthConnections, + identityFingerprints, +} from "@/features/chat/persistence/schema"; +import { createIdentityFingerprintStore } from "@/lib/identity-fingerprint-core"; + +import { createNotificationHandlers } from "./http-handlers"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; +import { notificationFeedResponseSchema } from "./types"; + +const pglite = new PGlite(); +// The fingerprint store wants the assistant schema; the notification store +// only its own three tables — one PGlite handle with both keeps them in the +// same database, as production does. +const db = drizzle(pglite, { + schema: { + assistantChatMessages, + assistantChats, + assistantDevboxRuntimes, + assistantEntitlements, + githubAppInstallSessions, + githubConnections, + githubOauthConnections, + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); + +const store = createNotificationStore(() => db); +const observeFingerprint = createIdentityFingerprintStore(() => db); + +afterAll(() => pglite.close()); + +const APP_TOKEN_SECRET = "cluster-shared-jwt-internal"; +const APP_TOKEN_CONFIG = { secret: APP_TOKEN_SECRET }; + +function mintAppToken(crName: string) { + return new SignJWT({ userCrName: crName, userUid: `${crName}-uid` }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(1_753_600_000) + .sign(new TextEncoder().encode(APP_TOKEN_SECRET)); +} + +function jwt(subject: string): string { + const header = Buffer.from( + JSON.stringify({ alg: "RS256", typ: "JWT" }) + ).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ sub: subject })).toString( + "base64url" + ); + return `${header}.${payload}.test-signature`; +} + +function encodedKubeconfig(namespace: string, workspaceActor: string): string { + return encodeURIComponent(` +apiVersion: v1 +clusters: + - name: cluster + cluster: + server: https://example.test +contexts: + - name: current + context: + cluster: cluster + namespace: ${namespace} + user: active-user +current-context: current +users: + - name: active-user + user: + token: ${jwt(`system:serviceaccount:user-system:${workspaceActor}`)} +`); +} + +async function request( + pathname: string, + options: { + actor: string; + appToken?: string | null; + body?: unknown; + namespace?: string; + } +): Promise { + const namespace = options.namespace ?? "ns-a"; + return new Request( + `https://brain.test/api/notifications${pathname}?namespace=${namespace}`, + { + body: options.body == null ? undefined : JSON.stringify(options.body), + headers: { + Authorization: `Bearer ${encodedKubeconfig(namespace, options.actor)}`, + "content-type": "application/json", + ...(options.appToken === null + ? {} + : { + "X-Sealos-App-Token": + options.appToken ?? (await mintAppToken(options.actor)), + }), + }, + method: options.body == null ? "GET" : "POST", + } + ); +} + +const handlers = createNotificationHandlers({ + appTokenConfig: APP_TOKEN_CONFIG, + observeFingerprint, + store, + verify: () => Promise.resolve({ ok: true }), +}); + +test("a quota observation crossing 100% writes one entry, and the feed lists it unread", async () => { + const full = { + quota: { + items: [ + { limit: 20_480, type: "storage", used: 20_480 }, + { limit: 4000, type: "cpu", used: 1000 }, + ], + }, + }; + + const first = await handlers.observeQuota( + await request("/quota-observation", { actor: "alice", body: full }) + ); + const retry = await handlers.observeQuota( + await request("/quota-observation", { actor: "alice", body: full }) + ); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { produced: ["storage"], released: [] }); + assert.deepEqual(await retry.json(), { produced: [], released: [] }); + + const feed = await handlers.feed(await request("", { actor: "alice" })); + assert.equal(feed.status, 200); + const parsed = notificationFeedResponseSchema.parse(await feed.json()); + assert.equal(parsed.messages.length, 1); + assert.deepEqual(parsed.messages[0]?.payload, { + kind: "quota-exhausted", + limit: 20_480, + resource: "storage", + used: 20_480, + }); + assert.deepEqual(parsed.receipts, []); +}); + +test("marking read writes a receipt the feed returns; other users keep theirs empty", async () => { + const feed = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "alice" }))).json() + ); + const dbId = `db:${feed.messages[0]?.id}`; + + const marked = await handlers.markRead( + await request("/read", { + actor: "alice", + body: { ids: [dbId, "cr:debt-choice-debtperiod:1756200000"] }, + }) + ); + assert.equal(marked.status, 200); + + const alice = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "alice" }))).json() + ); + assert.deepEqual( + [...alice.receipts].sort(), + [dbId, "cr:debt-choice-debtperiod:1756200000"].sort() + ); + + const bob = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "bob" }))).json() + ); + assert.equal(bob.messages.length, 1, "the workspace stream is shared"); + assert.deepEqual(bob.receipts, [], "read state is personal"); +}); + +test("the stream is scoped to the verified workspace; receipts follow the person", async () => { + const other = notificationFeedResponseSchema.parse( + await ( + await handlers.feed( + await request("", { actor: "alice", namespace: "ns-b" }) + ) + ).json() + ); + assert.deepEqual(other.messages, []); + assert.ok( + other.receipts.includes("cr:debt-choice-debtperiod:1756200000"), + "an account-level CR read in one workspace is read in every workspace" + ); +}); + +test("requests without an App Token are refused; malformed bodies answer 400", async () => { + const noToken = await handlers.feed( + await request("", { actor: "alice", appToken: null }) + ); + assert.equal(noToken.status, 401); + + const badRead = await handlers.markRead( + await request("/read", { actor: "alice", body: { ids: ["n1"] } }) + ); + assert.equal(badRead.status, 400); + + const badQuota = await handlers.observeQuota( + await request("/quota-observation", { actor: "alice", body: { quota: {} } }) + ); + assert.equal(badQuota.status, 400); +}); + +test("a visible gift writes one hint per user through the route; a retry writes nothing", async () => { + const body = { giftMicroUnits: 720_000 }; + const first = await handlers.observeGift( + await request("/gift-observation", { actor: "carol", body }) + ); + const retry = await handlers.observeGift( + await request("/gift-observation", { actor: "carol", body }) + ); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { produced: true }); + assert.deepEqual(await retry.json(), { produced: false }); + + const feed = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "carol" }))).json() + ); + const hint = feed.messages.find((message) => message.kind === "credit-hint"); + assert.deepEqual(hint?.payload, { + giftMicroUnits: 720_000, + kind: "credit-hint", + }); + // Account-scoped (catalog D4): the same hint is listed from a workspace + // that never observed the gift. + const elsewhere = notificationFeedResponseSchema.parse( + await ( + await handlers.feed( + await request("", { actor: "carol", namespace: "ns-b" }) + ) + ).json() + ); + assert.deepEqual( + elsewhere.messages.filter((message) => message.kind === "credit-hint"), + [hint] + ); + + const bad = await handlers.observeGift( + await request("/gift-observation", { + actor: "carol", + body: { giftMicroUnits: -1 }, + }) + ); + assert.equal(bad.status, 400); +}); + +test("a subscription change writes one receipt per transaction through the route", async () => { + const body = { + change: "upgraded", + planName: "Pro", + transactionId: "txn-route-1", + }; + const first = await handlers.observeSubscriptionChange( + await request("/subscription-change", { actor: "carol", body }) + ); + const again = await handlers.observeSubscriptionChange( + await request("/subscription-change", { actor: "carol", body }) + ); + assert.equal(first.status, 200); + assert.deepEqual(await first.json(), { produced: true }); + assert.deepEqual(await again.json(), { produced: false }); + + const feed = notificationFeedResponseSchema.parse( + await (await handlers.feed(await request("", { actor: "carol" }))).json() + ); + const receipt = feed.messages.find( + (message) => message.kind === "subscription-change" + ); + assert.deepEqual(receipt?.payload, { + change: "upgraded", + kind: "subscription-change", + planName: "Pro", + }); + + const bad = await handlers.observeSubscriptionChange( + await request("/subscription-change", { + actor: "carol", + body: { change: "renewed", planName: "Pro", transactionId: "t" }, + }) + ); + assert.equal(bad.status, 400); +}); diff --git a/apps/ui/src/features/notifications/http-handlers.ts b/apps/ui/src/features/notifications/http-handlers.ts new file mode 100644 index 00000000..4531aea5 --- /dev/null +++ b/apps/ui/src/features/notifications/http-handlers.ts @@ -0,0 +1,221 @@ +import type { z } from "zod"; + +import type { AppTokenVerificationConfig } from "@/lib/app-token"; +import type { ObserveIdentityFingerprint } from "@/lib/identity-fingerprint-core"; +import { + authorizePersonalResourceRequest, + jsonError, + supersededBindingResponse, +} from "@/lib/personal-resource-http"; +import type { VerifyKubeconfigNamespace } from "@/lib/request-kubeconfig-auth"; +import { + type VerifiedPersonalResourceActor, + verifiedPersonalResourceActor, +} from "@/lib/verified-personal-actor"; + +import { observeGiftCreditForNotifications } from "./producer-credit-hint"; +import { observeWorkspaceQuotaForNotifications } from "./producer-quota-exhausted"; +import { observeSubscriptionChangeForNotifications } from "./producer-subscription-change"; +import type { NotificationReader, NotificationStore } from "./store"; +import { + giftObservationRequestSchema, + markNotificationReadRequestSchema, + type NotificationFeedResponse, + quotaObservationRequestSchema, + subscriptionChangeObservationRequestSchema, +} from "./types"; + +export interface NotificationHandlerDependencies { + /** Test seam; defaults to `JWT_INTERNAL` from the env. */ + appTokenConfig?: AppTokenVerificationConfig | null; + /** Test seam; defaults to the region-local Identity Fingerprint store. */ + observeFingerprint?: ObserveIdentityFingerprint; + store: NotificationStore; + verify?: VerifyKubeconfigNamespace; +} + +function readerOf(actor: VerifiedPersonalResourceActor): NotificationReader { + return { + legacyWorkspaceActor: actor.legacyWorkspaceActor, + namespace: actor.owner.namespace, + userUid: actor.owner.userUid, + }; +} + +function unavailable(route: string, message: string): Response { + console.error(`[api/notifications/${route}] persistence unavailable`); + return jsonError({ + code: "notifications_unavailable", + message, + status: 503, + }); +} + +/** + * The Notification Center's Brain-side HTTP surface (ADR-0067): the `db:` + * stream plus the user's receipts, the per-user mark-read write, and the + * producers' observation points (quota, gift credit, subscription change). + * Every handler travels the verified-personal-actor choke point — receipts + * are uid-keyed rows (ADR-0059) and the stream is the verified workspace's + * messages plus the verified person's account-scoped ones, so both the + * workspace and the user must be verified. + */ +export function createNotificationHandlers( + dependencies: NotificationHandlerDependencies +) { + const authorize = async ( + request: Request + ): Promise< + | { ok: true; actor: VerifiedPersonalResourceActor } + | { ok: false; response: Response } + > => { + const result = await authorizePersonalResourceRequest(request, { + appTokenConfig: dependencies.appTokenConfig, + observeFingerprint: dependencies.observeFingerprint, + verify: dependencies.verify, + }); + return result.ok + ? { actor: verifiedPersonalResourceActor(result.authorization), ok: true } + : result; + }; + + /** Verify the actor, then parse the JSON body; a bad body is a 400. */ + const authorizeBody = async ( + request: Request, + schema: z.ZodType, + message: string + ): Promise< + | { ok: true; actor: VerifiedPersonalResourceActor; input: T } + | { ok: false; response: Response } + > => { + const authorization = await authorize(request); + if (!authorization.ok) { + return authorization; + } + const body = await request.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return { + ok: false, + response: jsonError({ code: "invalid_request", message, status: 400 }), + }; + } + return { actor: authorization.actor, input: parsed.data, ok: true }; + }; + + /** + * Every producer's observation point has one shape: verify the actor, + * parse the body, hand the verified namespace (and user) to the producer, + * answer its result. A persistence failure is a 503, never a crash. + */ + const observation = + (config: { + message: string; + produce: ( + actor: VerifiedPersonalResourceActor, + input: T + ) => Promise; + route: string; + schema: z.ZodType; + unavailableMessage: string; + }) => + async (request: Request): Promise => { + const authorized = await authorizeBody( + request, + config.schema, + config.message + ); + if (!authorized.ok) { + return authorized.response; + } + try { + return Response.json( + await config.produce(authorized.actor, authorized.input) + ); + } catch (error) { + const superseded = supersededBindingResponse(error); + if (superseded != null) { + return superseded; + } + return unavailable(config.route, config.unavailableMessage); + } + }; + + return { + feed: async (request: Request): Promise => { + const authorization = await authorize(request); + if (!authorization.ok) { + return authorization.response; + } + const reader = readerOf(authorization.actor); + try { + const [messages, receipts] = await Promise.all([ + dependencies.store.listMessages(reader), + dependencies.store.listReceipts(reader.userUid), + ]); + const body: NotificationFeedResponse = { messages, receipts }; + return Response.json(body); + } catch { + return unavailable("feed", "Notifications are unavailable."); + } + }, + markRead: async (request: Request): Promise => { + const authorized = await authorizeBody( + request, + markNotificationReadRequestSchema, + "Invalid mark-read request." + ); + if (!authorized.ok) { + return authorized.response; + } + const { ids } = authorized.input; + try { + await dependencies.store.markRead(readerOf(authorized.actor), ids); + return Response.json({ read: ids }); + } catch (error) { + const superseded = supersededBindingResponse(error); + if (superseded != null) { + return superseded; + } + return unavailable("read", "Could not record the read receipt."); + } + }, + observeGift: observation({ + message: "Invalid gift observation.", + produce: (actor, input) => + observeGiftCreditForNotifications(dependencies.store, { + ...input, + account: { + legacyWorkspaceActor: actor.legacyWorkspaceActor, + userUid: actor.owner.userUid, + }, + namespace: actor.owner.namespace, + }), + route: "gift-observation", + schema: giftObservationRequestSchema, + unavailableMessage: "Could not record the gift observation.", + }), + observeQuota: observation({ + message: "Invalid quota observation.", + produce: (actor, input) => + observeWorkspaceQuotaForNotifications(dependencies.store, { + namespace: actor.owner.namespace, + snapshot: input.quota, + }), + route: "quota-observation", + schema: quotaObservationRequestSchema, + unavailableMessage: "Could not record the quota observation.", + }), + observeSubscriptionChange: observation({ + message: "Invalid subscription-change observation.", + produce: (actor, input) => + observeSubscriptionChangeForNotifications(dependencies.store, { + ...input, + namespace: actor.owner.namespace, + }), + route: "subscription-change", + schema: subscriptionChangeObservationRequestSchema, + unavailableMessage: "Could not record the subscription change.", + }), + }; +} diff --git a/apps/ui/src/features/notifications/notification-ids.ts b/apps/ui/src/features/notifications/notification-ids.ts new file mode 100644 index 00000000..3e5860bc --- /dev/null +++ b/apps/ui/src/features/notifications/notification-ids.ts @@ -0,0 +1,30 @@ +import type { AppNotificationSource } from "@/features/shell/app-sidebar-notifications-model"; + +/** The `cr:` prefix every platform notification id carries. */ +export const CR_NOTIFICATION_ID_PREFIX = "cr:"; +/** The `db:` prefix every Brain-produced notification id carries. */ +export const DB_NOTIFICATION_ID_PREFIX = "db:"; + +/** + * A platform notification's id is versioned by the CR's own version (its + * `spec.timestamp`, or its generation when upstream wrote none): the + * platform overwrites fixed-name CRs in place on escalation, and the newer + * version makes the revived message a new id that no old receipt covers. + */ +export function crNotificationId(name: string, version: number): string { + return `${CR_NOTIFICATION_ID_PREFIX}${name}:${version}`; +} + +export function dbNotificationId(messageId: string): string { + return `${DB_NOTIFICATION_ID_PREFIX}${messageId}`; +} + +export function notificationSource(id: string): AppNotificationSource | null { + if (id.startsWith(CR_NOTIFICATION_ID_PREFIX)) { + return "cr"; + } + if (id.startsWith(DB_NOTIFICATION_ID_PREFIX)) { + return "db"; + } + return null; +} diff --git a/apps/ui/src/features/notifications/notification-time.test.ts b/apps/ui/src/features/notifications/notification-time.test.ts new file mode 100644 index 00000000..7ad627b5 --- /dev/null +++ b/apps/ui/src/features/notifications/notification-time.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { formatNotificationTime } from "./notification-time"; + +const NOW = Date.UTC(2026, 7, 27, 12, 0, 0); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const SHORT_DATE_RE = /^[A-Z][a-z]{2} \d{1,2}$/; + +test("relative time steps from just now through days", () => { + assert.equal(formatNotificationTime(NOW - 10_000, NOW), "now"); + assert.equal(formatNotificationTime(NOW - 2 * MINUTE, NOW), "2m"); + assert.equal(formatNotificationTime(NOW - 26 * MINUTE, NOW), "26m"); + assert.equal(formatNotificationTime(NOW - HOUR, NOW), "1h"); + assert.equal(formatNotificationTime(NOW - 23 * HOUR, NOW), "23h"); + assert.equal(formatNotificationTime(NOW - 30 * HOUR, NOW), "1d"); + assert.equal(formatNotificationTime(NOW - 2 * DAY, NOW), "2d"); + assert.equal(formatNotificationTime(NOW - 6 * DAY, NOW), "6d"); +}); + +test("older than a week falls back to a short date; the future reads as now", () => { + assert.match(formatNotificationTime(NOW - 30 * DAY, NOW), SHORT_DATE_RE); + assert.equal(formatNotificationTime(NOW + HOUR, NOW), "now"); + assert.equal(formatNotificationTime(Number.NaN, NOW), ""); +}); diff --git a/apps/ui/src/features/notifications/notification-time.ts b/apps/ui/src/features/notifications/notification-time.ts new file mode 100644 index 00000000..efe0be3c --- /dev/null +++ b/apps/ui/src/features/notifications/notification-time.ts @@ -0,0 +1,59 @@ +import { DAY_MS, HOUR_MS, MINUTE_MS } from "@/lib/time"; + +/** + * The short relative time on a Notification row, from a real timestamp + * (epoch ms): "now", "26m", "3h", "5d", then a short date. The row pairs it + * with `formatNotificationTimestamp` as a tooltip for the exact moment. + */ +export function formatNotificationTime( + timestamp: number, + now: number = Date.now() +): string { + if (!Number.isFinite(timestamp)) { + return ""; + } + const elapsed = Math.max(0, now - timestamp); + if (elapsed < MINUTE_MS) { + return "now"; + } + if (elapsed < HOUR_MS) { + return `${Math.floor(elapsed / MINUTE_MS)}m`; + } + if (elapsed < DAY_MS) { + return `${Math.floor(elapsed / HOUR_MS)}h`; + } + if (elapsed < 7 * DAY_MS) { + return `${Math.floor(elapsed / DAY_MS)}d`; + } + return new Date(timestamp).toLocaleDateString("en-US", { + day: "numeric", + month: "short", + }); +} + +/** The absolute moment behind a row's short time ("Aug 21, 2026, 3:05 PM"). */ +export function formatNotificationTimestamp(timestamp: number): string { + if (!Number.isFinite(timestamp)) { + return ""; + } + return new Date(timestamp).toLocaleString("en-US", { + day: "numeric", + hour: "numeric", + minute: "2-digit", + month: "short", + year: "numeric", + }); +} + +/** + * An absolute date for Notification bodies (design spec §10 rule 5, "Sep 3" + * style); an unparsable instant renders as an empty string so a sentence + * never carries "Invalid Date". + */ +export function formatNotificationDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return ""; + } + return date.toLocaleDateString("en-US", { day: "numeric", month: "short" }); +} diff --git a/apps/ui/src/features/notifications/producer-credit-hint.test.ts b/apps/ui/src/features/notifications/producer-credit-hint.test.ts new file mode 100644 index 00000000..9ca2691e --- /dev/null +++ b/apps/ui/src/features/notifications/producer-credit-hint.test.ts @@ -0,0 +1,140 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import { identityFingerprints } from "@/features/chat/persistence/schema"; + +import { + creditHintDedupeKey, + observeGiftCreditForNotifications, +} from "./producer-credit-hint"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; + +const pglite = new PGlite(); +const db = drizzle(pglite, { + schema: { + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); +const store = createNotificationStore(() => db); + +afterAll(() => pglite.close()); + +const NOW = new Date("2026-08-27T12:00:00Z"); + +/** The verified binding every account-scoped write re-checks (ADR-0059). */ +async function account(name: string) { + const owner = { legacyWorkspaceActor: name, userUid: `uid-${name}` }; + await db + .insert(identityFingerprints) + .values({ crName: name, mintedAt: 1, userUid: owner.userUid }) + .onConflictDoNothing(); + return owner; +} + +test("the dedupe key names the user, not the workspace", () => { + assert.equal(creditHintDedupeKey("uid-alice"), "credit-hint:uid-alice"); +}); + +test("a visible gift writes one hint per user; retries and other workspaces write nothing, and every workspace lists it", async () => { + const first = await observeGiftCreditForNotifications(store, { + giftMicroUnits: 720_000, + namespace: "ns-a", + now: NOW, + account: await account("alice"), + }); + const retry = await observeGiftCreditForNotifications(store, { + giftMicroUnits: 700_000, + namespace: "ns-a", + now: new Date(NOW.getTime() + 60_000), + account: await account("alice"), + }); + const elsewhere = await observeGiftCreditForNotifications(store, { + giftMicroUnits: 700_000, + namespace: "ns-b", + now: new Date(NOW.getTime() + 120_000), + account: await account("alice"), + }); + + assert.deepEqual(first, { produced: true }); + assert.deepEqual(retry, { produced: false }); + assert.deepEqual(elsewhere, { produced: false }); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "uid-alice", + }); + assert.equal(messages.length, 1); + assert.deepEqual(messages[0]?.payload, { + giftMicroUnits: 720_000, + kind: "credit-hint", + }); + // Account-scoped: the one row follows the person into a workspace that + // never observed the gift, and stays out of another person's inbox there. + assert.deepEqual( + await store.listMessages({ namespace: "ns-b", userUid: "uid-alice" }), + messages + ); + assert.deepEqual( + await store.listMessages({ namespace: "ns-a", userUid: "uid-someone" }), + [] + ); +}); + +test("another user gets their own hint; no gift writes nothing", async () => { + assert.deepEqual( + await observeGiftCreditForNotifications(store, { + giftMicroUnits: 0, + namespace: "ns-a", + now: NOW, + account: await account("bob"), + }), + { produced: false } + ); + assert.deepEqual( + await observeGiftCreditForNotifications(store, { + giftMicroUnits: 1_000_000, + namespace: "ns-a", + now: NOW, + account: await account("bob"), + }), + { produced: true } + ); + assert.equal( + (await store.listMessages({ namespace: "ns-a", userUid: "uid-bob" })) + .length, + 1, + "bob's inbox holds his own hint, not alice's" + ); +}); + +test("a blank user or workspace observes nothing", async () => { + assert.deepEqual( + await observeGiftCreditForNotifications(store, { + giftMicroUnits: 1_000_000, + namespace: " ", + account: await account("carol"), + }), + { produced: false } + ); + assert.deepEqual( + await observeGiftCreditForNotifications(store, { + account: { legacyWorkspaceActor: "carol", userUid: "" }, + giftMicroUnits: 1_000_000, + namespace: "ns-a", + }), + { produced: false } + ); +}); diff --git a/apps/ui/src/features/notifications/producer-credit-hint.ts b/apps/ui/src/features/notifications/producer-credit-hint.ts new file mode 100644 index 00000000..44cdfd7a --- /dev/null +++ b/apps/ui/src/features/notifications/producer-credit-hint.ts @@ -0,0 +1,53 @@ +import type { NotificationAccountOwner, NotificationStore } from "./store"; + +/** + * The $1 gift hint producer (catalog row D4): the first time a new user's + * gift credit is visible during one of their requests, exactly one + * reassuring entry appears — per user, not per workspace. The dedupe key + * carries the bare user uid and the row is account-scoped (`userUid` set), + * so it follows the person into every workspace's inbox; `namespace` only + * records where it was first observed. The key is never released: the gift + * is reissued monthly, the welcome is said once. + */ + +export const CREDIT_HINT_DEDUPE_PREFIX = "credit-hint"; + +export function creditHintDedupeKey(userUid: string): string { + return `${CREDIT_HINT_DEDUPE_PREFIX}:${userUid}`; +} + +export interface GiftCreditObservationResult { + produced: boolean; +} + +export type GiftCreditObservationStore = Pick; + +export async function observeGiftCreditForNotifications( + store: GiftCreditObservationStore, + input: { + /** The verified person the hint is for; the write re-checks the binding. */ + account: NotificationAccountOwner; + giftMicroUnits: number; + namespace: string; + now?: Date; + } +): Promise { + const namespace = input.namespace.trim(); + const userUid = input.account.userUid.trim(); + if ( + namespace === "" || + userUid === "" || + !(Number.isFinite(input.giftMicroUnits) && input.giftMicroUnits > 0) + ) { + return { produced: false }; + } + const produced = await store.produce({ + account: { ...input.account, userUid }, + dedupeKey: creditHintDedupeKey(userUid), + kind: "credit-hint", + namespace, + now: input.now, + payload: { giftMicroUnits: input.giftMicroUnits, kind: "credit-hint" }, + }); + return { produced }; +} diff --git a/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts b/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts new file mode 100644 index 00000000..e1e25752 --- /dev/null +++ b/apps/ui/src/features/notifications/producer-quota-exhausted.test.ts @@ -0,0 +1,149 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import type { WorkspaceQuotaItem } from "@/features/billing/workspace-resource-quota"; +import { identityFingerprints } from "@/features/chat/persistence/schema"; + +import { + isQuotaExhausted, + observeWorkspaceQuotaForNotifications, + quotaExhaustedDedupeKey, +} from "./producer-quota-exhausted"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; + +const pglite = new PGlite(); +const db = drizzle(pglite, { + schema: { + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); +const store = createNotificationStore(() => db); + +afterAll(() => pglite.close()); + +const NOW = new Date("2026-08-27T12:00:00Z"); + +function snapshot(...items: WorkspaceQuotaItem[]) { + return { items }; +} + +test("isQuotaExhausted fires at 100% and never on an unknown limit", () => { + assert.equal( + isQuotaExhausted({ limit: 20, type: "storage", used: 20 }), + true + ); + assert.equal( + isQuotaExhausted({ limit: 20, type: "storage", used: 25 }), + true + ); + assert.equal( + isQuotaExhausted({ limit: 20, type: "storage", used: 19.9 }), + false + ); + assert.equal(isQuotaExhausted({ limit: 0, type: "pod", used: 0 }), false); +}); + +test("the dedupe key names workspace and resource", () => { + assert.equal( + quotaExhaustedDedupeKey("ns-a", "storage"), + "quota-exhausted:ns-a:storage" + ); +}); + +test("crossing 100% writes one entry per resource; a retry writes nothing", async () => { + const full = snapshot( + { limit: 4000, type: "cpu", used: 4000 }, + { limit: 20_480, type: "storage", used: 20_480 }, + { limit: 4096, type: "memory", used: 3072 } + ); + + const first = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: NOW, + snapshot: full, + }); + const retry = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 60_000), + snapshot: full, + }); + + assert.deepEqual(first, { produced: ["cpu", "storage"], released: [] }); + assert.deepEqual(retry, { produced: [], released: [] }); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "viewer-uid", + }); + assert.equal(messages.length, 2, "exactly one entry per exhausted resource"); + const resources = messages.map((message) => + message.payload.kind === "quota-exhausted" ? message.payload.resource : "" + ); + assert.deepEqual(resources.sort(), ["cpu", "storage"]); + assert.deepEqual( + messages.find( + (message) => + message.payload.kind === "quota-exhausted" && + message.payload.resource === "storage" + )?.payload, + { + kind: "quota-exhausted", + limit: 20_480, + resource: "storage", + used: 20_480, + } + ); +}); + +test("falling below 100% resets; staying full re-sends nothing; re-crossing writes again", async () => { + const recovered = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 120_000), + snapshot: snapshot( + { limit: 4000, type: "cpu", used: 4000 }, + { limit: 20_480, type: "storage", used: 10_240 } + ), + }); + assert.deepEqual(recovered, { produced: [], released: ["storage"] }); + + const stillFull = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 180_000), + snapshot: snapshot({ limit: 4000, type: "cpu", used: 4000 }), + }); + assert.deepEqual(stillFull, { produced: [], released: [] }); + + const reCrossed = await observeWorkspaceQuotaForNotifications(store, { + namespace: "ns-a", + now: new Date(NOW.getTime() + 240_000), + snapshot: snapshot({ limit: 20_480, type: "storage", used: 20_480 }), + }); + assert.deepEqual(reCrossed, { produced: ["storage"], released: [] }); + + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "viewer-uid", + }); + assert.equal(messages.length, 3, "cpu once, storage twice across a recovery"); +}); + +test("a blank namespace observes nothing", async () => { + const result = await observeWorkspaceQuotaForNotifications(store, { + namespace: " ", + snapshot: snapshot({ limit: 1, type: "pod", used: 1 }), + }); + assert.deepEqual(result, { produced: [], released: [] }); +}); diff --git a/apps/ui/src/features/notifications/producer-quota-exhausted.ts b/apps/ui/src/features/notifications/producer-quota-exhausted.ts new file mode 100644 index 00000000..1885d962 --- /dev/null +++ b/apps/ui/src/features/notifications/producer-quota-exhausted.ts @@ -0,0 +1,88 @@ +import type { + WorkspaceQuotaItem, + WorkspaceResourceQuotaSnapshot, +} from "@/features/billing/workspace-resource-quota"; + +import type { NotificationStore } from "./store"; +import type { QuotaExhaustedResource } from "./types"; + +/** + * The first Brain-side producer (catalog row A1): when any workspace resource + * quota reaches 100% during a user's request, exactly one "quota is full" + * entry appears per resource. Edge-triggered by naming — the dedupe key is + * live while the resource stays full, so retries and repeated observations + * write nothing; falling below 100% releases the key so the next crossing + * writes a fresh entry. No scheduler, no periodic re-sends. + */ + +export const QUOTA_EXHAUSTED_DEDUPE_PREFIX = "quota-exhausted"; + +export function quotaExhaustedDedupeKey( + namespace: string, + resource: QuotaExhaustedResource +): string { + return `${QUOTA_EXHAUSTED_DEDUPE_PREFIX}:${namespace}:${resource}`; +} + +/** 100% of the limit; a zero or unknown limit never counts as exhausted. */ +export function isQuotaExhausted(item: WorkspaceQuotaItem): boolean { + return ( + Number.isFinite(item.limit) && + Number.isFinite(item.used) && + item.limit > 0 && + item.used >= item.limit + ); +} + +export interface QuotaObservationResult { + produced: QuotaExhaustedResource[]; + released: QuotaExhaustedResource[]; +} + +export type QuotaObservationStore = Pick< + NotificationStore, + "produce" | "release" +>; + +/** + * Observes one quota snapshot for a workspace: exhausted resources produce + * (deduped), recovered resources release. Resources absent from the snapshot + * are unknown and left alone. + */ +export async function observeWorkspaceQuotaForNotifications( + store: QuotaObservationStore, + input: { + namespace: string; + now?: Date; + snapshot: WorkspaceResourceQuotaSnapshot; + } +): Promise { + const result: QuotaObservationResult = { produced: [], released: [] }; + const namespace = input.namespace.trim(); + if (namespace === "") { + return result; + } + for (const item of input.snapshot.items) { + const dedupeKey = quotaExhaustedDedupeKey(namespace, item.type); + if (isQuotaExhausted(item)) { + const produced = await store.produce({ + dedupeKey, + kind: "quota-exhausted", + namespace, + now: input.now, + payload: { + kind: "quota-exhausted", + limit: item.limit, + resource: item.type, + used: item.used, + }, + }); + if (produced) { + result.produced.push(item.type); + } + } else if (await store.release({ dedupeKey, now: input.now })) { + result.released.push(item.type); + } + } + return result; +} diff --git a/apps/ui/src/features/notifications/producer-subscription-change.test.ts b/apps/ui/src/features/notifications/producer-subscription-change.test.ts new file mode 100644 index 00000000..a160a2c6 --- /dev/null +++ b/apps/ui/src/features/notifications/producer-subscription-change.test.ts @@ -0,0 +1,167 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import { identityFingerprints } from "@/features/chat/persistence/schema"; + +import { + observeSubscriptionChangeForNotifications, + subscriptionChangeDedupeKey, +} from "./producer-subscription-change"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; + +const pglite = new PGlite(); +const db = drizzle(pglite, { + schema: { + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); +const store = createNotificationStore(() => db); + +afterAll(() => pglite.close()); + +const NOW = new Date("2026-08-27T12:00:00Z"); + +test("the dedupe key names workspace and transaction", () => { + assert.equal( + subscriptionChangeDedupeKey("ns-a", "txn-1"), + "subscription-change:ns-a:txn-1" + ); +}); + +test("one receipt per successful change; the same transaction observed twice writes once", async () => { + const first = await observeSubscriptionChangeForNotifications(store, { + change: "upgraded", + namespace: "ns-a", + now: NOW, + planName: "Pro", + transactionId: "txn-1", + }); + const again = await observeSubscriptionChangeForNotifications(store, { + change: "upgraded", + namespace: "ns-a", + now: new Date(NOW.getTime() + 60_000), + planName: "Pro", + transactionId: "txn-1", + }); + const downgrade = await observeSubscriptionChangeForNotifications(store, { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + namespace: "ns-a", + now: new Date(NOW.getTime() + 120_000), + planName: "Hobby", + transactionId: "txn-2", + }); + + assert.deepEqual(first, { produced: true }); + assert.deepEqual(again, { produced: false }); + assert.deepEqual(downgrade, { produced: true }); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "viewer-uid", + }); + assert.deepEqual( + messages.map((message) => message.payload), + [ + { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + kind: "subscription-change", + planName: "Hobby", + }, + { change: "upgraded", kind: "subscription-change", planName: "Pro" }, + ] + ); +}); + +test("an upgrade over a scheduled downgrade is the next receipt; the superseded downgrade stays as history and never re-writes", async () => { + const scheduled = await observeSubscriptionChangeForNotifications(store, { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + namespace: "ns-seq", + now: NOW, + planName: "Hobby", + transactionId: "txn-down", + }); + const upgrade = await observeSubscriptionChangeForNotifications(store, { + change: "upgraded", + namespace: "ns-seq", + now: new Date(NOW.getTime() + 60_000), + planName: "Pro", + transactionId: "txn-up", + }); + // The platform later settles the same downgrade transaction: same id, no + // second entry. + const settled = await observeSubscriptionChangeForNotifications(store, { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + namespace: "ns-seq", + now: new Date(NOW.getTime() + 120_000), + planName: "Hobby", + transactionId: "txn-down", + }); + + assert.deepEqual(scheduled, { produced: true }); + assert.deepEqual(upgrade, { produced: true }); + assert.deepEqual(settled, { produced: false }); + const messages = await store.listMessages({ + namespace: "ns-seq", + userUid: "viewer-uid", + }); + assert.deepEqual( + messages.map((message) => message.payload), + [ + { change: "upgraded", kind: "subscription-change", planName: "Pro" }, + { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + kind: "subscription-change", + planName: "Hobby", + }, + ] + ); +}); + +test("the same transaction id in another workspace is another receipt; blanks write nothing", async () => { + assert.deepEqual( + await observeSubscriptionChangeForNotifications(store, { + change: "cancelled", + namespace: "ns-b", + now: NOW, + planName: "Pro", + transactionId: "txn-1", + }), + { produced: true } + ); + assert.deepEqual( + await observeSubscriptionChangeForNotifications(store, { + change: "cancelled", + namespace: "ns-b", + planName: "Pro", + transactionId: " ", + }), + { produced: false } + ); + assert.deepEqual( + await observeSubscriptionChangeForNotifications(store, { + change: "cancelled", + namespace: "", + planName: "Pro", + transactionId: "txn-3", + }), + { produced: false } + ); +}); diff --git a/apps/ui/src/features/notifications/producer-subscription-change.ts b/apps/ui/src/features/notifications/producer-subscription-change.ts new file mode 100644 index 00000000..48bfb4e1 --- /dev/null +++ b/apps/ui/src/features/notifications/producer-subscription-change.ts @@ -0,0 +1,63 @@ +import type { NotificationStore } from "./store"; +import type { SubscriptionChange } from "./types"; + +/** + * The subscription-change receipt producer (catalog row B5): one fact-only + * entry per successful upgrade, downgrade, or cancellation, written at the + * transaction observation point. The platform's transaction id is the + * identity — the same change observed again (a checkout poll and the Stripe + * return leg both settle the same payment) writes nothing. Never released: + * a receipt records history, it has no recovery — an upgrade over a still + * scheduled downgrade is simply the next receipt, and the superseded + * downgrade's entry stays as the record of what the user did. + */ + +export const SUBSCRIPTION_CHANGE_DEDUPE_PREFIX = "subscription-change"; + +export function subscriptionChangeDedupeKey( + namespace: string, + transactionId: string +): string { + return `${SUBSCRIPTION_CHANGE_DEDUPE_PREFIX}:${namespace}:${transactionId}`; +} + +export interface SubscriptionChangeObservationResult { + produced: boolean; +} + +export type SubscriptionChangeObservationStore = Pick< + NotificationStore, + "produce" +>; + +export async function observeSubscriptionChangeForNotifications( + store: SubscriptionChangeObservationStore, + input: { + change: SubscriptionChange; + effectiveAt?: string; + namespace: string; + now?: Date; + planName: string; + transactionId: string; + } +): Promise { + const namespace = input.namespace.trim(); + const transactionId = input.transactionId.trim(); + const planName = input.planName.trim(); + if (namespace === "" || transactionId === "" || planName === "") { + return { produced: false }; + } + const produced = await store.produce({ + dedupeKey: subscriptionChangeDedupeKey(namespace, transactionId), + kind: "subscription-change", + namespace, + now: input.now, + payload: { + change: input.change, + ...(input.effectiveAt == null ? {} : { effectiveAt: input.effectiveAt }), + kind: "subscription-change", + planName, + }, + }); + return { produced }; +} diff --git a/apps/ui/src/features/notifications/producers-through-fixtures.test.ts b/apps/ui/src/features/notifications/producers-through-fixtures.test.ts new file mode 100644 index 00000000..c1289c26 --- /dev/null +++ b/apps/ui/src/features/notifications/producers-through-fixtures.test.ts @@ -0,0 +1,172 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import { loadAccountCredits } from "@/features/billing/account-credits"; +import { loadSubscriptionTransactionStatus } from "@/features/billing/billing-plan-data"; +import { scenarioTestFetch } from "@/features/billing/server/dev-fixtures/scenario-test-fetch"; +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; +import { identityFingerprints } from "@/features/chat/persistence/schema"; + +import { observeGiftCreditForNotifications } from "./producer-credit-hint"; +import { observeWorkspaceQuotaForNotifications } from "./producer-quota-exhausted"; +import { observeSubscriptionChangeForNotifications } from "./producer-subscription-change"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore } from "./store"; +import { subscriptionChangeReceiptFromTransaction } from "./subscription-change-observer"; + +/** + * The producers driven from the dev-fixture scenario seam: each scenario's + * billing fixtures pass through the real loaders and into the producer, and + * the store shows exactly one row per crossing, none on retry, and a reset + * on recovery. + */ + +const pglite = new PGlite(); +const db = drizzle(pglite, { + schema: { + identityFingerprints, + notificationMessages, + notificationReadReceipts, + }, +}); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); +const store = createNotificationStore(() => db); +// The gift hint is account-scoped: its write re-checks this binding. +await db + .insert(identityFingerprints) + .values({ crName: "newcomer", mintedAt: 1, userUid: "uid-newcomer" }) + .onConflictDoNothing(); + +afterAll(() => pglite.close()); + +const CREDENTIALS = { appToken: "test-token", kubeconfig: "test-kubeconfig" }; +const WORKSPACE = "ns-fixture"; + +const QUANTITY_RE = /^(\d+(?:\.\d+)?)(m|Ki|Mi|Gi)?$/; + +/** Test-only reading of the fixture's Kubernetes quantities (Mi / millicores). */ +function quantity(value: string): number { + const match = QUANTITY_RE.exec(value); + assert.ok(match, `quantity ${value}`); + const amount = Number(match[1]); + switch (match[2]) { + case "Gi": + return amount * 1024; + case "Ki": + return amount / 1024; + case "m": + return amount; + case "Mi": + return amount; + default: + return amount * 1000; + } +} + +async function quotaSnapshotFor( + scenario: string +): Promise { + const response = await scenarioTestFetch(scenario)( + "/api/billing/workspace-quota", + { body: JSON.stringify({ workspace: WORKSPACE }), method: "POST" } + ); + const payload = (await response.json()) as { + quota: { hard: Record; used: Record }; + }; + const item = (type: "cpu" | "storage", key: string) => ({ + limit: quantity(payload.quota.hard[key] ?? "0"), + type, + used: quantity(payload.quota.used[key] ?? "0"), + }); + return { + items: [item("cpu", "limits.cpu"), item("storage", "requests.storage")], + }; +} + +test("quota-full's storage crosses 100% once; active's usage releases it", async () => { + const full = await quotaSnapshotFor("quota-full"); + const first = await observeWorkspaceQuotaForNotifications(store, { + namespace: WORKSPACE, + snapshot: full, + }); + const retry = await observeWorkspaceQuotaForNotifications(store, { + namespace: WORKSPACE, + snapshot: full, + }); + assert.deepEqual(first, { produced: ["storage"], released: [] }); + assert.deepEqual(retry, { produced: [], released: [] }); + + const recovered = await observeWorkspaceQuotaForNotifications(store, { + namespace: WORKSPACE, + snapshot: await quotaSnapshotFor("active"), + }); + assert.deepEqual(recovered, { produced: [], released: ["storage"] }); + assert.equal( + ( + await store.listMessages({ + namespace: WORKSPACE, + userUid: "uid-newcomer", + }) + ).length, + 1 + ); +}); + +test("free's visible gift writes the hint once; active carries no gift", async () => { + const gift = await loadAccountCredits(CREDENTIALS, scenarioTestFetch("free")); + assert.ok(gift.giftMicroUnits > 0); + const observe = (giftMicroUnits: number) => + observeGiftCreditForNotifications(store, { + account: { legacyWorkspaceActor: "newcomer", userUid: "uid-newcomer" }, + giftMicroUnits, + namespace: WORKSPACE, + }); + assert.deepEqual(await observe(gift.giftMicroUnits), { produced: true }); + assert.deepEqual(await observe(gift.giftMicroUnits), { produced: false }); + + const none = await loadAccountCredits( + CREDENTIALS, + scenarioTestFetch("active") + ); + assert.deepEqual(await observe(none.giftMicroUnits), { produced: false }); +}); + +test("active's settled checkout is one upgrade receipt, however often it is observed", async () => { + const transaction = await loadSubscriptionTransactionStatus( + { ...CREDENTIALS, regionDomain: "mock.sealos.run", workspace: WORKSPACE }, + { fetch: scenarioTestFetch("active") } + ); + const receipt = subscriptionChangeReceiptFromTransaction(transaction); + assert.deepEqual(receipt, { + change: "upgraded", + planName: "Hobby", + transactionId: "txn-mock-settled", + }); + assert.ok(receipt); + const observe = () => + observeSubscriptionChangeForNotifications(store, { + ...receipt, + namespace: WORKSPACE, + }); + assert.deepEqual(await observe(), { produced: true }); + assert.deepEqual(await observe(), { produced: false }); + + const kinds = ( + await store.listMessages({ namespace: WORKSPACE, userUid: "uid-newcomer" }) + ).map((message) => message.kind); + assert.deepEqual([...kinds].sort(), [ + "credit-hint", + "quota-exhausted", + "subscription-change", + ]); +}); diff --git a/apps/ui/src/features/notifications/producers.ts b/apps/ui/src/features/notifications/producers.ts new file mode 100644 index 00000000..2f096d7f --- /dev/null +++ b/apps/ui/src/features/notifications/producers.ts @@ -0,0 +1,25 @@ +import "server-only"; + +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; + +import { observeWorkspaceQuotaForNotifications } from "./producer-quota-exhausted"; +import { notificationStore } from "./server-store"; + +/** + * Fire-and-forget quota observation for request paths that already carry a + * snapshot (the chat turn). The producer must never fail the user's request: + * a persistence hiccup is logged and the next observation retries by naming. + */ +export function observeWorkspaceQuotaQuietly(input: { + namespace: string; + snapshot: WorkspaceResourceQuotaSnapshot; +}): void { + observeWorkspaceQuotaForNotifications(notificationStore, input).catch( + (error: unknown) => { + console.error( + "[notifications] quota observation failed:", + error instanceof Error ? error.message : String(error) + ); + } + ); +} diff --git a/apps/ui/src/features/notifications/quota-observation.test.ts b/apps/ui/src/features/notifications/quota-observation.test.ts new file mode 100644 index 00000000..62d881e0 --- /dev/null +++ b/apps/ui/src/features/notifications/quota-observation.test.ts @@ -0,0 +1,100 @@ +import { test } from "bun:test"; +import assert from "node:assert/strict"; + +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; + +import type { NotificationClientCredentials } from "./client"; +import { + observeWorkspaceQuotaForInbox, + type WorkspaceQuotaObservationDependencies, +} from "./quota-observation"; + +const CREDENTIALS: NotificationClientCredentials = { + appToken: "token", + kubeconfig: "kubeconfig", + namespace: "ns-a", +}; + +const SNAPSHOT: WorkspaceResourceQuotaSnapshot = { + items: [{ limit: 10, type: "cpu", used: 10 }], +}; + +function dependencies( + overrides: Partial = {} +) { + const calls = { + loaded: [] as string[], + refreshed: 0, + reported: [] as { + credentials: NotificationClientCredentials; + snapshot: WorkspaceResourceQuotaSnapshot; + }[], + }; + const deps: WorkspaceQuotaObservationDependencies = { + loadSnapshot: (namespace) => { + calls.loaded.push(namespace); + return Promise.resolve(SNAPSHOT); + }, + refreshFeed: () => { + calls.refreshed += 1; + return Promise.resolve(); + }, + report: (credentials, snapshot) => { + calls.reported.push({ credentials, snapshot }); + return Promise.resolve(); + }, + ...overrides, + }; + return { calls, deps }; +} + +test("a read snapshot is reported for the workspace and the inbox is refreshed", async () => { + const { calls, deps } = dependencies(); + + assert.equal(await observeWorkspaceQuotaForInbox(CREDENTIALS, deps), true); + assert.deepEqual(calls.loaded, ["ns-a"]); + assert.deepEqual(calls.reported, [ + { credentials: CREDENTIALS, snapshot: SNAPSHOT }, + ]); + assert.equal(calls.refreshed, 1); +}); + +test("missing credentials or an empty snapshot report nothing", async () => { + let loads = 0; + const { calls, deps } = dependencies({ + loadSnapshot: () => { + loads += 1; + return Promise.resolve(undefined); + }, + }); + + assert.equal( + await observeWorkspaceQuotaForInbox({ ...CREDENTIALS, appToken: "" }, deps), + false + ); + assert.equal(loads, 0, "no credentials, no read"); + assert.equal(await observeWorkspaceQuotaForInbox(CREDENTIALS, deps), false); + assert.equal(loads, 1); + assert.equal(calls.reported.length, 0); + assert.equal(calls.refreshed, 0); +}); + +test("a failed read or report resolves false without throwing and never refreshes", async () => { + const readFailed = dependencies({ + loadSnapshot: () => Promise.reject(new Error("sdk down")), + }); + const reportFailed = dependencies({ + report: () => Promise.reject(new Error("503")), + }); + + assert.equal( + await observeWorkspaceQuotaForInbox(CREDENTIALS, readFailed.deps), + false + ); + assert.equal( + await observeWorkspaceQuotaForInbox(CREDENTIALS, reportFailed.deps), + false + ); + assert.equal(readFailed.calls.refreshed, 0); + assert.equal(reportFailed.calls.refreshed, 0); +}); diff --git a/apps/ui/src/features/notifications/quota-observation.ts b/apps/ui/src/features/notifications/quota-observation.ts new file mode 100644 index 00000000..6af2d1d3 --- /dev/null +++ b/apps/ui/src/features/notifications/quota-observation.ts @@ -0,0 +1,72 @@ +import { mutate } from "swr"; + +import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client"; +import type { WorkspaceResourceQuotaSnapshot } from "@/features/billing/workspace-resource-quota"; + +import { + type NotificationClientCredentials, + reportWorkspaceQuotaObservation, +} from "./client"; + +/** The SWR key prefix of every mounted Notification Center feed. */ +const NOTIFICATION_FEED_KEY_PREFIX = "notifications-feed"; + +export interface WorkspaceQuotaObservationDependencies { + loadSnapshot: ( + namespace: string + ) => Promise; + refreshFeed: () => Promise; + report: ( + credentials: NotificationClientCredentials, + snapshot: WorkspaceResourceQuotaSnapshot + ) => Promise; +} + +/** Revalidates every mounted inbox so a produced or released A1 shows now, not next poll. */ +function refreshNotificationFeeds(): Promise { + return mutate( + (key) => Array.isArray(key) && key[0] === NOTIFICATION_FEED_KEY_PREFIX + ); +} + +const DEFAULT_DEPENDENCIES: WorkspaceQuotaObservationDependencies = { + loadSnapshot: loadWorkspaceQuotaSnapshot, + refreshFeed: refreshNotificationFeeds, + report: reportWorkspaceQuotaObservation, +}; + +/** + * The client-side observation point of the quota-exhausted producer (A1): + * reads the workspace's quota snapshot and hands it to the producer, then + * refreshes the inbox so the entry (or its release) lands with the banner + * that shows the same state. Every quota surface that polls — the App + * Sidebar on mount, the Status Hint on its cadence — reports through here, + * so the banner and the inbox observe the same snapshot at the same time + * and the producer's edge-triggering sees every crossing and recovery. + * Best-effort: a failed read or report resolves false and never throws; the + * producer dedupes by naming, so the next poll simply observes again. + */ +export async function observeWorkspaceQuotaForInbox( + credentials: NotificationClientCredentials, + dependencies: WorkspaceQuotaObservationDependencies = DEFAULT_DEPENDENCIES +): Promise { + const namespace = credentials.namespace.trim(); + if ( + namespace === "" || + credentials.appToken === "" || + credentials.kubeconfig === "" + ) { + return false; + } + try { + const snapshot = await dependencies.loadSnapshot(namespace); + if (snapshot == null) { + return false; + } + await dependencies.report({ ...credentials, namespace }, snapshot); + } catch { + return false; + } + await dependencies.refreshFeed().catch(() => undefined); + return true; +} diff --git a/apps/ui/src/features/notifications/read-dispatch.test.ts b/apps/ui/src/features/notifications/read-dispatch.test.ts new file mode 100644 index 00000000..d3445183 --- /dev/null +++ b/apps/ui/src/features/notifications/read-dispatch.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; + +import { planReadDispatch, shouldSyncCRReadLabel } from "./read-dispatch"; + +function item(overrides: Partial): AppNotification { + return { + id: "db:m1", + severity: "warning", + source: "db", + timestamp: 0, + title: "Storage quota is full", + unread: true, + ...overrides, + }; +} + +const MIXED = [ + item({}), + item({ + crName: "debt-choice-debtperiod", + id: "cr:debt-choice-debtperiod:1", + source: "cr", + }), + item({ + crName: "debt-choice-debtperiod", + id: "cr:debt-choice-debtperiod:2", + source: "cr", + }), +]; + +test("every id gets a receipt; Owners and Managers also patch the CRs once each", () => { + for (const role of ["OWNER", "MANAGER"] as const) { + const plan = planReadDispatch(MIXED, role); + assert.deepEqual(plan.receiptIds, [ + "db:m1", + "cr:debt-choice-debtperiod:1", + "cr:debt-choice-debtperiod:2", + ]); + assert.deepEqual(plan.crNames, ["debt-choice-debtperiod"]); + } +}); + +test("Developers skip the CR patch but still get the receipt", () => { + const plan = planReadDispatch(MIXED, "DEVELOPER"); + assert.equal(plan.receiptIds.length, 3); + assert.deepEqual(plan.crNames, []); + assert.equal(shouldSyncCRReadLabel("DEVELOPER"), false); +}); + +test("an unknown role tries the patch (the cluster decides)", () => { + assert.equal(shouldSyncCRReadLabel(null), true); + assert.equal(shouldSyncCRReadLabel(undefined), true); + assert.deepEqual(planReadDispatch(MIXED, null).crNames, [ + "debt-choice-debtperiod", + ]); +}); diff --git a/apps/ui/src/features/notifications/read-dispatch.ts b/apps/ui/src/features/notifications/read-dispatch.ts new file mode 100644 index 00000000..940fba39 --- /dev/null +++ b/apps/ui/src/features/notifications/read-dispatch.ts @@ -0,0 +1,39 @@ +import type { WorkspaceSubscriptionRole } from "@/features/billing/billing-plan-data"; +import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; + +/** + * Per-source mark-read dispatch. Any role always writes a Brain receipt for + * every id; platform items additionally patch the CR's `isRead` label so the + * desktop bell follows — but only for roles the cluster lets patch. Owners + * and Managers hold that permission, Developers do not, and an unknown role + * (PAYG workspaces carry none) tries and lets a 403 fall through silently. + */ +export interface ReadDispatch { + /** CR names to patch best-effort. */ + crNames: string[]; + /** Source-prefixed ids to record receipts for (always every id). */ + receiptIds: string[]; +} + +export function shouldSyncCRReadLabel( + role: WorkspaceSubscriptionRole | null | undefined +): boolean { + return role !== "DEVELOPER"; +} + +export function planReadDispatch( + items: readonly AppNotification[], + role: WorkspaceSubscriptionRole | null | undefined +): ReadDispatch { + const receiptIds = [...new Set(items.map((item) => item.id))]; + const crNames = shouldSyncCRReadLabel(role) + ? [ + ...new Set( + items.flatMap((item) => + item.source === "cr" && item.crName != null ? [item.crName] : [] + ) + ), + ] + : []; + return { crNames, receiptIds }; +} diff --git a/apps/ui/src/features/notifications/route-handlers.ts b/apps/ui/src/features/notifications/route-handlers.ts new file mode 100644 index 00000000..dd232f5d --- /dev/null +++ b/apps/ui/src/features/notifications/route-handlers.ts @@ -0,0 +1,57 @@ +import "server-only"; + +import type { NotificationDevMockHandler } from "@/features/billing/server/dev-fixtures/notifications"; + +import { createNotificationHandlers } from "./http-handlers"; +import { notificationStore } from "./server-store"; + +type NotificationRouteHandler = (request: Request) => Promise; + +/** + * Lets the Dev Mocks answer first in dev and demo builds + * (`NEXT_PUBLIC_DEV_TWEAKS=1` marks a demo image): the Notification Center + * mock layers its platform fixtures over the billing mock's feed or the + * real handler, whichever is below. A real production build statically + * drops the dynamic imports, so fixtures never reach production bundles — + * the same gate as the /api/billing routes. + */ +function withNotificationDevMock( + handler: NotificationDevMockHandler, + real: NotificationRouteHandler +): NotificationRouteHandler { + if ( + process.env.NODE_ENV === "production" && + process.env.NEXT_PUBLIC_DEV_TWEAKS !== "1" + ) { + return real; + } + return async (request) => { + const [ + { notificationDevMockResponse }, + { withPlatformNotificationDevMock }, + ] = await Promise.all([ + import("@/features/billing/server/dev-fixtures/notifications"), + import("./dev-fixtures"), + ]); + return withPlatformNotificationDevMock( + handler, + request, + async (layered) => + (await notificationDevMockResponse(handler, layered)) ?? real(layered) + ); + }; +} + +const handlers = createNotificationHandlers({ store: notificationStore }); + +/** Production wiring for the Notification Center routes (ADR-0067). */ +export const notificationRouteHandlers = { + feed: withNotificationDevMock("feed", handlers.feed), + markRead: withNotificationDevMock("read", handlers.markRead), + observeGift: withNotificationDevMock("observation", handlers.observeGift), + observeQuota: withNotificationDevMock("observation", handlers.observeQuota), + observeSubscriptionChange: withNotificationDevMock( + "observation", + handlers.observeSubscriptionChange + ), +}; diff --git a/apps/ui/src/features/notifications/schema.ts b/apps/ui/src/features/notifications/schema.ts new file mode 100644 index 00000000..873543b7 --- /dev/null +++ b/apps/ui/src/features/notifications/schema.ts @@ -0,0 +1,106 @@ +import { sql } from "drizzle-orm"; +import { + index, + jsonb, + pgSchema, + primaryKey, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +import type { NotificationMessageKind, NotificationPayload } from "./types"; + +/** + * Postgres schema owning the Notification Center's Brain-produced stream and + * the per-user read receipts (ADR-0067). Platform messages never land here: + * they stay in the cluster as Notification CRs and are read live. Isolated + * from `public` like every other app-owned schema. + */ +export const NOTIFICATION_DB_SCHEMA = "sealai_notification"; + +export const ns = pgSchema(NOTIFICATION_DB_SCHEMA); + +/** Brain-produced entries are kept this long; swept opportunistically on write. */ +export const NOTIFICATION_RETENTION_DAYS = 365; + +/** + * One Brain-produced Notification (`db:` stream). Rendering stays client-side: + * `payload` carries the structured parameters the display layer turns into + * copy, never final strings. `dedupeKey` is the producer's idempotency key — + * naming is dedupe, so a retried request re-inserts nothing. A producer + * "releases" the key (`releasedAt`) when the underlying state recovers so the + * next threshold crossing writes a fresh entry while history keeps the old + * one; the unique index therefore only spans live keys. A message is either + * workspace-scoped (`namespace` is the inbox it belongs to, `userUid` null) + * or account-scoped (`userUid` names the person: the message follows them + * into every workspace's inbox, and `namespace` only records where it was + * first observed). + */ +export const notificationMessages = ns.table( + "notification_messages", + { + id: text("id").primaryKey(), + /** Workspace namespace: the inbox for workspace-scoped messages, the observing workspace for account-scoped ones. */ + namespace: text("namespace").notNull(), + /** Bare global user UID (ADR-0059) for account-scoped messages; null for workspace-scoped ones. */ + userUid: text("user_uid"), + kind: text("kind").notNull().$type(), + /** Source Project when the message has one (deploy outcomes, later). */ + projectUid: text("project_uid"), + payload: jsonb("payload").notNull().$type(), + dedupeKey: text("dedupe_key").notNull(), + releasedAt: timestamp("released_at", { mode: "date", withTimezone: true }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("notification_messages_live_dedupe_key_idx") + .on(table.dedupeKey) + .where(sql`${table.releasedAt} IS NULL`), + index("notification_messages_namespace_created_at_idx").on( + table.namespace, + table.createdAt + ), + index("notification_messages_user_uid_created_at_idx").on( + table.userUid, + table.createdAt + ), + index("notification_messages_created_at_idx").on(table.createdAt), + ] +); + +/** + * One read receipt per user × message key. The key is the source-prefixed + * notification id: `db:` for Brain-produced entries (with + * `messageId` set so the 365-day sweep cascades) and `cr::` + * for platform CRs — versioned by the CR's own timestamp so an upstream + * revive (same fixed name, newer timestamp) reads as unread again. No + * workspace in the key: upstream writes account-level messages into every + * user namespace with the same name and timestamp, and a person reads a + * message once, not once per workspace. Any role writes receipts; the CR + * label is a separate, best-effort write. + */ +export const notificationReadReceipts = ns.table( + "notification_read_receipts", + { + /** Bare global user UID (ADR-0059). */ + userUid: text("user_uid").notNull(), + messageKey: text("message_key").notNull(), + messageId: text("message_id").references(() => notificationMessages.id, { + onDelete: "cascade", + }), + readAt: timestamp("read_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + primaryKey({ columns: [table.userUid, table.messageKey] }), + index("notification_read_receipts_message_id_idx").on(table.messageId), + ] +); + +export type NotificationMessageRow = typeof notificationMessages.$inferSelect; +export type NotificationReadReceiptRow = + typeof notificationReadReceipts.$inferSelect; diff --git a/apps/ui/src/features/notifications/server-store.ts b/apps/ui/src/features/notifications/server-store.ts new file mode 100644 index 00000000..579b0658 --- /dev/null +++ b/apps/ui/src/features/notifications/server-store.ts @@ -0,0 +1,7 @@ +import "server-only"; + +import { getNotificationDb } from "./db"; +import { createNotificationStore } from "./store"; + +/** The production store; the only place the database is injected. */ +export const notificationStore = createNotificationStore(getNotificationDb); diff --git a/apps/ui/src/features/notifications/store.test.ts b/apps/ui/src/features/notifications/store.test.ts new file mode 100644 index 00000000..6e9cc7fb --- /dev/null +++ b/apps/ui/src/features/notifications/store.test.ts @@ -0,0 +1,321 @@ +import { afterAll, test } from "bun:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { and, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; + +import { identityFingerprints } from "@/features/chat/persistence/schema"; +import { dbNotificationId } from "./notification-ids"; +import { notificationMessages, notificationReadReceipts } from "./schema"; +import { createNotificationStore, type NotificationReader } from "./store"; + +const testSchema = { + identityFingerprints, + notificationMessages, + notificationReadReceipts, +}; + +const pglite = new PGlite(); +const db = drizzle(pglite, { schema: testSchema }); +await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle" + ), +}); + +const store = createNotificationStore(() => db); + +afterAll(() => pglite.close()); + +const NOW = new Date("2026-08-27T12:00:00Z"); +const DAY_MS = 24 * 60 * 60 * 1000; +const SUPERSEDED_RE = /superseded/; + +async function bindIdentity(crName: string, userUid: string): Promise { + await db + .insert(identityFingerprints) + .values({ crName, mintedAt: 1, userUid }) + .onConflictDoNothing(); +} + +function reader( + overrides: Partial = {} +): NotificationReader { + return { + legacyWorkspaceActor: "alice", + namespace: "ns-a", + userUid: "alice-uid", + ...overrides, + }; +} + +function quotaPayload(resource: "cpu" | "memory" | "storage") { + return { kind: "quota-exhausted" as const, limit: 10, resource, used: 10 }; +} + +test("produce writes one entry per dedupe key and retries dedupe", async () => { + const first = await store.produce({ + dedupeKey: "quota-exhausted:ns-a:storage", + kind: "quota-exhausted", + namespace: "ns-a", + now: NOW, + payload: quotaPayload("storage"), + }); + const retry = await store.produce({ + dedupeKey: "quota-exhausted:ns-a:storage", + kind: "quota-exhausted", + namespace: "ns-a", + now: new Date(NOW.getTime() + 1000), + payload: quotaPayload("storage"), + }); + + assert.equal(first, true); + assert.equal(retry, false); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); + assert.equal(messages.length, 1); + assert.equal(messages[0]?.kind, "quota-exhausted"); + assert.deepEqual(messages[0]?.payload, quotaPayload("storage")); + assert.equal(messages[0]?.createdAt, NOW.getTime()); +}); + +test("release frees the dedupe key, keeps history, and lets the next crossing write again", async () => { + const released = await store.release({ + dedupeKey: "quota-exhausted:ns-a:storage", + now: new Date(NOW.getTime() + 2000), + }); + const releasedAgain = await store.release({ + dedupeKey: "quota-exhausted:ns-a:storage", + }); + const reproduced = await store.produce({ + dedupeKey: "quota-exhausted:ns-a:storage", + kind: "quota-exhausted", + namespace: "ns-a", + now: new Date(NOW.getTime() + 3000), + payload: quotaPayload("storage"), + }); + + assert.equal(released, true); + assert.equal(releasedAgain, false, "a released key releases nothing twice"); + assert.equal(reproduced, true, "recovery resets the edge trigger"); + const messages = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); + assert.equal(messages.length, 2, "the released entry stays as history"); + assert.equal(messages[0]?.createdAt, NOW.getTime() + 3000, "newest first"); +}); + +test("messages are isolated per namespace", async () => { + await store.produce({ + dedupeKey: "quota-exhausted:ns-b:cpu", + kind: "quota-exhausted", + namespace: "ns-b", + now: NOW, + payload: quotaPayload("cpu"), + }); + + const a = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); + const b = await store.listMessages({ + namespace: "ns-b", + userUid: "alice-uid", + }); + assert.equal(a.length, 2); + assert.equal(b.length, 1); + assert.equal( + b[0]?.payload.kind === "quota-exhausted" ? b[0].payload.resource : null, + "cpu" + ); +}); + +test("receipts are per user, follow the person across workspaces, and db receipts attach their row", async () => { + await bindIdentity("alice", "alice-uid"); + await bindIdentity("bob", "bob-uid"); + const [message] = await store.listMessages({ + namespace: "ns-b", + userUid: "alice-uid", + }); + assert.ok(message); + const dbId = dbNotificationId(message.id); + const crId = "cr:debt-choice-debtperiod:1756200000"; + + await store.markRead(reader({ namespace: "ns-b" }), [dbId, crId, crId]); + await store.markRead(reader({ namespace: "ns-b" }), [dbId]); + + assert.deepEqual( + (await store.listReceipts("alice-uid")).sort(), + [crId, dbId].sort() + ); + assert.deepEqual( + await store.listReceipts("bob-uid"), + [], + "another user's inbox is untouched" + ); + const [receipt] = await db + .select({ messageId: notificationReadReceipts.messageId }) + .from(notificationReadReceipts) + .where( + and( + eq(notificationReadReceipts.userUid, "alice-uid"), + eq(notificationReadReceipts.messageKey, dbId) + ) + ); + assert.equal(receipt?.messageId, message.id); +}); + +test("an account-scoped message follows the person into every workspace and takes its receipt from any of them", async () => { + await store.produce({ + account: { legacyWorkspaceActor: "alice", userUid: "alice-uid" }, + dedupeKey: "credit-hint:alice-uid", + kind: "credit-hint", + namespace: "ns-a", + now: NOW, + payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, + }); + const inNsB = await store.listMessages({ + namespace: "ns-b", + userUid: "alice-uid", + }); + const hint = inNsB.find((message) => message.kind === "credit-hint"); + assert.ok(hint, "a workspace that never observed the gift lists the hint"); + assert.equal( + (await store.listMessages({ namespace: "ns-a", userUid: "bob-uid" })).some( + (message) => message.kind === "credit-hint" + ), + false, + "another person's inbox in the observing workspace does not" + ); + + // The receipt attaches the row from a workspace other than the observing one. + const dbId = dbNotificationId(hint.id); + await store.markRead(reader({ namespace: "ns-b" }), [dbId]); + const [receipt] = await db + .select({ messageId: notificationReadReceipts.messageId }) + .from(notificationReadReceipts) + .where( + and( + eq(notificationReadReceipts.userUid, "alice-uid"), + eq(notificationReadReceipts.messageKey, dbId) + ) + ); + assert.equal(receipt?.messageId, hint.id); +}); + +test("a db receipt for a foreign or unknown message is ignored, not refused", async () => { + const [foreign] = await store.listMessages({ + namespace: "ns-a", + userUid: "alice-uid", + }); + assert.ok(foreign); + + await store.markRead(reader({ namespace: "ns-b" }), [ + dbNotificationId(foreign.id), + dbNotificationId("does-not-exist"), + ]); + + const receipts = await store.listReceipts("alice-uid"); + assert.equal(receipts.includes(dbNotificationId(foreign.id)), false); + assert.equal(receipts.includes(dbNotificationId("does-not-exist")), false); +}); + +test("marking read refuses a superseded identity binding", async () => { + await assert.rejects( + store.markRead( + reader({ legacyWorkspaceActor: "alice", userUid: "someone-else-uid" }), + ["cr:x:1"] + ), + SUPERSEDED_RE + ); +}); + +test("producing an account-scoped message refuses a superseded identity binding; workspace rows need none", async () => { + await assert.rejects( + store.produce({ + account: { legacyWorkspaceActor: "alice", userUid: "someone-else-uid" }, + dedupeKey: "credit-hint:someone-else-uid", + kind: "credit-hint", + namespace: "ns-a", + now: NOW, + payload: { giftMicroUnits: 720_000, kind: "credit-hint" }, + }), + SUPERSEDED_RE + ); + assert.equal( + ( + await db + .select({ id: notificationMessages.id }) + .from(notificationMessages) + .where(eq(notificationMessages.userUid, "someone-else-uid")) + ).length, + 0, + "the refused write left no row" + ); + // A workspace-scoped row is not a personal resource: no binding involved. + assert.equal( + await store.produce({ + dedupeKey: "quota-exhausted:ns-guard:memory", + kind: "quota-exhausted", + namespace: "ns-guard", + now: NOW, + payload: quotaPayload("memory"), + }), + true + ); +}); + +test("writes sweep entries older than 365 days and their receipts cascade", async () => { + const old = new Date(NOW.getTime() - 400 * DAY_MS); + await store.produce({ + dedupeKey: "quota-exhausted:ns-c:memory", + kind: "quota-exhausted", + namespace: "ns-c", + now: old, + payload: quotaPayload("memory"), + }); + const [oldMessage] = await store.listMessages({ + namespace: "ns-c", + userUid: "carol-uid", + }); + assert.ok(oldMessage); + await bindIdentity("carol", "carol-uid"); + const carol = reader({ + legacyWorkspaceActor: "carol", + namespace: "ns-c", + userUid: "carol-uid", + }); + await store.markRead(carol, [dbNotificationId(oldMessage.id), "cr:keep:1"]); + + // Any later write sweeps: here a different namespace's entry. + await store.produce({ + dedupeKey: "quota-exhausted:ns-d:cpu", + kind: "quota-exhausted", + namespace: "ns-d", + now: NOW, + payload: quotaPayload("cpu"), + }); + + assert.deepEqual( + await store.listMessages({ namespace: "ns-c", userUid: "carol-uid" }), + [] + ); + assert.deepEqual( + await store.listReceipts("carol-uid"), + ["cr:keep:1"], + "the swept row's receipt cascades; the CR receipt stays" + ); + assert.equal( + (await store.listMessages({ namespace: "ns-a", userUid: "alice-uid" })) + .length, + 3, + "entries inside the window survive (two workspace rows and alice's hint)" + ); +}); diff --git a/apps/ui/src/features/notifications/store.ts b/apps/ui/src/features/notifications/store.ts new file mode 100644 index 00000000..42992abe --- /dev/null +++ b/apps/ui/src/features/notifications/store.ts @@ -0,0 +1,264 @@ +import { randomUUID } from "node:crypto"; + +import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; + +import { requireCurrentIdentityBinding } from "@/lib/identity-fingerprint-core"; +import { DAY_MS } from "@/lib/time"; + +import type { NotificationPgDatabase } from "./db-types"; +import { DB_NOTIFICATION_ID_PREFIX } from "./notification-ids"; +import { + NOTIFICATION_RETENTION_DAYS, + type NotificationMessageRow, + notificationMessages, + notificationReadReceipts, +} from "./schema"; +import type { + NotificationMessage, + NotificationMessageKind, + NotificationPayload, +} from "./types"; + +type ReceiptInsert = typeof notificationReadReceipts.$inferInsert; + +/** + * Whose inbox: the workspace's own messages plus the account-scoped ones + * that follow this person into every workspace. + */ +export interface NotificationInbox { + namespace: string; + userUid: string; +} + +/** + * The reader marking notifications read: bare uid key + crName for the + * re-check, plus the inbox whose `db:` rows the receipts may attach to. + */ +export interface NotificationReader extends NotificationInbox { + legacyWorkspaceActor: string; +} + +/** + * The verified person an account-scoped row is keyed by: the bare uid the + * row carries plus the crName the write re-checks the binding against + * (ADR-0059), so a merge either sweeps the row or refuses the stale write. + */ +export interface NotificationAccountOwner { + legacyWorkspaceActor: string; + userUid: string; +} + +export interface ProduceNotificationInput { + /** + * Set for account-scoped messages (the gift hint, later the expiry + * reminder): the row follows the person into every workspace's inbox and + * `namespace` only records where it was first observed. + */ + account?: NotificationAccountOwner | null; + dedupeKey: string; + kind: NotificationMessageKind; + namespace: string; + /** Injection seam for the sweep's clock; defaults to now. */ + now?: Date; + payload: NotificationPayload; + projectUid?: string | null; +} + +export interface NotificationStore { + /** The inbox's entries — the workspace's own plus the person's account-scoped ones — newest first. */ + listMessages(inbox: NotificationInbox): Promise; + /** The user's receipts, as source-prefixed ids. */ + listReceipts(userUid: string): Promise; + /** + * Records receipts for the given source-prefixed ids. `db:` ids attach the + * message row so the sweep cascades; unknown or foreign `db:` ids are + * ignored rather than refused. Idempotent. + */ + markRead(reader: NotificationReader, ids: readonly string[]): Promise; + /** + * Edge-triggered write: inserts one entry unless a live row already holds + * the dedupe key, in which case nothing is written (retries are idempotent + * by naming). Every write also sweeps entries older than the retention + * window — no scheduler. Returns whether a row was inserted. + */ + produce(input: ProduceNotificationInput): Promise; + /** + * Recovery: frees the dedupe key so the next threshold crossing writes a + * fresh entry. The released row stays as history. Returns whether a live + * row was released. + */ + release(input: { dedupeKey: string; now?: Date }): Promise; +} + +/** + * `db:` receipts attach their message row so the sweep cascades; a `db:` id + * this inbox does not hold is skipped (it would outlive nothing). Every + * other id is a platform key and stands alone. + */ +function receiptRows( + keys: readonly string[], + ownedMessageIds: ReadonlySet, + reader: NotificationReader +): ReceiptInsert[] { + const rows: ReceiptInsert[] = []; + for (const key of keys) { + const isDbKey = key.startsWith(DB_NOTIFICATION_ID_PREFIX); + const messageId = isDbKey + ? key.slice(DB_NOTIFICATION_ID_PREFIX.length) + : null; + if (isDbKey && !(messageId != null && ownedMessageIds.has(messageId))) { + continue; + } + rows.push({ messageId, messageKey: key, userUid: reader.userUid }); + } + return rows; +} + +function toMessage(row: NotificationMessageRow): NotificationMessage { + return { + createdAt: row.createdAt.getTime(), + id: row.id, + kind: row.kind, + payload: row.payload, + projectUid: row.projectUid, + }; +} + +/** The rows one inbox holds: the workspace's own and the person's account-scoped ones. */ +function inboxMessages(inbox: NotificationInbox) { + return or( + and( + eq(notificationMessages.namespace, inbox.namespace), + isNull(notificationMessages.userUid) + ), + eq(notificationMessages.userUid, inbox.userUid) + ); +} + +/** + * Persistence for the Notification Center's Brain-produced stream and read + * receipts (ADR-0067). The handle's schema carries `identity_fingerprints` + * too: receipt writes must span the binding re-check (ADR-0059). + */ +export function createNotificationStore( + getDb: () => NotificationPgDatabase +): NotificationStore { + return { + listMessages: async (inbox) => { + const rows = await getDb() + .select() + .from(notificationMessages) + .where(inboxMessages(inbox)) + .orderBy( + desc(notificationMessages.createdAt), + desc(notificationMessages.id) + ); + return rows.map(toMessage); + }, + listReceipts: async (userUid) => { + const rows = await getDb() + .select({ messageKey: notificationReadReceipts.messageKey }) + .from(notificationReadReceipts) + .where(eq(notificationReadReceipts.userUid, userUid)); + return rows.map((row) => row.messageKey); + }, + markRead: (reader, ids) => + getDb().transaction(async (tx) => { + const keys = [...new Set(ids)]; + if (keys.length === 0) { + return; + } + // Receipts are uid-keyed rows: re-check the fingerprint in the same + // transaction so a concurrent merge either sweeps them or refuses + // the stale binding (ADR-0059). + await requireCurrentIdentityBinding(tx, { + crName: reader.legacyWorkspaceActor, + userUid: reader.userUid, + }); + const dbIds = keys + .filter((key) => key.startsWith(DB_NOTIFICATION_ID_PREFIX)) + .map((key) => key.slice(DB_NOTIFICATION_ID_PREFIX.length)); + const owned = + dbIds.length === 0 + ? [] + : await tx + .select({ id: notificationMessages.id }) + .from(notificationMessages) + .where( + and( + inboxMessages(reader), + inArray(notificationMessages.id, dbIds) + ) + ); + const ownedIds = new Set(owned.map((row) => row.id)); + const values = receiptRows(keys, ownedIds, reader); + if (values.length === 0) { + return; + } + await tx + .insert(notificationReadReceipts) + .values(values) + .onConflictDoNothing({ + target: [ + notificationReadReceipts.userUid, + notificationReadReceipts.messageKey, + ], + }); + }), + produce: (input) => + getDb().transaction(async (tx) => { + const now = input.now ?? new Date(); + // An account-scoped row is a uid-keyed personal resource: re-check + // the fingerprint in the same transaction, as markRead does, so an + // observation that authorized before a merge cannot commit a + // tombstone-keyed row after the merge's sweep (ADR-0059). + if (input.account != null) { + await requireCurrentIdentityBinding(tx, { + crName: input.account.legacyWorkspaceActor, + userUid: input.account.userUid, + }); + } + const inserted = await tx + .insert(notificationMessages) + .values({ + createdAt: now, + dedupeKey: input.dedupeKey, + id: randomUUID(), + kind: input.kind, + namespace: input.namespace, + payload: input.payload, + projectUid: input.projectUid ?? null, + userUid: input.account?.userUid ?? null, + }) + .onConflictDoNothing({ + target: notificationMessages.dedupeKey, + where: sql`${notificationMessages.releasedAt} IS NULL`, + }) + .returning({ id: notificationMessages.id }); + // Retention is enforced here, on the write path, so the table never + // needs a scheduler; receipts on swept rows cascade. + await tx + .delete(notificationMessages) + .where( + lt( + notificationMessages.createdAt, + new Date(now.getTime() - NOTIFICATION_RETENTION_DAYS * DAY_MS) + ) + ); + return inserted.length > 0; + }), + release: async (input) => { + const released = await getDb() + .update(notificationMessages) + .set({ releasedAt: input.now ?? new Date() }) + .where( + and( + eq(notificationMessages.dedupeKey, input.dedupeKey), + isNull(notificationMessages.releasedAt) + ) + ) + .returning({ id: notificationMessages.id }); + return released.length > 0; + }, + }; +} diff --git a/apps/ui/src/features/notifications/subscription-change-observer.test.ts b/apps/ui/src/features/notifications/subscription-change-observer.test.ts new file mode 100644 index 00000000..3f5f4beb --- /dev/null +++ b/apps/ui/src/features/notifications/subscription-change-observer.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { SubscriptionTransactionStatus } from "@/features/billing/billing-plan-data"; + +import { + cancellationReceipt, + observeSubscriptionChangeQuietly, + subscriptionChangeReceiptFromTransaction, +} from "./subscription-change-observer"; + +function tx( + overrides: Partial +): SubscriptionTransactionStatus { + return { + id: "txn-1", + operator: "upgraded", + payId: "pay-1", + planName: "Pro", + startAt: null, + status: "completed", + ...overrides, + }; +} + +test("a completed upgrade or first subscription is an upgrade receipt keyed by the transaction", () => { + assert.deepEqual(subscriptionChangeReceiptFromTransaction(tx({})), { + change: "upgraded", + planName: "Pro", + transactionId: "txn-1", + }); + assert.deepEqual( + subscriptionChangeReceiptFromTransaction( + tx({ id: "txn-2", operator: "created", planName: "Hobby" }) + ), + { change: "upgraded", planName: "Hobby", transactionId: "txn-2" } + ); +}); + +test("a scheduled downgrade is a receipt with its effective date; unpaid, renewed, or absent transactions are none", () => { + assert.deepEqual( + subscriptionChangeReceiptFromTransaction( + tx({ + id: "txn-3", + operator: "downgraded", + planName: "Hobby", + startAt: "2026-09-27T12:00:00.000Z", + status: "pending", + }) + ), + { + change: "downgraded", + effectiveAt: "2026-09-27T12:00:00.000Z", + planName: "Hobby", + transactionId: "txn-3", + } + ); + assert.equal( + subscriptionChangeReceiptFromTransaction(tx({ status: "pending" })), + null, + "an unpaid upgrade invoice is not a change" + ); + assert.equal( + subscriptionChangeReceiptFromTransaction(tx({ operator: "renewed" })), + null + ); + assert.equal(subscriptionChangeReceiptFromTransaction(null), null); + assert.equal(subscriptionChangeReceiptFromTransaction(tx({ id: "" })), null); +}); + +test("a cancellation is keyed by its transaction when the platform records one, else by the period end", () => { + assert.deepEqual( + cancellationReceipt({ + currentPeriodEndAt: "2026-09-08T00:00:00.000Z", + planName: "Hobby", + transaction: tx({ id: "txn-9", operator: "canceled" }), + }), + { + change: "cancelled", + effectiveAt: "2026-09-08T00:00:00.000Z", + planName: "Hobby", + transactionId: "txn-9", + } + ); + assert.deepEqual( + cancellationReceipt({ + currentPeriodEndAt: "2026-09-08T00:00:00.000Z", + planName: "Hobby", + transaction: tx({ id: "txn-1", operator: "upgraded" }), + }), + { + change: "cancelled", + effectiveAt: "2026-09-08T00:00:00.000Z", + planName: "Hobby", + transactionId: "cancel:2026-09-08T00:00:00.000Z", + } + ); +}); + +test("the quiet observer reads the last transaction and reports; failures never throw", async () => { + const reported: unknown[] = []; + await observeSubscriptionChangeQuietly( + { + appToken: "t", + kubeconfig: "k", + regionDomain: "r", + workspace: "ns-a", + }, + { + loadTransaction: () => Promise.resolve(tx({})), + report: (_credentials, observation) => { + reported.push(observation); + return Promise.resolve(); + }, + } + ); + assert.deepEqual(reported, [ + { change: "upgraded", planName: "Pro", transactionId: "txn-1" }, + ]); + + await observeSubscriptionChangeQuietly( + { + appToken: "t", + cancelled: { + currentPeriodEndAt: "2026-09-08T00:00:00.000Z", + planName: "Hobby", + }, + kubeconfig: "k", + regionDomain: "r", + workspace: "ns-a", + }, + { + loadTransaction: () => Promise.reject(new Error("offline")), + report: (_credentials, observation) => { + reported.push(observation); + return Promise.resolve(); + }, + } + ); + assert.equal(reported.length, 2); + assert.deepEqual(reported[1], { + change: "cancelled", + effectiveAt: "2026-09-08T00:00:00.000Z", + planName: "Hobby", + transactionId: "cancel:2026-09-08T00:00:00.000Z", + }); + + await observeSubscriptionChangeQuietly( + { appToken: "t", kubeconfig: "k", regionDomain: "r", workspace: "ns-a" }, + { + loadTransaction: () => Promise.resolve(tx({})), + report: () => Promise.reject(new Error("503")), + } + ); +}); diff --git a/apps/ui/src/features/notifications/subscription-change-observer.ts b/apps/ui/src/features/notifications/subscription-change-observer.ts new file mode 100644 index 00000000..1dfb0917 --- /dev/null +++ b/apps/ui/src/features/notifications/subscription-change-observer.ts @@ -0,0 +1,136 @@ +import { + loadSubscriptionTransactionStatus, + type SubscriptionTransactionStatus, +} from "@/features/billing/billing-plan-data"; + +import { + type NotificationClientCredentials, + reportSubscriptionChangeObservation, +} from "./client"; +import type { SubscriptionChangeObservationRequest } from "./types"; + +/** + * The client-side observation point for subscription-change receipts + * (catalog B5): after a change settles on the Plan view, the last + * transaction says what actually happened and names the transaction that + * keys the receipt. Pure derivations here; the quiet observer is best-effort + * and never fails the billing flow that called it. + */ + +/** + * An upgrade (or a first subscription from PAYG, which the product voices as + * an upgrade) is a change once its payment completed; an unpaid invoice is + * not. A downgrade lands at period end, so its scheduled transaction already + * is the change and carries the effective date. Renewals and lifecycle + * transactions are not receipts. + */ +export function subscriptionChangeReceiptFromTransaction( + transaction: SubscriptionTransactionStatus | null +): SubscriptionChangeObservationRequest | null { + if ( + transaction == null || + transaction.id === "" || + transaction.planName === "" + ) { + return null; + } + switch (transaction.operator) { + case "created": + case "upgraded": + return transaction.status === "completed" + ? { + change: "upgraded", + planName: transaction.planName, + transactionId: transaction.id, + } + : null; + case "downgraded": + return transaction.status === "pending" || + transaction.status === "completed" + ? { + change: "downgraded", + ...(transaction.startAt == null + ? {} + : { effectiveAt: transaction.startAt }), + planName: transaction.planName, + transactionId: transaction.id, + } + : null; + default: + return null; + } +} + +/** + * A cancellation keeps the plan until period end. The platform's transaction + * keys it when one was recorded; otherwise the period end is the natural + * identity — cancelling the same period twice is one event. + */ +export function cancellationReceipt(input: { + currentPeriodEndAt: string; + planName: string; + transaction: SubscriptionTransactionStatus | null; +}): SubscriptionChangeObservationRequest { + const recorded = + input.transaction?.operator === "canceled" && input.transaction.id !== "" + ? input.transaction.id + : `cancel:${input.currentPeriodEndAt}`; + return { + change: "cancelled", + effectiveAt: input.currentPeriodEndAt, + planName: input.planName, + transactionId: recorded, + }; +} + +export interface SubscriptionChangeObservationInput { + appToken: string; + /** Set when the change was an explicit cancellation (no payment settles). */ + cancelled?: { currentPeriodEndAt: string; planName: string }; + kubeconfig: string; + regionDomain: string; + workspace: string; +} + +export interface SubscriptionChangeObserverDependencies { + loadTransaction?: typeof loadSubscriptionTransactionStatus; + report?: ( + credentials: NotificationClientCredentials, + observation: SubscriptionChangeObservationRequest + ) => Promise; +} + +/** Best-effort: reads the last transaction, reports the receipt, swallows everything. */ +export async function observeSubscriptionChangeQuietly( + input: SubscriptionChangeObservationInput, + dependencies: SubscriptionChangeObserverDependencies = {} +): Promise { + const loadTransaction = + dependencies.loadTransaction ?? loadSubscriptionTransactionStatus; + const report = dependencies.report ?? reportSubscriptionChangeObservation; + try { + const transaction = await loadTransaction({ + appToken: input.appToken, + kubeconfig: input.kubeconfig, + regionDomain: input.regionDomain, + workspace: input.workspace, + }).catch(() => null); + const observation = + input.cancelled == null + ? subscriptionChangeReceiptFromTransaction(transaction) + : cancellationReceipt({ ...input.cancelled, transaction }); + if (observation == null) { + return; + } + await report( + { + appToken: input.appToken, + kubeconfig: input.kubeconfig, + namespace: input.workspace, + }, + observation + ); + } catch { + // A receipt is a nicety; the billing flow that settled must not notice. + } +} diff --git a/apps/ui/src/features/notifications/types.ts b/apps/ui/src/features/notifications/types.ts new file mode 100644 index 00000000..25605388 --- /dev/null +++ b/apps/ui/src/features/notifications/types.ts @@ -0,0 +1,196 @@ +import type { NotificationCRItem } from "@workspace/api/hooks"; +import { z } from "zod"; + +import { workspaceResourceQuotaSnapshotSchema } from "@/features/billing/workspace-resource-quota"; + +/** + * The Brain-produced Notification kinds (`db:` stream). Each kind's payload + * carries the structured parameters the display layer renders; strings never + * cross the wire. Catalog rows A1 (quota exhausted), D4 (the $1 gift hint), + * and B5 (subscription-change receipts). + */ +export const NOTIFICATION_MESSAGE_KINDS = [ + "quota-exhausted", + "credit-hint", + "subscription-change", +] as const; + +export type NotificationMessageKind = + (typeof NOTIFICATION_MESSAGE_KINDS)[number]; + +/** The five workspace quota resources a `quota-exhausted` entry can name. */ +export const QUOTA_EXHAUSTED_RESOURCES = [ + "cpu", + "memory", + "storage", + "pod", + "nodeport", +] as const; + +export type QuotaExhaustedResource = (typeof QUOTA_EXHAUSTED_RESOURCES)[number]; + +export const quotaExhaustedPayloadSchema = z + .object({ + kind: z.literal("quota-exhausted"), + resource: z.enum(QUOTA_EXHAUSTED_RESOURCES), + /** Snapshot at the crossing, in the quota item's native unit. */ + limit: z.number().finite().nonnegative(), + used: z.number().finite().nonnegative(), + }) + .strict(); + +/** ISO-8601 instant; the display layer renders it as an absolute date. */ +const isoInstantSchema = z.string().datetime({ offset: true }); + +export const creditHintPayloadSchema = z + .object({ + kind: z.literal("credit-hint"), + /** + * Gift expiry. Upstream's credits/info carries no per-row expiry today, + * so live entries omit it and the copy states the one-month validity; + * the renderer already speaks the dated form for when it lands. + */ + expiresAt: isoInstantSchema.optional(), + /** Remaining gift at first visibility, in micro-units. */ + giftMicroUnits: z.number().finite().nonnegative(), + }) + .strict(); + +export const SUBSCRIPTION_CHANGES = [ + "upgraded", + "downgraded", + "cancelled", +] as const; + +export type SubscriptionChange = (typeof SUBSCRIPTION_CHANGES)[number]; + +export const subscriptionChangePayloadSchema = z + .object({ + kind: z.literal("subscription-change"), + change: z.enum(SUBSCRIPTION_CHANGES), + /** When a scheduled change lands (a downgrade or cancellation at period end). */ + effectiveAt: isoInstantSchema.optional(), + planName: z.string().trim().min(1).max(64), + }) + .strict(); + +export const notificationPayloadSchema = z.discriminatedUnion("kind", [ + quotaExhaustedPayloadSchema, + creditHintPayloadSchema, + subscriptionChangePayloadSchema, +]); + +export type NotificationPayload = z.infer; + +/** The `db:` stream's wire shape (`GET /api/notifications`). */ +export const notificationMessageSchema = z + .object({ + id: z.string().min(1), + kind: z.enum(NOTIFICATION_MESSAGE_KINDS), + /** Unix epoch milliseconds. */ + createdAt: z.number().int().nonnegative(), + payload: notificationPayloadSchema, + projectUid: z.string().nullable(), + }) + .strict(); + +export type NotificationMessage = z.infer; + +/** + * One upstream Notification CR as the Go read proxy flattens it — pinned to + * `NotificationCRItem` from `@workspace/api/hooks` so the two cannot drift; + * only the dev fixtures ever send it over Brain's own wire. + */ +export const notificationCRItemSchema = z + .object({ + creationTimestamp: z.string().optional(), + desktopPopup: z.boolean(), + from: z.string().optional(), + importance: z.string().optional(), + isRead: z.boolean(), + message: z.string(), + name: z.string().min(1), + namespace: z.string(), + /** `spec.timestamp` in Unix seconds. */ + timestamp: z.number().int().nonnegative(), + title: z.string(), + uid: z.string().optional(), + /** The id's version component (see `NotificationCRItem.version`). */ + version: z.number().int().nonnegative(), + }) + .strict() satisfies z.ZodType; + +export const notificationFeedResponseSchema = z + .object({ + messages: z.array(notificationMessageSchema), + /** + * Fixture platform CRs, present only while the billing Dev Mock serves + * the feed: the client then takes them as the `cr:` stream instead of + * polling the cluster. Production never sends the field. + */ + platformItems: z.array(notificationCRItemSchema).optional(), + /** Source-prefixed notification ids the current user has read. */ + receipts: z.array(z.string().min(1)), + }) + .strict(); + +export type NotificationFeedResponse = z.infer< + typeof notificationFeedResponseSchema +>; + +/** A notification id is source-prefixed: `cr::` or `db:`. */ +export const NOTIFICATION_ID_PATTERN = /^(cr|db):.+$/; + +/** Ids per mark-read request; the client splits larger batches. */ +export const NOTIFICATION_READ_BATCH_LIMIT = 200; + +export const markNotificationReadRequestSchema = z + .object({ + ids: z + .array(z.string().regex(NOTIFICATION_ID_PATTERN)) + .min(1) + .max(NOTIFICATION_READ_BATCH_LIMIT), + }) + .strict(); + +export type MarkNotificationReadRequest = z.infer< + typeof markNotificationReadRequestSchema +>; + +export const quotaObservationRequestSchema = z + .object({ + quota: workspaceResourceQuotaSnapshotSchema, + }) + .strict(); + +export type QuotaObservationRequest = z.infer< + typeof quotaObservationRequestSchema +>; + +/** `POST /api/notifications/gift-observation`: the credits the client just read. */ +export const giftObservationRequestSchema = z + .object({ + giftMicroUnits: z.number().finite().nonnegative(), + }) + .strict(); + +export type GiftObservationRequest = z.infer< + typeof giftObservationRequestSchema +>; + +/** + * `POST /api/notifications/subscription-change`: a change the client saw + * settle, identified by the platform's transaction id. + */ +export const subscriptionChangeObservationRequestSchema = z + .object({ + change: z.enum(SUBSCRIPTION_CHANGES), + effectiveAt: isoInstantSchema.optional(), + planName: z.string().trim().min(1).max(64), + transactionId: z.string().trim().min(1).max(128), + }) + .strict(); + +export type SubscriptionChangeObservationRequest = z.infer< + typeof subscriptionChangeObservationRequestSchema +>; diff --git a/apps/ui/src/features/notifications/use-notification-feed.ts b/apps/ui/src/features/notifications/use-notification-feed.ts new file mode 100644 index 00000000..dacbd275 --- /dev/null +++ b/apps/ui/src/features/notifications/use-notification-feed.ts @@ -0,0 +1,255 @@ +"use client"; + +import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; +import { + markNotificationCRRead, + NOTIFICATION_CR_REFRESH_INTERVAL_MS, + useNotificationCRList, +} from "@workspace/api/hooks"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; + +import { loadAccountCredits } from "@/features/billing/account-credits"; +import { loadHasToppedUp } from "@/features/billing/account-top-up"; +import { + type AppNotification, + countUnreadNotifications, + isNotificationUnread, +} from "@/features/shell/app-sidebar-notifications-model"; +import { notificationReadIdsAtom } from "@/features/shell/app-sidebar-notifications-store"; +import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; +import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +import { + fetchNotificationFeed, + postNotificationReadReceipts, + reportGiftCreditObservation, +} from "./client"; +import { isGiftOnlyNewcomer, mergeNotificationFeed } from "./feed-model"; +import { planReadDispatch } from "./read-dispatch"; + +const EMPTY_ITEMS: readonly AppNotification[] = []; + +export interface NotificationFeed { + items: readonly AppNotification[]; + markAllRead: () => void; + markRead: (item: AppNotification) => void; + readIds: ReadonlySet; + unreadCount: number; +} + +/** + * The merged Notification Center feed: platform CRs polled from the cluster + * (≤5 minutes) and Brain-produced entries plus the user's receipts from + * Brain's store, merged client-side into one list with the display layer + * applied (override table, gift-only filter). Mark-read is optimistic and + * dispatched per source; a failed receipt rolls the optimistic state back so + * the item reads unread again and the click can be retried. The account's + * credits and top-up history decide the gift-only filter and are the gift + * hint's observation point; while the billing Dev Mock serves the feed, its + * fixture CRs replace the cluster poll. + */ +export function useNotificationFeed(): NotificationFeed { + const appToken = useAtomValue(appTokenAtom).trim(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const namespace = useAtomValue(namespaceAtom).trim(); + const [readIds, setReadIds] = useAtom(notificationReadIdsAtom); + const { data: subscription } = useWorkspaceSubscriptionSummary(); + + const credentialsReady = + appToken !== "" && kubeconfig !== "" && namespace !== ""; + const credentialKey = kubeconfigCredentialKey(kubeconfig); + + const brainFeed = useSWR( + credentialsReady + ? (["notifications-feed", namespace, credentialKey, appToken] as const) + : null, + () => fetchNotificationFeed({ appToken, kubeconfig, namespace }), + { + refreshInterval: NOTIFICATION_CR_REFRESH_INTERVAL_MS, + shouldRetryOnError: false, + } + ); + const { mutate: refreshBrainFeed } = brainFeed; + // Until the Brain feed answers, the cluster poll runs as in production; + // a mock session pays one cluster request before the fixtures take over. + const fixturePlatformItems = brainFeed.data?.platformItems; + const crList = useNotificationCRList({ + enabled: fixturePlatformItems == null, + kubeconfig, + namespace, + }); + const { mutate: refreshCRList } = crList; + + // The gift hint's observation point: a read that shows gift credit reports + // it. The latch holds while a report is in flight or after one landed; a + // failed report clears it so the next credits read (or a workspace or + // credential change) tries again instead of leaving D4 missing for the + // rest of the session. The producer dedupes by user, so a repeat is + // harmless. + const giftObservedFor = useRef(null); + const observeGift = useCallback( + (giftMicroUnits: number) => { + const key = `${namespace}|${credentialKey}`; + if ( + !credentialsReady || + giftMicroUnits <= 0 || + giftObservedFor.current === key + ) { + return; + } + giftObservedFor.current = key; + reportGiftCreditObservation( + { appToken, kubeconfig, namespace }, + { giftMicroUnits } + ) + .then(() => refreshBrainFeed()) + .catch(() => { + if (giftObservedFor.current === key) { + giftObservedFor.current = null; + } + }); + }, + [ + appToken, + credentialKey, + credentialsReady, + kubeconfig, + namespace, + refreshBrainFeed, + ] + ); + // Account facts for the display layer: gift and usable credit plus whether + // the account ever topped up. Both are account-scoped, so they key on the + // credentials alone. Credits burn down, so they follow the inbox's own + // cadence and every answered poll is a gift observation opportunity even + // when nothing changed (SWR keeps equal `data` referentially stable); + // top-up history only ever grows, so one read per session is the truth + // for the session. + const credits = useSWR( + credentialsReady + ? (["notifications-credits", credentialKey, appToken] as const) + : null, + () => loadAccountCredits({ appToken, kubeconfig }), + { + onSuccess: (data) => observeGift(data.giftMicroUnits), + refreshInterval: NOTIFICATION_CR_REFRESH_INTERVAL_MS, + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + const toppedUp = useSWR( + credentialsReady + ? (["notifications-topped-up", credentialKey, appToken] as const) + : null, + () => loadHasToppedUp({ appToken, kubeconfig }), + { revalidateOnFocus: false, shouldRetryOnError: false } + ); + // Unknown account state never hides a warning: the filter is on only once + // both facts are in and say so. + const giftOnly = + credits.data != null && + toppedUp.data != null && + isGiftOnlyNewcomer({ ...credits.data, hasToppedUp: toppedUp.data }); + + // Data already in SWR's cache answers without a fetch, so the first + // render observes it here; later polls observe through `onSuccess`. + const giftMicroUnits = credits.data?.giftMicroUnits ?? 0; + useEffect(() => { + observeGift(giftMicroUnits); + }, [giftMicroUnits, observeGift]); + + const items = useMemo(() => { + const crItems = fixturePlatformItems ?? crList.data?.items; + if (crItems == null && brainFeed.data == null) { + return EMPTY_ITEMS; + } + return mergeNotificationFeed({ + crItems: crItems ?? [], + dbMessages: brainFeed.data?.messages ?? [], + giftOnly, + receipts: brainFeed.data?.receipts ?? [], + }); + }, [brainFeed.data, crList.data, fixturePlatformItems, giftOnly]); + + const dispatchRead = useCallback( + (targets: readonly AppNotification[]) => { + if (targets.length === 0) { + return; + } + const targetIds = targets.map((target) => target.id); + setReadIds((previous) => { + const next = new Set(previous); + for (const id of targetIds) { + next.add(id); + } + return next; + }); + if (!credentialsReady) { + return; + } + const plan = planReadDispatch(targets, subscription?.role); + const credentials = { appToken, kubeconfig, namespace }; + // The receipt is the read state's source of truth (`readIds` is + // session-only): a failed write rolls the optimistic ids back so the + // dot returns and the user can click again, and says so once. + postNotificationReadReceipts(credentials, plan.receiptIds) + .then(() => refreshBrainFeed()) + .catch(() => { + setReadIds((previous) => { + const next = new Set(previous); + for (const id of targetIds) { + next.delete(id); + } + return next; + }); + toast.error( + targetIds.length === 1 + ? "Couldn't mark the notification as read. Try again." + : "Couldn't mark notifications as read. Try again." + ); + }); + if (fixturePlatformItems != null) { + return; + } + // Desktop parity is best-effort: a 403 (Developer RBAC) or any other + // failure is swallowed — the receipt already made the message read. + for (const name of plan.crNames) { + markNotificationCRRead({ kubeconfig, name, namespace }) + .then(() => refreshCRList()) + .catch(() => undefined); + } + }, + [ + appToken, + credentialsReady, + fixturePlatformItems, + kubeconfig, + namespace, + refreshBrainFeed, + refreshCRList, + setReadIds, + subscription?.role, + ] + ); + + const markRead = useCallback( + (item: AppNotification) => dispatchRead([item]), + [dispatchRead] + ); + const markAllRead = useCallback( + () => + dispatchRead(items.filter((item) => isNotificationUnread(item, readIds))), + [dispatchRead, items, readIds] + ); + + return { + items, + markAllRead, + markRead, + readIds, + unreadCount: countUnreadNotifications(items, readIds), + }; +} diff --git a/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts b/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts index bf3f668c..af785549 100644 --- a/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts +++ b/apps/ui/src/features/shell/app-sidebar-account-presentation.test.ts @@ -15,6 +15,10 @@ function summary( isPayg: false, lifecycle: "active", planName: "PRO", + recoveryVoice: "renew", + role: null, + warningDeadlineAt: null, + warningStage: null, ...overrides, }; } diff --git a/apps/ui/src/features/shell/app-sidebar-account.tsx b/apps/ui/src/features/shell/app-sidebar-account.tsx index 87257eca..8d745a72 100644 --- a/apps/ui/src/features/shell/app-sidebar-account.tsx +++ b/apps/ui/src/features/shell/app-sidebar-account.tsx @@ -30,12 +30,8 @@ import { useRef, useState, } from "react"; -import useSWR from "swr"; import { loadAiCredits } from "@/features/billing/billing-ai-credits"; -import { - loadWorkspaceSubscriptionSummary, - type WorkspaceSubscriptionSummary, -} from "@/features/billing/billing-plan-data"; +import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client"; import { fetchFreeChatTurnsUsage } from "@/features/chat/persistence/client"; @@ -56,6 +52,7 @@ import { quotaUsageTone, } from "@/features/shell/app-sidebar-quota"; import { useCloseOnSidebarToggle } from "@/features/shell/use-close-on-sidebar-toggle"; +import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; import { appTokenAtom, desktopUserAvatarAtom, @@ -758,15 +755,8 @@ export function AppSidebarAccount() { const credentialsReady = appToken !== "" && kubeconfig !== "" && workspace !== ""; - // Live billing data, not the login-time session snapshot: the badge and - // hint follow the same subscription route as the Billing Area's hooks. - const { data: subscriptionSummary, isLoading: subscriptionPending } = useSWR( - credentialsReady - ? (["app-sidebar-subscription", workspace, kubeconfig, appToken] as const) - : null, - () => loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }), - { revalidateOnFocus: false, shouldRetryOnError: false } - ); + const { data: subscriptionSummary, isLoading: subscriptionPending } = + useWorkspaceSubscriptionSummary(); const { badge, hint } = useMemo( () => deriveAppSidebarAccountPresentation( diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx b/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx deleted file mode 100644 index 4d699af5..00000000 --- a/apps/ui/src/features/shell/app-sidebar-notifications-dev-mock.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { - type DevTweaksMockSource, - type DevTweaksMockState, - useDevTweaksMock, -} from "@workspace/dev-tweaks"; -import { useSetAtom } from "jotai"; -import { useEffect } from "react"; -import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; -import { - appNotificationsAtom, - notificationReadIdsAtom, -} from "@/features/shell/app-sidebar-notifications-store"; - -/** - * The Notification Center's Dev Mock: fills the notifications store with - * fixture items so the panel can be designed and demoed before a real feed - * exists. Registered while the App Sidebar is mounted; disabled (the - * default) leaves the store empty — the shipped empty state. - */ - -export const NOTIFICATIONS_DEV_MOCK_KEY = "notifications-mock"; - -const NOTIFICATIONS_DEV_SCENARIOS = ["mixed", "flood", "all-read"] as const; - -const MIXED_ITEMS: AppNotification[] = [ - { - id: "n1", - kind: "deploy-failure", - project: "api-server", - time: "2m ago", - title: "Deployment failed", - unread: true, - }, - { - id: "n2", - kind: "deploy-success", - project: "web-app", - time: "26m ago", - title: "Deployment complete", - unread: true, - }, - { - id: "n3", - kind: "quota", - time: "1h ago", - title: "Memory quota at 82%", - unread: true, - }, - { - id: "n4", - kind: "billing", - time: "Yesterday", - title: "Free trial ends in 3 days", - unread: false, - }, - { - id: "n5", - kind: "deploy-success", - project: "pg-main", - time: "Yesterday", - title: "Database backup finished", - unread: false, - }, - { - id: "n6", - kind: "announcement", - time: "2d ago", - title: "New: database terminal", - unread: false, - }, -]; - -const FLOOD_ITEMS: AppNotification[] = [ - ...MIXED_ITEMS.slice(0, 3), - { - id: "n7", - kind: "deploy-success", - project: "landing", - time: "3h ago", - title: "Deployment complete", - unread: true, - }, - { - id: "n8", - kind: "quota", - time: "6h ago", - title: "Storage quota at 91%", - unread: true, - }, - ...MIXED_ITEMS.slice(3), - { - id: "n9", - kind: "deploy-failure", - project: "redis-cache", - time: "2d ago", - title: "Workload restarted", - unread: false, - }, - { - id: "n10", - kind: "billing", - time: "3d ago", - title: "Invoice ready", - unread: false, - }, - { - id: "n11", - kind: "announcement", - time: "5d ago", - title: "New: GitHub deployments", - unread: false, - }, - { - id: "n12", - kind: "deploy-success", - project: "docs-site", - time: "6d ago", - title: "Deployment complete", - unread: false, - }, -]; - -const SCENARIO_ITEMS: Record = { - "all-read": MIXED_ITEMS.map((item) => ({ ...item, unread: false })), - flood: FLOOD_ITEMS, - mixed: MIXED_ITEMS, -}; - -// In-memory source: the mock has no server side and nothing rewrites it -// behind the panel's back, so session-only module state is the whole truth. -let mockState: DevTweaksMockState | null = null; - -const notificationsDevMockSource: DevTweaksMockSource = { - load: () => mockState, - set: (state) => { - mockState = state; - }, -}; - -/** Registers the mock while the App Sidebar is mounted; renders nothing. */ -export function AppSidebarNotificationsDevMock() { - const mock = useDevTweaksMock(NOTIFICATIONS_DEV_MOCK_KEY, { - note: "Fills the Notification Center with fixture items", - scenarios: NOTIFICATIONS_DEV_SCENARIOS, - source: notificationsDevMockSource, - title: "Notifications mock", - }); - const setItems = useSetAtom(appNotificationsAtom); - const setReadIds = useSetAtom(notificationReadIdsAtom); - - useEffect(() => { - setItems( - mock.enabled ? (SCENARIO_ITEMS[mock.scenario] ?? MIXED_ITEMS) : [] - ); - // A scenario switch is a fresh inbox: session read receipts reset with it. - setReadIds(new Set()); - }, [mock.enabled, mock.scenario, setItems, setReadIds]); - - return null; -} diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts b/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts index 501d9f6b..f368fecb 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-model.test.ts @@ -11,10 +11,11 @@ import { function item(overrides: Partial): AppNotification { return { - id: "n1", - kind: "deploy-success", - time: "2m ago", - title: "Deployment complete", + id: "db:n1", + severity: "info", + source: "db", + timestamp: Date.UTC(2026, 7, 27, 12, 0, 0), + title: "Subscription upgraded", unread: false, ...overrides, }; diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-model.ts b/apps/ui/src/features/shell/app-sidebar-notifications-model.ts index 3493293c..ebc2201e 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-model.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-model.ts @@ -1,28 +1,50 @@ /** * The Notification Center's model: what one Notification is, plus the pure * derivations the panel renders from (unread accounting, tab filtering, the - * entry badge). No data source is wired yet — items arrive via the - * notifications store, which only the dev mock writes today. + * entry badge). Items arrive from the merged feed + * (`@/features/notifications/feed-model`): platform CRs read live from the + * cluster and Brain-produced entries from Brain's own store, one list sorted + * by real time. */ -export type AppNotificationKind = - | "announcement" - | "billing" - | "deploy-failure" - | "deploy-success" - | "quota"; +/** + * Notification Severity: how much the message matters, derived from what it + * is about — critical (already suspended or facing deletion), warning (a + * threshold crossed or a deadline near; action prevents the next stage), + * info (a receipt, a hint, an announcement). Shown without escalation. + */ +export type NotificationSeverity = "critical" | "warning" | "info"; + +/** Which stream a Notification came from; also its id prefix. */ +export type AppNotificationSource = "cr" | "db"; + +/** + * The one way out a Notification offers (design spec §10 rule 6): a verb + * from the CTA table deep-linking to the page that solves the problem. + * Receipts and hints carry none. + */ +export interface NotificationCTA { + href: string; + label: string; +} /** * One Notification: a message addressed to the current user, aggregated - * across Projects. `time` is a preformatted display string until a real - * data source lands; `unread` is the item's own state before session read - * receipts are applied on top. + * across Projects. `id` is source-prefixed (`cr::` or + * `db:`) and doubles as the read-receipt key; `timestamp` is epoch + * milliseconds; `unread` is the item's own state (the CR label, or "never + * read" for Brain entries) before receipts are applied on top. */ export interface AppNotification { + body?: string; + /** The CR to patch when a `cr:` item is marked read (best-effort). */ + crName?: string; + cta?: NotificationCTA; id: string; - kind: AppNotificationKind; project?: string; - time: string; + severity: NotificationSeverity; + source: AppNotificationSource; + timestamp: number; title: string; unread: boolean; } diff --git a/apps/ui/src/features/shell/app-sidebar-notifications-store.ts b/apps/ui/src/features/shell/app-sidebar-notifications-store.ts index bb0cc505..701927da 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications-store.ts +++ b/apps/ui/src/features/shell/app-sidebar-notifications-store.ts @@ -1,17 +1,11 @@ import { atom } from "jotai"; -import type { AppNotification } from "@/features/shell/app-sidebar-notifications-model"; /** - * The Notification Center's items. Empty until a data source writes it — in - * the shell phase only the dev mock does, so production users see the empty - * state. A future real feed replaces the writer, not the readers. - */ -export const appNotificationsAtom = atom([]); - -/** - * Session-local read receipts layered over the items' own `unread` flags: - * clicking a row or "mark all as read" lands here. Deliberately not - * persisted while notifications have no backend identity to key on. + * Optimistic read receipts layered over the items' own `unread` flags: + * clicking a row or "mark all as read" lands here first, and the server + * receipt (and, best-effort, the CR label) follows. Keyed by the + * source-prefixed notification id, so a revived platform message (new + * timestamp, new id) is never covered by a stale receipt. */ export const notificationReadIdsAtom = atom>( new Set() diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx new file mode 100644 index 00000000..997f4cab --- /dev/null +++ b/apps/ui/src/features/shell/app-sidebar-notifications.interaction.test.tsx @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "@testing-library/react/pure"; +import type { NotificationCRItem } from "@workspace/api/hooks"; + +import { CR_OVERRIDES } from "@/features/notifications/cr-overrides"; +import { mergeNotificationFeed } from "@/features/notifications/feed-model"; +import type { NotificationMessage } from "@/features/notifications/types"; +import type { NotificationFeed } from "@/features/notifications/use-notification-feed"; +import { withTestDom } from "@/features/project-canvas/react-test-harness"; + +import { + type AppNotification, + countUnreadNotifications, +} from "./app-sidebar-notifications-model"; + +/** + * AIM-334's display layer, as the user sees it: the 8 fixed-name CRs paint + * their Brain-voiced title, body, and CTA, and the Brain-produced entries + * (A1, D4, B5) paint the sentence that carries the fact — not just a title. + */ + +const NOW_SECONDS = Math.floor(Date.parse("2026-08-27T12:00:00Z") / 1000); + +function platformCR( + name: string, + overrides: Partial = {} +): NotificationCRItem { + return { + desktopPopup: true, + from: "Debt-System", + isRead: false, + message: `upstream body for ${name}`, + name, + namespace: "ns-a", + timestamp: NOW_SECONDS, + title: `upstream title for ${name}`, + version: NOW_SECONDS, + ...overrides, + }; +} + +const GIFT_HINT: NotificationMessage = { + createdAt: (NOW_SECONDS - 60) * 1000, + id: "gift-1", + kind: "credit-hint", + payload: { giftMicroUnits: 1_000_000, kind: "credit-hint" }, + projectUid: null, +}; + +const DOWNGRADE_RECEIPT: NotificationMessage = { + createdAt: (NOW_SECONDS - 120) * 1000, + id: "sub-1", + kind: "subscription-change", + payload: { + change: "downgraded", + effectiveAt: "2026-09-30T00:00:00Z", + kind: "subscription-change", + planName: "Hobby", + }, + projectUid: null, +}; + +const QUOTA_FULL: NotificationMessage = { + createdAt: (NOW_SECONDS - 180) * 1000, + id: "quota-1", + kind: "quota-exhausted", + payload: { kind: "quota-exhausted", limit: 10, resource: "cpu", used: 10 }, + projectUid: null, +}; + +function feedOf(items: readonly AppNotification[]): NotificationFeed { + return { + items, + markAllRead: () => undefined, + markRead: () => undefined, + readIds: new Set(), + unreadCount: countUnreadNotifications(items, new Set()), + }; +} + +async function renderPanel( + feed: NotificationFeed, + run: ( + rendered: ReturnType, + act: (run: () => void) => Promise + ) => void | Promise +) { + await withTestDom(async (act) => { + const { NotificationsPanel } = await import("./app-sidebar-notifications"); + let rendered: ReturnType | undefined; + try { + await act(() => { + rendered = render(); + }); + if (rendered != null) { + await run(rendered, act); + } + } finally { + await act(() => rendered?.unmount()); + } + }); +} + +test("every fixed-name debt-ladder CR renders its override title, body, and CTA", async () => { + const names = Object.keys(CR_OVERRIDES); + assert.equal(names.length, 8); + const items = mergeNotificationFeed({ + crItems: names.map((name, index) => + platformCR(name, { + timestamp: NOW_SECONDS - index, + version: NOW_SECONDS - index, + }) + ), + dbMessages: [], + receipts: [], + }); + + await renderPanel(feedOf(items), (rendered) => { + const text = rendered.container.textContent ?? ""; + for (const name of names) { + const override = CR_OVERRIDES[name]; + assert.ok(override); + assert.ok(text.includes(override.title), `${name}: title is shown`); + assert.ok(text.includes(override.body), `${name}: body is shown`); + assert.equal( + rendered + .getAllByRole("button", { name: override.cta.label }) + .some((cta) => cta.getAttribute("href") === override.cta.href), + true, + `${name}: CTA ${override.cta.label} → ${override.cta.href}` + ); + // The display layer replaces upstream copy; it never shows both. + assert.equal(text.includes(`upstream title for ${name}`), false); + assert.equal(text.includes(`upstream body for ${name}`), false); + } + }); +}); + +test("an unknown platform message falls back to its upstream title and body", async () => { + const items = mergeNotificationFeed({ + crItems: [ + platformCR("release-notes", { from: "Sealos", message: "v3 shipped." }), + ], + dbMessages: [], + receipts: [], + }); + + await renderPanel(feedOf(items), (rendered) => { + const text = rendered.container.textContent ?? ""; + assert.ok(text.includes("upstream title for release-notes")); + assert.ok(text.includes("v3 shipped.")); + }); +}); + +test("Brain-produced entries render the sentence that carries the fact", async () => { + const items = mergeNotificationFeed({ + crItems: [], + dbMessages: [GIFT_HINT, DOWNGRADE_RECEIPT, QUOTA_FULL], + receipts: [], + }); + + await renderPanel(feedOf(items), (rendered) => { + const text = rendered.container.textContent ?? ""; + // D4: the welcome and what the gift is for. + assert.ok(text.includes("You have a $1 welcome gift")); + assert.ok( + text.includes( + "It covers your first deployments and expires a month after it was granted." + ) + ); + // B5: the receipt names the plan and the date. + assert.ok(text.includes("Subscription downgraded")); + assert.ok(text.includes("This workspace moves to Hobby on")); + // A1: the resource, the consequence, and the way out. + assert.ok(text.includes("CPU quota is full")); + assert.ok(text.includes("CPU is at 100%. New deployments can't start.")); + assert.equal( + rendered.getByRole("button", { name: "View usage" }).getAttribute("href"), + "/billing/usage" + ); + }); +}); + +test("clicking a row marks it read and expands its clamped body", async () => { + const items = mergeNotificationFeed({ + crItems: [platformCR("workspace-debt-debt")], + dbMessages: [], + receipts: [], + }); + const read: string[] = []; + const feed: NotificationFeed = { + ...feedOf(items), + markRead: (item) => { + read.push(item.id); + }, + }; + + await renderPanel(feed, async (rendered, act) => { + const row = rendered.container.querySelector( + '[data-slot="app-sidebar-notification-row"]' + ); + assert.ok(row); + // The innermost span holding the copy is the clamped one. + const body = [...row.querySelectorAll("span")] + .reverse() + .find((span) => + span.textContent?.includes( + CR_OVERRIDES["workspace-debt-debt"]?.body ?? "" + ) + ); + assert.ok(body, "the body is in the document before any click"); + assert.ok(body.classList.contains("line-clamp-3"), "clamped at rest"); + const toggle = row.querySelector("button"); + assert.ok(toggle); + await act(() => toggle.click()); + assert.deepEqual( + read, + items.map((item) => item.id) + ); + assert.equal(body.classList.contains("line-clamp-3"), false, "expanded"); + }); +}); diff --git a/apps/ui/src/features/shell/app-sidebar-notifications.tsx b/apps/ui/src/features/shell/app-sidebar-notifications.tsx index 8fd93ee2..605ea602 100644 --- a/apps/ui/src/features/shell/app-sidebar-notifications.tsx +++ b/apps/ui/src/features/shell/app-sidebar-notifications.tsx @@ -1,110 +1,176 @@ "use client"; +import { AppButton } from "@workspace/ui/components/app-button"; import { Popover, PopoverContent, PopoverTrigger, } from "@workspace/ui/components/popover"; import { useSidebar } from "@workspace/ui/components/sidebar"; +import { SlidingToggle } from "@workspace/ui/components/sliding-toggle"; import { cn } from "@workspace/ui/lib/utils"; -import { useAtom, useAtomValue } from "jotai"; import { Bell, - CheckCheck, - CircleCheck, - CircleX, - CreditCard, - Gauge, + CircleAlert, + Info, type LucideIcon, - Megaphone, + TriangleAlert, } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; -import { AppSidebarNotificationsDevMockGate } from "@/features/shell/app-sidebar-notifications-dev-mock-gate"; +import Link from "next/link"; +import { useRef, useState } from "react"; +import { BillingDevMockGate } from "@/features/billing/billing-dev-mock-gate"; +import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; +import { NotificationsDevMockGate } from "@/features/notifications/dev-mock-gate"; +import { + formatNotificationTime, + formatNotificationTimestamp, +} from "@/features/notifications/notification-time"; +import { + type NotificationFeed, + useNotificationFeed, +} from "@/features/notifications/use-notification-feed"; import { type AppNotification, - type AppNotificationKind, - countUnreadNotifications, isNotificationUnread, + type NotificationSeverity, type NotificationTab, notificationBadgeLabel, visibleNotifications, } from "@/features/shell/app-sidebar-notifications-model"; -import { - appNotificationsAtom, - notificationReadIdsAtom, -} from "@/features/shell/app-sidebar-notifications-store"; import { useCloseOnSidebarToggle } from "@/features/shell/use-close-on-sidebar-toggle"; -const KIND_META: Record< - AppNotificationKind, +/** + * Severity is marked, never shouted (CONTEXT.md, Notification Severity): the + * icon and the CTA chip take the semantic color, the same three hues as the + * Status Hint's tint recipe; the card itself stays neutral at every level. + */ +const SEVERITY_META: Record< + NotificationSeverity, { icon: LucideIcon; tint: string } > = { - announcement: { icon: Megaphone, tint: "text-blue-400" }, - billing: { icon: CreditCard, tint: "text-neutral-300" }, - "deploy-failure": { icon: CircleX, tint: "text-red-400" }, - "deploy-success": { icon: CircleCheck, tint: "text-emerald-400" }, - quota: { icon: Gauge, tint: "text-amber-400" }, + critical: { icon: CircleAlert, tint: "text-destructive" }, + info: { icon: Info, tint: "text-blue-600 dark:text-blue-400" }, + warning: { icon: TriangleAlert, tint: "text-amber-600 dark:text-amber-400" }, }; -function NotificationKindIcon({ kind }: { kind: AppNotificationKind }) { - const meta = KIND_META[kind]; - const Icon = meta.icon; +function NotificationTime({ timestamp }: { timestamp: number }) { return ( - - - + ); } -function NotificationRow({ +function NotificationCard({ + expanded, item, onRead, + onToggle, unread, }: { + expanded: boolean; item: AppNotification; onRead: () => void; + onToggle: () => void; unread: boolean; }) { - const meta = [item.project, item.time].filter(Boolean).join(" · "); + const meta = SEVERITY_META[item.severity]; + const Icon = meta.icon; + // The CTA is the card's one way out (design spec §10): a sibling link + // below the message, not a control nested in the row button. Billing CTAs + // record the return route first so the Billing Area's close button comes + // back here. + const cta = item.cta; return ( - +
+ {cta == null ? null : ( + { + if (cta.href.startsWith("/billing")) { + recordBillingReturnRoute(); + } + onRead(); + }} + > + {cta.label} + + } + size="sm" + variant="secondary" + /> + )} +
- + ); } -function NotificationsEmptyState() { +function NotificationsEmptyState({ tab }: { tab: NotificationTab }) { + const nothingYet = tab === "all"; return ( -
+
- No notifications + {nothingYet ? "No notifications yet" : "You're all caught up"} - You're all caught up. + {nothingYet + ? "Billing, quota, and platform messages land here." + : "Nothing unread."}
); } -function NotificationsPanel() { - const items = useAtomValue(appNotificationsAtom); - const [readIds, setReadIds] = useAtom(notificationReadIdsAtom); +export function NotificationsPanel({ feed }: { feed: NotificationFeed }) { + const { items, markAllRead, markRead, readIds, unreadCount } = feed; const [tab, setTab] = useState("all"); + const [expanded, setExpanded] = useState>( + () => new Set() + ); - const unreadCount = countUnreadNotifications(items, readIds); const visible = visibleNotifications(items, tab, readIds); - const markRead = useCallback( - (id: string) => { - setReadIds((previous) => new Set(previous).add(id)); - }, - [setReadIds] - ); - const markAllRead = useCallback(() => { - setReadIds((previous) => { - const next = new Set(previous); - for (const item of items) { + const toggle = (item: AppNotification) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(item.id)) { + next.delete(item.id); + } else { next.add(item.id); } return next; }); - }, [items, setReadIds]); + markRead(item); + }; return ( <> -
+
Notifications - + Mark all as read +
-
- {( - [ - { key: "all", label: "All" }, +
+ 0 ? `Unread ${unreadCount}` : "Unread", + label: ( + + Unread + {unreadCount > 0 ? ( + + {unreadCount} + + ) : null} + + ), + value: "unread", }, - ] as const - ).map((entry) => ( - - ))} + ]} + size="sm" + value={tab} + width="full" + />
{visible.length === 0 ? ( - + ) : ( -
-
+
+
{visible.map((item) => ( - markRead(item.id)} + onRead={() => markRead(item)} + onToggle={() => toggle(item)} unread={isNotificationUnread(item, readIds)} /> ))} @@ -218,9 +288,8 @@ function NotificationsPanel() { export function AppSidebarNotifications() { const { state } = useSidebar(); const expanded = state === "expanded"; - const items = useAtomValue(appNotificationsAtom); - const readIds = useAtomValue(notificationReadIdsAtom); - const unreadCount = countUnreadNotifications(items, readIds); + const feed = useNotificationFeed(); + const { unreadCount } = feed; const badgeLabel = notificationBadgeLabel(unreadCount); const [open, setOpen] = useState(false); // Collapsed anchor: the row keeps its full expanded width under the rail's @@ -244,7 +313,10 @@ export function AppSidebarNotifications() { return ( <> - + {/* The billing Dev Mock's scenarios drive the inbox fixtures too, so + its panel entry is reachable from anywhere the inbox is. */} + + {/* Collapsed rail: anchor the icon slot, sideOffset 6 (the rail-wide convention for popovers) — the default trigger anchor sits at the - clipped full-width row's edge, far past the visible rail. */} + clipped full-width row's edge, far past the visible rail. The + panel keeps a floor height so an empty inbox and a one-item inbox + read as the same surface. */} - + diff --git a/apps/ui/src/features/shell/app-sidebar.tsx b/apps/ui/src/features/shell/app-sidebar.tsx index ed1e4434..d374b2b0 100644 --- a/apps/ui/src/features/shell/app-sidebar.tsx +++ b/apps/ui/src/features/shell/app-sidebar.tsx @@ -33,6 +33,7 @@ import { useState, } from "react"; import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client"; +import { observeWorkspaceQuotaForInbox } from "@/features/notifications/quota-observation"; import { projectIdFromPathname } from "@/features/panes/use-project-id"; import { useProjectsExplorerReadModel } from "@/features/projects/explorer/use-projects-explorer"; import type { @@ -42,7 +43,7 @@ import type { import { createAppSidebarProjectGroups } from "@/features/shell/app-sidebar.groups"; import { AppSidebarAccount } from "@/features/shell/app-sidebar-account"; import { AppSidebarNotifications } from "@/features/shell/app-sidebar-notifications"; -import { kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; +import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; const APP_SIDEBAR_NAV_ID = "app-sidebar-nav"; const APP_SIDEBAR_WIDTH = "13.75rem"; @@ -540,6 +541,7 @@ function AppSidebarChrome({ currentProjectId: string | undefined; projectsActive: boolean; }) { + const appToken = useAtomValue(appTokenAtom).trim(); const kubeconfig = useAtomValue(kubeconfigAtom).trim(); const namespace = useAtomValue(namespaceAtom); const { devMockActive, states } = useProjectsExplorerReadModel({ @@ -556,10 +558,18 @@ function AppSidebarChrome({ ); // Warm the workspace-quota cache so chat turns can inject the snapshot - // without waiting on the desktop SDK (see project-workspace-layout). + // without waiting on the desktop SDK (see project-workspace-layout), and + // let the quota-exhausted producer observe the first snapshot; the Status + // Hint keeps observing on its polling cadence. useEffect(() => { - loadWorkspaceQuotaSnapshot(namespace).catch(() => undefined); - }, [namespace]); + if (appToken === "" || kubeconfig === "") { + loadWorkspaceQuotaSnapshot(namespace).catch(() => undefined); + return; + } + observeWorkspaceQuotaForInbox({ appToken, kubeconfig, namespace }).catch( + () => undefined + ); + }, [appToken, kubeconfig, namespace]); return ( loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }), + { + refreshInterval: options.refreshInterval, + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); +} diff --git a/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx b/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx new file mode 100644 index 00000000..bcd1c45a --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { render } from "@testing-library/react/pure"; + +import { withTestDom } from "@/features/project-canvas/react-test-harness"; + +import type { StatusHint } from "./status-hint-model"; + +const PAYMENT_DUE: StatusHint = { + cta: { href: "/billing", label: "Renew plan" }, + description: + "This workspace is suspended. Resources will be deleted on Sep 6 unless the subscription is renewed.", + dismissible: false, + id: "payment-due", + title: "Workspace suspended — payment due", + tone: "destructive", +}; + +const QUOTA_FULL: StatusHint = { + cta: { href: "/billing/usage", label: "View usage" }, + description: + "New deployments can't start until storage is freed or the plan is upgraded.", + dismissible: true, + id: "quota-full", + title: "Storage quota is full", + tone: "warning", +}; + +async function renderHint( + hint: StatusHint, + run: ( + rendered: ReturnType, + dismissals: () => number, + act: (run: () => void) => Promise + ) => void | Promise +) { + await withTestDom(async (act) => { + const { StatusHintBannerView } = await import("./status-hint-banner"); + let rendered: ReturnType | undefined; + let dismissed = 0; + try { + await act(() => { + rendered = render( + { + dismissed += 1; + }} + /> + ); + }); + if (rendered != null) { + await run(rendered, () => dismissed, act); + } + } finally { + await act(() => rendered?.unmount()); + } + }); +} + +test("a non-dismissible hint renders title, description, and CTA with no close button", async () => { + await renderHint(PAYMENT_DUE, (rendered) => { + // A destructive, non-dismissible state announces itself as an alert. + const banner = rendered.getByRole("alert"); + assert.equal(banner.dataset.state, "payment-due"); + assert.equal(banner.dataset.tone, "destructive"); + const text = banner.textContent ?? ""; + assert.ok(text.includes("Workspace suspended — payment due")); + assert.ok(text.includes("Resources will be deleted on Sep 6")); + const cta = rendered.getByRole("button", { name: "Renew plan" }); + assert.equal(cta.getAttribute("href"), "/billing"); + assert.equal(rendered.queryByRole("button", { name: "Dismiss" }), null); + }); +}); + +test("a dismissible hint pins a close button that reports the dismissal", async () => { + await renderHint(QUOTA_FULL, async (rendered, dismissals, act) => { + assert.equal(rendered.getByRole("status").dataset.state, "quota-full"); + const close = rendered.getByRole("button", { name: "Dismiss" }); + await act(() => close.click()); + assert.equal(dismissals(), 1); + assert.equal( + rendered.getByRole("button", { name: "View usage" }).getAttribute("href"), + "/billing/usage" + ); + }); +}); + +test("the CTA records the billing return route before navigating", async () => { + await renderHint(QUOTA_FULL, async (rendered, _dismissals, act) => { + window.history.replaceState({}, "", "/project/demo?tab=canvas"); + window.sessionStorage.removeItem("billing-return-route"); + const cta = rendered.getByRole("button", { name: "View usage" }); + await act(() => { + cta.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + }); + assert.equal( + window.sessionStorage.getItem("billing-return-route"), + "/project/demo?tab=canvas" + ); + }); +}); diff --git a/apps/ui/src/features/status-hint/status-hint-banner.tsx b/apps/ui/src/features/status-hint/status-hint-banner.tsx new file mode 100644 index 00000000..17094cec --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-banner.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { AppButton } from "@workspace/ui/components/app-button"; +import { AppIconButton } from "@workspace/ui/components/app-icon-button"; +import { cn } from "@workspace/ui/lib/utils"; +import { Info, TriangleAlert, X } from "lucide-react"; +import Link from "next/link"; + +import { recordBillingReturnRoute } from "@/features/billing/billing-return-route"; +import { BILLING_SURFACE_TONES } from "@/features/billing/billing-surface-tones"; + +import type { StatusHint } from "./status-hint-model"; +import { useStatusHint } from "./use-status-hint"; + +/** + * The status hint surface (design spec §7): a full-width tinted strip at the + * very top of the content area, in document flow, that explains a holding + * billing state and offers the fix. One tonal recipe across tones — a + * payment-due stage only ever changes its words, never its visuals. + */ + +export function StatusHintBannerView({ + hint, + onDismiss, +}: { + hint: StatusHint; + onDismiss: () => void; +}) { + const Icon = hint.tone === "info" ? Info : TriangleAlert; + return ( +
+ + + + {hint.title} + + + + {hint.description} + + + {hint.cta.label} + + } + size="sm" + variant="secondary" + /> + {hint.dismissible ? ( + + + + ) : null} +
+ ); +} + +/** + * The connected banner. Mounted at the top of every app shell's content + * column (project and Billing Area alike); renders nothing while no state + * holds. The Plan view's own subscription warning stays — it owns "what + * next on this page", the banner owns the global state. + */ +export function StatusHintBanner() { + const { dismiss, hint } = useStatusHint(); + if (hint == null) { + return null; + } + return ( + dismiss(hint.id)} /> + ); +} diff --git a/apps/ui/src/features/status-hint/status-hint-model.test.ts b/apps/ui/src/features/status-hint/status-hint-model.test.ts new file mode 100644 index 00000000..972785ba --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-model.test.ts @@ -0,0 +1,360 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; + +import { + evaluateStatusHints, + reconcileDismissed, + type StatusHintInputs, + selectStatusHint, +} from "./status-hint-model"; + +const NOW = new Date("2026-08-25T12:00:00Z"); + +function subscription( + overrides: Partial +): WorkspaceSubscriptionSummary { + return { + currentPeriodEndAt: "2026-09-12T12:00:00Z", + isActiveFreeTrial: false, + isPayg: false, + lifecycle: "active", + planName: "Hobby", + recoveryVoice: "renew", + role: "OWNER", + warningDeadlineAt: null, + warningStage: null, + ...overrides, + }; +} + +const QUIET: StatusHintInputs = { + availableBalanceMicroUnits: 50_000_000, + now: NOW, + quota: [ + { label: "CPU", percentUsed: 37.5, type: "cpu" }, + { label: "Memory", percentUsed: 75, type: "memory" }, + { label: "Storage", percentUsed: 60, type: "storage" }, + { label: "Ports", percentUsed: 25, type: "nodeport" }, + { label: "Traffic", percentUsed: 100, type: "traffic" }, + ], + subscription: subscription({}), +}; + +const PAYMENT_DUE: Partial = { + currentPeriodEndAt: "2026-08-23T12:00:00Z", + lifecycle: "payment-due", + warningDeadlineAt: "2026-09-06T12:00:00Z", + warningStage: "expired", +}; + +function ids(inputs: StatusHintInputs) { + return evaluateStatusHints(inputs).hints.map((hint) => hint.id); +} + +test("a quiet workspace holds no state and settles every input", () => { + const evaluation = evaluateStatusHints(QUIET); + assert.deepEqual(evaluation.hints, []); + assert.deepEqual(evaluation.settled, [ + "payment-due", + "account-debt", + "quota-full", + "trial-expiry", + ]); +}); + +test("payment-due while suspended ships the settled strings with the derived deletion date", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + subscription: subscription(PAYMENT_DUE), + }).hints; + assert.deepEqual(hint, { + cta: { href: "/billing", label: "Renew plan" }, + description: + "This workspace is suspended. Resources will be deleted on Sep 6 unless the subscription is renewed.", + dismissible: false, + id: "payment-due", + title: "Workspace suspended — payment due", + tone: "destructive", + }); +}); + +test("payment-due stage progression changes copy and dates only — never the visuals", () => { + const suspended = evaluateStatusHints({ + ...QUIET, + subscription: subscription(PAYMENT_DUE), + }).hints[0]; + const imminent = evaluateStatusHints({ + ...QUIET, + subscription: subscription({ + ...PAYMENT_DUE, + currentPeriodEndAt: "2026-08-15T12:00:00Z", + warningDeadlineAt: "2026-08-29T12:00:00Z", + warningStage: "deletion-imminent", + }), + }).hints[0]; + assert.ok(suspended && imminent); + assert.equal(imminent.title, "Workspace suspended — deletion imminent"); + assert.equal( + imminent.description, + "Resources will be permanently deleted on Aug 29. This cannot be undone." + ); + assert.equal(imminent.tone, suspended.tone); + assert.equal(imminent.dismissible, suspended.dismissible); + assert.deepEqual(imminent.cta, suspended.cta); +}); + +test("an expired Free trial never asks for a renewal", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + subscription: subscription({ + ...PAYMENT_DUE, + planName: "Free", + recoveryVoice: "resubscribe", + }), + }).hints; + assert.ok(hint); + assert.equal(hint.id, "payment-due"); + assert.deepEqual(hint.cta, { + href: "/billing?mode=upgrade", + label: "Upgrade plan", + }); + assert.equal( + hint.description, + "This workspace is suspended. Resources will be deleted on Sep 6 unless you upgrade to a paid plan." + ); +}); + +test("a missing deadline speaks of deletion without inventing a date", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + subscription: subscription({ ...PAYMENT_DUE, warningDeadlineAt: null }), + }).hints; + assert.ok(hint); + assert.equal( + hint.description, + "This workspace is suspended. Resources will be deleted soon unless the subscription is renewed." + ); +}); + +test("Account Debt lights up from the available balance and never mentions a subscription", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + availableBalanceMicroUnits: 0, + }).hints; + assert.deepEqual(hint, { + cta: { href: "/billing", label: "Top up balance" }, + description: + "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.", + dismissible: false, + id: "account-debt", + title: "Account balance in debt", + tone: "destructive", + }); +}); + +test("a PAYG workspace reported in debt is Account Debt, not payment-due", () => { + // The platform reports it as a DEBT status with no subscription and no + // timestamps — CONTEXT.md: never voice it as a subscription expiring. + assert.deepEqual( + ids({ + ...QUIET, + availableBalanceMicroUnits: null, + subscription: subscription({ + isPayg: true, + lifecycle: "payment-due", + planName: "PAYG", + warningStage: "expired", + }), + }), + ["account-debt"] + ); +}); + +test("quota-full names the first full resource and is a warning, not red", () => { + const [hint] = evaluateStatusHints({ + ...QUIET, + quota: [ + { label: "CPU", percentUsed: 80, type: "cpu" }, + { label: "Memory", percentUsed: 100, type: "memory" }, + { label: "Storage", percentUsed: 100, type: "storage" }, + ], + }).hints; + assert.deepEqual(hint, { + cta: { href: "/billing/usage", label: "View usage" }, + description: + "New deployments can't start until memory is freed or the plan is upgraded.", + dismissible: true, + id: "quota-full", + title: "Memory quota is full", + tone: "warning", + }); +}); + +test("quota-full covers pods", () => { + const [pods] = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Pods", percentUsed: 100, type: "pod" }], + }).hints; + assert.equal(pods?.title, "Pods quota is full"); + assert.equal( + pods?.description, + "New deployments can't start until pods is freed or the plan is upgraded." + ); +}); + +test("quota-full keeps CPU capitalised and ignores traffic", () => { + const [cpu] = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "CPU", percentUsed: 100, type: "cpu" }], + }).hints; + assert.equal(cpu?.title, "CPU quota is full"); + assert.equal( + cpu?.description, + "New deployments can't start until CPU is freed or the plan is upgraded." + ); + assert.deepEqual( + ids({ + ...QUIET, + quota: [{ label: "Traffic", percentUsed: 100, type: "traffic" }], + }), + [] + ); +}); + +test("trial-expiry opens three days before the Free trial ends and counts down", () => { + const trial = (endsAt: string) => + evaluateStatusHints({ + ...QUIET, + subscription: subscription({ + currentPeriodEndAt: endsAt, + isActiveFreeTrial: true, + planName: "Free", + }), + }).hints; + assert.deepEqual(trial("2026-09-04T12:00:00Z"), []); + assert.deepEqual(trial("2026-08-28T12:00:00Z"), [ + { + cta: { href: "/billing?mode=upgrade", label: "View plans" }, + description: + "Your workspace will be suspended when the trial ends on Aug 28. Upgrade to keep it running.", + dismissible: true, + id: "trial-expiry", + title: "Free trial ends in 3 days", + tone: "info", + }, + ]); + assert.equal( + trial("2026-08-26T12:00:00Z")[0]?.title, + "Free trial ends tomorrow" + ); + assert.equal( + trial("2026-08-25T12:30:00Z")[0]?.title, + "Free trial ends today" + ); + // Once expired, section B owns the flow. + assert.deepEqual(trial("2026-08-24T12:00:00Z"), []); +}); + +test("severity orders payment-due > account-debt > quota-full > trial-expiry", () => { + assert.deepEqual( + ids({ + availableBalanceMicroUnits: -1, + now: NOW, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + subscription: subscription(PAYMENT_DUE), + }), + ["payment-due", "account-debt", "quota-full"] + ); + assert.deepEqual( + ids({ + availableBalanceMicroUnits: -1, + now: NOW, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + subscription: subscription({ + currentPeriodEndAt: "2026-08-27T12:00:00Z", + isActiveFreeTrial: true, + planName: "Free", + }), + }), + ["account-debt", "quota-full", "trial-expiry"] + ); +}); + +test("unknown inputs neither light a state nor settle it", () => { + const evaluation = evaluateStatusHints({ + availableBalanceMicroUnits: null, + now: NOW, + quota: null, + subscription: null, + }); + assert.deepEqual(evaluation.hints, []); + assert.deepEqual(evaluation.settled, []); + // The subscription alone settles the subscription-driven states. + assert.deepEqual( + evaluateStatusHints({ + availableBalanceMicroUnits: null, + now: NOW, + quota: null, + subscription: subscription({}), + }).settled, + ["payment-due", "trial-expiry"] + ); +}); + +test("the single slot shows the most severe undismissed hint", () => { + const { hints } = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + subscription: subscription({ + currentPeriodEndAt: "2026-08-27T12:00:00Z", + isActiveFreeTrial: true, + planName: "Free", + }), + }); + assert.equal(selectStatusHint(hints, [])?.id, "quota-full"); + assert.equal(selectStatusHint(hints, ["quota-full"])?.id, "trial-expiry"); + assert.equal(selectStatusHint(hints, ["quota-full", "trial-expiry"]), null); +}); + +test("a suppressed state takes over when the higher one clears", () => { + const withDebt = evaluateStatusHints({ + ...QUIET, + availableBalanceMicroUnits: 0, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + }); + assert.equal(selectStatusHint(withDebt.hints, [])?.id, "account-debt"); + const recovered = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + }); + assert.equal(selectStatusHint(recovered.hints, [])?.id, "quota-full"); +}); + +test("dismissals survive while the state holds and revive on re-entry", () => { + const full = evaluateStatusHints({ + ...QUIET, + quota: [{ label: "Storage", percentUsed: 100, type: "storage" }], + }); + const dismissed = ["quota-full", "trial-expiry"] as const; + // Still full: the quota dismissal stands; the trial state is settled + // absent, so its stale dismissal is forgotten. + assert.deepEqual(reconcileDismissed(dismissed, full), ["quota-full"]); + // Freed: the dismissal is forgotten, so the next fill shows the banner. + assert.deepEqual( + reconcileDismissed(["quota-full"], evaluateStatusHints(QUIET)), + [] + ); + // Unknown quota: nothing is decided yet, so the dismissal is kept. + assert.deepEqual( + reconcileDismissed( + ["quota-full"], + evaluateStatusHints({ ...QUIET, quota: null }) + ), + ["quota-full"] + ); + // Reconciling is identity-stable when nothing changes. + const stable = ["quota-full"] as const; + assert.equal(reconcileDismissed(stable, full), stable); +}); diff --git a/apps/ui/src/features/status-hint/status-hint-model.ts b/apps/ui/src/features/status-hint/status-hint-model.ts new file mode 100644 index 00000000..50d0584e --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-model.ts @@ -0,0 +1,293 @@ +import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; +import type { BillingSurfaceTone } from "@/features/billing/billing-surface-tones"; +import type { + BillingQuotaType, + BillingUsageRow, +} from "@/features/billing/billing-usage-data"; +import type { NotificationCTA } from "@/features/shell/app-sidebar-notifications-model"; +import { DAY_MS } from "@/lib/time"; + +/** + * The status hint surface's domain model (design spec §7): which billing + * states currently hold, in severity order, and the single-slot and + * dismiss/revive rules the banner applies to them. Pure — the hook feeds it + * already-proxied account, subscription, and quota reads. + */ + +export type StatusHintId = + | "payment-due" + | "account-debt" + | "quota-full" + | "trial-expiry"; + +export type StatusHintTone = BillingSurfaceTone; + +export interface StatusHint { + /** The fix, deep-linking to the page that solves the problem. */ + cta: NotificationCTA; + description: string; + /** Critical, blocking, system-condition hints get no close button. */ + dismissible: boolean; + id: StatusHintId; + title: string; + tone: StatusHintTone; +} + +export type StatusHintQuotaRow = Pick< + BillingUsageRow, + "label" | "percentUsed" | "type" +>; + +/** `null` marks an input that has not answered yet — unknown, not absent. */ +export interface StatusHintInputs { + /** Balance − DeductionBalance + usable credits, the platform's debt formula. */ + availableBalanceMicroUnits: number | null; + now: Date; + quota: readonly StatusHintQuotaRow[] | null; + subscription: WorkspaceSubscriptionSummary | null; +} + +export interface StatusHintEvaluation { + /** Every holding state, most severe first. */ + hints: StatusHint[]; + /** States whose inputs answered — their absence from `hints` is a fact. */ + settled: StatusHintId[]; +} + +/** Severity order: dunning > global suspension > hard stop > heads-up. */ +const SEVERITY: readonly StatusHintId[] = [ + "payment-due", + "account-debt", + "quota-full", + "trial-expiry", +]; + +const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", +}); + +/** The trial-expiry state opens this many days before the Free trial ends. */ +export const TRIAL_EXPIRY_NOTICE_DAYS = 3; + +/** The quota resources the catalog names (A2); traffic and GPU stay out. */ +const QUOTA_FULL_TYPES: ReadonlySet = new Set([ + "cpu", + "memory", + "storage", + "pod", + "nodeport", +]); + +function parsedDate(iso: string | null): Date | null { + if (iso == null || iso.trim() === "") { + return null; + } + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatDate(date: Date): string { + return DATE_FORMATTER.format(date); +} + +function paymentDueHint( + subscription: WorkspaceSubscriptionSummary +): StatusHint | null { + // A PAYG workspace in DEBT is Account Debt (no subscription, no dates). + if (subscription.isPayg || subscription.lifecycle !== "payment-due") { + return null; + } + // An unpriced Free plan is not a renewal target: its recovery is choosing + // a paid plan, so the CTA opens the Plan Picker and never says "renew". + const resubscribe = subscription.recoveryVoice === "resubscribe"; + const cta = resubscribe + ? { href: "/billing?mode=upgrade", label: "Upgrade plan" } + : { href: "/billing", label: "Renew plan" }; + const deadline = parsedDate(subscription.warningDeadlineAt); + if (subscription.warningStage === "deletion-imminent") { + // Copy advances with the stage; the visuals deliberately do not. + return { + cta, + description: + deadline == null + ? "Resources will be permanently deleted soon. This cannot be undone." + : `Resources will be permanently deleted on ${formatDate(deadline)}. This cannot be undone.`, + dismissible: false, + id: "payment-due", + title: "Workspace suspended — deletion imminent", + tone: "destructive", + }; + } + const when = deadline == null ? "soon" : `on ${formatDate(deadline)}`; + const wayOut = resubscribe + ? "unless you upgrade to a paid plan" + : "unless the subscription is renewed"; + return { + cta, + description: `This workspace is suspended. Resources will be deleted ${when} ${wayOut}.`, + dismissible: false, + id: "payment-due", + title: "Workspace suspended — payment due", + tone: "destructive", + }; +} + +const ACCOUNT_DEBT_HINT: StatusHint = { + cta: { href: "/billing", label: "Top up balance" }, + description: + "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.", + dismissible: false, + id: "account-debt", + title: "Account balance in debt", + tone: "destructive", +}; + +function accountDebtHolds(inputs: StatusHintInputs): boolean | null { + // The platform treats only a strictly positive available amount as good + // standing; a PAYG workspace it already reports in DEBT is the same fact. + if ( + inputs.subscription?.isPayg && + inputs.subscription.lifecycle === "payment-due" + ) { + return true; + } + if (inputs.availableBalanceMicroUnits == null) { + return null; + } + return inputs.availableBalanceMicroUnits <= 0; +} + +/** "CPU" keeps its initialism mid-sentence; the rest read as common nouns. */ +function resourceNoun(label: string): string { + return label === label.toUpperCase() ? label : label.toLowerCase(); +} + +function quotaFullHint( + quota: readonly StatusHintQuotaRow[] +): StatusHint | null { + const full = quota.find( + (row) => QUOTA_FULL_TYPES.has(row.type) && row.percentUsed >= 100 + ); + if (full == null) { + return null; + } + return { + cta: { href: "/billing/usage", label: "View usage" }, + description: `New deployments can't start until ${resourceNoun(full.label)} is freed or the plan is upgraded.`, + dismissible: true, + id: "quota-full", + title: `${full.label} quota is full`, + tone: "warning", + }; +} + +/** Whole calendar days from `now` to `target` in the viewer's time zone. */ +function calendarDaysUntil(target: Date, now: Date): number { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const end = new Date( + target.getFullYear(), + target.getMonth(), + target.getDate() + ); + return Math.round((end.getTime() - start.getTime()) / DAY_MS); +} + +function trialEndsIn(days: number): string { + if (days <= 0) { + return "today"; + } + if (days === 1) { + return "tomorrow"; + } + return `in ${days} days`; +} + +function trialExpiryHint( + subscription: WorkspaceSubscriptionSummary, + now: Date +): StatusHint | null { + if (!subscription.isActiveFreeTrial) { + return null; + } + const endsAt = parsedDate(subscription.currentPeriodEndAt); + if (endsAt == null) { + return null; + } + // Once expired, the payment-due pipeline owns the flow. + if (endsAt.getTime() < now.getTime()) { + return null; + } + const days = calendarDaysUntil(endsAt, now); + if (days > TRIAL_EXPIRY_NOTICE_DAYS) { + return null; + } + return { + cta: { href: "/billing?mode=upgrade", label: "View plans" }, + description: `Your workspace will be suspended when the trial ends on ${formatDate(endsAt)}. Upgrade to keep it running.`, + dismissible: true, + id: "trial-expiry", + title: `Free trial ends ${trialEndsIn(days)}`, + tone: "info", + }; +} + +export function evaluateStatusHints( + inputs: StatusHintInputs +): StatusHintEvaluation { + const { subscription } = inputs; + const debt = accountDebtHolds(inputs); + let accountDebt: StatusHint | null | undefined; + if (debt != null) { + accountDebt = debt ? ACCOUNT_DEBT_HINT : null; + } + const outcomes: Record = { + "account-debt": accountDebt, + "payment-due": + subscription == null ? undefined : paymentDueHint(subscription), + "quota-full": + inputs.quota == null ? undefined : quotaFullHint(inputs.quota), + "trial-expiry": + subscription == null + ? undefined + : trialExpiryHint(subscription, inputs.now), + }; + const hints: StatusHint[] = []; + const settled: StatusHintId[] = []; + for (const id of SEVERITY) { + const outcome = outcomes[id]; + if (outcome === undefined) { + continue; + } + settled.push(id); + if (outcome != null) { + hints.push(outcome); + } + } + return { hints, settled }; +} + +/** The one hint the single slot shows, skipping user-dismissed ones. */ +export function selectStatusHint( + hints: readonly StatusHint[], + dismissed: readonly StatusHintId[] +): StatusHint | null { + return hints.find((hint) => !dismissed.includes(hint.id)) ?? null; +} + +/** + * Edge semantics for dismissals: a dismissal stands while its state holds + * (or is still unknown) and is forgotten once the state is settled absent, + * so the banner revives when the state re-enters. Returns the same array + * when nothing changes, so stores can skip a write. + */ +export function reconcileDismissed( + dismissed: readonly StatusHintId[], + evaluation: StatusHintEvaluation +): readonly StatusHintId[] { + const holding = new Set(evaluation.hints.map((hint) => hint.id)); + const next = dismissed.filter( + (id) => holding.has(id) || !evaluation.settled.includes(id) + ); + return next.length === dismissed.length ? dismissed : next; +} diff --git a/apps/ui/src/features/status-hint/status-hint-store.ts b/apps/ui/src/features/status-hint/status-hint-store.ts new file mode 100644 index 00000000..3ce789db --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-store.ts @@ -0,0 +1,14 @@ +import { atomWithStorage, createJSONStorage } from "jotai/utils"; + +import type { StatusHintId } from "./status-hint-model"; + +/** + * Ids of dismissible hints the user closed. Session-scoped so a reload keeps + * a dismissal, while `reconcileDismissed` forgets it once its state is + * settled absent — re-entry revives the banner (edge semantics). + */ +export const statusHintDismissedAtom = atomWithStorage( + "status-hint-dismissed", + [], + createJSONStorage(() => sessionStorage) +); diff --git a/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts b/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts new file mode 100644 index 00000000..9de72432 --- /dev/null +++ b/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance"; +import { loadAccountCredits } from "@/features/billing/account-credits"; +import { loadWorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data"; +import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data"; +import { + BILLING_DEV_SCENARIOS, + type BillingDevScenario, +} from "@/features/billing/dev-mock-cookie"; +import { scenarioTestFetch } from "@/features/billing/server/dev-fixtures/scenario-test-fetch"; + +import { + evaluateStatusHints, + type StatusHintId, + selectStatusHint, +} from "./status-hint-model"; + +/** + * Pins the banner states to the dev fixtures through the same loaders the + * hook uses, so every state the design catalogs can be simulated locally + * and the fixtures keep meaning what their names promise. + */ + +const DATED_DELETION_PATTERN = /deleted on [A-Z][a-z]{2} \d/; +const RENEWAL_PATTERN = /renew/i; + +const CREDENTIALS = { + appToken: "test-token", + kubeconfig: "test-kubeconfig", + workspace: "ns-test", +}; + +async function hintFor(scenario: BillingDevScenario) { + const fetch = scenarioTestFetch(scenario); + const [subscription, balance, credits, quota] = await Promise.all([ + loadWorkspaceSubscriptionSummary(CREDENTIALS, { fetch }), + loadAccountBalanceMicroUnits(CREDENTIALS, fetch), + loadAccountCredits(CREDENTIALS, fetch), + loadWorkspaceQuotaUsage(CREDENTIALS, fetch), + ]); + const evaluation = evaluateStatusHints({ + availableBalanceMicroUnits: balance + credits.usableMicroUnits, + now: new Date(), + quota, + subscription, + }); + assert.deepEqual( + evaluation.settled, + ["payment-due", "account-debt", "quota-full", "trial-expiry"], + `${scenario}: every input answers` + ); + return { evaluation, hint: selectStatusHint(evaluation.hints, []) }; +} + +const EXPECTED: Record = { + active: null, + "active-balance": null, + cancelling: null, + deleted: null, + free: null, + "free-expired": "payment-due", + "free-expiring": "trial-expiry", + "mixed-workspaces": null, + paused: null, + payg: null, + "payg-debt": "account-debt", + "payg-debt-deletion": "account-debt", + "payg-debt-final": "account-debt", + "payment-due": "payment-due", + "payment-due-deletion": "payment-due", + "payment-due-final": "payment-due", + "pending-upgrade": null, + "quota-full": "quota-full", + "status-unknown": null, +}; + +test("every scenario lands on the banner state its name promises", async () => { + for (const scenario of BILLING_DEV_SCENARIOS) { + const { hint } = await hintFor(scenario); + assert.equal(hint?.id ?? null, EXPECTED[scenario], `${scenario}: slot`); + } +}); + +test("the payment-due scenarios walk the stages with identical visuals", async () => { + const suspended = (await hintFor("payment-due")).hint; + const deletion = (await hintFor("payment-due-deletion")).hint; + const final = (await hintFor("payment-due-final")).hint; + assert.equal(suspended?.title, "Workspace suspended — payment due"); + assert.match(suspended?.description ?? "", DATED_DELETION_PATTERN); + assert.equal(deletion?.title, "Workspace suspended — deletion imminent"); + assert.equal(final?.title, "Workspace suspended — deletion imminent"); + for (const stage of [deletion, final]) { + assert.equal(stage?.tone, suspended?.tone); + assert.equal(stage?.dismissible, false); + assert.deepEqual(stage?.cta, suspended?.cta); + } + // Account Debt is suppressed behind payment-due here and takes over once + // the subscription recovers; the slot is one banner regardless. + const { evaluation } = await hintFor("payment-due"); + assert.deepEqual( + evaluation.hints.map((hint) => hint.id), + ["payment-due", "account-debt"] + ); +}); + +test("an expired Free trial asks for an upgrade, never a renewal", async () => { + const { hint } = await hintFor("free-expired"); + assert.deepEqual(hint?.cta, { + href: "/billing?mode=upgrade", + label: "Upgrade plan", + }); + assert.doesNotMatch(hint?.description ?? "", RENEWAL_PATTERN); +}); + +test("quota-full names storage and stays a warning", async () => { + const { hint } = await hintFor("quota-full"); + assert.equal(hint?.title, "Storage quota is full"); + assert.equal(hint?.tone, "warning"); + assert.equal(hint?.dismissible, true); +}); + +test("free-expiring sits inside the three-day trial notice", async () => { + const { hint } = await hintFor("free-expiring"); + assert.equal(hint?.title, "Free trial ends in 3 days"); + assert.equal(hint?.tone, "info"); + assert.equal(hint?.cta.href, "/billing?mode=upgrade"); +}); diff --git a/apps/ui/src/features/status-hint/use-status-hint.ts b/apps/ui/src/features/status-hint/use-status-hint.ts new file mode 100644 index 00000000..ee303012 --- /dev/null +++ b/apps/ui/src/features/status-hint/use-status-hint.ts @@ -0,0 +1,142 @@ +"use client"; + +import { kubeconfigCredentialKey } from "@workspace/api/credential-key"; +import { useAtom, useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; + +import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance"; +import { loadAccountCredits } from "@/features/billing/account-credits"; +import { accountCreditsSwrKey } from "@/features/billing/billing-subscription-settlement"; +import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data"; +import { observeWorkspaceQuotaForInbox } from "@/features/notifications/quota-observation"; +import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary"; +import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store"; + +import { + evaluateStatusHints, + reconcileDismissed, + type StatusHint, + type StatusHintId, + selectStatusHint, +} from "./status-hint-model"; +import { statusHintDismissedAtom } from "./status-hint-store"; + +/** States clear on their own; the inputs follow the inbox's polling cadence. */ +export const STATUS_HINT_REFRESH_INTERVAL_MS = 5 * 60_000; + +export interface StatusHintSlot { + dismiss: (id: StatusHintId) => void; + hint: StatusHint | null; +} + +/** + * The single slot's contents, evaluated from already-proxied account, + * subscription, and quota reads: the subscription summary the App Sidebar + * already holds, the account's cash and usable credits (the platform's debt + * formula), and the workspace's resource quota. A read that has not + * answered leaves its state unknown — never lit, never cleared. + */ +export function useStatusHint(): StatusHintSlot { + const appToken = useAtomValue(appTokenAtom).trim(); + const kubeconfig = useAtomValue(kubeconfigAtom).trim(); + const workspace = useAtomValue(namespaceAtom).trim(); + const credentialsReady = + appToken !== "" && kubeconfig !== "" && workspace !== ""; + const credentialKey = kubeconfigCredentialKey(kubeconfig); + + const subscription = useWorkspaceSubscriptionSummary({ + refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS, + }); + const swrOptions = { + refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS, + revalidateOnFocus: false, + shouldRetryOnError: false, + }; + const balance = useSWR( + credentialsReady + ? (["status-hint-balance", credentialKey, appToken] as const) + : null, + () => loadAccountBalanceMicroUnits({ appToken, kubeconfig }), + swrOptions + ); + // The settlement flow refreshes this key after a payment, so a top-up + // that lands through Brain clears Account Debt without waiting a cycle. + const credits = useSWR( + credentialsReady ? accountCreditsSwrKey({ appToken, kubeconfig }) : null, + () => loadAccountCredits({ appToken, kubeconfig }), + swrOptions + ); + // Every quota poll is also the quota-exhausted producer's observation + // point (A1): the inbox observes on the banner's cadence, so the two + // cannot disagree for minutes, and a recovery between chat turns still + // releases the live key. + const quota = useSWR( + credentialsReady + ? (["status-hint-quota", workspace, credentialKey, appToken] as const) + : null, + () => loadWorkspaceQuotaUsage({ appToken, kubeconfig, workspace }), + { + ...swrOptions, + onSuccess: () => { + observeWorkspaceQuotaForInbox({ + appToken, + kubeconfig, + namespace: workspace, + }).catch(() => undefined); + }, + } + ); + + // Trial-expiry is a clock state as much as a data state: the window opens + // and the title counts down while the subscription payload stays the same, + // so the clock advances on the polling cadence too. + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const timer = window.setInterval(() => { + setNow(new Date()); + }, STATUS_HINT_REFRESH_INTERVAL_MS); + return () => { + window.clearInterval(timer); + }; + }, []); + + const balanceMicroUnits = balance.data; + const usableCreditMicroUnits = credits.data?.usableMicroUnits; + const quotaRows = quota.data; + const subscriptionSummary = subscription.data; + const evaluation = useMemo( + () => + evaluateStatusHints({ + availableBalanceMicroUnits: + balanceMicroUnits == null || usableCreditMicroUnits == null + ? null + : balanceMicroUnits + usableCreditMicroUnits, + now, + quota: quotaRows ?? null, + subscription: subscriptionSummary ?? null, + }), + [ + balanceMicroUnits, + now, + quotaRows, + subscriptionSummary, + usableCreditMicroUnits, + ] + ); + + const [dismissed, setDismissed] = useAtom(statusHintDismissedAtom); + useEffect(() => { + setDismissed((previous) => reconcileDismissed(previous, evaluation)); + }, [evaluation, setDismissed]); + const dismiss = useCallback( + (id: StatusHintId) => { + setDismissed((previous) => + previous.includes(id) ? previous : [...previous, id] + ); + }, + [setDismissed] + ); + + return { dismiss, hint: selectStatusHint(evaluation.hints, dismissed) }; +} diff --git a/apps/ui/src/lib/identity-fingerprint-core.test.ts b/apps/ui/src/lib/identity-fingerprint-core.test.ts index 49c61540..c802f282 100644 --- a/apps/ui/src/lib/identity-fingerprint-core.test.ts +++ b/apps/ui/src/lib/identity-fingerprint-core.test.ts @@ -24,6 +24,10 @@ import { marketingAttributionSubjects, marketingLifecycleEvents, } from "@/features/marketing/schema"; +import { + notificationMessages, + notificationReadReceipts, +} from "@/features/notifications/schema"; import { onboardingProfiles } from "@/features/onboarding/schema"; import { @@ -682,6 +686,185 @@ test("where both merged accounts hold a profile, the survivor's row wins and the assert.equal(telemetry?.profilesReleased, 1); }); +function selectReceipts(userUids: string[]) { + return db + .select({ + messageId: notificationReadReceipts.messageId, + messageKey: notificationReadReceipts.messageKey, + userUid: notificationReadReceipts.userUid, + }) + .from(notificationReadReceipts) + .where(inArray(notificationReadReceipts.userUid, userUids)) + .orderBy( + notificationReadReceipts.userUid, + notificationReadReceipts.messageKey + ); +} + +function selectMessages(userUids: string[]) { + return db + .select({ + dedupeKey: notificationMessages.dedupeKey, + id: notificationMessages.id, + namespace: notificationMessages.namespace, + releasedAt: notificationMessages.releasedAt, + userUid: notificationMessages.userUid, + }) + .from(notificationMessages) + .where(inArray(notificationMessages.userUid, userUids)) + .orderBy(notificationMessages.userUid, notificationMessages.id); +} + +test("a merge re-keys the tombstone's read receipts and drops those the survivor already holds", async () => { + await observe({ + crName: "receipt-cr", + mintedAt: 10_000, + userUid: "receipt-tombstone-uid", + }); + await db.insert(notificationMessages).values({ + dedupeKey: "quota-exhausted:receipt-ns:cpu", + id: "receipt-msg", + kind: "quota-exhausted", + namespace: "receipt-ns", + payload: { kind: "quota-exhausted", limit: 1, resource: "cpu", used: 1 }, + }); + await db.insert(notificationReadReceipts).values([ + // Only the tombstone read these: they follow the survivor, the db one + // still attached to its row. + { + messageKey: "cr:debt-choice-debtperiod:1", + userUid: "receipt-tombstone-uid", + }, + { + messageId: "receipt-msg", + messageKey: "db:receipt-msg", + userUid: "receipt-tombstone-uid", + }, + // Both read this one: the survivor's stands, the tombstone's is dropped. + { + messageKey: "cr:workspace-debt-debt:1", + userUid: "receipt-tombstone-uid", + }, + { messageKey: "cr:workspace-debt-debt:1", userUid: "receipt-survivor-uid" }, + ]); + + const telemetry = await observeCapturingTelemetry({ + crName: "receipt-cr", + mintedAt: 11_000, + userUid: "receipt-survivor-uid", + }); + + assert.deepEqual( + await selectReceipts(["receipt-tombstone-uid", "receipt-survivor-uid"]), + [ + { + messageId: null, + messageKey: "cr:debt-choice-debtperiod:1", + userUid: "receipt-survivor-uid", + }, + { + messageId: null, + messageKey: "cr:workspace-debt-debt:1", + userUid: "receipt-survivor-uid", + }, + { + messageId: "receipt-msg", + messageKey: "db:receipt-msg", + userUid: "receipt-survivor-uid", + }, + ] + ); + assert.equal(telemetry?.receiptsRekeyed, 2); + assert.equal(telemetry?.receiptsReleased, 1); +}); + +test("a merge re-keys the tombstone's account-scoped messages, key included, and collapses onto the survivor's live row", async () => { + await observe({ + crName: "message-cr", + mintedAt: 12_000, + userUid: "message-tombstone-uid", + }); + const giftPayload = { + giftMicroUnits: 1_000_000, + kind: "credit-hint", + } as const; + await db.insert(notificationMessages).values([ + // The tombstone's welcome, observed in its own workspace. + { + dedupeKey: "credit-hint:message-tombstone-uid", + id: "message-tombstone-gift", + kind: "credit-hint", + namespace: "tombstone-ns", + payload: giftPayload, + userUid: "message-tombstone-uid", + }, + ]); + + const first = await observeCapturingTelemetry({ + crName: "message-cr", + mintedAt: 13_000, + userUid: "message-survivor-uid", + }); + + // The row and its dedupe key follow the survivor: a later gift observation + // for the survivor finds this row and writes no second welcome. + assert.deepEqual( + await selectMessages(["message-tombstone-uid", "message-survivor-uid"]), + [ + { + dedupeKey: "credit-hint:message-survivor-uid", + id: "message-tombstone-gift", + namespace: "tombstone-ns", + releasedAt: null, + userUid: "message-survivor-uid", + }, + ] + ); + assert.equal(first?.messagesRekeyed, 1); + assert.equal(first?.messagesReleased, 0); + + // A second merge into the same survivor, from an account that also holds + // a live welcome: one row per person, so the newcomer's is deleted and + // its receipt cascades. + await observe({ + crName: "message-cr-2", + mintedAt: 14_000, + userUid: "message-tombstone-2-uid", + }); + await db.insert(notificationMessages).values({ + dedupeKey: "credit-hint:message-tombstone-2-uid", + id: "message-tombstone-2-gift", + kind: "credit-hint", + namespace: "tombstone-2-ns", + payload: giftPayload, + userUid: "message-tombstone-2-uid", + }); + await db.insert(notificationReadReceipts).values({ + messageId: "message-tombstone-2-gift", + messageKey: "db:message-tombstone-2-gift", + userUid: "message-tombstone-2-uid", + }); + + const second = await observeCapturingTelemetry({ + crName: "message-cr-2", + mintedAt: 15_000, + userUid: "message-survivor-uid", + }); + + assert.deepEqual( + ( + await selectMessages(["message-tombstone-2-uid", "message-survivor-uid"]) + ).map((row) => row.id), + ["message-tombstone-gift"] + ); + assert.deepEqual( + await selectReceipts(["message-tombstone-2-uid", "message-survivor-uid"]), + [] + ); + assert.equal(second?.messagesRekeyed, 0); + assert.equal(second?.messagesReleased, 1); +}); + test("a write-transaction re-check passes only while the binding is current", async () => { await observe({ crName: "guard-cr", mintedAt: 5100, userUid: "guard-uid" }); diff --git a/apps/ui/src/lib/identity-fingerprint-core.ts b/apps/ui/src/lib/identity-fingerprint-core.ts index b2725f66..0adfceec 100644 --- a/apps/ui/src/lib/identity-fingerprint-core.ts +++ b/apps/ui/src/lib/identity-fingerprint-core.ts @@ -1,4 +1,4 @@ -import { and, eq, notExists, sql } from "drizzle-orm"; +import { and, eq, isNull, notExists, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { @@ -17,6 +17,10 @@ import { marketingAttributionSubjects, marketingLifecycleEvents, } from "@/features/marketing/schema"; +import { + notificationMessages, + notificationReadReceipts, +} from "@/features/notifications/schema"; import { onboardingProfiles } from "@/features/onboarding/schema"; import { rekeyCanonicalIdentityUids } from "@/lib/identity-uid-canonicalization"; @@ -228,8 +232,12 @@ async function rekeyPersonalResources( identityUidCanonicalizationsRekeyed: number; installSessionsRekeyed: number; lifecycleEventsRekeyed: number; + messagesReleased: number; + messagesRekeyed: number; profilesReleased: number; profilesRekeyed: number; + receiptsReleased: number; + receiptsRekeyed: number; }> { const identityUidCanonicalizationsRekeyed = await rekeyCanonicalIdentityUids( tx, @@ -413,6 +421,73 @@ async function rekeyPersonalResources( .where(eq(onboardingProfiles.userUid, input.tombstoneUserUid)) .returning({ userUid: onboardingProfiles.userUid }); + // Notification read receipts are keyed by (uid, message key): the + // tombstone's receipts follow the survivor except where the survivor + // already read the same message, and the rest are deleted — a receipt is + // a fact about one person, so two rows for one message collapse to one. + const survivorReceipts = alias(notificationReadReceipts, "survivor_receipts"); + const rekeyedReceipts = await tx + .update(notificationReadReceipts) + .set({ userUid: input.survivorUserUid }) + .where( + and( + eq(notificationReadReceipts.userUid, input.tombstoneUserUid), + notExists( + tx + .select({ userUid: survivorReceipts.userUid }) + .from(survivorReceipts) + .where( + and( + eq(survivorReceipts.userUid, input.survivorUserUid), + eq( + survivorReceipts.messageKey, + notificationReadReceipts.messageKey + ) + ) + ) + ) + ) + ) + .returning({ messageKey: notificationReadReceipts.messageKey }); + const releasedReceipts = await tx + .delete(notificationReadReceipts) + .where(eq(notificationReadReceipts.userUid, input.tombstoneUserUid)) + .returning({ messageKey: notificationReadReceipts.messageKey }); + + // Account-scoped notification messages (ADR-0067: the gift hint) are + // uid-keyed rows whose dedupe key names the uid too, so the key moves with + // the row — a later observation then finds the survivor's row instead of + // writing a second welcome. Where the survivor already holds a live row + // under the re-keyed name, the tombstone's is a duplicate of the same + // one-per-person fact and is deleted (its receipts cascade). + const survivorDedupeKey = sql`replace(${notificationMessages.dedupeKey}, ${input.tombstoneUserUid}, ${input.survivorUserUid})`; + const survivorMessages = alias(notificationMessages, "survivor_messages"); + const rekeyedMessages = await tx + .update(notificationMessages) + .set({ dedupeKey: survivorDedupeKey, userUid: input.survivorUserUid }) + .where( + and( + eq(notificationMessages.userUid, input.tombstoneUserUid), + notExists( + tx + .select({ id: survivorMessages.id }) + .from(survivorMessages) + .where( + and( + eq(survivorMessages.userUid, input.survivorUserUid), + isNull(survivorMessages.releasedAt), + eq(survivorMessages.dedupeKey, survivorDedupeKey) + ) + ) + ) + ) + ) + .returning({ id: notificationMessages.id }); + const releasedMessages = await tx + .delete(notificationMessages) + .where(eq(notificationMessages.userUid, input.tombstoneUserUid)) + .returning({ id: notificationMessages.id }); + return { attributionSubjectsRekeyed: rekeyedAttributionSubjects.length, attributionSubjectsReleased: releasedAttributionSubjects.length, @@ -424,7 +499,11 @@ async function rekeyPersonalResources( identityUidCanonicalizationsRekeyed, installSessionsRekeyed: rekeyedInstallSessions.length, lifecycleEventsRekeyed: rekeyedLifecycleEvents.length, + messagesReleased: releasedMessages.length, + messagesRekeyed: rekeyedMessages.length, profilesReleased: releasedProfiles.length, profilesRekeyed: rekeyedProfiles.length, + receiptsReleased: releasedReceipts.length, + receiptsRekeyed: rekeyedReceipts.length, }; } diff --git a/apps/ui/src/lib/time.ts b/apps/ui/src/lib/time.ts new file mode 100644 index 00000000..3961dd40 --- /dev/null +++ b/apps/ui/src/lib/time.ts @@ -0,0 +1,4 @@ +/** Millisecond durations shared by countdowns, relative times, and sweeps. */ +export const MINUTE_MS = 60_000; +export const HOUR_MS = 60 * MINUTE_MS; +export const DAY_MS = 24 * HOUR_MS; diff --git a/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md b/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md new file mode 100644 index 00000000..260f5ce9 --- /dev/null +++ b/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md @@ -0,0 +1,105 @@ +# Store Notifications Hybrid: Read Platform CRs Live, Keep Brain's Own in App Postgres + +The Notification Center is the user's single inbox for platform messages +(the account and workspace-subscription controllers' debt ladder) and +Brain-produced messages (quota exhausted, and later the gift hint and +subscription-change receipts). The platform already carries its messages as +Notification CRs (`notifications.notification.sealos.io/v1`) in every user +namespace, with special semantics Brain cannot reproduce: fixed names per +scenario overwritten in place, a revive to unread on escalation, and an +automatic read-back on recovery. Brain, in turn, holds no standing cluster +credentials — every request arrives with the user's own kubeconfig +(ADR-0052) — and no controller of its own. + +## Decision + +Each message has exactly one source of truth, chosen by who produces it. + +- **Platform messages are read live from the cluster and never copied.** The + Go API serves a read proxy (`/api/notification/v1alpha1`) that lists the + current namespace's Notification CRs with the caller's kubeconfig bearer + token and merge-patches the same `isRead` label the Sealos desktop writes. + The client polls it at the desktop's cadence (≤5 minutes). The display + layer may substitute Brain-voiced copy and a CTA for the known fixed-name + debt-ladder CRs and hide the low-balance tiers from gift-only newcomers — + display-only: the CR is never touched, and unknown names show upstream's + text as written. +- **Brain-produced messages live in Brain's own Postgres** under a new schema, + `sealai_notification`: `notification_messages` (namespace, kind, project, + structured payload, `dedupe_key`, optional `user_uid`) rendered + client-side. A message is workspace-scoped (its namespace is the inbox, + like the rest of the app's schemas) or account-scoped (`user_uid` names + the person, the row follows them into every workspace's inbox, and the + namespace only records where it was first observed — the gift hint, later + the expiry reminder). An inbox lists its workspace's rows plus its + person's account rows. Producers write at + natural observation points during user requests — no scheduler, no + controller. The dedupe key is the idempotency mechanism (naming is dedupe; + a partial unique index spans live keys, and recovery releases the key so a + re-entry writes a fresh entry while history stays). Retention is 365 days, + swept opportunistically on every write. +- **Read state is a per-user, additive receipt** (`notification_read_receipts`: + user × message key × read_at). The key is the source-prefixed notification + id — `db:` for Brain entries, `cr::` for platform CRs, + versioned by the CR's own timestamp so an upstream revive reads as unread + again. No workspace in the key: upstream writes account-level messages + into every user namespace, and a person reads a message once. Any role can mark anything read; Owners and Managers + additionally patch the CR label best-effort for desktop parity, Developers + skip. A platform message is unread iff the label says unread and no receipt + exists; upstream's auto-read stacks on top with no reconciliation. +- **The frontend merges the two streams** into one list sorted by real time, + with `cr:`/`db:` id prefixes and per-source mark-read dispatch. + +## Considered Options + +- **All-CRD: Brain writes its own messages as Notification CRs too.** The + survey (AIM-316) showed this would make Brain's events visible in the + desktop's own inbox for free. Rejected: writing CRs needs a + ServiceAccount with cluster-wide `notifications` create/update (the + "architecture D" controller verified in AIM-321), which means standing + credentials, a deployment surface Brain does not have today, and a + per-user-namespace fan-out for every producer. Fixed-name overwrite + semantics also cannot represent history ("one entry per threshold + crossing"). +- **Full mirror: ingest every platform CR into Brain's store and serve from + there.** Rejected: it duplicates the source of truth for messages whose + lifecycle (overwrite, revive, auto-read) the platform owns, so the mirror + is wrong whenever the watch lags and would need reconciliation logic for + every upstream rule. Brain would also need credentials to watch namespaces + it is not currently serving. +- **Ingest-on-read: copy CRs into the store when a user opens the inbox, + then serve the copy.** Rejected: it inherits the mirror's staleness for + exactly the revive and auto-read cases that matter most, adds a write to + every read, and buys nothing the live read does not already give. + +The pivot that decided it: **Brain holds no standing credentials.** Every +option that writes or watches cluster state outside a user's request needs +credentials Brain does not have; reading with the caller's kubeconfig and +keeping Brain's own messages in Brain's own database needs none. + +## Consequences + +- The inbox's aggregation boundary is the current workspace, because the + caller's kubeconfig reaches one namespace. Account-level platform messages + still appear everywhere since upstream writes them to every user namespace; + account-scoped Brain messages appear everywhere because the inbox query + adds the person's `user_uid` rows. +- Brain-produced messages are invisible to the desktop's own inbox; the + desktop is a later channel if ever wanted. +- Read state can diverge between Brain and the desktop for Developers (no + patch permission) and whenever the best-effort patch fails; the receipt is + authoritative inside Brain. +- Producers only fire where a request carries the observed data (the chat + turn and the sidebar's quota warm-up for quota; the inbox's own credits + read for the gift hint; the Plan view's settlement for subscription-change + receipts). A state that changes while no + one is using Brain is noticed on the next request, not at the moment it + changes. The observed snapshot is the client's (the desktop SDK's quota + read, already trusted for chat context), so a workspace member could post + a fabricated one — the write lands only in that member's own verified + workspace, as a nuisance entry, never across workspaces. +- Freshness is poll-bound (≤5 minutes); a WATCH upgrade is a later change to + the proxy only. +- `sealai_notification` joins the app-owned schemas: migrations apply at + boot, PGlite tests replay them, and receipts re-key on account merge with + the other uid-keyed personal resources (ADR-0059). diff --git a/docs/adr/README.md b/docs/adr/README.md index 064f2ec9..e01b26dd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -38,6 +38,7 @@ One line per decision; the linked record is authoritative. When adding an ADR, t - [0064 — Pin the Current Billing Region by Deployment-Declared Domain](0064-pin-the-current-billing-region-by-deployment-declared-domain.md) - [0065 — Gate Free Chat Turns on the Active Free Trial and block on exhaustion](0065-gate-free-chat-turns-on-the-active-free-trial-and-block-on-exhaustion.md) *(replaces the deleted ADR-0033)* - [0066 — Give Canvas Resources Editable Display Names Stored on the Resource](0066-store-resource-display-names-in-annotations.md) +- [0067 — Store Notifications Hybrid: Read Platform CRs Live, Keep Brain's Own in App Postgres](0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md) ## Conventions diff --git a/packages/api/src/constants.ts b/packages/api/src/constants.ts index ba24f608..667e680c 100644 --- a/packages/api/src/constants.ts +++ b/packages/api/src/constants.ts @@ -61,6 +61,12 @@ export const API_ROUTES = { stop: "/api/db/v1alpha1/stop", }, + notification: { + base: "/api/notification/v1alpha1", + /** GET list of the namespace's upstream Notification CRs — group root. */ + root: "/api/notification/v1alpha1", + }, + telemetry: { base: "/api/telemetry/v1alpha1", logsHealth: "/api/telemetry/v1alpha1/logs/health", diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts index 534181ed..eb37f718 100644 --- a/packages/api/src/hooks/index.ts +++ b/packages/api/src/hooks/index.ts @@ -49,6 +49,17 @@ export { type K8sNamespacedListRefreshInterval, useK8sNamespacedList, } from "./use-k8s-namespaced-list"; +export { + buildNotificationCRListRequest, + buildNotificationCRReadRequest, + markNotificationCRRead, + NOTIFICATION_CR_REFRESH_INTERVAL_MS, + type NotificationCRItem, + type NotificationCRListResponse, + type NotificationCRReadResponse, + notificationCRReadPath, + useNotificationCRList, +} from "./use-notification-crs"; export { type BrainProductResourceKind, type UseBrainProductResourceOptions, diff --git a/packages/api/src/hooks/use-notification-crs.test.ts b/packages/api/src/hooks/use-notification-crs.test.ts new file mode 100644 index 00000000..cb74b66c --- /dev/null +++ b/packages/api/src/hooks/use-notification-crs.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { API_ROUTES } from "../constants"; +import { + buildNotificationCRListRequest, + buildNotificationCRReadRequest, + notificationCRReadPath, +} from "./use-notification-crs"; + +const KUBECONFIG = "apiVersion: v1\nclusters: []"; +const AUTHORIZATION = "Bearer apiVersion%3A%20v1%0Aclusters%3A%20%5B%5D"; + +test("the list request reads the namespace's Notification CRs with the kubeconfig bearer", () => { + const got = buildNotificationCRListRequest({ + kubeconfig: KUBECONFIG, + namespace: "ns-a", + }); + + assert.equal(got.method, "GET"); + assert.equal(got.path, API_ROUTES.notification.root); + assert.deepEqual(got.query, { namespace: "ns-a" }); + assert.deepEqual(got.header, { Authorization: AUTHORIZATION }); +}); + +test("the mark-read request patches one CR by name", () => { + const got = buildNotificationCRReadRequest({ + kubeconfig: KUBECONFIG, + name: "debt-choice-debtperiod", + namespace: "ns-a", + }); + + assert.equal(got.method, "PATCH"); + assert.equal( + got.path, + "/api/notification/v1alpha1/debt-choice-debtperiod/read" + ); + assert.deepEqual(got.query, { namespace: "ns-a" }); + assert.deepEqual(got.header, { Authorization: AUTHORIZATION }); +}); + +test("CR names are path-encoded", () => { + assert.equal( + notificationCRReadPath("weird/name"), + "/api/notification/v1alpha1/weird%2Fname/read" + ); +}); diff --git a/packages/api/src/hooks/use-notification-crs.ts b/packages/api/src/hooks/use-notification-crs.ts new file mode 100644 index 00000000..ae73c606 --- /dev/null +++ b/packages/api/src/hooks/use-notification-crs.ts @@ -0,0 +1,129 @@ +"use client"; + +import useSWR from "swr"; +import { API_ROUTES } from "../constants"; +import { + kubeconfigBearerHeader, + kubeconfigCredentialKey, +} from "../credential-key"; +import { type FetcherOptions, fetcher } from "../fetch"; +import { ApiUrl } from "../utils"; + +/** + * One upstream Notification CR as the Go read proxy flattens it. The + * platform owns these messages (fixed names, overwritten in place, revived + * as unread on escalation, auto-read on recovery); Brain reads them live with + * the caller's own kubeconfig and never copies them. + */ +export interface NotificationCRItem { + creationTimestamp?: string; + desktopPopup: boolean; + from?: string; + importance?: string; + /** `metadata.labels.isRead === "true"` — the desktop's read state. */ + isRead: boolean; + message: string; + name: string; + namespace: string; + /** `spec.timestamp` in Unix seconds (creation time when upstream omitted it). */ + timestamp: number; + title: string; + uid?: string; + /** + * The id's version component: `spec.timestamp`, or the CR's generation + * when upstream omitted it. Changes on an in-place overwrite, never on a + * label patch — so a revived fixed-name CR is a new id no receipt covers. + */ + version: number; +} + +export interface NotificationCRListResponse { + items: NotificationCRItem[]; + namespace: string; +} + +export interface NotificationCRReadResponse { + isRead: boolean; + name: string; + namespace: string; +} + +export type NotificationCRFetchRequest = Omit; + +/** The desktop polls its own inbox every 5 minutes; match it. */ +export const NOTIFICATION_CR_REFRESH_INTERVAL_MS = 5 * 60_000; + +export function notificationCRReadPath(name: string): string { + return `${API_ROUTES.notification.root}/${encodeURIComponent(name)}/read`; +} + +export function buildNotificationCRListRequest(options: { + kubeconfig: string; + namespace: string; +}): NotificationCRFetchRequest { + return { + header: { + Authorization: kubeconfigBearerHeader(options.kubeconfig), + }, + method: "GET", + path: API_ROUTES.notification.root, + query: { namespace: options.namespace }, + }; +} + +export function buildNotificationCRReadRequest(options: { + kubeconfig: string; + name: string; + namespace: string; +}): NotificationCRFetchRequest { + return { + header: { + Authorization: kubeconfigBearerHeader(options.kubeconfig), + }, + method: "PATCH", + path: notificationCRReadPath(options.name), + query: { namespace: options.namespace }, + }; +} + +/** Merge-patches the CR's `isRead` label — the desktop-compatible read write. */ +export function markNotificationCRRead(options: { + kubeconfig: string; + name: string; + namespace: string; +}): Promise { + return fetcher({ + base: ApiUrl(), + ...buildNotificationCRReadRequest(options), + }); +} + +export function useNotificationCRList(options: { + enabled?: boolean; + kubeconfig?: string; + namespace: string; + /** @default NOTIFICATION_CR_REFRESH_INTERVAL_MS */ + refreshInterval?: number; +}) { + const { + enabled = true, + refreshInterval = NOTIFICATION_CR_REFRESH_INTERVAL_MS, + } = options; + const kubeconfig = options.kubeconfig ?? ""; + const namespace = options.namespace.trim(); + const credentialKey = kubeconfigCredentialKey(kubeconfig); + const shouldFetch = enabled && kubeconfig.trim() !== "" && namespace !== ""; + const swrKey = shouldFetch + ? ([API_ROUTES.notification.root, namespace, credentialKey] as const) + : null; + + return useSWR( + swrKey, + () => + fetcher({ + base: ApiUrl(), + ...buildNotificationCRListRequest({ kubeconfig, namespace }), + }), + { refreshInterval, revalidateOnFocus: true } + ); +}