mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
1 parent
76e45d4178
commit
2464aa45fa
14 files changed
+332
-8
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"}
|
||||
|
||||
func TestListCountsUnreadAgainstCursor(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
feed, err := svc.List(ctx, demoPrincipal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(feed.Events) == 0 {
|
||||
t.Fatal("expected seeded demo events")
|
||||
}
|
||||
if feed.UnreadCount != len(feed.Events) {
|
||||
t.Fatalf("expected all %d events unread without cursor, got %d", len(feed.Events), feed.UnreadCount)
|
||||
}
|
||||
if feed.ReadAt != nil {
|
||||
t.Fatalf("expected nil readAt before first mark, got %v", feed.ReadAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkAllReadPersistsCursor(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
readAt, err := svc.MarkAllRead(ctx, demoPrincipal)
|
||||
if err != nil {
|
||||
t.Fatalf("mark all read: %v", err)
|
||||
}
|
||||
if readAt.IsZero() {
|
||||
t.Fatal("expected non-zero readAt")
|
||||
}
|
||||
|
||||
feed, err := svc.List(ctx, demoPrincipal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if feed.UnreadCount != 0 {
|
||||
t.Fatalf("expected 0 unread after mark, got %d", feed.UnreadCount)
|
||||
}
|
||||
if feed.ReadAt == nil || !feed.ReadAt.Equal(readAt) {
|
||||
t.Fatalf("expected readAt %v, got %v", readAt, feed.ReadAt)
|
||||
}
|
||||
|
||||
// A new event after the cursor is unread again.
|
||||
if err := repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
ActorKind: "staff",
|
||||
Action: "booking.created",
|
||||
EntityType: "booking",
|
||||
Summary: "New — Booking",
|
||||
}); err != nil {
|
||||
t.Fatalf("append event: %v", err)
|
||||
}
|
||||
feed, err = svc.List(ctx, demoPrincipal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if feed.UnreadCount != 1 {
|
||||
t.Fatalf("expected 1 unread after new event, got %d", feed.UnreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkAllReadRequiresMembership(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := NewService(repo)
|
||||
if _, err := svc.MarkAllRead(context.Background(), domain.Principal{Subject: "nobody"}); err == nil {
|
||||
t.Fatal("expected membership error for unknown subject")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user