From b5032aa2bef4c0a0acbedf75d355ea0697113ecf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:30:56 +0800 Subject: [PATCH] feat(account): add admin notification recipients API (#7290) feat(account): add admin notification recipients API (#7247) * feat(account): add admin notification recipients API * fix(account): validate notification namespaces * fix(account): handle ambiguous notification owners * fix(account): satisfy golangci-lint Co-authored-by: Yun Pan --- service/account/api/admin_notification.go | 40 +++ service/account/dao/admin_notification.go | 298 ++++++++++++++++++ .../account/dao/admin_notification_test.go | 160 ++++++++++ service/account/dao/interface.go | 3 + service/account/docs/docs.go | 147 +++++++++ service/account/docs/swagger.json | 149 ++++++++- service/account/docs/swagger.yaml | 96 ++++++ service/account/helper/admin_notification.go | 99 ++++++ .../account/helper/admin_notification_test.go | 99 ++++++ service/account/helper/common.go | 1 + service/account/router/router.go | 1 + 11 files changed, 1092 insertions(+), 1 deletion(-) create mode 100644 service/account/api/admin_notification.go create mode 100644 service/account/dao/admin_notification.go create mode 100644 service/account/dao/admin_notification_test.go create mode 100644 service/account/helper/admin_notification.go create mode 100644 service/account/helper/admin_notification_test.go diff --git a/service/account/api/admin_notification.go b/service/account/api/admin_notification.go new file mode 100644 index 000000000..fcd854577 --- /dev/null +++ b/service/account/api/admin_notification.go @@ -0,0 +1,40 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/labring/sealos/service/account/dao" + "github.com/labring/sealos/service/account/helper" +) + +// AdminListNotificationRecipients returns notify-compatible notification recipients for workspace owners. +// It includes OAuth contacts and enabled user-configured notification contacts, without active-user filtering. +// @Summary List notification recipients for admin +// @Description Resolve workspace namespaces to notify-compatible email or phone recipients. Notification methods default to email. +// @Tags AdminRead +// @Accept json +// @Produce json +// @Param request body helper.AdminNotificationRecipientsReq true "Notification recipient request" +// @Success 200 {object} helper.AdminNotificationRecipientsResp +// @Failure 400 {object} helper.ErrorMessage +// @Failure 401 {object} helper.ErrorMessage +// @Failure 500 {object} helper.ErrorMessage +// @Router /admin/v1alpha1/notification-recipients [post] +func AdminListNotificationRecipients(c *gin.Context) { + if err := authenticateAdminRequest(c); err != nil { + adminReadUnauthorized(c, err) + return + } + req, err := helper.ParseAdminNotificationRecipientsReq(c) + if err != nil { + c.JSON(http.StatusBadRequest, helper.ErrorMessage{Error: err.Error()}) + return + } + result, err := dao.DBClient.ListAdminNotificationRecipients(*req) + if err != nil { + adminReadFailure(c, err) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/service/account/dao/admin_notification.go b/service/account/dao/admin_notification.go new file mode 100644 index 000000000..c6a6d4f27 --- /dev/null +++ b/service/account/dao/admin_notification.go @@ -0,0 +1,298 @@ +package dao + +import ( + "fmt" + "net/mail" + "slices" + "sort" + "strings" + + "github.com/google/uuid" + "github.com/labring/sealos/controllers/pkg/types" + "github.com/labring/sealos/service/account/helper" +) + +type adminNotificationNamespaceRow struct { + Namespace string `gorm:"column:namespace"` + UserUID uuid.UUID `gorm:"column:user_uid"` +} + +type adminNotificationContactSources struct { + OauthProviders helper.AdminNotificationContacts + NotificationContacts helper.AdminNotificationContacts +} + +// ListAdminNotificationRecipients returns notification contacts for workspace owners. +// It intentionally does not evaluate account balances or subscription status. +func (g *Cockroach) ListAdminNotificationRecipients( + req helper.AdminNotificationRecipientsReq, +) (helper.AdminNotificationRecipientsResp, error) { + rows, err := g.listAdminNotificationNamespaces(req.Namespaces) + if err != nil { + return helper.AdminNotificationRecipientsResp{}, err + } + + userUIDs := make([]uuid.UUID, 0, len(rows)) + seenUserUIDs := make(map[uuid.UUID]struct{}, len(rows)) + for _, row := range rows { + if row.UserUID == uuid.Nil { + continue + } + if _, ok := seenUserUIDs[row.UserUID]; ok { + continue + } + seenUserUIDs[row.UserUID] = struct{}{} + userUIDs = append(userUIDs, row.UserUID) + } + + var providers []types.OauthProvider + var alertAccounts []types.UserAlertNotificationAccount + if len(userUIDs) > 0 { + db := g.ck.GetGlobalDB() + if err := db.Where(`"userUid" IN ?`, userUIDs).Find(&providers).Error; err != nil { + return helper.AdminNotificationRecipientsResp{}, fmt.Errorf( + "failed to list oauth providers for notification recipients: %w", err, + ) + } + if err := db.Where(`"user_uid" IN ? AND "is_enabled" = ?`, userUIDs, true). + Find(&alertAccounts).Error; err != nil { + return helper.AdminNotificationRecipientsResp{}, fmt.Errorf( + "failed to list notification contacts: %w", err, + ) + } + } + + return buildAdminNotificationRecipients( + req.Namespaces, + rows, + providers, + alertAccounts, + req.NotificationMethods, + ), nil +} + +func (g *Cockroach) listAdminNotificationNamespaces( + namespaces []string, +) ([]adminNotificationNamespaceRow, error) { + var rows []adminNotificationNamespaceRow + err := g.ck.GetLocalDB().Table(`"Workspace"`). + Select(`"Workspace"."id" AS namespace, "UserCr"."userUid" AS user_uid`). + Joins(`JOIN "UserWorkspace" ON "Workspace".uid = "UserWorkspace"."workspaceUid"`). + Joins(`JOIN "UserCr" ON "UserWorkspace"."userCrUid" = "UserCr".uid`). + Where(`"Workspace"."id" IN ?`, namespaces). + Where(`"UserWorkspace"."role" = ?`, "OWNER"). + Find(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to resolve notification namespaces: %w", err) + } + return rows, nil +} + +func buildAdminNotificationRecipients( + namespaces []string, + rows []adminNotificationNamespaceRow, + providers []types.OauthProvider, + alertAccounts []types.UserAlertNotificationAccount, + methods []string, +) helper.AdminNotificationRecipientsResp { + if len(methods) == 0 { + methods = []string{helper.NotificationMethodEmail} + } + methodSet := make(map[string]struct{}, len(methods)) + for _, method := range methods { + methodSet[method] = struct{}{} + } + + contactsByUser := make(map[uuid.UUID]*adminNotificationContactSources) + for _, provider := range providers { + method := notificationMethod(provider.ProviderType) + if !notificationMethodRequested(method, methodSet) { + continue + } + sources := contactsByUser[provider.UserUID] + if sources == nil { + sources = &adminNotificationContactSources{} + contactsByUser[provider.UserUID] = sources + } + addAdminNotificationContact(&sources.OauthProviders, method, provider.ProviderID) + } + for _, account := range alertAccounts { + if !account.IsEnabled { + continue + } + method := notificationMethod(account.ProviderType) + if !notificationMethodRequested(method, methodSet) { + continue + } + sources := contactsByUser[account.UserUID] + if sources == nil { + sources = &adminNotificationContactSources{} + contactsByUser[account.UserUID] = sources + } + addAdminNotificationContact(&sources.NotificationContacts, method, account.ProviderID) + } + + ownerUIDsByNamespace := make(map[string][]uuid.UUID, len(rows)) + for _, row := range rows { + ownerUIDsByNamespace[row.Namespace] = append( + ownerUIDsByNamespace[row.Namespace], + row.UserUID, + ) + } + result := helper.AdminNotificationRecipientsResp{ + Recipients: make([]helper.AdminNotificationRecipient, 0), + Users: make([]helper.AdminNotificationUser, 0, len(namespaces)), + UnresolvedNamespaces: make([]string, 0), + NamespacesWithoutRecipients: make([]string, 0), + } + seenRecipients := make(map[string]struct{}) + + for _, namespace := range namespaces { + userUID, ok := resolveAdminNotificationNamespaceOwner(ownerUIDsByNamespace[namespace]) + if !ok { + result.UnresolvedNamespaces = append(result.UnresolvedNamespaces, namespace) + continue + } + + sources := contactsByUser[userUID] + if sources == nil { + sources = &adminNotificationContactSources{} + } + user := helper.AdminNotificationUser{ + Namespace: namespace, + UserUID: userUID, + OauthProviders: sources.OauthProviders, + NotificationContacts: sources.NotificationContacts, + } + initializeAdminNotificationContacts(&user.OauthProviders) + initializeAdminNotificationContacts(&user.NotificationContacts) + result.Users = append(result.Users, user) + + userRecipientCount := 0 + for _, contact := range adminNotificationContactsForMethods(sources, methods) { + userRecipientCount++ + key := contact.Type + "\x00" + contact.Value + if _, exists := seenRecipients[key]; exists { + continue + } + seenRecipients[key] = struct{}{} + result.Recipients = append(result.Recipients, contact) + } + if userRecipientCount == 0 { + result.NamespacesWithoutRecipients = append( + result.NamespacesWithoutRecipients, + namespace, + ) + } + } + + return result +} + +func resolveAdminNotificationNamespaceOwner(userUIDs []uuid.UUID) (uuid.UUID, bool) { + var ownerUID uuid.UUID + for _, userUID := range userUIDs { + if userUID == uuid.Nil { + return uuid.Nil, false + } + if ownerUID == uuid.Nil { + ownerUID = userUID + continue + } + if ownerUID != userUID { + return uuid.Nil, false + } + } + if ownerUID == uuid.Nil { + return uuid.Nil, false + } + return ownerUID, true +} + +func notificationMethod(providerType types.OauthProviderType) string { + switch providerType { + case types.OauthProviderTypeEmail: + return helper.NotificationMethodEmail + case types.OauthProviderTypePhone: + return helper.NotificationMethodPhone + default: + return "" + } +} + +func notificationMethodRequested(method string, methods map[string]struct{}) bool { + if method == "" { + return false + } + _, ok := methods[method] + return ok +} + +func addAdminNotificationContact( + contacts *helper.AdminNotificationContacts, + method, value string, +) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if method == helper.NotificationMethodEmail { + value = strings.ToLower(value) + parsed, err := mail.ParseAddress(value) + if err != nil || parsed.Address != value { + return + } + if !containsString(contacts.Emails, value) { + contacts.Emails = append(contacts.Emails, value) + } + return + } + if method == helper.NotificationMethodPhone && !containsString(contacts.PhoneNumbers, value) { + contacts.PhoneNumbers = append(contacts.PhoneNumbers, value) + } +} + +func initializeAdminNotificationContacts(contacts *helper.AdminNotificationContacts) { + if contacts.Emails == nil { + contacts.Emails = []string{} + } + if contacts.PhoneNumbers == nil { + contacts.PhoneNumbers = []string{} + } + sort.Strings(contacts.Emails) + sort.Strings(contacts.PhoneNumbers) +} + +func adminNotificationContactsForMethods( + sources *adminNotificationContactSources, + methods []string, +) []helper.AdminNotificationRecipient { + contacts := make([]helper.AdminNotificationRecipient, 0) + for _, method := range methods { + var values []string + switch method { + case helper.NotificationMethodEmail: + values = append(values, sources.OauthProviders.Emails...) + values = append(values, sources.NotificationContacts.Emails...) + case helper.NotificationMethodPhone: + values = append(values, sources.OauthProviders.PhoneNumbers...) + values = append(values, sources.NotificationContacts.PhoneNumbers...) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + contacts = append( + contacts, + helper.AdminNotificationRecipient{Type: method, Value: value}, + ) + } + } + return contacts +} + +func containsString(values []string, value string) bool { + return slices.Contains(values, value) +} diff --git a/service/account/dao/admin_notification_test.go b/service/account/dao/admin_notification_test.go new file mode 100644 index 000000000..42a4ee72b --- /dev/null +++ b/service/account/dao/admin_notification_test.go @@ -0,0 +1,160 @@ +package dao + +import ( + "testing" + + "github.com/google/uuid" + "github.com/labring/sealos/controllers/pkg/types" + "github.com/labring/sealos/service/account/helper" +) + +func TestBuildAdminNotificationRecipients(t *testing.T) { + userA := uuid.New() + userB := uuid.New() + result := buildAdminNotificationRecipients( + []string{"ns-a", "ns-b", "ns-missing"}, + []adminNotificationNamespaceRow{ + {Namespace: "ns-a", UserUID: userA}, + {Namespace: "ns-b", UserUID: userB}, + }, + []types.OauthProvider{ + { + UserUID: userA, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: " OAuth@Example.com ", + }, + {UserUID: userA, ProviderType: types.OauthProviderTypePhone, ProviderID: "+1-555-0001"}, + { + UserUID: userB, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "oauth@example.com", + }, + }, + []types.UserAlertNotificationAccount{ + { + UserUID: userA, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "oauth@example.com", + IsEnabled: true, + }, + { + UserUID: userA, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "notify@example.com", + IsEnabled: true, + }, + { + UserUID: userA, + ProviderType: types.OauthProviderTypePhone, + ProviderID: "+1-555-0002", + IsEnabled: true, + }, + { + UserUID: userA, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "disabled@example.com", + IsEnabled: false, + }, + }, + []string{helper.NotificationMethodEmail, helper.NotificationMethodPhone}, + ) + + if len(result.UnresolvedNamespaces) != 1 || result.UnresolvedNamespaces[0] != "ns-missing" { + t.Fatalf("unresolved namespaces = %v, want [ns-missing]", result.UnresolvedNamespaces) + } + if len(result.Users) != 2 { + t.Fatalf("users = %d, want 2", len(result.Users)) + } + if len(result.NamespacesWithoutRecipients) != 0 { + t.Fatalf( + "namespaces without recipients = %v, want empty", + result.NamespacesWithoutRecipients, + ) + } + + user := result.Users[0] + if user.OauthProviders.Emails[0] != "oauth@example.com" || + len(user.OauthProviders.PhoneNumbers) != 1 || + len(user.NotificationContacts.Emails) != 2 || + user.NotificationContacts.Emails[0] != "notify@example.com" || + user.NotificationContacts.Emails[1] != "oauth@example.com" || + len(user.NotificationContacts.PhoneNumbers) != 1 { + t.Fatalf("user contact sources = %+v", user) + } + + if len(result.Recipients) != 4 { + t.Fatalf("recipients = %+v, want 4 unique recipients", result.Recipients) + } + want := []helper.AdminNotificationRecipient{ + {Type: helper.NotificationMethodEmail, Value: "oauth@example.com"}, + {Type: helper.NotificationMethodEmail, Value: "notify@example.com"}, + {Type: helper.NotificationMethodPhone, Value: "+1-555-0001"}, + {Type: helper.NotificationMethodPhone, Value: "+1-555-0002"}, + } + for i := range want { + if result.Recipients[i] != want[i] { + t.Fatalf("recipient[%d] = %+v, want %+v", i, result.Recipients[i], want[i]) + } + } +} + +func TestBuildAdminNotificationRecipientsWithoutContacts(t *testing.T) { + userUID := uuid.New() + result := buildAdminNotificationRecipients( + []string{"ns-empty"}, + []adminNotificationNamespaceRow{{Namespace: "ns-empty", UserUID: userUID}}, + nil, + nil, + []string{helper.NotificationMethodEmail}, + ) + + if len(result.Recipients) != 0 { + t.Fatalf("recipients = %+v, want empty", result.Recipients) + } + if len(result.NamespacesWithoutRecipients) != 1 || + result.NamespacesWithoutRecipients[0] != "ns-empty" { + t.Fatalf( + "namespaces without recipients = %v, want [ns-empty]", + result.NamespacesWithoutRecipients, + ) + } +} + +func TestBuildAdminNotificationRecipientsWithAmbiguousOwner(t *testing.T) { + ownerA := uuid.New() + ownerB := uuid.New() + result := buildAdminNotificationRecipients( + []string{"ns-ambiguous", "ns-duplicate"}, + []adminNotificationNamespaceRow{ + {Namespace: "ns-ambiguous", UserUID: ownerA}, + {Namespace: "ns-ambiguous", UserUID: ownerB}, + {Namespace: "ns-duplicate", UserUID: ownerA}, + {Namespace: "ns-duplicate", UserUID: ownerA}, + }, + []types.OauthProvider{ + { + UserUID: ownerA, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "owner@example.com", + }, + { + UserUID: ownerB, + ProviderType: types.OauthProviderTypeEmail, + ProviderID: "other@example.com", + }, + }, + nil, + []string{helper.NotificationMethodEmail}, + ) + + if len(result.UnresolvedNamespaces) != 1 || result.UnresolvedNamespaces[0] != "ns-ambiguous" { + t.Fatalf("unresolved namespaces = %v, want [ns-ambiguous]", result.UnresolvedNamespaces) + } + if len(result.Users) != 1 || result.Users[0].Namespace != "ns-duplicate" || + result.Users[0].UserUID != ownerA { + t.Fatalf("users = %+v, want one user for ns-duplicate owned by ownerA", result.Users) + } + if len(result.Recipients) != 1 || result.Recipients[0].Value != "owner@example.com" { + t.Fatalf("recipients = %+v, want only owner@example.com", result.Recipients) + } +} diff --git a/service/account/dao/interface.go b/service/account/dao/interface.go index 975037007..c4043941b 100644 --- a/service/account/dao/interface.go +++ b/service/account/dao/interface.go @@ -146,6 +146,9 @@ type Interface interface { // Admin read-only account management methods. ListAdminUsers(req helper.AdminUserListReq) (helper.AdminUserListResp, error) + ListAdminNotificationRecipients( + req helper.AdminNotificationRecipientsReq, + ) (helper.AdminNotificationRecipientsResp, error) GetAdminUser(id string) (*helper.AdminUserDetail, error) ListAdminUserRechargeRecords( id string, pageIndex, pageSize int, diff --git a/service/account/docs/docs.go b/service/account/docs/docs.go index 882e722bf..0423a4062 100644 --- a/service/account/docs/docs.go +++ b/service/account/docs/docs.go @@ -1510,6 +1510,58 @@ const docTemplate = `{ } } }, + "/admin/v1alpha1/notification-recipients": { + "post": { + "description": "Resolve workspace namespaces to notify-compatible email or phone recipients. Notification methods default to email.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminRead" + ], + "summary": "List notification recipients for admin", + "parameters": [ + { + "description": "Notification recipient request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/helper.AdminNotificationRecipientsReq" + } + } + ], + "responses": { + "200": { + "description": "successful notification recipient lookup", + "schema": { + "$ref": "#/definitions/helper.AdminNotificationRecipientsResp" + } + }, + "400": { + "description": "invalid notification recipient request", + "schema": { + "$ref": "#/definitions/helper.ErrorMessage" + } + }, + "401": { + "description": "authentication failed", + "schema": { + "$ref": "#/definitions/helper.ErrorMessage" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/helper.ErrorMessage" + } + } + } + } + }, "/admin/v1alpha1/charge": { "post": { "description": "Charge billing", @@ -1588,6 +1640,101 @@ const docTemplate = `{ } }, "definitions": { + "helper.AdminNotificationContacts": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "phoneNumbers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "helper.AdminNotificationRecipient": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "helper.AdminNotificationRecipientsReq": { + "type": "object", + "required": [ + "namespaces" + ], + "properties": { + "namespaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "notificationMethods": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "helper.AdminNotificationRecipientsResp": { + "type": "object", + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/definitions/helper.AdminNotificationRecipient" + } + }, + "unresolvedNamespaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/helper.AdminNotificationUser" + } + }, + "namespacesWithoutRecipients": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "helper.AdminNotificationUser": { + "type": "object", + "properties": { + "namespace": { + "type": "string" + }, + "notificationContacts": { + "$ref": "#/definitions/helper.AdminNotificationContacts" + }, + "oauthProviders": { + "$ref": "#/definitions/helper.AdminNotificationContacts" + }, + "userUid": { + "type": "string", + "format": "uuid" + } + } + }, "common.PropertyQuery": { "type": "object", "properties": { diff --git a/service/account/docs/swagger.json b/service/account/docs/swagger.json index cffbff5dd..eebe5c7c7 100644 --- a/service/account/docs/swagger.json +++ b/service/account/docs/swagger.json @@ -1503,6 +1503,58 @@ } } }, + "/admin/v1alpha1/notification-recipients": { + "post": { + "description": "Resolve workspace namespaces to notify-compatible email or phone recipients. Notification methods default to email.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminRead" + ], + "summary": "List notification recipients for admin", + "parameters": [ + { + "description": "Notification recipient request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/helper.AdminNotificationRecipientsReq" + } + } + ], + "responses": { + "200": { + "description": "successful notification recipient lookup", + "schema": { + "$ref": "#/definitions/helper.AdminNotificationRecipientsResp" + } + }, + "400": { + "description": "invalid notification recipient request", + "schema": { + "$ref": "#/definitions/helper.ErrorMessage" + } + }, + "401": { + "description": "authentication failed", + "schema": { + "$ref": "#/definitions/helper.ErrorMessage" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/helper.ErrorMessage" + } + } + } + } + }, "/admin/v1alpha1/charge": { "post": { "description": "Charge billing", @@ -1581,6 +1633,101 @@ } }, "definitions": { + "helper.AdminNotificationContacts": { + "type": "object", + "properties": { + "emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "phoneNumbers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "helper.AdminNotificationRecipient": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "helper.AdminNotificationRecipientsReq": { + "type": "object", + "required": [ + "namespaces" + ], + "properties": { + "namespaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "notificationMethods": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "helper.AdminNotificationRecipientsResp": { + "type": "object", + "properties": { + "recipients": { + "type": "array", + "items": { + "$ref": "#/definitions/helper.AdminNotificationRecipient" + } + }, + "unresolvedNamespaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/helper.AdminNotificationUser" + } + }, + "namespacesWithoutRecipients": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "helper.AdminNotificationUser": { + "type": "object", + "properties": { + "namespace": { + "type": "string" + }, + "notificationContacts": { + "$ref": "#/definitions/helper.AdminNotificationContacts" + }, + "oauthProviders": { + "$ref": "#/definitions/helper.AdminNotificationContacts" + }, + "userUid": { + "type": "string", + "format": "uuid" + } + } + }, "common.PropertyQuery": { "type": "object", "properties": { @@ -2420,4 +2567,4 @@ } } } -} \ No newline at end of file +} diff --git a/service/account/docs/swagger.yaml b/service/account/docs/swagger.yaml index fb7bc9c8b..50edc3561 100644 --- a/service/account/docs/swagger.yaml +++ b/service/account/docs/swagger.yaml @@ -1,4 +1,66 @@ definitions: + helper.AdminNotificationContacts: + properties: + emails: + items: + type: string + type: array + phoneNumbers: + items: + type: string + type: array + type: object + helper.AdminNotificationRecipient: + properties: + type: + type: string + value: + type: string + type: object + helper.AdminNotificationRecipientsReq: + properties: + namespaces: + items: + type: string + type: array + notificationMethods: + items: + type: string + type: array + required: + - namespaces + type: object + helper.AdminNotificationRecipientsResp: + properties: + recipients: + items: + $ref: '#/definitions/helper.AdminNotificationRecipient' + type: array + unresolvedNamespaces: + items: + type: string + type: array + users: + items: + $ref: '#/definitions/helper.AdminNotificationUser' + type: array + namespacesWithoutRecipients: + items: + type: string + type: array + type: object + helper.AdminNotificationUser: + properties: + namespace: + type: string + notificationContacts: + $ref: '#/definitions/helper.AdminNotificationContacts' + oauthProviders: + $ref: '#/definitions/helper.AdminNotificationContacts' + userUid: + type: string + format: uuid + type: object common.PropertyQuery: properties: alias: @@ -1739,6 +1801,40 @@ paths: summary: Get user account tags: - Account + /admin/v1alpha1/notification-recipients: + post: + consumes: + - application/json + description: Resolve workspace namespaces to notify-compatible email or phone recipients. Notification methods default to email. + parameters: + - description: Notification recipient request + in: body + name: request + required: true + schema: + $ref: '#/definitions/helper.AdminNotificationRecipientsReq' + produces: + - application/json + responses: + "200": + description: successful notification recipient lookup + schema: + $ref: '#/definitions/helper.AdminNotificationRecipientsResp' + "400": + description: invalid notification recipient request + schema: + $ref: '#/definitions/helper.ErrorMessage' + "401": + description: authentication failed + schema: + $ref: '#/definitions/helper.ErrorMessage' + "500": + description: internal server error + schema: + $ref: '#/definitions/helper.ErrorMessage' + summary: List notification recipients for admin + tags: + - AdminRead /admin/v1alpha1/charge: post: consumes: diff --git a/service/account/helper/admin_notification.go b/service/account/helper/admin_notification.go new file mode 100644 index 000000000..59c813928 --- /dev/null +++ b/service/account/helper/admin_notification.go @@ -0,0 +1,99 @@ +package helper + +import ( + "errors" + "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "k8s.io/apimachinery/pkg/api/validation" +) + +const ( + NotificationMethodEmail = "email" + NotificationMethodPhone = "phone" +) + +// AdminNotificationRecipientsReq requests notification contacts for workspace owners. +// notificationMethods defaults to email when omitted. +type AdminNotificationRecipientsReq struct { + Namespaces []string `json:"namespaces" binding:"required"` + NotificationMethods []string `json:"notificationMethods"` +} + +func ParseAdminNotificationRecipientsReq(c *gin.Context) (*AdminNotificationRecipientsReq, error) { + var req AdminNotificationRecipientsReq + if err := c.ShouldBindJSON(&req); err != nil { + return nil, fmt.Errorf("failed to bind request: %w", err) + } + + namespaces := make([]string, 0, len(req.Namespaces)) + seenNamespaces := make(map[string]struct{}, len(req.Namespaces)) + for _, namespace := range req.Namespaces { + namespace = strings.TrimSpace(namespace) + if namespace == "" { + return nil, errors.New("namespaces must not contain empty values") + } + if errs := validation.ValidateNamespaceName(namespace, false); len(errs) > 0 { + return nil, fmt.Errorf("invalid namespace %q: %s", namespace, strings.Join(errs, "; ")) + } + if _, ok := seenNamespaces[namespace]; ok { + continue + } + seenNamespaces[namespace] = struct{}{} + namespaces = append(namespaces, namespace) + } + if len(namespaces) == 0 { + return nil, errors.New("namespaces must contain at least one value") + } + if len(namespaces) > 1000 { + return nil, errors.New("namespaces must contain at most 1000 values") + } + + methods := req.NotificationMethods + if len(methods) == 0 { + methods = []string{NotificationMethodEmail} + } + normalizedMethods := make([]string, 0, len(methods)) + seenMethods := make(map[string]struct{}, len(methods)) + for _, method := range methods { + method = strings.ToLower(strings.TrimSpace(method)) + if method != NotificationMethodEmail && method != NotificationMethodPhone { + return nil, fmt.Errorf("unsupported notification method %q", method) + } + if _, ok := seenMethods[method]; ok { + continue + } + seenMethods[method] = struct{}{} + normalizedMethods = append(normalizedMethods, method) + } + + req.Namespaces = namespaces + req.NotificationMethods = normalizedMethods + return &req, nil +} + +type AdminNotificationRecipient struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type AdminNotificationContacts struct { + Emails []string `json:"emails"` + PhoneNumbers []string `json:"phoneNumbers"` +} + +type AdminNotificationUser struct { + Namespace string `json:"namespace"` + UserUID uuid.UUID `json:"userUid"` + OauthProviders AdminNotificationContacts `json:"oauthProviders"` + NotificationContacts AdminNotificationContacts `json:"notificationContacts"` +} + +type AdminNotificationRecipientsResp struct { + Recipients []AdminNotificationRecipient `json:"recipients"` + Users []AdminNotificationUser `json:"users"` + UnresolvedNamespaces []string `json:"unresolvedNamespaces"` + NamespacesWithoutRecipients []string `json:"namespacesWithoutRecipients"` +} diff --git a/service/account/helper/admin_notification_test.go b/service/account/helper/admin_notification_test.go new file mode 100644 index 000000000..6ef278ee5 --- /dev/null +++ b/service/account/helper/admin_notification_test.go @@ -0,0 +1,99 @@ +package helper + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestParseAdminNotificationRecipientsReq(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + methods []string + ns []string + wantErr bool + }{ + { + name: "defaults to email and normalizes values", + body: `{"namespaces":[" ns-a ","ns-a"]}`, + methods: []string{NotificationMethodEmail}, + ns: []string{"ns-a"}, + }, + { + name: "accepts email and phone", + body: `{"namespaces":["ns-a"],"notificationMethods":[" EMAIL ","phone"]}`, + methods: []string{NotificationMethodEmail, NotificationMethodPhone}, + ns: []string{"ns-a"}, + }, + { + name: "rejects unsupported method", + body: `{"namespaces":["ns-a"],"notificationMethods":["sms"]}`, + wantErr: true, + }, + { + name: "rejects empty namespace", + body: `{"namespaces":[" "]}`, + wantErr: true, + }, + { + name: "rejects invalid kubernetes namespace", + body: `{"namespaces":["ns_a"]}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext( + context.Background(), + "POST", + "/admin/v1alpha1/notification-recipients", + strings.NewReader(tt.body), + ) + c.Request.Header.Set("Content-Type", "application/json") + + got, err := ParseAdminNotificationRecipientsReq(c) + if (err != nil) != tt.wantErr { + t.Fatalf( + "ParseAdminNotificationRecipientsReq() error = %v, wantErr %v", + err, + tt.wantErr, + ) + } + if tt.wantErr { + return + } + if len(got.Namespaces) != len(tt.ns) || + len(got.NotificationMethods) != len(tt.methods) { + t.Fatalf( + "parsed request = %+v, want namespaces %v and methods %v", + got, + tt.ns, + tt.methods, + ) + } + for i := range tt.ns { + if got.Namespaces[i] != tt.ns[i] { + t.Fatalf("namespace[%d] = %q, want %q", i, got.Namespaces[i], tt.ns[i]) + } + } + for i := range tt.methods { + if got.NotificationMethods[i] != tt.methods[i] { + t.Fatalf( + "method[%d] = %q, want %q", + i, + got.NotificationMethods[i], + tt.methods[i], + ) + } + } + }) + } +} diff --git a/service/account/helper/common.go b/service/account/helper/common.go index e4f1ee042..bacc40d1d 100644 --- a/service/account/helper/common.go +++ b/service/account/helper/common.go @@ -66,6 +66,7 @@ const ( // Admin read-only account management routes. AdminUserList = "/users" + AdminNotificationRecipients = "/notification-recipients" AdminUserDetailPath = "/user" AdminUserRechargeRecordsPath = "/user/recharge-records" AdminUserBalanceAdjustRecords = "/user/balance-adjust-records" diff --git a/service/account/router/router.go b/service/account/router/router.go index ea45356a3..250d4bbef 100644 --- a/service/account/router/router.go +++ b/service/account/router/router.go @@ -102,6 +102,7 @@ func RegisterPayRouter() { GET(helper.AdminGetAccountWithWorkspace, api.AdminGetAccountWithWorkspaceID). GET(helper.AdminGetUserRealNameInfo, api.AdminGetUserRealNameInfo). GET(helper.AdminUserList, api.AdminListUsers). + POST(helper.AdminNotificationRecipients, api.AdminListNotificationRecipients). GET(helper.AdminUserDetailPath, api.AdminGetUser). GET(helper.AdminUserRechargeRecordsPath, api.AdminListUserRechargeRecords). GET(helper.AdminUserBalanceAdjustRecords, api.AdminListUserBalanceAdjustRecords).