1package usecase
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "sort"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/dto"
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/domain/repository"
16 domainurl "github.com/bnema/dumber/internal/domain/url"
17 "github.com/bnema/dumber/internal/logging"
18)
19
20// Compile-time checks: ManageFavoritesUseCase must satisfy favorites application ports.
21var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
22var _ port.FavoritesSidebarFavorites = (*ManageFavoritesUseCase)(nil)
23
24// ManageFavoritesUseCase handles favorite and tag operations.
25type ManageFavoritesUseCase struct {
26 favoriteRepo repository.FavoriteRepository
27 tagRepo repository.TagRepository
28
29 cacheMu sync.Mutex
30 favoritesCache []*entity.Favorite
31 favoriteURLsCache map[string]struct{}
32 cacheTime time.Time
33}
34
35const favoritesCacheTTL = 2 * time.Second
36
37// NewManageFavoritesUseCase creates a new favorites management use case.
38func NewManageFavoritesUseCase(
39 favoriteRepo repository.FavoriteRepository,
40 tagRepo repository.TagRepository,
41) *ManageFavoritesUseCase {
42 return &ManageFavoritesUseCase{
43 favoriteRepo: favoriteRepo,
44 tagRepo: tagRepo,
45 }
46}
47
48// AddFavoriteInput contains parameters for adding a favorite.
49type AddFavoriteInput struct {
50 URL string
51 Title string
52 FaviconURL string
53 Tags []entity.TagID
54}
55
56// Add creates a new favorite.
57func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
58 log := logging.FromContext(ctx)
59 log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("adding favorite")
60
61 // Check if already favorited
62 existing, err := uc.favoriteRepo.FindByURL(ctx, input.URL)
63 if err != nil {
64 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
65 }
66 if existing != nil {
67 log.Debug().Str("url", logging.RedactURL(input.URL)).Msg("URL already favorited")
68 return existing, nil
69 }
70
71 return uc.addNewFavorite(ctx, input)
72}
73
74func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
75 log := logging.FromContext(ctx)
76 fav := entity.NewFavorite(input.URL, input.Title)
77 fav.FaviconURL = input.FaviconURL
78
79 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
80 return nil, fmt.Errorf("failed to save favorite: %w", err)
81 }
82
83 // Assign tags
84 for _, tagID := range input.Tags {
85 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, fav.ID); err != nil {
86 return nil, fmt.Errorf("failed to assign tag %d to favorite %d: %w", tagID, fav.ID, err)
87 }
88 }
89
90 log.Info().Str("url", logging.RedactURL(input.URL)).Int64("id", int64(fav.ID)).Msg("favorite added")
91 uc.invalidateCache()
92 return fav, nil
93}
94
95// AddFavorite creates a favorite from the UI-facing application port.
96func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
97 for _, tagID := range input.Tags {
98 if tagID <= 0 {
99 return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID)
100 }
101 }
102 favoriteURL, err := normalizeFavoriteURL(input.URL)
103 if err != nil {
104 return nil, err
105 }
106 if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil {
107 return nil, err
108 } else if existing != nil {
109 return existing, nil
110 }
111 tags := append([]entity.TagID(nil), input.Tags...)
112 title := strings.TrimSpace(input.Title)
113 if title == "" {
114 title = favoriteURL
115 }
116 return uc.addNewFavorite(ctx, AddFavoriteInput{
117 URL: favoriteURL,
118 Title: title,
119 FaviconURL: strings.TrimSpace(input.FaviconURL),
120 Tags: tags,
121 })
122}
123
124func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(
125 ctx context.Context,
126 canonicalURL string,
127 rawURL string,
128) (*entity.Favorite, error) {
129 existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
130 if err != nil {
131 return nil, fmt.Errorf("failed to check existing favorite: %w", err)
132 }
133 if existing != nil {
134 return existing, nil
135 }
136 rawURL = strings.TrimSpace(rawURL)
137 if rawURL == "" || rawURL == canonicalURL {
138 return nil, nil
139 }
140 existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL)
141 if err != nil {
142 return nil, fmt.Errorf("failed to check legacy favorite: %w", err)
143 }
144 return existing, nil
145}
146
147func normalizeFavoriteURL(raw string) (string, error) {
148 favoriteURL := domainurl.Normalize(strings.TrimSpace(raw))
149 if favoriteURL == "" {
150 return "", fmt.Errorf("favorite URL is required")
151 }
152 parsed, err := url.Parse(favoriteURL)
153 if err != nil || parsed.Scheme == "" {
154 return "", fmt.Errorf("favorite URL must be absolute")
155 }
156 switch strings.ToLower(parsed.Scheme) {
157 case "http", "https":
158 if parsed.Host == "" {
159 return "", fmt.Errorf("favorite URL host is required")
160 }
161 case "dumb":
162 if parsed.Host != "" {
163 return favoriteURL, nil
164 }
165 if parsed.Opaque != "" {
166 return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
167 }
168 return "", fmt.Errorf("favorite URL host is required")
169 default:
170 return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
171 }
172 return favoriteURL, nil
173}
174
175// UpdateFavorite updates editable favorite metadata from the UI-facing application port.
176func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
177 if input.ID <= 0 {
178 return nil, fmt.Errorf("favorite ID is required")
179 }
180 if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
181 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
182 }
183
184 fav, err := uc.favoriteRepo.FindByID(ctx, input.ID)
185 if err != nil {
186 return nil, fmt.Errorf("failed to find favorite: %w", err)
187 }
188 if fav == nil {
189 return nil, fmt.Errorf("favorite %d not found", input.ID)
190 }
191
192 title := strings.TrimSpace(input.Title)
193 if title == "" {
194 return nil, fmt.Errorf("favorite title is required")
195 }
196 fav.Title = title
197 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
198 if input.ShortcutKeySet {
199 fav.ShortcutKey = input.ShortcutKey
200 }
201
202 if err := uc.Update(ctx, fav); err != nil {
203 return nil, err
204 }
205 return fav, nil
206}
207
208// DeleteFavorite deletes a favorite from the UI-facing application port.
209func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
210 return uc.Remove(ctx, id)
211}
212
213// Remove deletes a favorite by ID.
214func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
215 log := logging.FromContext(ctx)
216 log.Debug().Int64("id", int64(id)).Msg("removing favorite")
217
218 if err := uc.favoriteRepo.Delete(ctx, id); err != nil {
219 return fmt.Errorf("failed to delete favorite: %w", err)
220 }
221
222 log.Info().Int64("id", int64(id)).Msg("favorite removed")
223 uc.invalidateCache()
224 return nil
225}
226
227// RemoveByURL deletes a favorite by its URL.
228func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
229 log := logging.FromContext(ctx)
230 log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("removing favorite by URL")
231
232 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
233 if err != nil {
234 return fmt.Errorf("failed to find favorite: %w", err)
235 }
236 if fav == nil {
237 log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("favorite not found")
238 return nil
239 }
240
241 return uc.Remove(ctx, fav.ID)
242}
243
244// Update modifies a favorite's metadata.
245func (uc *ManageFavoritesUseCase) Update(ctx context.Context, fav *entity.Favorite) error {
246 log := logging.FromContext(ctx)
247 log.Debug().Int64("id", int64(fav.ID)).Msg("updating favorite")
248
249 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
250 return fmt.Errorf("failed to update favorite: %w", err)
251 }
252
253 log.Info().Int64("id", int64(fav.ID)).Msg("favorite updated")
254 uc.invalidateCache()
255 return nil
256}
257
258// SetShortcut assigns or removes a keyboard shortcut (1-9).
259func (uc *ManageFavoritesUseCase) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
260 log := logging.FromContext(ctx)
261
262 if key != nil && (*key < 1 || *key > 9) {
263 return fmt.Errorf("shortcut key must be 1-9, got %d", *key)
264 }
265
266 log.Debug().
267 Int64("id", int64(id)).
268 Msg("setting favorite shortcut")
269
270 if err := uc.favoriteRepo.SetShortcut(ctx, id, key); err != nil {
271 return fmt.Errorf("failed to set shortcut: %w", err)
272 }
273
274 uc.invalidateCache()
275 return nil
276}
277
278// GetByShortcut finds the favorite with the given shortcut key.
279func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
280 log := logging.FromContext(ctx)
281 log.Debug().Int("key", key).Msg("getting favorite by shortcut")
282
283 if key < 1 || key > 9 {
284 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", key)
285 }
286
287 return uc.favoriteRepo.GetByShortcut(ctx, key)
288}
289
290// GetByURL finds a favorite by its URL.
291func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
292 log := logging.FromContext(ctx)
293 log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("getting favorite by URL")
294
295 return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
296}
297
298// GetAll retrieves all favorites.
299func (uc *ManageFavoritesUseCase) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
300 log := logging.FromContext(ctx)
301 log.Debug().Msg("getting all favorites")
302
303 uc.cacheMu.Lock()
304 if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
305 cached := uc.favoritesCache
306 uc.cacheMu.Unlock()
307 return cached, nil
308 }
309 uc.cacheMu.Unlock()
310
311 favs, err := uc.favoriteRepo.GetAll(ctx)
312 if err != nil {
313 return nil, fmt.Errorf("failed to get favorites: %w", err)
314 }
315
316 uc.cacheMu.Lock()
317 if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoritesCache != nil {
318 cached := uc.favoritesCache
319 uc.cacheMu.Unlock()
320 return cached, nil
321 }
322 uc.favoritesCache = favs
323 uc.favoriteURLsCache = buildFavoriteURLSet(favs)
324 uc.cacheTime = time.Now()
325 uc.cacheMu.Unlock()
326
327 log.Debug().Int("count", len(favs)).Msg("retrieved favorites")
328 return favs, nil
329}
330
331// FilterForOmnibox returns favorites filtered and ranked for omnibox display.
332func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query string) ([]*entity.Favorite, error) {
333 favs, err := uc.GetAll(ctx)
334 if err != nil {
335 return nil, err
336 }
337
338 queryLower := strings.ToLower(strings.TrimSpace(query))
339 if queryLower == "" {
340 return favs, nil
341 }
342
343 filtered := make([]*entity.Favorite, 0, len(favs))
344 for _, fav := range favs {
345 if fav == nil {
346 continue
347 }
348 titleLower := strings.ToLower(fav.Title)
349 urlLower := strings.ToLower(fav.URL)
350 if !strings.Contains(titleLower, queryLower) && !strings.Contains(urlLower, queryLower) {
351 continue
352 }
353 filtered = append(filtered, fav)
354 }
355
356 sort.SliceStable(filtered, func(i, j int) bool {
357 leftScore := omniboxFavoriteScore(filtered[i], queryLower)
358 rightScore := omniboxFavoriteScore(filtered[j], queryLower)
359 if leftScore != rightScore {
360 return leftScore > rightScore
361 }
362 if filtered[i].Position != filtered[j].Position {
363 return filtered[i].Position < filtered[j].Position
364 }
365 return filtered[i].URL < filtered[j].URL
366 })
367
368 return filtered, nil
369}
370
371// IsFavorite checks if a URL is favorited.
372func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) {
373 fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
374 if err != nil {
375 return false, fmt.Errorf("failed to check favorite: %w", err)
376 }
377 return fav != nil, nil
378}
379
380func omniboxFavoriteScore(fav *entity.Favorite, queryLower string) int {
381 if fav == nil || queryLower == "" {
382 return 0
383 }
384
385 const (
386 favoriteExactMatchScore = 600
387 favoriteHostPrefixScore = 520
388 favoriteTitlePrefixScore = 440
389 favoriteTitleContainsScore = 320
390 favoriteURLContainsScore = 280
391 )
392
393 urlLower := strings.ToLower(fav.URL)
394 strippedURLLower := strings.ToLower(strings.TrimPrefix(domainurl.ExtractDomain(fav.URL), "www."))
395 if strippedURLLower == "" {
396 strippedURLLower = strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(fav.URL, "https://"), "http://"))
397 }
398 titleLower := strings.ToLower(fav.Title)
399
400 switch {
401 case titleLower == queryLower || urlLower == queryLower:
402 return favoriteExactMatchScore
403 case strings.HasPrefix(strippedURLLower, queryLower) || strings.HasPrefix(urlLower, queryLower):
404 return favoriteHostPrefixScore
405 case strings.HasPrefix(titleLower, queryLower):
406 return favoriteTitlePrefixScore
407 case strings.Contains(titleLower, queryLower):
408 return favoriteTitleContainsScore
409 case strings.Contains(urlLower, queryLower):
410 return favoriteURLContainsScore
411 default:
412 return 0
413 }
414}
415
416// ToggleResult indicates the result of a toggle operation.
417type ToggleResult struct {
418 Added bool // true if favorite was added, false if removed
419 URL string
420 Title string
421 Message string // User-friendly message
422}
423
424// Toggle adds or removes a URL from favorites based on current state.
425// If the URL is already a favorite, it removes it. Otherwise, it adds it.
426func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) {
427 favoriteURL, err := normalizeFavoriteURL(rawURL)
428 if err != nil {
429 return nil, err
430 }
431 title = strings.TrimSpace(title)
432
433 log := logging.FromContext(ctx)
434 log.Debug().Str("url", logging.RedactURL(favoriteURL)).Msg("toggling favorite")
435
436 // Check if already favorited, including legacy rows stored before URL canonicalization.
437 existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
438 if err != nil {
439 return nil, err
440 }
441
442 if existing != nil {
443 // Remove favorite
444 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
445 return nil, fmt.Errorf("failed to remove favorite: %w", err)
446 }
447 log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
448 uc.invalidateCache()
449 return &ToggleResult{
450 Added: false,
451 URL: favoriteURL,
452 Title: existing.Title,
453 Message: "Favorite removed",
454 }, nil
455 }
456
457 // Add favorite
458 fav := entity.NewFavorite(favoriteURL, title)
459 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
460 return nil, fmt.Errorf("failed to add favorite: %w", err)
461 }
462 log.Info().Str("url", logging.RedactURL(favoriteURL)).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
463 uc.invalidateCache()
464 return &ToggleResult{
465 Added: true,
466 URL: favoriteURL,
467 Title: title,
468 Message: "Favorite added",
469 }, nil
470}
471
472// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
473func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
474 log := logging.FromContext(ctx)
475 log.Debug().Msg("getting all favorite URLs")
476
477 uc.cacheMu.Lock()
478 if time.Since(uc.cacheTime) < favoritesCacheTTL && uc.favoriteURLsCache != nil {
479 cached := uc.favoriteURLsCache
480 uc.cacheMu.Unlock()
481 return cached, nil
482 }
483 uc.cacheMu.Unlock()
484
485 favs, err := uc.GetAll(ctx)
486 if err != nil {
487 return nil, fmt.Errorf("failed to get favorites: %w", err)
488 }
489
490 urls := buildFavoriteURLSet(favs)
491
492 log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
493 return urls, nil
494}
495
496func buildFavoriteURLSet(favs []*entity.Favorite) map[string]struct{} {
497 urls := make(map[string]struct{}, len(favs))
498 for _, fav := range favs {
499 if fav == nil || fav.URL == "" {
500 continue
501 }
502 urls[fav.URL] = struct{}{}
503 }
504 return urls
505}
506
507func (uc *ManageFavoritesUseCase) invalidateCache() {
508 uc.cacheMu.Lock()
509 defer uc.cacheMu.Unlock()
510 uc.favoritesCache = nil
511 uc.favoriteURLsCache = nil
512 uc.cacheTime = time.Time{}
513}
514
515// AddTag creates a new tag.
516func (uc *ManageFavoritesUseCase) AddTag(ctx context.Context, name, color string) (*entity.Tag, error) {
517 name = strings.TrimSpace(name)
518 color = strings.TrimSpace(color)
519 if name == "" {
520 return nil, fmt.Errorf("tag name is required")
521 }
522 log := logging.FromContext(ctx)
523 log.Debug().Str("name", name).Str("color", color).Msg("creating tag")
524
525 // Check if tag already exists
526 existing, err := uc.tagRepo.FindByName(ctx, name)
527 if err != nil {
528 return nil, fmt.Errorf("failed to check existing tag: %w", err)
529 }
530 if existing != nil {
531 log.Debug().Str("name", name).Msg("tag already exists")
532 return existing, nil
533 }
534
535 tag := entity.NewTag(name)
536 if color != "" {
537 tag.Color = color
538 }
539
540 if err := uc.tagRepo.Save(ctx, tag); err != nil {
541 return nil, fmt.Errorf("failed to create tag: %w", err)
542 }
543
544 log.Info().Str("name", name).Int64("id", int64(tag.ID)).Msg("tag created")
545 return tag, nil
546}
547
548// DeleteTag removes a tag.
549func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID) error {
550 log := logging.FromContext(ctx)
551 log.Debug().Int64("id", int64(id)).Msg("deleting tag")
552
553 if err := uc.tagRepo.Delete(ctx, id); err != nil {
554 return fmt.Errorf("failed to delete tag: %w", err)
555 }
556
557 log.Info().Int64("id", int64(id)).Msg("tag deleted")
558 uc.invalidateCache()
559 return nil
560}
561
562// GetAllTags retrieves all tags.
563func (uc *ManageFavoritesUseCase) GetAllTags(ctx context.Context) ([]*entity.Tag, error) {
564 log := logging.FromContext(ctx)
565 log.Debug().Msg("getting all tags")
566
567 return uc.tagRepo.GetAll(ctx)
568}
569
570// TagFavorite assigns a tag to a favorite.
571func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
572 log := logging.FromContext(ctx)
573 log.Debug().
574 Int64("favorite_id", int64(favID)).
575 Int64("tag_id", int64(tagID)).
576 Msg("tagging favorite")
577
578 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
579 return fmt.Errorf("failed to tag favorite: %w", err)
580 }
581 uc.invalidateCache()
582
583 return nil
584}
585
586// UntagFavorite removes a tag from a favorite.
587func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error {
588 log := logging.FromContext(ctx)
589 log.Debug().
590 Int64("favorite_id", int64(favID)).
591 Int64("tag_id", int64(tagID)).
592 Msg("untagging favorite")
593
594 if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
595 return fmt.Errorf("failed to untag favorite: %w", err)
596 }
597 uc.invalidateCache()
598
599 return nil
600}
601
602// UpdateFavoriteTags reconciles a favorite's tag assignments with the requested state.
603// Failed mutations are rolled back in reverse order when the repository cannot
604// provide a transaction boundary.
605func (uc *ManageFavoritesUseCase) UpdateFavoriteTags(ctx context.Context, favID entity.FavoriteID, tagIDs []entity.TagID) error {
606 currentTags, err := uc.tagRepo.GetForFavorite(ctx, favID)
607 if err != nil {
608 return fmt.Errorf("failed to load tags for favorite %d: %w", favID, err)
609 }
610
611 current := make(map[entity.TagID]struct{}, len(currentTags))
612 for _, tag := range currentTags {
613 if tag != nil {
614 current[tag.ID] = struct{}{}
615 }
616 }
617 wanted := make(map[entity.TagID]struct{}, len(tagIDs))
618 for _, id := range tagIDs {
619 wanted[id] = struct{}{}
620 }
621
622 toRemove := make([]entity.TagID, 0, len(current))
623 for id := range current {
624 if _, ok := wanted[id]; !ok {
625 toRemove = append(toRemove, id)
626 }
627 }
628 toAdd := make([]entity.TagID, 0, len(wanted))
629 for id := range wanted {
630 if _, ok := current[id]; !ok {
631 toAdd = append(toAdd, id)
632 }
633 }
634 sort.Slice(toRemove, func(i, j int) bool { return toRemove[i] < toRemove[j] })
635 sort.Slice(toAdd, func(i, j int) bool { return toAdd[i] < toAdd[j] })
636
637 type mutation struct {
638 tagID entity.TagID
639 added bool
640 }
641 applied := make([]mutation, 0, len(toRemove)+len(toAdd))
642 rollbackLog := logging.FromContext(ctx)
643 rollback := func() {
644 for i := len(applied) - 1; i >= 0; i-- {
645 change := applied[i]
646 var rollbackErr error
647 if change.added {
648 rollbackErr = uc.UntagFavorite(ctx, favID, change.tagID)
649 } else {
650 rollbackErr = uc.TagFavorite(ctx, favID, change.tagID)
651 }
652 if rollbackErr != nil {
653 rollbackLog.Warn().
654 Err(rollbackErr).
655 Int64("favorite_id", int64(favID)).
656 Int64("tag_id", int64(change.tagID)).
657 Msg("failed to roll back favorite tag mutation")
658 }
659 }
660 }
661
662 for _, id := range toRemove {
663 if err := uc.UntagFavorite(ctx, favID, id); err != nil {
664 rollback()
665 return fmt.Errorf("failed to remove tag %d from favorite %d: %w", id, favID, err)
666 }
667 applied = append(applied, mutation{tagID: id})
668 }
669 for _, id := range toAdd {
670 if err := uc.TagFavorite(ctx, favID, id); err != nil {
671 rollback()
672 return fmt.Errorf("failed to add tag %d to favorite %d: %w", id, favID, err)
673 }
674 applied = append(applied, mutation{tagID: id, added: true})
675 }
676 return nil
677}
678
679// GetTagsForFavorite retrieves all tags for a favorite.
680func (uc *ManageFavoritesUseCase) GetTagsForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
681 log := logging.FromContext(ctx)
682 log.Debug().Int64("favorite_id", int64(favID)).Msg("getting tags for favorite")
683
684 return uc.tagRepo.GetForFavorite(ctx, favID)
685}
686
687// UpdateTag updates a tag's name and color.
688func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID, name, color string) error {
689 name = strings.TrimSpace(name)
690 color = strings.TrimSpace(color)
691 if name == "" {
692 return fmt.Errorf("tag name is required")
693 }
694 log := logging.FromContext(ctx)
695 log.Debug().Int64("id", int64(id)).Str("name", name).Str("color", color).Msg("updating tag")
696
697 tag, err := uc.tagRepo.FindByID(ctx, id)
698 if err != nil {
699 return fmt.Errorf("failed to find tag: %w", err)
700 }
701 if tag == nil {
702 return fmt.Errorf("tag %d not found", id)
703 }
704
705 tag.Name = name
706 if color != "" {
707 tag.Color = color
708 }
709
710 if err := uc.tagRepo.Save(ctx, tag); err != nil {
711 return fmt.Errorf("failed to update tag: %w", err)
712 }
713
714 log.Info().Int64("id", int64(id)).Msg("tag updated")
715 uc.invalidateCache()
716 return nil
717}