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

@@ -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<Date | null>(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)));
};
@@ -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.",