feat: Migrate database from MongoDB to PostgreSQL, updating all services and introducing a new schema.
This commit is contained in:
@@ -1,96 +1,100 @@
|
||||
import Notification, { INotification } from '../models/Notification.js';
|
||||
import StockItem from '../models/StockItem.js';
|
||||
import Instrument from '../models/Instrument.js';
|
||||
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 = {
|
||||
// Criar uma notificação
|
||||
async create(data: Partial<INotification>) {
|
||||
async create(data: any) {
|
||||
try {
|
||||
const notification = new Notification(data);
|
||||
await notification.save();
|
||||
return notification;
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar se já existe uma notificação recente para evitar spam
|
||||
async isAlreadyNotified(orgId: string, metadata: Record<string, string>, graceDays: number = 30) {
|
||||
try {
|
||||
const graceDate = new Date();
|
||||
graceDate.setDate(graceDate.getDate() - graceDays);
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
organizationId: orgId
|
||||
};
|
||||
|
||||
// Adicionar campos de metadata à query
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
query[`metadata.${key}`] = value;
|
||||
}
|
||||
|
||||
// Verificar se existe alguma notificação com essa metadata nos últimos graceDays
|
||||
// Independente de estar lida ou não, para evitar duplicidade.
|
||||
query.createdAt = { $gte: graceDate };
|
||||
|
||||
const existing = await Notification.findOne(query);
|
||||
return !!existing;
|
||||
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
|
||||
// Obter notificações de um usuário (ou globais da organização)
|
||||
async getUserNotifications(userId: string, organizationId: string, includeArchived: boolean = false) {
|
||||
try {
|
||||
const query: Record<string, unknown> = {
|
||||
organizationId,
|
||||
$or: [
|
||||
{ recipientId: userId },
|
||||
{ recipientId: null } // Notificações globais
|
||||
],
|
||||
deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário
|
||||
};
|
||||
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) {
|
||||
// Filtra as arquivadas (pelo usuário ou globalmente)
|
||||
query.isArchived = false;
|
||||
query.archivedBy = { $ne: userId };
|
||||
where += ' AND is_archived = FALSE AND NOT ($2 = ANY(archived_by))';
|
||||
}
|
||||
|
||||
return await Notification.find(query).sort({ createdAt: -1 }).limit(50);
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar como lida
|
||||
async markAsRead(id: string) {
|
||||
try {
|
||||
return await Notification.findByIdAndUpdate(id, { isRead: true }, { new: true });
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar todas como lidas para um usuário
|
||||
async markAllAsRead(userId: string, organizationId: string) {
|
||||
try {
|
||||
return await Notification.updateMany(
|
||||
{
|
||||
organizationId,
|
||||
$or: [
|
||||
{ recipientId: userId },
|
||||
{ recipientId: null }
|
||||
],
|
||||
isRead: false
|
||||
},
|
||||
{ isRead: true }
|
||||
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);
|
||||
@@ -98,171 +102,66 @@ export const notificationService = {
|
||||
}
|
||||
},
|
||||
|
||||
// Arquivar uma notificação para um usuário
|
||||
async archive(id: string, userId: string) {
|
||||
try {
|
||||
const notification = await Notification.findById(id);
|
||||
if (!notification) return null;
|
||||
const check = await query('SELECT * FROM notifications WHERE id = $1', [id]);
|
||||
const notif = check?.rows[0];
|
||||
if (!notif) return null;
|
||||
|
||||
if (notification.recipientId) {
|
||||
// Notificação pessoal
|
||||
notification.isArchived = true;
|
||||
notification.isRead = true;
|
||||
if (notif.user_id) {
|
||||
await query('UPDATE notifications SET is_archived = TRUE, read = TRUE WHERE id = $1', [id]);
|
||||
} else {
|
||||
// Notificação global
|
||||
if (!notification.archivedBy.includes(userId)) {
|
||||
notification.archivedBy.push(userId);
|
||||
}
|
||||
// Marcar como lida também? Opcional
|
||||
if (!notification.readBy?.includes(userId)) {
|
||||
// Nota: se quisermos readBy global, precisaríamos desse campo.
|
||||
// Para simplificar, vamos assumir que arquivar esconde da lista ativa.
|
||||
}
|
||||
await query('UPDATE notifications SET archived_by = array_append(archived_by, $1) WHERE id = $2', [userId, id]);
|
||||
}
|
||||
return await notification.save();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error archiving notification:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Deletar (esconder) uma notificação para um usuário
|
||||
async softDelete(id: string, userId: string) {
|
||||
try {
|
||||
const notification = await Notification.findById(id);
|
||||
if (!notification) return null;
|
||||
const check = await query('SELECT * FROM notifications WHERE id = $1', [id]);
|
||||
const notif = check?.rows[0];
|
||||
if (!notif) return null;
|
||||
|
||||
if (notification.recipientId && notification.recipientId === userId) {
|
||||
// Se for pessoal, podemos deletar do banco ou apenas marcar
|
||||
return await Notification.findByIdAndDelete(id);
|
||||
if (notif.user_id === userId) {
|
||||
await query('DELETE FROM notifications WHERE id = $1', [id]);
|
||||
} else {
|
||||
// Se for global, apenas adicionar ao deletedBy
|
||||
if (!notification.deletedBy.includes(userId)) {
|
||||
notification.deletedBy.push(userId);
|
||||
}
|
||||
return await notification.save();
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
// Limpar todas (esconder todas as atuais)
|
||||
async clearAll(userId: string, organizationId: string) {
|
||||
try {
|
||||
// Para notificações pessoais: Deletar
|
||||
await Notification.deleteMany({
|
||||
organizationId,
|
||||
recipientId: userId
|
||||
});
|
||||
|
||||
// Para notificações globais: Marcar como deletadas por esse usuário
|
||||
const globalNotifications = await Notification.find({
|
||||
organizationId,
|
||||
recipientId: null,
|
||||
deletedBy: { $ne: userId }
|
||||
});
|
||||
|
||||
for (const notif of globalNotifications) {
|
||||
notif.deletedBy.push(userId);
|
||||
await notif.save();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error clearing all notifications:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar vencimentos de estoque e gerar notificações
|
||||
async checkStockExpirations() {
|
||||
console.log('Running stock expiration checkJob...');
|
||||
try {
|
||||
// Buscar todos os itens de estoque com data de validade que ainda não venceram ou venceram recentemente
|
||||
// Otimização: Em um sistema real, faríamos isso por query direta, mas aqui vamos iterar para aplicar a lógica de 2 meses, 1 mês, vencido.
|
||||
const stockItems = await StockItem.find({ expirationDate: { $exists: true, $ne: null }, quantity: { $gt: 0 } });
|
||||
|
||||
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) {
|
||||
if (!item.expirationDate) continue;
|
||||
|
||||
const expirationDate = new Date(item.expirationDate);
|
||||
const itemId = item._id.toString();
|
||||
const orgId = item.organizationId;
|
||||
const expDate = new Date(item.expiration_date);
|
||||
const itemId = item.id;
|
||||
const orgId = item.organization_id;
|
||||
|
||||
if (!orgId) continue;
|
||||
|
||||
let message = '';
|
||||
let title = '';
|
||||
let type: 'warning' | 'error' = 'warning';
|
||||
|
||||
// Lógica de notificação
|
||||
// 1. Vencido
|
||||
if (isBefore(expirationDate, now)) {
|
||||
title = 'Item Vencido';
|
||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} venceu em ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'error';
|
||||
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: itemId,
|
||||
triggerType: 'expired'
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: { stockItemId: itemId, triggerType: 'expired' }
|
||||
});
|
||||
}
|
||||
}
|
||||
// 2. Vence em 1 mês (aprox)
|
||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
||||
title = 'Vencimento Próximo (1 mês)';
|
||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em menos de 1 mês (${expirationDate.toLocaleDateString()}).`;
|
||||
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: itemId,
|
||||
triggerType: 'expire_1_month'
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type: 'warning',
|
||||
metadata: { stockItemId: itemId, triggerType: 'expire_1_month' }
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
// 3. Vence em 2 meses (aprox)
|
||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
||||
title = 'Vencimento em 2 meses';
|
||||
message = `O item ${item.rrNumber} - Lote ${item.batchNumber} vencerá em 2 meses (${expirationDate.toLocaleDateString()}).`;
|
||||
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: itemId,
|
||||
triggerType: 'expire_2_months'
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type: 'info',
|
||||
metadata: { stockItemId: itemId, triggerType: 'expire_2_months' }
|
||||
});
|
||||
}
|
||||
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) {
|
||||
@@ -270,78 +169,39 @@ export const notificationService = {
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar calibração de instrumentos
|
||||
async checkInstrumentCalibrations() {
|
||||
console.log('Running instrument calibration checkJob...');
|
||||
try {
|
||||
const instruments = await Instrument.find({
|
||||
calibrationExpirationDate: { $exists: true, $ne: null },
|
||||
status: { $ne: 'inactive' }
|
||||
});
|
||||
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 instrument of instruments) {
|
||||
if (!instrument.calibrationExpirationDate) continue;
|
||||
|
||||
const expirationDate = new Date(instrument.calibrationExpirationDate);
|
||||
const instrumentId = instrument._id.toString();
|
||||
const orgId = instrument.organizationId;
|
||||
|
||||
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;
|
||||
|
||||
let title = '';
|
||||
let message = '';
|
||||
let type: 'info' | 'warning' | 'error' = 'info';
|
||||
let triggerType = '';
|
||||
|
||||
// 1. Vencido
|
||||
if (isBefore(expirationDate, now)) {
|
||||
title = 'Calibração Vencida';
|
||||
message = `O instrumento ${instrument.name} (${instrument.serialNumber}) está com a calibração vencida desde ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'error';
|
||||
triggerType = 'calibration_expired';
|
||||
|
||||
// Atualizar status para expired se não estiver
|
||||
if (instrument.status !== 'expired') {
|
||||
instrument.status = 'expired';
|
||||
await instrument.save();
|
||||
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 });
|
||||
}
|
||||
|
||||
}
|
||||
// 2. Vence em 1 mês
|
||||
else if (isBefore(expirationDate, oneMonthFromNow)) {
|
||||
title = 'Calibração vence em 1 mês';
|
||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'warning';
|
||||
triggerType = 'calibration_1_month';
|
||||
}
|
||||
// 3. Vence em 2 meses
|
||||
else if (isBefore(expirationDate, twoMonthsFromNow)) {
|
||||
title = 'Calibração vence em 2 meses';
|
||||
message = `A calibração do instrumento ${instrument.name} (${instrument.serialNumber}) vence em ${expirationDate.toLocaleDateString()}.`;
|
||||
type = 'info';
|
||||
triggerType = 'calibration_2_months';
|
||||
} else {
|
||||
continue; // Não precisa notificar
|
||||
}
|
||||
|
||||
// Evitar spam
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
instrumentId,
|
||||
triggerType
|
||||
});
|
||||
|
||||
if (!notified) {
|
||||
await this.create({
|
||||
organizationId: orgId,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
metadata: { instrumentId, triggerType }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -349,44 +209,30 @@ export const notificationService = {
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar se o estoque está abaixo do mínimo (Aggregated by Product + Color)
|
||||
async checkLowStock(stockItemId: string) {
|
||||
try {
|
||||
const item = await StockItem.findById(stockItemId).populate('dataSheetId', 'name manufacturer');
|
||||
if (!item || !item.minStock || item.minStock <= 0) return;
|
||||
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.organizationId;
|
||||
if (!orgId) 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);
|
||||
|
||||
// Aggregate total quantity for this Product + Color
|
||||
const siblings = await StockItem.find({
|
||||
organizationId: orgId,
|
||||
dataSheetId: item.dataSheetId,
|
||||
color: item.color
|
||||
});
|
||||
|
||||
const totalQuantity = siblings.reduce((sum, s) => sum + s.quantity, 0);
|
||||
|
||||
if (totalQuantity < item.minStock) {
|
||||
// Check throttling
|
||||
const notified = await this.isAlreadyNotified(orgId.toString(), {
|
||||
stockItemId: stockItemId, // Keep using specific item ID as reference or maybe composite key?
|
||||
// Let's use a composite key for the trigger to avoid spamming for every batch in the group
|
||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
||||
triggerType: 'low_stock_aggregated'
|
||||
}, 3);
|
||||
|
||||
if (!notified) {
|
||||
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.dataSheetId?.name} (Cor: ${item.color || 'N/A'}) atingiu o nível crítico. Total: ${totalQuantity.toFixed(1)}${item.unit}. (Mínimo: ${item.minStock}${item.unit})`,
|
||||
message: `O produto ${item.data_sheet_name} atingiu nível crítico (${totalQuantity} ${item.unit}). Mínimo: ${item.min_stock}.`,
|
||||
type: 'error',
|
||||
metadata: {
|
||||
stockItemId,
|
||||
productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`,
|
||||
triggerType: 'low_stock_aggregated'
|
||||
}
|
||||
metadata
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -395,3 +241,4 @@ export const notificationService = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user