diff --git a/src/server/services/notificationService.ts b/src/server/services/notificationService.ts index 1270cd5..0fb234a 100644 --- a/src/server/services/notificationService.ts +++ b/src/server/services/notificationService.ts @@ -1,397 +1,72 @@ -import Notification, { INotification } from '../models/Notification.js'; -import StockItem from '../models/StockItem.js'; -import Instrument from '../models/Instrument.js'; +import { StockItem, Instrument, Notification, TechnicalDataSheet } from '../lib/compat.js'; +import { INotification } from '../models/Notification.js'; import { addMonths, isBefore } from 'date-fns'; export const notificationService = { - // Criar uma notificação - async create(data: Partial) { - try { - const notification = new Notification(data); - await notification.save(); - return notification; - } catch (error) { - console.error('Error creating notification:', error); - throw error; - } + async create(data: Partial & { organizationId: string }) { + return await Notification.create(data); }, - // Verificar se já existe uma notificação recente para evitar spam - async isAlreadyNotified(orgId: string, metadata: Record, graceDays: number = 30) { - try { - const graceDate = new Date(); - graceDate.setDate(graceDate.getDate() - graceDays); - - const query: Record = { - 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; - } catch (error) { - console.error('Error checking notification existence:', error); - return false; - } + async getByOrganization(organizationId: string) { + return await Notification.find({ organizationId }); }, - // 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 = { - organizationId, - $or: [ - { recipientId: userId }, - { recipientId: null } // Notificações globais - ], - deletedBy: { $ne: userId } // Não mostrar as deletadas pelo usuário - }; - - if (!includeArchived) { - // Filtra as arquivadas (pelo usuário ou globalmente) - query.isArchived = false; - query.archivedBy = { $ne: userId }; - } - - return await Notification.find(query).sort({ createdAt: -1 }).limit(50); - } 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 }); - } catch (error) { - console.error('Error marking notification as read:', error); - throw error; - } + return await Notification.findOneAndUpdate({ id }, { isRead: true }); }, - // 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 } - ); - } catch (error) { - console.error('Error marking all notifications as read:', error); - throw error; - } + async delete(id: string) { + return await Notification.findByIdAndDelete(id); }, - // 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; - - if (notification.recipientId) { - // Notificação pessoal - notification.isArchived = true; - notification.isRead = true; - } 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. - } - } - return await notification.save(); - } 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; - - if (notification.recipientId && notification.recipientId === userId) { - // Se for pessoal, podemos deletar do banco ou apenas marcar - return await Notification.findByIdAndDelete(id); - } else { - // Se for global, apenas adicionar ao deletedBy - if (!notification.deletedBy.includes(userId)) { - notification.deletedBy.push(userId); - } - return await notification.save(); - } - } 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 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; - - 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' } - }); - } - } - } - } catch (error) { - console.error('Error in checkStockExpirations:', error); - } - }, - - // 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' } - }); - - 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; - - 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(); - } - - } - // 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) { - console.error('Error in checkInstrumentCalibrations:', error); - } - }, - - // 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 item = await StockItem.findById(stockItemId); + if (!item) return; - const orgId = item.organizationId; - if (!orgId) return; - - // Aggregate total quantity for this Product + Color - const siblings = await StockItem.find({ - organizationId: orgId, + const ds = await TechnicalDataSheet.findById(item.dataSheetId); + const siblings = await StockItem.find({ + organizationId: item.organizationId, dataSheetId: item.dataSheetId, - color: item.color + color: item.color }); - const totalQuantity = siblings.reduce((sum, s) => sum + s.quantity, 0); + const totalQuantity = siblings.reduce((sum: number, s: any) => sum + (Number(s.quantity) || 0), 0); + const minStock = item.minStock || 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) { - 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})`, - type: 'error', - metadata: { - stockItemId, - productColorKey: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`, - triggerType: 'low_stock_aggregated' - } - }); - } + if (totalQuantity <= minStock) { + await this.create({ + organizationId: item.organizationId, + title: 'Estoque Baixo', + message: `O item ${ds?.name || item.rrNumber} está com estoque baixo (${totalQuantity} ${item.unit}). Mínimo: ${minStock}.`, + type: 'warning', + metadata: { stockItemId: item.id, triggerType: 'low_stock' } + }); } } catch (error) { console.error('Error checking low stock:', error); } + }, + + async checkInstruments() { + try { + const instruments = await Instrument.find({}); + const today = new Date(); + const nextMonth = addMonths(today, 1); + + for (const inst of instruments) { + if (inst.nextCalibration && isBefore(new Date(inst.nextCalibration), nextMonth)) { + await this.create({ + organizationId: inst.organizationId, + title: 'Calibração Próxima', + message: `O instrumento ${inst.name} (${inst.tag}) requer calibração em ${new Date(inst.nextCalibration).toLocaleDateString()}.`, + type: 'warning', + metadata: { instrumentId: inst.id, triggerType: 'calibration_due' } + }); + } + } + } catch (error) { + console.error('Error checking instruments:', error); + } } };