Files
robin 2c97ca7641
PR Checks / lint-test-and-build (pull_request) Successful in 3m6s
fix: list fixes and ci improvement
2026-09-11 19:43:32 +02:00

320 lines
8.3 KiB
Go

package domain
import (
"context"
"errors"
"log/slog"
"strings"
"time"
)
var (
ErrListIDMissing = errors.New("list id is required")
ErrListNameMissing = errors.New("list name is required")
ErrListItemIDMissing = errors.New("list item id is required")
ErrListItemTitleMissing = errors.New("list item title is required")
ErrUserNotInList = errors.New("user not in list")
ErrStaleListIDs = errors.New("list ids out of date")
)
type List struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"`
Position int `json:"position"`
}
type ListItem struct {
ID string `json:"id"`
ListID string `json:"list_id"`
Title string `json:"title"`
IsCompleted bool `json:"is_completed"`
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
}
type ListRepository interface {
CreateList(ctx context.Context, name string) (*List, error)
DeleteList(ctx context.Context, listID string) error
GetLists(ctx context.Context, userID string) ([]List, error)
AddUserToList(ctx context.Context, listID string, userID string) error
RemoveUserFromList(ctx context.Context, listID string, userID string) error
OrderLists(ctx context.Context, userID string, listIDs []string) error
LockUsersLists(ctx context.Context, userID string) ([]string, error)
IsUserInList(ctx context.Context, listID string, userID string) (bool, error)
IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error)
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
DeleteListItem(ctx context.Context, listItemID string) error
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error
SetListItemTitle(ctx context.Context, listItemID string, title string) error
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
GetListItems(ctx context.Context, listID string) ([]ListItem, error)
}
type ListService struct {
tx Transactor
repo ListRepository
}
func NewListService(tx Transactor, r ListRepository) *ListService {
return &ListService{tx: tx, repo: r}
}
func (s *ListService) CreateList(ctx context.Context, authUserID string, name string) (*List, error) {
if name == "" {
return nil, ErrListNameMissing
}
var list *List
err := s.tx.WithinTx(ctx, func(ctx context.Context) error {
l, err := s.repo.CreateList(ctx, name)
if err != nil {
return err
}
err = s.repo.AddUserToList(ctx, l.ID, authUserID)
if err != nil {
return err
}
list = l
return nil
})
if err != nil {
return nil, err
}
return list, nil
}
func (s *ListService) DeleteList(ctx context.Context, authUserID string, listID string) error {
if listID == "" {
return ErrListIDMissing
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return err
}
return s.repo.DeleteList(ctx, listID)
}
func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List, error) {
return s.repo.GetLists(ctx, authUserID)
}
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDMissing
}
if userID == "" {
return ErrUserIDMissing
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return err
}
return s.repo.AddUserToList(ctx, listID, userID)
}
func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" {
return ErrListIDMissing
}
if userID == "" {
return ErrUserIDMissing
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return err
}
return s.repo.RemoveUserFromList(ctx, listID, userID)
}
func (s *ListService) OrderLists(ctx context.Context, authUserID string, listIDs []string) error {
if authUserID == "" {
return ErrUserIDMissing
}
if len(listIDs) == 0 {
return ErrListIDMissing
}
return s.tx.WithinTx(ctx, func(ctx context.Context) error {
serverIDs, err := s.repo.LockUsersLists(ctx, authUserID)
if err != nil {
return err
}
if !isPermutation(listIDs, serverIDs) {
slog.ErrorContext(ctx, "no permutation",
slog.Any("client_list_ids", listIDs),
slog.Any("server_list_ids", serverIDs))
return ErrStaleListIDs
}
err = s.repo.OrderLists(ctx, authUserID, listIDs)
if err != nil {
return err
}
return nil
})
}
func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
if listID == "" {
return nil, ErrListIDMissing
}
if title == "" {
return nil, ErrListItemTitleMissing
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return nil, err
}
return s.repo.CreateListItem(ctx, listID, title)
}
func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, listItemID string) error {
if listItemID == "" {
return ErrListItemIDMissing
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.DeleteListItem(ctx, listItemID)
}
func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDMissing
}
if title == "" {
return ErrListItemTitleMissing
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
}
func (s *ListService) SetListItemTitle(ctx context.Context, authUserID string, listItemID string, title string) error {
if listItemID == "" {
return ErrListItemIDMissing
}
if title == "" {
return ErrListItemTitleMissing
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.SetListItemTitle(ctx, listItemID, title)
}
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
if listItemID == "" {
return ErrListItemIDMissing
}
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
return err
}
return s.repo.SetListItemCompleted(ctx, listItemID, isCompleted)
}
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
if listID == "" {
return nil, ErrListIDMissing
}
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
return nil, err
}
return s.repo.GetListItems(ctx, listID)
}
func (s *ListService) userAllowedToAccessList(ctx context.Context, authUserID string, listID string) error {
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
if err != nil {
slog.ErrorContext(ctx,
"failed to check if user is in list",
slog.String("user_id", authUserID),
slog.String("list_id", listID),
slog.Any("error", err),
)
return err
}
if !inList {
slog.WarnContext(ctx,
"user tried to access list without permission",
slog.String("user_id", authUserID),
slog.String("list_id", listID),
)
return ErrUserNotInList
}
return nil
}
func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserID string, listItemID string) error {
inList, err := s.repo.IsUserInListByItemID(ctx, listItemID, authUserID)
if err != nil {
slog.ErrorContext(ctx,
"failed to check if user is in list",
slog.String("user_id", authUserID),
slog.String("list_item_id", listItemID),
slog.Any("error", err),
)
return err
}
if !inList {
slog.WarnContext(ctx,
"user tried to access list item without permission",
slog.String("user_id", authUserID),
slog.String("list_item_id", listItemID),
)
return ErrUserNotInList
}
return nil
}
func isPermutation(a []string, b []string) bool {
if len(a) != len(b) {
return false
}
aMap := make(map[string]struct{}, len(a))
for _, v := range a {
aMap[strings.ToLower(v)] = struct{}{}
}
seen := make(map[string]struct{}, len(a))
for _, v := range b {
id := strings.ToLower(v)
if _, ok := aMap[id]; !ok {
return false
}
if _, dup := seen[id]; dup {
return false
}
seen[id] = struct{}{}
}
return true
}