diff --git a/apps/backend/internal/activity/service.go b/apps/backend/internal/activity/service.go index 18b96ba..d54b5eb 100644 --- a/apps/backend/internal/activity/service.go +++ b/apps/backend/internal/activity/service.go @@ -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 } diff --git a/apps/backend/internal/activity/service_test.go b/apps/backend/internal/activity/service_test.go new file mode 100644 index 0000000..d93d783 --- /dev/null +++ b/apps/backend/internal/activity/service_test.go @@ -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") + } +} diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 91a0cbe..c7bb66e 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -1296,6 +1296,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.JSON(http.StatusOK, response) }) + protected.POST("/activity/read", func(c *gin.Context) { + readAt, err := activityService.MarkAllRead(c.Request.Context(), auth.PrincipalFromContext(c)) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, activity.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"readAt": readAt}) + }) + protected.GET("/metrics/staff", func(c *gin.Context) { days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) response, err := bookingService.StaffMetrics(c.Request.Context(), auth.PrincipalFromContext(c), days) diff --git a/apps/backend/internal/db/activity.go b/apps/backend/internal/db/activity.go index 0928c93..4d4b807 100644 --- a/apps/backend/internal/db/activity.go +++ b/apps/backend/internal/db/activity.go @@ -2,6 +2,10 @@ package db import ( "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" ) func (r *PGRepository) AppendActivityEvent(ctx context.Context, params ActivityEventParams) error { @@ -40,3 +44,28 @@ func (r *PGRepository) ListActivityEvents(ctx context.Context, tenantID string, } return events, rows.Err() } + +func (r *PGRepository) GetNotificationCursor(ctx context.Context, tenantID string, userSubject string) (*time.Time, error) { + var readAt time.Time + err := r.pool.QueryRow(ctx, ` + SELECT read_at FROM notification_cursors + WHERE tenant_id = $1 AND user_subject = $2 + `, tenantID, userSubject).Scan(&readAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &readAt, nil +} + +func (r *PGRepository) SetNotificationCursor(ctx context.Context, tenantID string, userSubject string, readAt time.Time) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO notification_cursors (tenant_id, user_subject, read_at) + VALUES ($1, $2, $3) + ON CONFLICT (tenant_id, user_subject) + DO UPDATE SET read_at = EXCLUDED.read_at, updated_at = now() + `, tenantID, userSubject, readAt) + return err +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 1e5fc87..435421d 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -146,6 +146,9 @@ type Repository interface { // Activity feed AppendActivityEvent(ctx context.Context, params ActivityEventParams) error ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, error) + // GetNotificationCursor returns nil when the user has no cursor yet. + GetNotificationCursor(ctx context.Context, tenantID string, userSubject string) (*time.Time, error) + SetNotificationCursor(ctx context.Context, tenantID string, userSubject string, readAt time.Time) error // Staff metrics GetStaffMetrics(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]StaffMetricsRecord, error) @@ -864,6 +867,7 @@ type MemoryRepository struct { tenantFeedToken string staffFeedTokens map[string]string activityEvents []ActivityEventRecord + notificationCursors map[string]time.Time } func NewMemoryRepository() *MemoryRepository { @@ -990,6 +994,7 @@ func NewMemoryRepository() *MemoryRepository { staffFeedTokens: map[string]string{ staffID: "demo-staff-feed-token", }, + notificationCursors: map[string]time.Time{}, activityEvents: []ActivityEventRecord{ { ID: uuid.NewString(), TenantID: tenantID, ActorKind: "staff", ActorName: "Demo Stylist", @@ -1409,6 +1414,19 @@ func (r *MemoryRepository) ListActivityEvents(_ context.Context, tenantID string return events, nil } +func (r *MemoryRepository) GetNotificationCursor(_ context.Context, tenantID string, userSubject string) (*time.Time, error) { + readAt, ok := r.notificationCursors[tenantID+"/"+userSubject] + if !ok { + return nil, nil + } + return &readAt, nil +} + +func (r *MemoryRepository) SetNotificationCursor(_ context.Context, tenantID string, userSubject string, readAt time.Time) error { + r.notificationCursors[tenantID+"/"+userSubject] = readAt + return nil +} + func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { if tenantID != r.tenant.ID { return nil, nil diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 51187a7..85664fd 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -553,7 +553,9 @@ type ActivityEvent struct { } type ActivityFeedResponse struct { - Events []ActivityEvent `json:"events"` + Events []ActivityEvent `json:"events"` + UnreadCount int `json:"unreadCount"` + ReadAt *time.Time `json:"readAt,omitempty"` } // StaffMetric aggregates appointment workload for one staff member over a diff --git a/apps/backend/internal/notifications/service.go b/apps/backend/internal/notifications/service.go index 01b6e6d..89e073e 100644 --- a/apps/backend/internal/notifications/service.go +++ b/apps/backend/internal/notifications/service.go @@ -141,6 +141,19 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe return domain.DispatchReminderJobsResponse{}, err } + action := "reminder.sent" + if status != "sent" { + action = "reminder.failed" + } + _ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{ + TenantID: job.TenantID, + ActorKind: "system", + Action: action, + EntityType: "booking", + EntityID: &job.BookingID, + Summary: job.CustomerName + " — " + job.Channel, + }) + if status == "sent" { response.SentCount++ } else { diff --git a/apps/backend/internal/notifications/service_test.go b/apps/backend/internal/notifications/service_test.go index f6f4e5a..f5f98cb 100644 --- a/apps/backend/internal/notifications/service_test.go +++ b/apps/backend/internal/notifications/service_test.go @@ -61,6 +61,20 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) { if len(pending) != 0 { t.Fatalf("expected no pending jobs after dispatch, got %d", len(pending)) } + + events, err := repo.ListActivityEvents(context.Background(), "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", 10) + if err != nil { + t.Fatalf("list activity: %v", err) + } + for _, event := range events { + if event.Action == "reminder.sent" && event.ActorKind == "system" { + if event.EntityID == nil || *event.EntityID != created.ID { + t.Fatalf("expected entity id %s, got %v", created.ID, event.EntityID) + } + return + } + } + t.Fatal("expected system reminder.sent activity event") } func TestDispatchDueFailsUnknownChannel(t *testing.T) { diff --git a/apps/backend/migrations/00017_notification_cursors.sql b/apps/backend/migrations/00017_notification_cursors.sql new file mode 100644 index 0000000..484e0f7 --- /dev/null +++ b/apps/backend/migrations/00017_notification_cursors.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Persistent read state for the notification feed: one cursor per +-- dashboard user per tenant. Events with created_at <= read_at count as +-- read; everything newer is unread. Per-event flags were rejected — the +-- feed is an audit list, not a per-item inbox. +CREATE TABLE notification_cursors ( + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_subject text NOT NULL, + read_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, user_subject) +); + +-- +goose Down +DROP TABLE IF EXISTS notification_cursors; diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index e9f260c..e46b493 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1431,6 +1431,30 @@ paths: $ref: "#/components/schemas/ActivityFeedResponse" "404": description: Tenant membership not found + /v1/activity/read: + post: + tags: [Activity] + operationId: markActivityRead + security: + - bearerAuth: [] + summary: Mark all activity events as read + description: >- + Persists the caller's read cursor — activity events at or before the + returned timestamp count as read for this user on subsequent loads. + responses: + "200": + description: Read cursor persisted + content: + application/json: + schema: + type: object + required: [readAt] + properties: + readAt: + type: string + format: date-time + "404": + description: Tenant membership not found /v1/metrics/staff: get: tags: [Metrics] @@ -2180,12 +2204,19 @@ components: format: date-time ActivityFeedResponse: type: object - required: [events] + required: [events, unreadCount] properties: events: type: array items: $ref: "#/components/schemas/ActivityEvent" + unreadCount: + type: integer + description: Events newer than the caller's persisted read cursor + readAt: + type: string + format: date-time + description: Caller's read cursor; absent until the first mark-read StaffMetric: type: object required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents] diff --git a/apps/frontend/src/components/dashboard/notification-dropdown.tsx b/apps/frontend/src/components/dashboard/notification-dropdown.tsx index 1971fd9..f54c716 100644 --- a/apps/frontend/src/components/dashboard/notification-dropdown.tsx +++ b/apps/frontend/src/components/dashboard/notification-dropdown.tsx @@ -26,7 +26,8 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t const i18n = useI18n(); const [open, setOpen] = createSignal(false); - const [activity] = createResource( + const [readAt, setReadAt] = createSignal(null); + const [activity, { refetch: refetchActivity }] = createResource( () => props.token?.(), async (bearer) => { if (!bearer) return [] as ActivityEvent[]; @@ -34,7 +35,9 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t params: { query: { limit: 20 } }, headers: { Authorization: `Bearer ${bearer}` }, }); - return (response.data?.events ?? []) as ActivityEvent[]; + const data = response.data; + setReadAt(data?.readAt ? new Date(data.readAt) : null); + return (data?.events ?? []) as ActivityEvent[]; }, { initialValue: [] }, ); @@ -52,6 +55,7 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t const translated = i18n.t(key); return translated === key ? action : translated; }; + const cursor = readAt(); events.forEach((event) => { const actorSuffix = event.actorKind === "staff" && event.actorName ? ` · ${event.actorName}` : ""; items.push({ @@ -60,7 +64,7 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t title: actionLabel(event.action), message: `${event.summary}${actorSuffix}`, time: new Date(event.createdAt).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }), - read: false, + read: cursor != null && new Date(event.createdAt) <= cursor, }); }); } else { @@ -113,7 +117,19 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t const unreadCount = createMemo(() => filteredNotifications().filter((n) => !n.read).length); - const markAllRead = () => { + const markAllRead = async () => { + const bearer = props.token?.(); + if (bearer) { + try { + const response = await (apiClient as any).POST("/v1/activity/read", { + headers: { Authorization: `Bearer ${bearer}` }, + }); + if (response.data?.readAt) setReadAt(new Date(response.data.readAt)); + void refetchActivity(); + } catch { + // Cursor write failed — fall back to local marks for this session. + } + } setReadIds(new Set(filteredNotifications().map((n) => n.id))); }; diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index c50776e..efb479a 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -367,6 +367,8 @@ const dictionaries = { "dashboard.activity.booking.rescheduled": "Rezervace přesunuta", "dashboard.activity.booking.moved": "Přesun na jinou lekci", "dashboard.activity.booking.promoted": "Povýšení z čekací listiny", + "dashboard.activity.reminder.sent": "Připomínka odeslána", + "dashboard.activity.reminder.failed": "Připomínka selhala", "dashboard.exportCsv": "Export CSV", "dashboard.demoBanner": "Demo režim", "dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.", @@ -971,6 +973,8 @@ const dictionaries = { "dashboard.activity.booking.rescheduled": "Booking rescheduled", "dashboard.activity.booking.moved": "Moved to another session", "dashboard.activity.booking.promoted": "Promoted from waitlist", + "dashboard.activity.reminder.sent": "Reminder sent", + "dashboard.activity.reminder.failed": "Reminder failed", "dashboard.exportCsv": "Export CSV", "dashboard.demoBanner": "Demo mode", "dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.", diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index b26ec6d..8ac0515 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -35,6 +35,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/activity/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mark all activity events as read + * @description Persists the caller's read cursor — activity events at or before the returned timestamp count as read for this user on subsequent loads. + */ + post: operations["markActivityRead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/admin/stats": { parameters: { query?: never; @@ -1161,6 +1181,13 @@ export interface components { }; ActivityFeedResponse: { events: components["schemas"]["ActivityEvent"][]; + /** + * Format: date-time + * @description Caller's read cursor; absent until the first mark-read + */ + readAt?: string; + /** @description Events newer than the caller's persisted read cursor */ + unreadCount: number; }; AuthResponse: { accessToken: string; @@ -1832,6 +1859,36 @@ export interface operations { }; }; }; + markActivityRead: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Read cursor persisted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: date-time */ + readAt: string; + }; + }; + }; + /** @description Tenant membership not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getAdminStats: { parameters: { query?: never; diff --git a/project.md b/project.md index 3937149..29b1ed7 100644 --- a/project.md +++ b/project.md @@ -188,6 +188,13 @@ - demo repository seeds four trailing-window bookings (3 confirmed + 1 cancelled) via `NewRepository`'s demo branch only — unit tests constructing `NewMemoryRepository` directly are unaffected - verified live: endpoint returns 4/3/1, 75% utilization, 3 600 Kč; `days=7` and `days=999` normalize correctly; browser-checked both locales and mobile wrap, console clean; focused aggregation test passes - remaining: live staging validation on Neon + pgx repository path +- `[done/partial]` Milestone 27: Persistent notification center + - `notification_cursors` table (migration 00017) — one read-cursor per (tenant, user_subject); per-event flags rejected deliberately, the feed is an audit list not a per-item inbox + - `GET /v1/activity` now returns `unreadCount` + `readAt` alongside events; `POST /v1/activity/read` persists the cursor and returns it — mark-all-read survives reloads and sessions + - delivery history joins the feed: `DispatchDue` appends `reminder.sent` / `reminder.failed` system events (entity = booking, summary = `{customer} — {channel}`) after each delivery-log write, best-effort so notifications never fail on audit + - dropdown derives per-item read state from `createdAt <= readAt` — only post-cursor events carry the unread dot; per-item clicks still mark locally for the session; the billing/trial nag stays session-scoped (state badge, not an event) + - verified live: 3 unread → mark → 0 → new booking → exactly 1 unread; browser-confirmed the badge and dimming persist across reload; focused tests cover cursor lifecycle and the reminder event + - remaining: live staging validation on Neon + pgx repository path ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. @@ -440,7 +447,7 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal - **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request. - **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios. - **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light. -- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both. +- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach. - **Capacity-based utilization**: M26 measures utilization as the confirmed share of bookings; a truer measure compares booked minutes against each member's working-hours rules — needs availability-rule integration in the metrics query. - **Report-style exports**: M25 shipped raw row exports; a summarized report (per-staff metrics, revenue by service, period comparisons) can reuse `GetStaffMetrics`-style aggregation once the metric set stabilizes.