mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
notification_cursors table stores one read-cursor per user per tenant; GET /v1/activity returns unreadCount + readAt, POST /v1/activity/read persists the cursor. Reminder dispatches now append reminder.sent / reminder.failed system events so delivery history reaches the feed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
79 lines
2.3 KiB
Go
79 lines
2.3 KiB
Go
package activity
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"bookra/apps/backend/internal/db"
|
|
"bookra/apps/backend/internal/domain"
|
|
)
|
|
|
|
var ErrTenantMembership = errors.New("tenant membership not found")
|
|
|
|
const defaultLimit = 50
|
|
const maxLimit = 100
|
|
|
|
type Service struct {
|
|
repo db.Repository
|
|
}
|
|
|
|
func NewService(repo db.Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// List returns the tenant's recent activity, newest first. The feed is
|
|
// tenant-scoped — every dashboard user sees the same audit trail.
|
|
func (s *Service) List(ctx context.Context, principal domain.Principal, limit int) (domain.ActivityFeedResponse, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return domain.ActivityFeedResponse{}, ErrTenantMembership
|
|
}
|
|
if limit <= 0 || limit > maxLimit {
|
|
limit = defaultLimit
|
|
}
|
|
records, err := s.repo.ListActivityEvents(ctx, membership.Tenant.ID, limit)
|
|
if err != nil {
|
|
return domain.ActivityFeedResponse{}, err
|
|
}
|
|
readAt, err := s.repo.GetNotificationCursor(ctx, membership.Tenant.ID, principal.Subject)
|
|
if err != nil {
|
|
return domain.ActivityFeedResponse{}, err
|
|
}
|
|
unread := 0
|
|
events := make([]domain.ActivityEvent, 0, len(records))
|
|
for _, rec := range records {
|
|
event := domain.ActivityEvent{
|
|
ID: rec.ID,
|
|
ActorKind: rec.ActorKind,
|
|
ActorName: rec.ActorName,
|
|
Action: rec.Action,
|
|
EntityType: rec.EntityType,
|
|
Summary: rec.Summary,
|
|
CreatedAt: rec.CreatedAt,
|
|
}
|
|
if rec.EntityID != nil {
|
|
event.EntityID = *rec.EntityID
|
|
}
|
|
if readAt == nil || rec.CreatedAt.After(*readAt) {
|
|
unread++
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return domain.ActivityFeedResponse{Events: events, UnreadCount: unread, ReadAt: readAt}, nil
|
|
}
|
|
|
|
// MarkAllRead persists the user's cursor — events at or before now count as
|
|
// read for this subject on every subsequent load.
|
|
func (s *Service) MarkAllRead(ctx context.Context, principal domain.Principal) (time.Time, error) {
|
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
|
if err != nil {
|
|
return time.Time{}, ErrTenantMembership
|
|
}
|
|
readAt := time.Now().UTC()
|
|
if err := s.repo.SetNotificationCursor(ctx, membership.Tenant.ID, principal.Subject, readAt); err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return readAt, nil
|
|
}
|