Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions drizzle/migrations/0025_notifications_cursor_idx.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Migration: 0025_notifications_cursor_idx
-- Adds a composite index that supports cursor-based keyset pagination on
-- GET /api/notifications ordered by (created_at DESC, id DESC) per user.
--
-- The existing notifications_user_id_idx covers equality lookups on user_id
-- but does not satisfy the keyset predicate:
-- WHERE user_id = $1
-- AND (created_at < $2 OR (created_at = $2 AND id < $3))
-- ORDER BY created_at DESC, id DESC
--
-- With (user_id, created_at DESC, id DESC) Postgres can satisfy both the
-- filter and the ORDER BY in a single index scan.

CREATE INDEX CONCURRENTLY IF NOT EXISTS notifications_user_id_cursor_idx
ON notifications (user_id, created_at DESC, id DESC);
7 changes: 7 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,13 @@ export const notifications = pgTable(
t.userId,
t.readAt,
),
// Composite index for cursor-based keyset pagination on GET /api/notifications.
// Covers: WHERE user_id = ? AND (created_at < ? OR ...) ORDER BY created_at DESC, id DESC
notificationsUserIdCursorIdx: index("notifications_user_id_cursor_idx").on(
t.userId,
t.createdAt,
t.id,
),
}),
);

Expand Down
72 changes: 71 additions & 1 deletion src/routes/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
notificationChannels,
patchNotificationPreferences,
} from "../services/notificationPrefs";
import { markNotificationsAsRead } from "../services/notificationService";
import { listNotifications, markNotificationsAsRead } from "../services/notificationService";
import { idempotency } from "../middleware/idempotency";
import { RouteErrorFactory } from "../errors";
import { notificationsCors } from "../middleware/cors";
Expand Down Expand Up @@ -59,6 +59,76 @@ notificationsRouter.use(notificationsCors());
notificationsRouter.use(requireAuth);
notificationsRouter.use(notificationsMetricsMiddleware);

/**
* GET /api/notifications
*
* Returns the authenticated user's notifications, newest first.
*
* Query parameters:
* limit — integer 1–100 (default 20)
* cursor — opaque page token returned as `nextCursor` from a prior call
*
* Response:
* 200 { data: NotificationItem[], nextCursor: string | null, correlationId: string }
* 400 { error: { code: "validation_error", details: [...] } }
*
* Ordering: DESC by (created_at, id) — stable under concurrent writes.
* Cursor encodes the last row of the previous page; an invalid or tampered
* cursor is silently ignored and restarts from the first page.
*/
const listQuerySchema = z
.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
cursor: z.string().min(1).optional(),
})
.strict();

notificationsRouter.get(
"/",
async (req: Request, res, next) => {
try {
const parsed = listQuerySchema.safeParse(req.query);
if (!parsed.success) {
logger.warn(
{
reqId: (req as Request & { id?: string }).id,
issues: parsed.error.issues,
},
"notifications_list_validation_failed",
);
return res.status(400).json({
error: {
code: "validation_error",
details: parsed.error.issues,
},
});
}

const userId = (req as Request & { user: { id: string } }).user.id;
const { cursor, limit } = parsed.data;

const page = await listNotifications({ userId, cursor, limit });

logger.info(
{
reqId: (req as Request & { id?: string }).id,
userId,
returned: page.data.length,
hasMore: page.nextCursor !== null,
},
"notifications_listed",
);

return res.status(200).json({
data: page.data,
nextCursor: page.nextCursor,
});
} catch (error) {
return next(error);
}
},
);

notificationsRouter.get(
"/preferences",
async (req, res, next) => {
Expand Down
109 changes: 107 additions & 2 deletions src/services/notificationService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
import { db } from "../db/client";
import { notifications } from "../db/schema";
import { notifications, type Notification } from "../db/schema";
import {
clampLimit,
decodeCursor,
encodeCursor,
type CursorKey,
type Page,
} from "../utils/cursor";

export interface MarkNotificationsAsReadParams {
userId: string;
Expand Down Expand Up @@ -40,4 +47,102 @@ export async function markNotificationsAsRead(
}

return { updatedCount };
}

// ---------------------------------------------------------------------------
// List notifications with cursor pagination
// ---------------------------------------------------------------------------

/**
* Shape of a single notification item returned to callers.
* Keeps the service boundary explicit — consumers don't depend on the raw
* Drizzle row type.
*/
export interface NotificationItem {
id: string;
type: string;
title: string;
body: string;
data: unknown;
readAt: Date | null;
createdAt: Date;
}

export interface ListNotificationsParams {
userId: string;
/** Opaque cursor returned by a previous call (base64url-encoded). */
cursor?: string;
/** Page size; clamped to [1, 100], defaults to 20. */
limit?: number;
}

/**
* Build the cursor key for the last row of a page.
* Sort column is `created_at` (ISO-8601 string), tie-broken by `id`.
*/
function rowToCursorKey(row: Pick<Notification, "createdAt" | "id">): CursorKey {
return {
sortValue: row.createdAt.toISOString(),
id: row.id,
};
}

/**
* Cursor-paginated list of notifications for a single user.
*
* Ordering: `(created_at DESC, id DESC)` — stable under concurrent inserts
* because the composite key is unique.
*
* A cursor encodes the last row of the previous page. Pass it back as
* `?cursor=` to advance. An invalid or tampered cursor is silently treated
* as absent (restarts from page one) so no 400 is returned for stale tokens.
*/
export async function listNotifications(
params: ListNotificationsParams,
): Promise<Page<NotificationItem>> {
const { userId, cursor: rawCursor, limit: rawLimit } = params;

const limit = clampLimit(rawLimit);
const cursor = decodeCursor(rawCursor);

// Build the keyset predicate.
// Under DESC ordering, "after cursor" means:
// created_at < cursor.sortValue OR (created_at = cursor.sortValue AND id < cursor.id)
const where = cursor
? and(
eq(notifications.userId, userId),
or(
lt(notifications.createdAt, new Date(cursor.sortValue)),
and(
eq(notifications.createdAt, new Date(cursor.sortValue)),
lt(notifications.id, cursor.id),
),
),
)
: eq(notifications.userId, userId);

// Fetch one extra row to detect whether a next page exists.
const rows = await db
.select({
id: notifications.id,
type: notifications.type,
title: notifications.title,
body: notifications.body,
data: notifications.data,
readAt: notifications.readAt,
createdAt: notifications.createdAt,
})
.from(notifications)
.where(where)
.orderBy(desc(notifications.createdAt), desc(notifications.id))
.limit(limit + 1);

const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const last = pageRows[pageRows.length - 1];

return {
data: pageRows as NotificationItem[],
nextCursor: hasMore && last ? encodeCursor(rowToCursorKey(last)) : null,
};
}
Loading
Loading