ultisuite-backend/internal/api/mail/unified_folders.go
2026-05-24 00:03:36 +02:00

285 lines
7.2 KiB
Go

package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/ultisuite/ulti-backend/internal/api/query"
)
type UnifiedFoldersList struct {
Folders []map[string]any `json:"folders"`
Pagination query.PaginationMeta `json:"pagination,omitempty"`
}
func scanUnifiedFolderRow(
id string,
accountID *string,
parentID *string,
name, color string,
sortOrder int,
createdAt, updatedAt any,
) map[string]any {
row := map[string]any{
"id": id,
"name": name,
"color": color,
"sort_order": sortOrder,
"created_at": createdAt,
"updated_at": updatedAt,
"scope": "global",
}
if accountID != nil && *accountID != "" {
row["account_id"] = *accountID
row["scope"] = "account"
}
if parentID != nil && *parentID != "" {
row["parent_id"] = *parentID
}
return row
}
func (s *Service) ListUnifiedFolders(ctx context.Context, externalID, accountID string, params query.ListParams) (UnifiedFoldersList, error) {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return UnifiedFoldersList{}, err
}
countQuery := `
SELECT COUNT(*) FROM mail_unified_folders
WHERE user_id = $1
`
listQuery := `
SELECT id, account_id, parent_id, name, color, sort_order, created_at, updated_at
FROM mail_unified_folders
WHERE user_id = $1
`
args := []any{userID}
switch strings.TrimSpace(accountID) {
case "", "all":
// all scopes
case "global":
countQuery += ` AND account_id IS NULL`
listQuery += ` AND account_id IS NULL`
default:
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2
)
`, accountID, userID).Scan(&owned); err != nil {
return UnifiedFoldersList{}, err
}
if !owned {
return UnifiedFoldersList{}, ErrAccountNotFound
}
countQuery += ` AND account_id = $2`
listQuery += ` AND account_id = $2`
args = append(args, accountID)
}
var total int64
if err := s.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return UnifiedFoldersList{}, err
}
listQuery += fmt.Sprintf(` ORDER BY sort_order ASC, name ASC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, params.Limit(), params.Offset())
rows, err := s.db.Query(ctx, listQuery, args...)
if err != nil {
return UnifiedFoldersList{}, err
}
defer rows.Close()
folders := make([]map[string]any, 0)
for rows.Next() {
var id, name, color string
var accountIDVal, parentIDVal *string
var sortOrder int
var createdAt, updatedAt any
if err := rows.Scan(&id, &accountIDVal, &parentIDVal, &name, &color, &sortOrder, &createdAt, &updatedAt); err != nil {
return UnifiedFoldersList{}, err
}
folders = append(folders, scanUnifiedFolderRow(id, accountIDVal, parentIDVal, name, color, sortOrder, createdAt, updatedAt))
}
if err := rows.Err(); err != nil {
return UnifiedFoldersList{}, err
}
return UnifiedFoldersList{
Folders: folders,
Pagination: params.Meta(&total),
}, nil
}
func (s *Service) CreateUnifiedFolder(ctx context.Context, userID string, req *createUnifiedFolderRequest) (string, error) {
scopeAccountID := strings.TrimSpace(req.AccountID)
parentID := strings.TrimSpace(req.ParentID)
if scopeAccountID != "" {
var owned bool
if err := s.db.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM mail_accounts WHERE id = $1 AND user_id = $2)
`, scopeAccountID, userID).Scan(&owned); err != nil {
return "", err
}
if !owned {
return "", ErrAccountNotFound
}
}
if parentID != "" {
var parentAccountID *string
if err := s.db.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders
WHERE id = $1 AND user_id = $2
`, parentID, userID).Scan(&parentAccountID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
if scopeAccountID == "" {
if parentAccountID != nil {
return "", ErrInvalidFolderScope
}
} else if parentAccountID == nil || *parentAccountID != scopeAccountID {
return "", ErrInvalidFolderScope
}
}
var id string
var accountArg any
if scopeAccountID == "" {
accountArg = nil
} else {
accountArg = scopeAccountID
}
var parentArg any
if parentID == "" {
parentArg = nil
} else {
parentArg = parentID
}
err := s.db.QueryRow(ctx, `
INSERT INTO mail_unified_folders (user_id, account_id, parent_id, name, color, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, userID, accountArg, parentArg, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return "", ErrDuplicateFolder
}
return "", err
}
return id, nil
}
func (s *Service) UpdateUnifiedFolder(ctx context.Context, externalID, folderID string, req *updateUnifiedFolderRequest) error {
userID, err := s.ResolveUserID(ctx, externalID)
if err != nil {
return err
}
if req.ParentID != nil {
parentID := strings.TrimSpace(*req.ParentID)
if parentID != "" {
var parentAccountID *string
if err := s.db.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders
WHERE id = $1 AND user_id = $2
`, parentID, userID).Scan(&parentAccountID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
var folderAccountID *string
if err := s.db.QueryRow(ctx, `
SELECT account_id FROM mail_unified_folders
WHERE id = $1 AND user_id = $2
`, folderID, userID).Scan(&folderAccountID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if (folderAccountID == nil) != (parentAccountID == nil) {
return ErrInvalidFolderScope
}
if folderAccountID != nil && parentAccountID != nil && *folderAccountID != *parentAccountID {
return ErrInvalidFolderScope
}
}
}
var parentArg any
if req.ParentID == nil {
result, err := s.db.Exec(ctx, `
UPDATE mail_unified_folders SET
name = $1,
color = $2,
sort_order = $3,
updated_at = NOW()
WHERE id = $4 AND user_id = $5
`, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder, folderID, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
parentID := strings.TrimSpace(*req.ParentID)
if parentID == "" {
parentArg = nil
} else {
parentArg = parentID
}
result, err := s.db.Exec(ctx, `
UPDATE mail_unified_folders SET
name = $1,
color = $2,
sort_order = $3,
parent_id = $4,
updated_at = NOW()
WHERE id = $5 AND user_id = $6
`, strings.TrimSpace(req.Name), strings.TrimSpace(req.Color), req.SortOrder, parentArg, folderID, userID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Service) DeleteUnifiedFolder(ctx context.Context, externalID, folderID string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM mail_unified_folders
WHERE id = $1 AND user_id = (SELECT id FROM users WHERE external_id = $2)
`, folderID, externalID)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrFolderHasChildren
}
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}