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 <dinoallo@netc.it>
This commit is contained in:
github-actions[bot]
2026-09-03 10:30:56 +08:00
committed by GitHub
co-authored by Yun Pan
parent 8aea93621b
commit b5032aa2be
11 changed files with 1092 additions and 1 deletions
+40
View File
@@ -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)
}
+298
View File
@@ -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)
}
@@ -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)
}
}
+3
View File
@@ -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,
+147
View File
@@ -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": {
+148 -1
View File
@@ -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 @@
}
}
}
}
}
+96
View File
@@ -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:
@@ -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"`
}
@@ -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],
)
}
}
})
}
}
+1
View File
@@ -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"
+1
View File
@@ -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).