feat(notifications): persistent read cursor + delivery history

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 16:55:21 +02:00
1 parent 76e45d4178
commit 2464aa45fa
14 files changed
+332 -8

No files matched your search

+24 -1
View File
@@ -3,6 +3,7 @@ package activity
import (
"context"
"errors"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
@@ -35,6 +36,11 @@ func (s *Service) List(ctx context.Context, principal domain.Principal, limit in
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{
@@ -49,7 +55,24 @@ func (s *Service) List(ctx context.Context, principal domain.Principal, limit in
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}, nil
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
}