mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #10 from Dvorinka/feat/notification-center
feat(notifications): persistent read state + delivery history
This commit is contained in:
14 files changed
+332
-8
No files matched your search
@@ -3,6 +3,7 @@ package activity
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
@@ -35,6 +36,11 @@ func (s *Service) List(ctx context.Context, principal domain.Principal, limit in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ActivityFeedResponse{}, err
|
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))
|
events := make([]domain.ActivityEvent, 0, len(records))
|
||||||
for _, rec := range records {
|
for _, rec := range records {
|
||||||
event := domain.ActivityEvent{
|
event := domain.ActivityEvent{
|
||||||
@@ -49,7 +55,24 @@ func (s *Service) List(ctx context.Context, principal domain.Principal, limit in
|
|||||||
if rec.EntityID != nil {
|
if rec.EntityID != nil {
|
||||||
event.EntityID = *rec.EntityID
|
event.EntityID = *rec.EntityID
|
||||||
}
|
}
|
||||||
|
if readAt == nil || rec.CreatedAt.After(*readAt) {
|
||||||
|
unread++
|
||||||
|
}
|
||||||
events = append(events, event)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1296,6 +1296,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusOK, response)
|
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) {
|
protected.GET("/metrics/staff", func(c *gin.Context) {
|
||||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||||
response, err := bookingService.StaffMetrics(c.Request.Context(), auth.PrincipalFromContext(c), days)
|
response, err := bookingService.StaffMetrics(c.Request.Context(), auth.PrincipalFromContext(c), days)
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *PGRepository) AppendActivityEvent(ctx context.Context, params ActivityEventParams) error {
|
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()
|
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
|
||||||
|
}
|
||||||
@@ -146,6 +146,9 @@ type Repository interface {
|
|||||||
// Activity feed
|
// Activity feed
|
||||||
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
|
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
|
||||||
ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, 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
|
// Staff metrics
|
||||||
GetStaffMetrics(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]StaffMetricsRecord, error)
|
GetStaffMetrics(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]StaffMetricsRecord, error)
|
||||||
@@ -864,6 +867,7 @@ type MemoryRepository struct {
|
|||||||
tenantFeedToken string
|
tenantFeedToken string
|
||||||
staffFeedTokens map[string]string
|
staffFeedTokens map[string]string
|
||||||
activityEvents []ActivityEventRecord
|
activityEvents []ActivityEventRecord
|
||||||
|
notificationCursors map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryRepository() *MemoryRepository {
|
func NewMemoryRepository() *MemoryRepository {
|
||||||
@@ -990,6 +994,7 @@ func NewMemoryRepository() *MemoryRepository {
|
|||||||
staffFeedTokens: map[string]string{
|
staffFeedTokens: map[string]string{
|
||||||
staffID: "demo-staff-feed-token",
|
staffID: "demo-staff-feed-token",
|
||||||
},
|
},
|
||||||
|
notificationCursors: map[string]time.Time{},
|
||||||
activityEvents: []ActivityEventRecord{
|
activityEvents: []ActivityEventRecord{
|
||||||
{
|
{
|
||||||
ID: uuid.NewString(), TenantID: tenantID, ActorKind: "staff", ActorName: "Demo Stylist",
|
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
|
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) {
|
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
|
||||||
if tenantID != r.tenant.ID {
|
if tenantID != r.tenant.ID {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -553,7 +553,9 @@ type ActivityEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActivityFeedResponse 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
|
// StaffMetric aggregates appointment workload for one staff member over a
|
||||||
|
|||||||
@@ -141,6 +141,19 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
|
|||||||
return domain.DispatchReminderJobsResponse{}, err
|
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" {
|
if status == "sent" {
|
||||||
response.SentCount++
|
response.SentCount++
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ func TestDispatchDueProcessesPendingEmailReminders(t *testing.T) {
|
|||||||
if len(pending) != 0 {
|
if len(pending) != 0 {
|
||||||
t.Fatalf("expected no pending jobs after dispatch, got %d", len(pending))
|
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) {
|
func TestDispatchDueFailsUnknownChannel(t *testing.T) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1431,6 +1431,30 @@ paths:
|
|||||||
$ref: "#/components/schemas/ActivityFeedResponse"
|
$ref: "#/components/schemas/ActivityFeedResponse"
|
||||||
"404":
|
"404":
|
||||||
description: Tenant membership not found
|
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:
|
/v1/metrics/staff:
|
||||||
get:
|
get:
|
||||||
tags: [Metrics]
|
tags: [Metrics]
|
||||||
@@ -2180,12 +2204,19 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
ActivityFeedResponse:
|
ActivityFeedResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [events]
|
required: [events, unreadCount]
|
||||||
properties:
|
properties:
|
||||||
events:
|
events:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/ActivityEvent"
|
$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:
|
StaffMetric:
|
||||||
type: object
|
type: object
|
||||||
required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents]
|
required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents]
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t
|
|||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const [open, setOpen] = createSignal(false);
|
const [open, setOpen] = createSignal(false);
|
||||||
|
|
||||||
const [activity] = createResource(
|
const [readAt, setReadAt] = createSignal<Date | null>(null);
|
||||||
|
const [activity, { refetch: refetchActivity }] = createResource(
|
||||||
() => props.token?.(),
|
() => props.token?.(),
|
||||||
async (bearer) => {
|
async (bearer) => {
|
||||||
if (!bearer) return [] as ActivityEvent[];
|
if (!bearer) return [] as ActivityEvent[];
|
||||||
@@ -34,7 +35,9 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t
|
|||||||
params: { query: { limit: 20 } },
|
params: { query: { limit: 20 } },
|
||||||
headers: { Authorization: `Bearer ${bearer}` },
|
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: [] },
|
{ initialValue: [] },
|
||||||
);
|
);
|
||||||
@@ -52,6 +55,7 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t
|
|||||||
const translated = i18n.t(key);
|
const translated = i18n.t(key);
|
||||||
return translated === key ? action : translated;
|
return translated === key ? action : translated;
|
||||||
};
|
};
|
||||||
|
const cursor = readAt();
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
const actorSuffix = event.actorKind === "staff" && event.actorName ? ` · ${event.actorName}` : "";
|
const actorSuffix = event.actorKind === "staff" && event.actorName ? ` · ${event.actorName}` : "";
|
||||||
items.push({
|
items.push({
|
||||||
@@ -60,7 +64,7 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t
|
|||||||
title: actionLabel(event.action),
|
title: actionLabel(event.action),
|
||||||
message: `${event.summary}${actorSuffix}`,
|
message: `${event.summary}${actorSuffix}`,
|
||||||
time: new Date(event.createdAt).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }),
|
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 {
|
} else {
|
||||||
@@ -113,7 +117,19 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any; t
|
|||||||
|
|
||||||
const unreadCount = createMemo(() => filteredNotifications().filter((n) => !n.read).length);
|
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)));
|
setReadIds(new Set(filteredNotifications().map((n) => n.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ const dictionaries = {
|
|||||||
"dashboard.activity.booking.rescheduled": "Rezervace přesunuta",
|
"dashboard.activity.booking.rescheduled": "Rezervace přesunuta",
|
||||||
"dashboard.activity.booking.moved": "Přesun na jinou lekci",
|
"dashboard.activity.booking.moved": "Přesun na jinou lekci",
|
||||||
"dashboard.activity.booking.promoted": "Povýšení z čekací listiny",
|
"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.exportCsv": "Export CSV",
|
||||||
"dashboard.demoBanner": "Demo režim",
|
"dashboard.demoBanner": "Demo režim",
|
||||||
"dashboard.demoBannerDesc": "Prozkoumáte Bookra s ukázkovými daty. Pro plnou funkčnost se zaregistrujte.",
|
"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.rescheduled": "Booking rescheduled",
|
||||||
"dashboard.activity.booking.moved": "Moved to another session",
|
"dashboard.activity.booking.moved": "Moved to another session",
|
||||||
"dashboard.activity.booking.promoted": "Promoted from waitlist",
|
"dashboard.activity.booking.promoted": "Promoted from waitlist",
|
||||||
|
"dashboard.activity.reminder.sent": "Reminder sent",
|
||||||
|
"dashboard.activity.reminder.failed": "Reminder failed",
|
||||||
"dashboard.exportCsv": "Export CSV",
|
"dashboard.exportCsv": "Export CSV",
|
||||||
"dashboard.demoBanner": "Demo mode",
|
"dashboard.demoBanner": "Demo mode",
|
||||||
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
"dashboard.demoBannerDesc": "You're exploring Bookra with sample data. Register for full functionality.",
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/admin/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1161,6 +1181,13 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ActivityFeedResponse: {
|
ActivityFeedResponse: {
|
||||||
events: components["schemas"]["ActivityEvent"][];
|
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: {
|
AuthResponse: {
|
||||||
accessToken: string;
|
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: {
|
getAdminStats: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+8
-1
@@ -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
|
- 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
|
- 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
|
- 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
|
## 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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user