mirror of
https://github.com/labring/sealos.git
synced 2026-09-19 02:23:07 +08:00
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:
co-authored by
Yun Pan
parent
8aea93621b
commit
b5032aa2be
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user