diff --git a/app.js b/app.js index 67ee5fe..6ab9a98 100644 --- a/app.js +++ b/app.js @@ -9,7 +9,7 @@ const compression = require("compression"); const unknownEndpoint = require("./middleware/unKnownEndpoint"); const { handleError } = require("./helpers/error"); const { checkConnection, mqttClient } = require("./config"); -const { onNotification } = require("./services/notifikasi-wa.service"); +const notifikasiWaService = require("./services/notifikasi-wa.service"); const app = express(); @@ -51,7 +51,10 @@ app.use(handleError); // Saat pesan diterima mqttClient.on('message', (topic, message) => { console.log(`Received message on topic "${topic}":`, message.toString()); - onNotification(topic, message); + notifikasiWaService.onNotification(topic, message); + notifikasiWaService.onNotificationReminder(topic, message); }); +notifikasiWaService.runReminder(); + module.exports = app; diff --git a/config/index.js b/config/index.js index 3e66129..30e7a9b 100644 --- a/config/index.js +++ b/config/index.js @@ -301,7 +301,10 @@ const mqttOptions = { }; const mqttUrl = process.env.MQTT_HOST; // Ganti dengan broker kamu -const topic = process.env.TOPIC_COD ?? 'morek'; +const topics = [ + process.env.TOPIC_COD ?? 'PIU_COD/ERROR_CODE', + process.env.TOPIC_COD_REMINDER ?? 'PIU_COD/REMINDERSPAREPART' +]; const mqttClient = mqtt.connect(mqttUrl, mqttOptions); @@ -310,9 +313,9 @@ mqttClient.on('connect', () => { console.log('MQTT connected'); // Subscribe ke topik tertentu - mqttClient.subscribe(topic, (err) => { + mqttClient.subscribe(topics, (err) => { if (!err) { - console.log(`Subscribed to topic "${topic}"`); + console.log(`Subscribed to topic "${topics}"`); } else { console.error('Subscribe error:', err); } diff --git a/db/device.db.js b/db/device.db.js index b2000c1..b2b9968 100644 --- a/db/device.db.js +++ b/db/device.db.js @@ -69,6 +69,7 @@ const getDeviceByIdDb = async (id) => { SELECT a.*, b.brand_name, + a.reminder_at AT TIME ZONE 'UTC' AT TIME ZONE 'SE Asia Standard Time' AS reminder_at_local, COALESCE(a.device_code, '') + ' - ' + COALESCE(a.device_name, '') AS device_code_name FROM m_device a LEFT JOIN m_brands b ON a.brand_id = b.brand_id @@ -124,10 +125,25 @@ const deleteDeviceDb = async (id, deletedBy) => { return true; }; +const getDeviceReminderChanelDb = async (id) => { + const queryText = ` + SELECT + a.*, + b.brand_name, + COALESCE(a.device_code, '') + ' - ' + COALESCE(a.device_name, '') AS device_code_name + FROM m_device a + LEFT JOIN m_brands b ON a.brand_id = b.brand_id + WHERE a.listen_channel_reminder = $1 AND a.deleted_at IS NULL + `; + const result = await pool.query(queryText, [id]); + return result.recordset; +}; + module.exports = { getAllDevicesDb, getDeviceByIdDb, createDeviceDb, updateDeviceDb, deleteDeviceDb, + getDeviceReminderChanelDb }; diff --git a/db/notification_error.db.js b/db/notification_error.db.js index 65733bb..823a496 100644 --- a/db/notification_error.db.js +++ b/db/notification_error.db.js @@ -61,6 +61,25 @@ const getDeviceNotificationByIdDb = async (chanel_id) => { return result.recordset[0]; }; +const getDeviceChannelReminder = async (chanel_reminder) => { + const queryText = ` + SELECT + brand_id, + device_code, + device_name, + device_location, + listen_channel, + listen_channel_reminder, + reminder_at, + + FROM m_device + WHERE listen_channel_reminder = $1 + AND deleted_at IS NULL + `; + const result = await pool.query(queryText, [chanel_reminder]); + return result.recordset[0]; +}; + const getAllNotificationDb = async (searchParams = {}) => { let queryParams = []; @@ -202,12 +221,31 @@ const getUsersNotificationErrorDb = async (id) => { return result.recordset; }; +const getReminderNotificationErrorByYearlyDb = async (errorCode, chanel, year) => { + const queryText = ` + SELECT a.* + + FROM notification_error a + + WHERE a.error_code_id = $1 + AND a.error_chanel = $2 + AND YEAR(a.created_at) = $3 + AND a.message_error_issue LIKE 'reminder%' + AND a.deleted_at IS NULL + `; + + const result = await pool.query(queryText, [errorCode, chanel, year]); + return result.recordset[0]; +}; + module.exports = { getNotificationByIdDb, getDeviceNotificationByIdDb, getAllNotificationDb, InsertNotificationErrorDb, updateNotificationErrorDb, - getUsersNotificationErrorDb + getUsersNotificationErrorDb, + getDeviceChannelReminder, + getReminderNotificationErrorByYearlyDb }; diff --git a/db/sparepart.db.js b/db/sparepart.db.js index 2861a87..d6d50f0 100644 --- a/db/sparepart.db.js +++ b/db/sparepart.db.js @@ -179,6 +179,24 @@ const getSparepartsByIdsDb = async (sparepartIds) => { return result.recordset; }; +const getSparepartsByYearlyDb = async (yearly) => { + + const queryText = ` + SELECT * + FROM m_sparepart + WHERE deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM OPENJSON(sparepart_yearly) + WHERE value = $1 + ) + `; + + const result = await pool.query(queryText, [yearly]); + + return result.recordset; +}; + module.exports = { getAllSparepartDb, getSparepartByIdDb, @@ -187,4 +205,5 @@ module.exports = { createSparepartDb, updateSparepartDb, deleteSparepartDb, + getSparepartsByYearlyDb }; diff --git a/scheduler/log.json b/scheduler/log.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/scheduler/log.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/scheduler/reminder.json b/scheduler/reminder.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/scheduler/reminder.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/services/notifikasi-wa.service.js b/services/notifikasi-wa.service.js index 3869d38..aceddb3 100644 --- a/services/notifikasi-wa.service.js +++ b/services/notifikasi-wa.service.js @@ -2,10 +2,13 @@ const { getAllContactDb } = require("../db/contact.db"); const { InsertNotificationErrorDb, updateNotificationErrorDb, + getDeviceChannelReminder, + getReminderNotificationErrorByYearlyDb, } = require("../db/notification_error.db"); const { createNotificationErrorUserDb, updateNotificationErrorUserDb, + getNotificationErrorByIdDb, } = require("../db/notification_error_user.db"); const { generateTokenRedirect, @@ -18,10 +21,76 @@ const { getDeviceNotificationByIdDb } = require("../db/notification_error.db"); const { exec } = require("child_process"); const util = require("util"); const execPromise = util.promisify(exec); -const fs = require("fs"); +const fs = require('fs').promises; const path = require("path"); +const { getSparepartsByYearlyDb } = require("../db/sparepart.db"); +const { getDeviceReminderChanelDb } = require("../db/device.db"); + +const filePath = path.join(process.cwd(), 'scheduler', 'reminder.json'); +const filePathLog = path.join(process.cwd(), 'scheduler', 'log.json'); class NotifikasiWaService { + + async saveReminder(data) { + console.log(`Reminder proses dibuat`); + + try { + let existing = []; + + // pastikan folder ada + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + try { + const raw = await fs.readFile(filePath, 'utf-8'); + if (raw) { + existing = JSON.parse(raw); + } + } catch (err) { + // file belum ada → buat file kosong + await fs.writeFile(filePath, '[]', 'utf-8'); + existing = []; + } + + existing.push(data); + + await fs.writeFile(filePath, JSON.stringify(existing, null, 2), 'utf-8'); + + console.log(`Reminder berhasil disimpan`); + } catch (error) { + console.log('Error saveReminder:', error); + } + } + + async saveLogReminder(data) { + console.log(`Reminder proses dibuat`); + + try { + let existing = []; + + // pastikan folder ada + await fs.mkdir(path.dirname(filePathLog), { recursive: true }); + + try { + const raw = await fs.readFile(filePathLog, 'utf-8'); + if (raw) { + existing = JSON.parse(raw); + } + } catch (err) { + // file belum ada → buat file kosong + await fs.writeFile(filePathLog, '[]', 'utf-8'); + existing = []; + } + + existing.push(data); + + await fs.writeFile(filePathLog, JSON.stringify(existing, null, 2), 'utf-8'); + + console.log(`Reminder berhasil disimpan`); + } catch (error) { + console.log('Error saveReminder:', error); + } + } + async onNotification(topic, message) { try { const paramDb = { @@ -93,8 +162,7 @@ class NotifikasiWaService { const bodyMessage = `Hai ${dataUser.contact_name || "-"},\n` + - `Terjadi peringatan dengan kode ${chanel?.value ?? "-"} "${ - errorCode?.error_code_name ?? "" + `Terjadi peringatan dengan kode ${chanel?.value ?? "-"} "${errorCode?.error_code_name ?? "" }", Chanel ${chanel?.chanel_id ?? "-"} ` + `pada device ${deviceNotification?.device_name ?? "berikut"},` + `\nSilahkan cek detail pada link :` + @@ -149,6 +217,291 @@ class NotifikasiWaService { } } + async loadReminder() { + if (!fs.existsSync(filePath)) return []; + return JSON.parse(fs.readFileSync(filePath)); + } + + async deleteReminder(id) { + let data = this.loadReminder(); + + const newData = data.filter(item => item.notification_log !== id); + + // cek apakah ada yang terhapus + if (data.length === newData.length) { + console.log(`Reminder ${id} tidak ditemukan`); + return false; + } + + this.saveReminder(newData); + + console.log(`Reminder ${id} berhasil dihapus`); + return true; + } + + async runReminder() { + setInterval(async () => { + const now = new Date(); + let data = this.loadReminder(); + + data = data.filter(async (item) => { + + // ❌ jika non aktif → hapus + if (!item.active) return false; + + // ❌ jika sudah max → stop & hapus + if (item.count >= item.max) return false; + + const [hour, minute] = item.start_at.split('.'); + + const start = new Date(); + start.setHours(parseInt(hour), parseInt(minute), 0, 0); + + // delay logic sederhana (first run or interval run) + if (!item.last_run) { + item.last_run = start.toISOString(); + } + + // 🔥 interval dalam JAM + const nextRun = new Date(lastRun.getTime() + (item.interval * 60 * 60 * 1000)); + + // eksekusi kalau sudah waktunya + if (now >= nextRun) { + + const results = await getNotificationErrorByIdDb(item.notification_log); + + const dataUsers = results.data; + + for (const dataUser of dataUsers) { + const resultSend = await sendNotifikasi( + dataUser.userPhone, + dataUser.bodyMessage + ); + + await this.saveLogReminder(resultSend) + + await updateNotificationErrorUserDb( + dataUser.notification_error_user_id, + { + is_send: resultSend.success, + } + ); + + if (resultSend.success) { + await updateNotificationErrorDb( + item.notification_log, + { + is_send: true, + is_delivered: true, + } + ); + } + } + + item.last_run = now.toISOString(); + item.count += 1; + } + + return true; + }); + + await this.saveReminder(data); + + }, 60 * 1000); + } + + async onNotificationReminder(topic, message) { + try { + const paramDb = { + limit: 100, + page: 1, + criteria: "", + active: 1, + }; + + // const chanel = { + // "time": "2025-12-11 11:10:58", + // "c_4501": 4, + // "c_5501": 3, + // "c_6501": 0 + // } + + if (topic === process.env.TOPIC_COD_REMINDER ?? "morek") { + const dataMqtt = JSON.parse(message); + + const resultChanel = []; + + Object.entries(dataMqtt).forEach(([key, value]) => { + if (key.startsWith("c_")) { + resultChanel.push({ + chanel_id: Number(key.slice(2)), + value, + }); + } + }); + + // console.log('Reminder triggered A',resultChanel); + + for (const chanel of resultChanel) { + + const deviceReminder = await getDeviceReminderChanelDb( + Number(chanel.chanel_id) + ); + + if (deviceReminder?.[0]?.reminder_at) { + const timeZone = 'Asia/Jakarta'; + + const now = new Date(); + const year = now.getFullYear(); + const reminderAt = new Date(deviceReminder[0].reminder_at); + + // 🔹 ambil bulan & jam dalam timezone server + const nowParts = new Intl.DateTimeFormat('en-US', { + timeZone, + month: 'numeric', + hour: 'numeric', + hour12: false, + }).formatToParts(now); + + const reminderParts = new Intl.DateTimeFormat('en-US', { + timeZone, + month: 'numeric', + hour: 'numeric', + hour12: false, + }).formatToParts(reminderAt); + + const timeReminderString = reminderAt.toLocaleTimeString('id-ID', { + timeZone: 'Asia/Jakarta', + hour: '2-digit', + minute: '2-digit', + hour12: false + }).replace('.', ':'); // ganti titik jadi titik dua + + const getPart = (parts, type) => + parseInt(parts.find(p => p.type === type).value, 10); + + const nowMonth = getPart(nowParts, 'month'); + const nowHour = getPart(nowParts, 'hour'); + + const reminderMonth = getPart(reminderParts, 'month'); + const reminderHour = getPart(reminderParts, 'hour'); + + // 🔥 logic utama + const isTriggered = + nowMonth > reminderMonth || + (nowMonth === reminderMonth && nowHour >= reminderHour); + + if (isTriggered) { + const valMqtt = chanel.value; + const yearly = parseInt(valMqtt.match(/\d+/)?.[0], 10); + + if (!isNaN(yearly)) { + const checkNotifExist = await getReminderNotificationErrorByYearlyDb(yearly ?? chanel.value, chanel.chanel_id, year); + + if (!checkNotifExist) { + + const deviceNotification = await getSparepartsByYearlyDb(yearly); + + const data = { + error_code_id: yearly ?? chanel.value, + error_chanel: chanel.chanel_id, + is_send: false, + is_delivered: false, + is_read: false, + is_active: true, + message_error_issue: `reminder ${chanel.value} in ${year}`, + }; + + const resultNotificationError = await InsertNotificationErrorDb(data); + + await this.saveReminder({ + notification_log: resultNotificationError.notification_error_id, + start_at: timeReminderString, + interval: 1, + max: 3, + active: true, + count: 0 + }); + + const results = await getAllContactDb(paramDb); + + const dataUsers = results.data; + + let isSendNotification = false; + + for (const dataUser of dataUsers) { + if (dataUser.is_active) { + + const tokenRedirect = await generateTokenRedirect( + dataUser.contact_phone, + dataUser.contact_name, + resultNotificationError.notification_error_id + ); + + const encodedToken = encodeURIComponent(tokenRedirect); + + const shortUrl = await shortUrltiny(encodedToken); + + const bodyMessage = + `Hai ${dataUser.contact_name || "-"},\n` + + `Diberitahukan bahwa terdapat sparepart pada device "${deviceNotification?.device_name ?? "-"}" ` + + `yang telah memasuki jadwal perawatan tahunan.\n` + + `\nSilakan segera lakukan pengecekan dan perawatan untuk memastikan kinerja tetap optimal.` + + `\nDetail sparepart dapat dilihat pada link berikut:\n${shortUrl}`; + + const param = { + idData: resultNotificationError.notification_error_id, + userPhone: dataUser.contact_phone, + userName: dataUser.contact_name, + bodyMessage: bodyMessage, + }; + + const resultNotificationErrorUser = + await createNotificationErrorUserDb({ + notification_error_id: param.idData, + + contact_phone: param.userPhone, + contact_name: param.userName, + message_error_issue: param.bodyMessage, + is_send: false, + }); + + const resultSend = await sendNotifikasi( + param.userPhone, + param.bodyMessage + ); + + await updateNotificationErrorUserDb( + resultNotificationErrorUser[0].notification_error_user_id, + { + is_send: resultSend.success, + } + ); + + if (resultSend.success) { + isSendNotification = resultSend.success; + } + } + } + + await updateNotificationErrorDb( + resultNotificationError.notification_error_id, + { + is_send: isSendNotification, + is_delivered: isSendNotification, + } + ); + } + } + } + } + } + } + } catch (err) { + return err; + } + } + async restartWhatsapp() { try { const processName = "Whatsapp-API-notification" && "cod-whatsapp-notif";