diff --git a/backend/internal/handler/notifications.go b/backend/internal/handler/notifications.go index c2d10c8d..b0803e21 100644 --- a/backend/internal/handler/notifications.go +++ b/backend/internal/handler/notifications.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/httpx" @@ -12,7 +13,7 @@ import ( ) type NotificationsRepository interface { - FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) + FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) MarkRead(ctx context.Context, id, userID string) error MarkAllRead(ctx context.Context, userID string) error UpsertDeviceToken(ctx context.Context, userID, token, platform string) error @@ -28,17 +29,28 @@ func NewNotificationsHandler(repo NotificationsRepository) *NotificationsHandler // ListNotifications godoc // @Summary List notifications -// @Description Returns the most recent notifications for the authenticated user +// @Description Returns the most recent notifications for the authenticated user, paginated by cursor // @Tags notifications // @Produce json +// @Param before query string false "Cursor: return notifications created before this RFC3339 timestamp" // @Success 200 {array} models.Notification +// @Failure 400 {object} errs.HTTPError // @Failure 500 {object} errs.HTTPError // @Security BearerAuth // @Router /notifications [get] func (h *NotificationsHandler) ListNotifications(c *fiber.Ctx) error { userID := c.Locals("userId").(string) - notifications, err := h.repo.FindByUserID(c.Context(), userID) + var before *time.Time + if raw := c.Query("before"); raw != "" { + t, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return errs.BadRequest("before must be a valid RFC3339 timestamp") + } + before = &t + } + + notifications, err := h.repo.FindByUserID(c.Context(), userID, before) if err != nil { slog.Error("failed to list notifications", "err", err) return errs.InternalServerError() diff --git a/backend/internal/handler/notifications_test.go b/backend/internal/handler/notifications_test.go index 3ad18b05..424c37bc 100644 --- a/backend/internal/handler/notifications_test.go +++ b/backend/internal/handler/notifications_test.go @@ -19,15 +19,15 @@ import ( const testUserID = "user_test_123" type mockNotificationsRepository struct { - findByUserIDFunc func(ctx context.Context, userID string) ([]*models.Notification, error) + findByUserIDFunc func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) markReadFunc func(ctx context.Context, id, userID string) error markAllReadFunc func(ctx context.Context, userID string) error upsertDeviceTokenFunc func(ctx context.Context, userID, token, platform string) error } -func (m *mockNotificationsRepository) FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) { +func (m *mockNotificationsRepository) FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { if m.findByUserIDFunc != nil { - return m.findByUserIDFunc(ctx, userID) + return m.findByUserIDFunc(ctx, userID, before) } return nil, nil } @@ -76,7 +76,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { readAt := time.Now() mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { return []*models.Notification{ { ID: "notif-1", @@ -105,7 +105,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { t.Parallel() mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { return nil, nil }, } @@ -124,7 +124,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { var capturedUserID string mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { capturedUserID = userID return nil, nil }, @@ -142,7 +142,7 @@ func TestNotificationsHandler_ListNotifications(t *testing.T) { t.Parallel() mock := &mockNotificationsRepository{ - findByUserIDFunc: func(ctx context.Context, userID string) ([]*models.Notification, error) { + findByUserIDFunc: func(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { return nil, errors.New("db error") }, } diff --git a/backend/internal/repository/notifications.go b/backend/internal/repository/notifications.go index c29f728b..5c62c099 100644 --- a/backend/internal/repository/notifications.go +++ b/backend/internal/repository/notifications.go @@ -3,10 +3,12 @@ package repository import ( "context" "encoding/json" + "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -37,14 +39,26 @@ func (r *NotificationsRepository) InsertNotification(ctx context.Context, userID return n, nil } -func (r *NotificationsRepository) FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) { - rows, err := r.db.Query(ctx, ` - SELECT id, user_id, type, title, body, data, read_at, created_at - FROM public.notifications - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT 50 - `, userID) +func (r *NotificationsRepository) FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) { + var rows pgx.Rows + var err error + if before != nil { + rows, err = r.db.Query(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM public.notifications + WHERE user_id = $1 AND created_at < $2 + ORDER BY created_at DESC + LIMIT 50 + `, userID, before) + } else { + rows, err = r.db.Query(ctx, ` + SELECT id, user_id, type, title, body, data, read_at, created_at + FROM public.notifications + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 50 + `, userID) + } if err != nil { return nil, err } diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 2b7b391e..ee70894a 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -9,7 +9,7 @@ import ( type NotificationsRepository interface { InsertNotification(ctx context.Context, userID string, notifType models.NotificationType, title, body string) (*models.Notification, error) - FindByUserID(ctx context.Context, userID string) ([]*models.Notification, error) + FindByUserID(ctx context.Context, userID string, before *time.Time) ([]*models.Notification, error) MarkRead(ctx context.Context, id, userID string) error MarkAllRead(ctx context.Context, userID string) error UpsertDeviceToken(ctx context.Context, userID, token, platform string) error diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index 23f9a506..f2486dab 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -42,6 +42,7 @@ function AppLayout() { + data?.pages.flat() ?? [], [data]); + + // Snapshot which IDs were unread when the screen first loaded so dots remain + // visible while user is reading — markAllRead fires immediately in the bg. + const initialUnreadIds = useRef | null>(null); + + useEffect(() => { + if (notifications.length > 0 && initialUnreadIds.current === null) { + initialUnreadIds.current = new Set( + notifications.filter((n) => !n.read_at).map((n) => n.id), + ); + markAllRead(); + } + }, [notifications, markAllRead]); + + return ( + + + + {isLoading ? ( + + + + ) : ( + + data={notifications} + keyExtractor={(item) => item.id} + renderItem={({ item }) => ( + + )} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} + onEndReachedThreshold={0.3} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + showsVerticalScrollIndicator={false} + ListEmptyComponent={ + + + No notifications + + + } + /> + )} + + ); +} diff --git a/clients/mobile/components/notifications/notification-item.tsx b/clients/mobile/components/notifications/notification-item.tsx new file mode 100644 index 00000000..06fd2168 --- /dev/null +++ b/clients/mobile/components/notifications/notification-item.tsx @@ -0,0 +1,168 @@ +import Feather from "@expo/vector-icons/Feather"; +import { Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; + +import type { Notification } from "@shared/types/notifications"; +import { NotificationType } from "@shared/types/notifications"; + +const PRIMARY = "#15502c"; +const PRIORITY_HIGH = "#a21313"; + +function formatTimestamp(iso: string, showUnreadDot: boolean): string { + const date = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + + if (showUnreadDot) { + if (diffMins < 60) return `Added ${diffMins}m ago`; + if (diffHours < 24) return `Added ${diffHours}h ago`; + } + + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); +} + +function formatDueTime(dueAt?: unknown): string | null { + if (typeof dueAt !== "string") return null; + const due = new Date(dueAt); + const now = new Date(); + const diffMs = due.getTime() - now.getTime(); + if (diffMs <= 0) return "Overdue"; + const diffHours = Math.floor(diffMs / 3_600_000); + const diffMins = Math.floor((diffMs % 3_600_000) / 60_000); + if (diffHours >= 1) + return `Due in ${diffHours} hour${diffHours > 1 ? "s" : ""}`; + return `Due in ${diffMins}m`; +} + +function NewTasksBadge() { + return ( + + + new tasks + + ); +} + +type NotificationItemProps = { + notification: Notification; + showUnreadDot: boolean; +}; + +export function NotificationItem({ + notification, + showUnreadDot, +}: NotificationItemProps) { + const router = useRouter(); + const isUrgent = notification.type === NotificationType.HighPriorityTask; + + const taskId = + notification.data && typeof notification.data === "object" + ? (notification.data as Record).task_id + : undefined; + + function handlePress() { + if (typeof taskId === "string") { + router.push(`/task/${taskId}`); + } else { + router.push("/(tabs)/tasks"); + } + } + + if (isUrgent) { + const dueTime = formatDueTime(notification.data?.due_at); + + return ( + + {/* Title */} + + {showUnreadDot && ( + + )} + + {notification.title} + + + + {/* Due time */} + {dueTime !== null && ( + + + + {dueTime} + + + )} + + {/* Body */} + + {"An "} + + + + {"urgent task"} + + + {" for your department needs your attention. Claim it now!"} + + + {/* CTA */} + + Claim Now! + + + ); + } + + // Split "New Tasks Assigned for Monday" → bold prefix + regular suffix + const forIndex = notification.title.indexOf(" for "); + const titleBold = + forIndex >= 0 ? notification.title.slice(0, forIndex) : notification.title; + const titleSuffix = forIndex >= 0 ? notification.title.slice(forIndex) : ""; + const timestamp = formatTimestamp(notification.created_at, showUnreadDot); + + return ( + + + {/* Title */} + + {showUnreadDot && } + + {titleBold} + {titleSuffix} + + + + {/* Timestamp */} + + {timestamp} + + + {/* Body */} + + + Your + + + + have been assigned for the day + + + + + ); +} diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index dc2d39ae..29f7b54c 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -1,5 +1,8 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; + +import { useGetNotifications } from "@shared/api/notifications"; type TasksHeaderProps = { onFilterPress?: () => void; @@ -7,6 +10,10 @@ type TasksHeaderProps = { }; export function TasksHeader({ onFilterPress, filterActive }: TasksHeaderProps) { + const router = useRouter(); + const { data } = useGetNotifications(); + const unreadCount = data?.pages.flat().filter((n) => !n.read_at).length ?? 0; + return ( @@ -30,6 +37,19 @@ export function TasksHeader({ onFilterPress, filterActive }: TasksHeaderProps) { color={filterActive ? "#124425" : "#000"} /> + router.push("/notifications")} + className="w-[34px] h-[34px] items-center justify-center rounded relative" + > + + {unreadCount > 0 && ( + + + {unreadCount > 9 ? "9+" : unreadCount} + + + )} + ); diff --git a/clients/mobile/components/ui/screen-header.tsx b/clients/mobile/components/ui/screen-header.tsx new file mode 100644 index 00000000..d7bcad01 --- /dev/null +++ b/clients/mobile/components/ui/screen-header.tsx @@ -0,0 +1,24 @@ +import Feather from "@expo/vector-icons/Feather"; +import { Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; + +type ScreenHeaderProps = { + title: string; +}; + +export function ScreenHeader({ title }: ScreenHeaderProps) { + const router = useRouter(); + return ( + <> + + router.back()} hitSlop={8}> + + + + {title} + + + + + ); +} diff --git a/clients/mobile/hooks/__tests__/use-push-notifications.test.ts b/clients/mobile/hooks/__tests__/use-push-notifications.test.ts index 63f4dae7..273030f7 100644 --- a/clients/mobile/hooks/__tests__/use-push-notifications.test.ts +++ b/clients/mobile/hooks/__tests__/use-push-notifications.test.ts @@ -38,8 +38,9 @@ const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return ({ children }: { children: React.ReactNode }) => + const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); + return Wrapper; }; describe("registerForPushNotificationsAsync", () => { diff --git a/clients/shared/src/api/notifications.ts b/clients/shared/src/api/notifications.ts index 2212e48c..b0ef3b09 100644 --- a/clients/shared/src/api/notifications.ts +++ b/clients/shared/src/api/notifications.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { Notification, RegisterDeviceTokenInput } from "../types/notifications"; import { useAPIClient } from "./client"; @@ -6,9 +6,19 @@ export const NOTIFICATIONS_QUERY_KEY = ["notifications"] as const; export const useGetNotifications = () => { const api = useAPIClient(); - return useQuery({ + return useInfiniteQuery({ queryKey: NOTIFICATIONS_QUERY_KEY, - queryFn: () => api.get("/notifications"), + queryFn: ({ pageParam }: { pageParam: string | undefined }) => { + const url = pageParam + ? `/notifications?before=${encodeURIComponent(pageParam)}` + : "/notifications"; + return api.get(url); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: Notification[]) => { + if (lastPage.length < 50) return undefined; + return lastPage[lastPage.length - 1].created_at; + }, staleTime: 30_000, }); };