Files
GPI/src/server/services/notificationService.ts

257 lines
11 KiB
TypeScript

import { query } from '../config/database.js';
import { addMonths, isBefore } from 'date-fns';
const mapNotification = (n: any) => ({
...n,
organizationId: n.organization_id,
userId: n.user_id,
isRead: n.read,
metadata: n.metadata,
createdAt: n.created_at,
archivedBy: n.archived_by || [],
deletedBy: n.deleted_by || [],
});
export const notificationService = {
async create(data: any) {
try {
const columns: string[] = [];
const values: any[] = [];
let i = 1;
const dbData = { ...data };
if (dbData.organizationId) {
dbData.organization_id = dbData.organizationId;
delete dbData.organizationId;
}
if (dbData.userId) {
dbData.user_id = dbData.userId;
delete dbData.userId;
}
Object.entries(dbData).forEach(([key, value]) => {
const sqlKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
columns.push(sqlKey);
values.push(typeof value === 'object' ? JSON.stringify(value) : value);
});
const result = await query(
`INSERT INTO notifications (${columns.join(', ')}) VALUES (${columns.map(() => `$${i++}`).join(', ')}) RETURNING *`,
values
);
return mapNotification(result?.rows[0]);
} catch (error) {
console.error('Error creating notification:', error);
throw error;
}
},
async isAlreadyNotified(orgId: string, metadata: Record<string, string>, graceDays: number = 30) {
try {
const graceDate = new Date();
graceDate.setDate(graceDate.getDate() - graceDays);
// To check JSONB metadata properly in PG: metadata @> '{"stockItemId": "..."}'
const result = await query(
'SELECT * FROM notifications WHERE organization_id = $1 AND metadata @> $2 AND created_at >= $3 LIMIT 1',
[orgId, JSON.stringify(metadata), graceDate]
);
return !!result?.rows[0];
} catch (error) {
console.error('Error checking notification existence:', error);
return false;
}
},
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
try {
let where = 'organization_id = $1 AND (user_id = $2 OR user_id IS NULL) AND NOT ($2 = ANY(deleted_by))';
const params: any[] = [organizationId, userId];
if (!includeArchived) {
where += ' AND is_archived = FALSE AND NOT ($2 = ANY(archived_by))';
}
const result = await query(`SELECT * FROM notifications WHERE ${where} ORDER BY created_at DESC LIMIT 50`, params);
return (result?.rows || []).map(mapNotification);
} catch (error) {
console.error('Error fetching notifications:', error);
throw error;
}
},
async markAsRead(id: string) {
try {
const result = await query('UPDATE notifications SET read = TRUE WHERE id = $1 RETURNING *', [id]);
return mapNotification(result?.rows[0]);
} catch (error) {
console.error('Error marking notification as read:', error);
throw error;
}
},
async markAllAsRead(userId: string, organizationId: string) {
try {
await query(
'UPDATE notifications SET read = TRUE WHERE organization_id = $1 AND (user_id = $2 OR user_id IS NULL)',
[organizationId, userId]
);
} catch (error) {
console.error('Error marking all notifications as read:', error);
throw error;
}
},
async clearAll(userId: string, organizationId: string) {
try {
await query(
'DELETE FROM notifications WHERE organization_id = $1 AND (user_id = $2 OR user_id IS NULL)',
[organizationId, userId]
);
} catch (error) {
console.error('Error clearing all notifications:', error);
throw error;
}
},
async archive(id: string, userId: string) {
try {
const check = await query('SELECT * FROM notifications WHERE id = $1', [id]);
const notif = check?.rows[0];
if (!notif) return null;
if (notif.user_id) {
await query('UPDATE notifications SET is_archived = TRUE, read = TRUE WHERE id = $1', [id]);
} else {
await query('UPDATE notifications SET archived_by = array_append(archived_by, $1) WHERE id = $2', [userId, id]);
}
return true;
} catch (error) {
console.error('Error archiving notification:', error);
throw error;
}
},
async softDelete(id: string, userId: string) {
try {
const check = await query('SELECT * FROM notifications WHERE id = $1', [id]);
const notif = check?.rows[0];
if (!notif) return null;
if (notif.user_id === userId) {
await query('DELETE FROM notifications WHERE id = $1', [id]);
} else {
await query('UPDATE notifications SET deleted_by = array_append(deleted_by, $1) WHERE id = $2', [userId, id]);
}
return true;
} catch (error) {
console.error('Error soft deleting notification:', error);
throw error;
}
},
async checkStockExpirations() {
try {
const result = await query('SELECT * FROM stock_items WHERE expiration_date IS NOT NULL AND quantity > 0');
const stockItems = result?.rows || [];
const now = new Date();
const twoMonthsFromNow = addMonths(now, 2);
const oneMonthFromNow = addMonths(now, 1);
for (const item of stockItems) {
const expDate = new Date(item.expiration_date);
const itemId = item.id;
const orgId = item.organization_id;
if (!orgId) continue;
if (isBefore(expDate, now)) {
await this.notifyCheck(orgId, itemId, 'expired', 'Item Vencido',
`O item ${item.rr_number} - Lote ${item.batch_number} venceu em ${expDate.toLocaleDateString()}.`, 'error');
} else if (isBefore(expDate, oneMonthFromNow)) {
await this.notifyCheck(orgId, itemId, 'expire_1_month', 'Vencimento Próximo (1 mês)',
`O item ${item.rr_number} - Lote ${item.batch_number} vencerá em menos de 1 mês.`, 'warning');
} else if (isBefore(expDate, twoMonthsFromNow)) {
await this.notifyCheck(orgId, itemId, 'expire_2_months', 'Vencimento em 2 meses',
`O item ${item.rr_number} - Lote ${item.batch_number} vencerá em 2 meses.`, 'info');
}
}
} catch (error) {
console.error('Error in checkStockExpirations:', error);
}
},
async notifyCheck(orgId: string, stockItemId: string, triggerType: string, title: string, message: string, type: string) {
const metadata = { stockItemId, triggerType };
const notified = await this.isAlreadyNotified(orgId, metadata);
if (!notified) {
await this.create({ organizationId: orgId, title, message, type, metadata });
}
},
async checkInstrumentCalibrations() {
try {
const result = await query('SELECT * FROM instruments WHERE calibration_expiration_date IS NOT NULL AND status != $1', ['inactive']);
const instruments = result?.rows || [];
const now = new Date();
const twoMonthsFromNow = addMonths(now, 2);
const oneMonthFromNow = addMonths(now, 1);
for (const inst of instruments) {
const expDate = new Date(inst.calibration_expiration_date);
const instId = inst.id;
const orgId = inst.organization_id;
if (!orgId) continue;
if (isBefore(expDate, now)) {
const metadata = { instrumentId: instId, triggerType: 'calibration_expired' };
if (!(await this.isAlreadyNotified(orgId, metadata))) {
await this.create({ organizationId: orgId, title: 'Calibração Vencida', message: `O instrumento ${inst.name} (${inst.serial_number}) está vencido.`, type: 'error', metadata });
}
await query('UPDATE instruments SET status = $1 WHERE id = $2', ['expired', instId]);
} else if (isBefore(expDate, oneMonthFromNow)) {
const metadata = { instrumentId: instId, triggerType: 'calibration_1_month' };
if (!(await this.isAlreadyNotified(orgId, metadata))) {
await this.create({ organizationId: orgId, title: 'Calibração vence em 1 mês', message: `A calibração do instrumento ${inst.name} vence em breve.`, type: 'warning', metadata });
}
}
}
} catch (error) {
console.error('Error in checkInstrumentCalibrations:', error);
}
},
async checkLowStock(stockItemId: string) {
try {
const itemResult = await query(
`SELECT si.*, tds.name as data_sheet_name
FROM stock_items si
LEFT JOIN technical_data_sheets tds ON si.data_sheet_id = tds.id
WHERE si.id = $1`, [stockItemId]
);
const item = itemResult?.rows[0];
if (!item || !item.min_stock || item.min_stock <= 0) return;
const orgId = item.organization_id;
const siblingsResult = await query('SELECT quantity FROM stock_items WHERE organization_id = $1 AND data_sheet_id = $2 AND color = $3', [orgId, item.data_sheet_id, item.color]);
const totalQuantity = (siblingsResult?.rows || []).reduce((sum, s) => sum + Number(s.quantity), 0);
if (totalQuantity < item.min_stock) {
const metadata = { product_color_key: `${item.data_sheet_id}-${item.color}`, triggerType: 'low_stock_aggregated' };
if (!(await this.isAlreadyNotified(orgId, metadata, 3))) {
await this.create({
organizationId: orgId,
title: 'Estoque Baixo (Total)',
message: `O produto ${item.data_sheet_name} atingiu nível crítico (${totalQuantity} ${item.unit}). Mínimo: ${item.min_stock}.`,
type: 'error',
metadata
});
}
}
} catch (error) {
console.error('Error checking low stock:', error);
}
}
};