Files
flyer-crawler.projectium.com/src/services/db/notification.db.ts
Torben Sorensen 40580dbf15
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
database work !
2025-12-31 17:01:35 -08:00

192 lines
6.9 KiB
TypeScript

// src/services/db/notification.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { NotFoundError, handleDbError } from './errors.db';
import type { Logger } from 'pino';
import type { Notification } from '../../types';
export class NotificationRepository {
// The repository only needs an object with a `query` method, matching the Pool/PoolClient interface.
// Using `Pick` makes this dependency explicit and simplifies testing by reducing the mock surface.
private db: Pick<Pool | PoolClient, 'query'>;
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
this.db = db;
}
/**
* Inserts a single notification into the database.
* @param userId The ID of the user to notify.
* @param content The text content of the notification.
* @param linkUrl An optional URL for the notification to link to.
* @returns A promise that resolves to the newly created Notification object.
*/
async createNotification(
userId: string,
content: string,
logger: Logger,
linkUrl?: string,
): Promise<Notification> {
try {
const res = await this.db.query<Notification>(
`INSERT INTO public.notifications (user_id, content, link_url) VALUES ($1, $2, $3) RETURNING *`,
[userId, content, linkUrl || null],
);
return res.rows[0];
} catch (error) {
handleDbError(error, logger, 'Database error in createNotification', { userId, content, linkUrl }, {
fkMessage: 'The specified user does not exist.',
defaultMessage: 'Failed to create notification.',
});
}
}
/**
* Inserts multiple notifications into the database in a single query.
* This is more efficient than inserting one by one.
* @param notifications An array of notification objects to be inserted.
*/
async createBulkNotifications(
notifications: Omit<
Notification,
'notification_id' | 'is_read' | 'created_at' | 'updated_at'
>[],
logger: Logger,
): Promise<void> {
if (notifications.length === 0) {
return;
}
// This method assumes it might be part of a larger transaction, so it uses `this.db`.
// The calling service is responsible for acquiring and releasing a client if needed.
try {
// This is the secure way to perform bulk inserts.
// We use the `unnest` function in PostgreSQL to turn arrays of parameters
// into a set of rows that can be inserted. This avoids string concatenation
// and completely prevents SQL injection.
const query = `
INSERT INTO public.notifications (user_id, content, link_url)
SELECT * FROM unnest($1::uuid[], $2::text[], $3::text[])
`;
const userIds = notifications.map((n) => n.user_id);
const contents = notifications.map((n) => n.content);
const linkUrls = notifications.map((n) => n.link_url || null);
await this.db.query(query, [userIds, contents, linkUrls]);
} catch (error) {
handleDbError(error, logger, 'Database error in createBulkNotifications', { notifications }, {
fkMessage: 'One or more of the specified users do not exist.',
defaultMessage: 'Failed to create bulk notifications.',
});
}
}
/**
* Retrieves a paginated list of notifications for a specific user.
* @param userId The ID of the user whose notifications are to be retrieved.
* @param limit The maximum number of notifications to return.
* @param offset The number of notifications to skip for pagination.
* @returns A promise that resolves to an array of Notification objects.
*/
async getNotificationsForUser(
userId: string,
limit: number,
offset: number,
includeRead: boolean,
logger: Logger,
): Promise<Notification[]> {
try {
const params: (string | number)[] = [userId, limit, offset];
let query = `SELECT * FROM public.notifications WHERE user_id = $1`;
if (!includeRead) {
query += ` AND is_read = false`;
}
query += ` ORDER BY created_at DESC LIMIT $2 OFFSET $3`;
const res = await this.db.query<Notification>(query, params);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getNotificationsForUser',
{ userId, limit, offset, includeRead },
{ defaultMessage: 'Failed to retrieve notifications.' },
);
}
}
/**
* Marks all unread notifications for a user as read.
* @param userId The ID of the user whose notifications should be marked as read.
* @returns A promise that resolves when the operation is complete.
*/
async markAllNotificationsAsRead(userId: string, logger: Logger): Promise<void> {
try {
await this.db.query(
`UPDATE public.notifications SET is_read = true WHERE user_id = $1 AND is_read = false`,
[userId],
);
} catch (error) {
handleDbError(error, logger, 'Database error in markAllNotificationsAsRead', { userId }, {
defaultMessage: 'Failed to mark notifications as read.',
});
}
}
/**
* Marks a single notification as read for a specific user.
* Ensures that a user can only mark their own notifications.
* @param notificationId The ID of the notification to mark as read.
* @param userId The ID of the user who owns the notification.
* @returns A promise that resolves to the updated Notification object.
* @throws An error if the notification is not found or does not belong to the user.
*/
async markNotificationAsRead(
notificationId: number,
userId: string,
logger: Logger,
): Promise<Notification> {
try {
const res = await this.db.query<Notification>(
`UPDATE public.notifications SET is_read = true WHERE notification_id = $1 AND user_id = $2 RETURNING *`,
[notificationId, userId],
);
if (res.rowCount === 0) {
throw new NotFoundError('Notification not found or user does not have permission.');
}
return res.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Database error in markNotificationAsRead',
{ notificationId, userId },
{ defaultMessage: 'Failed to mark notification as read.' },
);
}
}
/**
* Deletes notifications that are older than a specified number of days.
* This is intended for a periodic cleanup job.
* @param daysOld The minimum age in days for a notification to be deleted.
* @returns A promise that resolves to the number of deleted notifications.
*/
async deleteOldNotifications(daysOld: number, logger: Logger): Promise<number> {
try {
const res = await this.db.query(
`DELETE FROM public.notifications WHERE created_at < NOW() - ($1 * interval '1 day')`,
[daysOld],
);
return res.rowCount ?? 0;
} catch (error) {
handleDbError(error, logger, 'Database error in deleteOldNotifications', { daysOld }, {
defaultMessage: 'Failed to delete old notifications.',
});
}
}
}