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, 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); } } };