Commit 915b7a4a authored by erio's avatar erio
Browse files

feat(notify): convert email lists to NotifyEmailEntry struct with toggle support

- Change balance_notify_extra_emails and account_quota_notify_emails
  from []string to []NotifyEmailEntry{email, disabled, verified}
- Add per-email enable/disable toggle for both user and admin notifications
- Add PUT /user/notify-email/toggle API endpoint
- Fix critical bug: API key auth cache snapshot missing balance notify
  fields (Email, Username, BalanceNotifyEnabled, etc.), causing
  notifications to never fire on cached request paths
- Bump cache snapshot version 3→4 to invalidate stale entries
- Add SQL migration 104 to convert old format data
- Backward compatible: parseNotifyEmails auto-detects old/new format
- User balance notify: max 3 emails (primary + 2 extra)
- Admin quota notify: unlimited emails, each with toggle
parent 61aa197b
......@@ -178,7 +178,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
PaymentEnabled: paymentCfg.Enabled,
PaymentMinAmount: paymentCfg.MinAmount,
PaymentMaxAmount: paymentCfg.MaxAmount,
......@@ -311,7 +311,7 @@ type UpdateSettingsRequest struct {
// Balance low notification
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
AccountQuotaNotifyEmails *[]string `json:"account_quota_notify_emails"`
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
// Payment configuration (integrated into settings, full replace)
PaymentEnabled *bool `json:"payment_enabled"`
......@@ -902,9 +902,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.BalanceLowNotifyThreshold
}(),
AccountQuotaNotifyEmails: func() []string {
AccountQuotaNotifyEmails: func() []service.NotifyEmailEntry {
if req.AccountQuotaNotifyEmails != nil {
return *req.AccountQuotaNotifyEmails
return dto.NotifyEmailEntriesToService(*req.AccountQuotaNotifyEmails)
}
return previousSettings.AccountQuotaNotifyEmails
}(),
......@@ -1056,7 +1056,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableCCHSigning: updatedSettings.EnableCCHSigning,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
PaymentEnabled: updatedPaymentCfg.Enabled,
PaymentMinAmount: updatedPaymentCfg.MinAmount,
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
......
......@@ -26,7 +26,7 @@ func UserFromServiceShallow(u *service.User) *User {
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails,
BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
TotalRecharged: u.TotalRecharged,
}
}
......
package dto
import "github.com/Wei-Shaw/sub2api/internal/service"
// NotifyEmailEntry represents a notification email with enable/disable and verification state.
// Email="" is a placeholder for the "primary email" (user's registration email or first admin email).
type NotifyEmailEntry struct {
Email string `json:"email"`
Disabled bool `json:"disabled"`
Verified bool `json:"verified"`
}
// NotifyEmailEntriesFromService converts service entries to DTO entries.
func NotifyEmailEntriesFromService(entries []service.NotifyEmailEntry) []NotifyEmailEntry {
if entries == nil {
return nil
}
result := make([]NotifyEmailEntry, len(entries))
for i, e := range entries {
result[i] = NotifyEmailEntry{
Email: e.Email,
Disabled: e.Disabled,
Verified: e.Verified,
}
}
return result
}
// NotifyEmailEntriesToService converts DTO entries to service entries.
func NotifyEmailEntriesToService(entries []NotifyEmailEntry) []service.NotifyEmailEntry {
if entries == nil {
return nil
}
result := make([]service.NotifyEmailEntry, len(entries))
for i, e := range entries {
result[i] = service.NotifyEmailEntry{
Email: e.Email,
Disabled: e.Disabled,
Verified: e.Verified,
}
}
return result
}
......@@ -152,7 +152,7 @@ type SystemSettings struct {
// Balance low notification
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"`
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
}
type DefaultSubscriptionSetting struct {
......
......@@ -22,7 +22,7 @@ type User struct {
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
BalanceNotifyExtraEmails []string `json:"balance_notify_extra_emails"`
BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails"`
TotalRecharged float64 `json:"total_recharged"`
APIKeys []APIKey `json:"api_keys,omitempty"`
......
......@@ -214,3 +214,39 @@ func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) {
response.Success(c, dto.UserFromService(updatedUser))
}
// ToggleNotifyEmailRequest represents the request to toggle a notify email's disabled state
type ToggleNotifyEmailRequest struct {
Email string `json:"email"` // empty string for primary email placeholder
Disabled bool `json:"disabled"`
}
// ToggleNotifyEmail toggles the disabled state of a notification email
// PUT /api/v1/user/notify-email/toggle
func (h *UserHandler) ToggleNotifyEmail(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
var req ToggleNotifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
err := h.userService.ToggleNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Disabled)
if err != nil {
response.ErrorFrom(c, err)
return
}
updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.UserFromService(updatedUser))
}
......@@ -3,7 +3,6 @@ package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
......@@ -667,12 +666,9 @@ func userEntityToService(u *dbent.User) *service.User {
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
// Parse extra emails JSON array
// Parse extra emails JSON (supports both old []string and new []NotifyEmailEntry format)
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
var emails []string
if err := json.Unmarshal([]byte(u.BalanceNotifyExtraEmails), &emails); err == nil {
out.BalanceNotifyExtraEmails = emails
}
out.BalanceNotifyExtraEmails = service.ParseNotifyEmails(u.BalanceNotifyExtraEmails)
}
return out
}
......
......@@ -3,7 +3,6 @@ package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
......@@ -563,16 +562,9 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) {
dst.UpdatedAt = src.UpdatedAt
}
// marshalExtraEmails serializes a string slice to JSON for storage.
func marshalExtraEmails(emails []string) string {
if len(emails) == 0 {
return "[]"
}
data, err := json.Marshal(emails)
if err != nil {
return "[]"
}
return string(data)
// marshalExtraEmails serializes notify email entries to JSON for storage.
func marshalExtraEmails(entries []service.NotifyEmailEntry) string {
return service.MarshalNotifyEmails(entries)
}
// UpdateTotpSecret 更新用户的 TOTP 加密密钥
......
......@@ -31,6 +31,7 @@ func RegisterUserRoutes(
{
notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode)
notifyEmail.POST("/verify", h.User.VerifyNotifyEmail)
notifyEmail.PUT("/toggle", h.User.ToggleNotifyEmail)
notifyEmail.DELETE("", h.User.RemoveNotifyEmail)
}
......
......@@ -34,6 +34,14 @@ type APIKeyAuthUserSnapshot struct {
Role string `json:"role"`
Balance float64 `json:"balance"`
Concurrency int `json:"concurrency"`
// Balance notification fields (required for CheckBalanceAfterDeduction)
Email string `json:"email"`
Username string `json:"username"`
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"`
BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails,omitempty"`
}
// APIKeyAuthGroupSnapshot 分组快照
......
......@@ -13,7 +13,7 @@ import (
"github.com/dgraph-io/ristretto"
)
const apiKeyAuthSnapshotVersion = 3
const apiKeyAuthSnapshotVersion = 4 // v4: added balance notification fields to UserSnapshot
type apiKeyAuthCacheConfig struct {
l1Size int
......@@ -219,11 +219,17 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
RateLimit1d: apiKey.RateLimit1d,
RateLimit7d: apiKey.RateLimit7d,
User: APIKeyAuthUserSnapshot{
ID: apiKey.User.ID,
Status: apiKey.User.Status,
Role: apiKey.User.Role,
Balance: apiKey.User.Balance,
Concurrency: apiKey.User.Concurrency,
ID: apiKey.User.ID,
Status: apiKey.User.Status,
Role: apiKey.User.Role,
Balance: apiKey.User.Balance,
Concurrency: apiKey.User.Concurrency,
Email: apiKey.User.Email,
Username: apiKey.User.Username,
BalanceNotifyEnabled: apiKey.User.BalanceNotifyEnabled,
BalanceNotifyThresholdType: apiKey.User.BalanceNotifyThresholdType,
BalanceNotifyThreshold: apiKey.User.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails,
},
}
if apiKey.Group != nil {
......@@ -274,11 +280,17 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
RateLimit1d: snapshot.RateLimit1d,
RateLimit7d: snapshot.RateLimit7d,
User: &User{
ID: snapshot.User.ID,
Status: snapshot.User.Status,
Role: snapshot.User.Role,
Balance: snapshot.User.Balance,
Concurrency: snapshot.User.Concurrency,
ID: snapshot.User.ID,
Status: snapshot.User.Status,
Role: snapshot.User.Role,
Balance: snapshot.User.Balance,
Concurrency: snapshot.User.Concurrency,
Email: snapshot.User.Email,
Username: snapshot.User.Username,
BalanceNotifyEnabled: snapshot.User.BalanceNotifyEnabled,
BalanceNotifyThresholdType: snapshot.User.BalanceNotifyThresholdType,
BalanceNotifyThreshold: snapshot.User.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails,
},
}
if snapshot.Group != nil {
......
......@@ -176,13 +176,38 @@ func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context)
return val == "true"
}
// getAccountQuotaNotifyEmails reads admin notification emails from settings.
// getAccountQuotaNotifyEmails reads admin notification emails from settings,
// filtering out disabled entries. Entries with email="" are resolved to the first admin's email.
func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" {
return nil
}
return parseJSONStringArray(raw)
entries := ParseNotifyEmails(raw)
if len(entries) == 0 {
return nil
}
var recipients []string
seen := make(map[string]bool)
for _, entry := range entries {
if entry.Disabled {
continue
}
email := strings.TrimSpace(entry.Email)
// email="" placeholder is not resolved here; admin should configure actual emails
if email == "" {
continue
}
lower := strings.ToLower(email)
if seen[lower] {
continue
}
seen[lower] = true
recipients = append(recipients, email)
}
return recipients
}
// getSiteName reads site name from settings with fallback.
......@@ -194,18 +219,36 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
return name
}
// collectBalanceNotifyRecipients collects all email recipients for balance notifications.
// collectBalanceNotifyRecipients collects all non-disabled email recipients for balance notifications.
// Entries with email="" are resolved to the user's primary email.
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
var recipients []string
if user.Email != "" {
recipients = append(recipients, user.Email)
}
for _, extra := range user.BalanceNotifyExtraEmails {
email := strings.TrimSpace(extra)
if email != "" && !strings.EqualFold(email, user.Email) {
recipients = append(recipients, email)
seen := make(map[string]bool)
for _, entry := range user.BalanceNotifyExtraEmails {
if entry.Disabled {
continue
}
email := strings.TrimSpace(entry.Email)
if email == "" {
email = user.Email // Resolve primary email placeholder
}
if email == "" {
continue
}
lower := strings.ToLower(email)
if seen[lower] {
continue
}
seen[lower] = true
recipients = append(recipients, email)
}
// If no entries exist at all (legacy/empty), fall back to user's primary email
if len(user.BalanceNotifyExtraEmails) == 0 && user.Email != "" {
recipients = append(recipients, user.Email)
}
return recipients
}
......
package service
import (
"encoding/json"
"strings"
)
// NotifyEmailEntry represents a notification email with enable/disable and verification state.
// Email="" is a placeholder for the "primary email" (user's registration email or first admin email).
type NotifyEmailEntry struct {
Email string `json:"email"`
Disabled bool `json:"disabled"`
Verified bool `json:"verified"`
}
// parseNotifyEmails parses a JSON string into []NotifyEmailEntry.
// It auto-detects the format:
// - Old format ["email1","email2"] → converted to [{email, disabled:false, verified:true}, ...]
// - New format [{email,disabled,verified}, ...] → parsed directly
//
// Returns nil on empty/invalid input.
func ParseNotifyEmails(raw string) []NotifyEmailEntry {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "[]" {
return nil
}
// Try parsing as new format first (array of objects)
var entries []NotifyEmailEntry
if err := json.Unmarshal([]byte(raw), &entries); err == nil && len(entries) > 0 {
// Verify it's actually the new format by checking the first element
// json.Unmarshal into []NotifyEmailEntry succeeds even for ["string"]
// because it tries to fit "string" into NotifyEmailEntry and gets zero values.
// We need to detect old format explicitly.
if !isOldStringArrayFormat(raw) {
return entries
}
}
// Try parsing as old format (array of strings)
var emails []string
if err := json.Unmarshal([]byte(raw), &emails); err == nil {
result := make([]NotifyEmailEntry, 0, len(emails))
for _, e := range emails {
e = strings.TrimSpace(e)
if e != "" {
result = append(result, NotifyEmailEntry{
Email: e,
Disabled: false,
Verified: false, // Old format emails default to unverified
})
}
}
return result
}
return nil
}
// isOldStringArrayFormat checks if the JSON is a string array like ["email1","email2"].
func isOldStringArrayFormat(raw string) bool {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(raw), &arr); err != nil || len(arr) == 0 {
return false
}
// Check if first element starts with a quote (string) vs { (object)
first := strings.TrimSpace(string(arr[0]))
return len(first) > 0 && first[0] == '"'
}
// marshalNotifyEmails serializes []NotifyEmailEntry to JSON string.
func MarshalNotifyEmails(entries []NotifyEmailEntry) string {
if len(entries) == 0 {
return "[]"
}
data, err := json.Marshal(entries)
if err != nil {
return "[]"
}
return string(data)
}
// filterEnabledEmails returns only non-disabled email addresses from entries.
// Empty email placeholders are skipped (caller should resolve them separately).
func FilterEnabledEmails(entries []NotifyEmailEntry) []string {
var result []string
for _, e := range entries {
if e.Disabled {
continue
}
email := strings.TrimSpace(e.Email)
if email != "" {
result = append(result, email)
}
}
return result
}
// isPrimaryDisabled checks if the primary email placeholder (email="") exists and is disabled.
func IsPrimaryDisabled(entries []NotifyEmailEntry) bool {
for _, e := range entries {
if e.Email == "" {
return e.Disabled
}
}
return false // No primary placeholder = not disabled
}
......@@ -1272,13 +1272,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Account quota notification
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
var emails []string
if err := json.Unmarshal([]byte(raw), &emails); err == nil {
result.AccountQuotaNotifyEmails = emails
}
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
}
if result.AccountQuotaNotifyEmails == nil {
result.AccountQuotaNotifyEmails = []string{}
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
}
return result
......
......@@ -113,7 +113,7 @@ type SystemSettings struct {
// Account quota notification
AccountQuotaNotifyEnabled bool
AccountQuotaNotifyEmails []string
AccountQuotaNotifyEmails []NotifyEmailEntry
}
type DefaultSubscriptionSetting struct {
......
......@@ -34,7 +34,7 @@ type User struct {
BalanceNotifyEnabled bool
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
BalanceNotifyThreshold *float64
BalanceNotifyExtraEmails []string
BalanceNotifyExtraEmails []NotifyEmailEntry
TotalRecharged float64
APIKeys []APIKey
......
......@@ -18,7 +18,7 @@ var (
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
)
const maxNotifyExtraEmails = 5
const maxNotifyEmails = 3 // Total limit: primary (email="") + up to 2 extra
// UserListFilters contains all filter options for listing users
type UserListFilters struct {
......@@ -338,17 +338,21 @@ func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64,
// Check if already exists
for _, e := range user.BalanceNotifyExtraEmails {
if strings.EqualFold(e, email) {
if strings.EqualFold(e.Email, email) {
return nil // Already added
}
}
// Check limit
if len(user.BalanceNotifyExtraEmails) >= maxNotifyExtraEmails {
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d extra notification emails allowed", maxNotifyExtraEmails))
// Check limit (total includes primary email="" placeholder + extra emails)
if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d notification emails allowed", maxNotifyEmails))
}
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, email)
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, NotifyEmailEntry{
Email: email,
Disabled: false,
Verified: true,
})
return s.userRepo.Update(ctx, user)
}
......@@ -359,9 +363,9 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
return err
}
filtered := make([]string, 0, len(user.BalanceNotifyExtraEmails))
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
for _, e := range user.BalanceNotifyExtraEmails {
if !strings.EqualFold(e, email) {
if !strings.EqualFold(e.Email, email) {
filtered = append(filtered, e)
}
}
......@@ -369,6 +373,28 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
return s.userRepo.Update(ctx, user)
}
// ToggleNotifyEmail toggles the disabled state of a notification email entry.
func (s *UserService) ToggleNotifyEmail(ctx context.Context, userID int64, email string, disabled bool) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
found := false
for i, e := range user.BalanceNotifyExtraEmails {
if strings.EqualFold(e.Email, email) {
user.BalanceNotifyExtraEmails[i].Disabled = disabled
found = true
break
}
}
if !found {
return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
}
return s.userRepo.Update(ctx, user)
}
// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
func buildNotifyVerifyEmailBody(code, siteName string) string {
return fmt.Sprintf(`
......
-- Migrate notification email lists from old []string format to new []NotifyEmailEntry format
-- Old: ["a@x.com", "b@x.com"]
-- New: [{"email":"a@x.com","disabled":false,"verified":true}, ...]
-- Existing emails are marked as verified=false (unverified), disabled=false (enabled)
-- 1. User balance notification emails
UPDATE users
SET balance_notify_extra_emails = (
SELECT COALESCE(
jsonb_agg(jsonb_build_object('email', elem::text, 'disabled', false, 'verified', false)),
'[]'::jsonb
)::text
FROM jsonb_array_elements_text(balance_notify_extra_emails::jsonb) AS elem
)
WHERE balance_notify_extra_emails IS NOT NULL
AND balance_notify_extra_emails <> '[]'
AND balance_notify_extra_emails <> ''
AND (balance_notify_extra_emails::jsonb -> 0) IS NOT NULL
AND jsonb_typeof(balance_notify_extra_emails::jsonb -> 0) = 'string';
-- 2. Admin account quota notification emails
UPDATE settings
SET value = (
SELECT COALESCE(
jsonb_agg(jsonb_build_object('email', elem::text, 'disabled', false, 'verified', false)),
'[]'::jsonb
)::text
FROM jsonb_array_elements_text(value::jsonb) AS elem
)
WHERE key = 'account_quota_notify_emails'
AND value IS NOT NULL
AND value <> '[]'
AND value <> ''
AND (value::jsonb -> 0) IS NOT NULL
AND jsonb_typeof(value::jsonb -> 0) = 'string';
......@@ -4,7 +4,7 @@
*/
import { apiClient } from '../client'
import type { CustomMenuItem, CustomEndpoint } from '@/types'
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types'
export interface DefaultSubscriptionSetting {
group_id: number
......@@ -139,7 +139,7 @@ export interface SystemSettings {
balance_low_notify_enabled: boolean
balance_low_notify_threshold: number
account_quota_notify_enabled: boolean
account_quota_notify_emails: string[]
account_quota_notify_emails: NotifyEmailEntry[]
}
export interface UpdateSettingsRequest {
......@@ -243,7 +243,7 @@ export interface UpdateSettingsRequest {
balance_low_notify_enabled?: boolean
balance_low_notify_threshold?: number
account_quota_notify_enabled?: boolean
account_quota_notify_emails?: string[]
account_quota_notify_emails?: NotifyEmailEntry[]
}
/**
......
......@@ -4,7 +4,7 @@
*/
import { apiClient } from './client'
import type { User, ChangePasswordRequest } from '@/types'
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
/**
* Get current user profile
......@@ -24,7 +24,7 @@ export async function updateProfile(profile: {
username?: string
balance_notify_enabled?: boolean
balance_notify_threshold?: number | null
balance_notify_extra_emails?: string[]
balance_notify_extra_emails?: NotifyEmailEntry[]
}): Promise<User> {
const { data } = await apiClient.put<User>('/user', profile)
return data
......@@ -73,13 +73,24 @@ export async function removeNotifyEmail(email: string): Promise<void> {
await apiClient.delete('/user/notify-email', { data: { email } })
}
/**
* Toggle a notify email's disabled state
* @param email - Email address (empty string for primary email placeholder)
* @param disabled - Whether to disable the email
*/
export async function toggleNotifyEmail(email: string, disabled: boolean): Promise<User> {
const { data } = await apiClient.put<User>('/user/notify-email/toggle', { email, disabled })
return data
}
export const userAPI = {
getProfile,
updateProfile,
changePassword,
sendNotifyEmailCode,
verifyNotifyEmail,
removeNotifyEmail
removeNotifyEmail,
toggleNotifyEmail
}
export default userAPI
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment