260 lines
9.7 KiB
JavaScript
260 lines
9.7 KiB
JavaScript
const db = require("../config/db");
|
||
const Notification = require("../utils/notification");
|
||
const sharp = require('sharp');
|
||
const TelegramBot = require("node-telegram-bot-api");
|
||
|
||
const util = require('util');
|
||
const dbAll = util.promisify(db.all).bind(db);
|
||
const dbRun = util.promisify(db.run).bind(db);
|
||
|
||
const TOKEN = process.env.TELEGRAM_TOKEN;
|
||
const STAND_CHAT_ID = process.env.STAND_CHAT_ID;
|
||
|
||
const bot = new TelegramBot(TOKEN, { polling: false });
|
||
|
||
|
||
class Stands {
|
||
async check_add() {
|
||
const sqlStands = `
|
||
SELECT id
|
||
FROM stand_list
|
||
WHERE status = '1'
|
||
ORDER BY id
|
||
`;
|
||
|
||
db.all(sqlStands, (err, stands) => {
|
||
if (err) {
|
||
console.error('DB error:', err.message);
|
||
return;
|
||
}
|
||
|
||
if (!stands.length) {
|
||
console.log('There are no active stands');
|
||
return;
|
||
}
|
||
|
||
const dateNow = Date.now();
|
||
|
||
const sqlSchedule = `
|
||
SELECT 1
|
||
FROM stand_schedule
|
||
WHERE stand_id = ?
|
||
AND date >= ?
|
||
LIMIT 1
|
||
`;
|
||
|
||
let checked = 0;
|
||
let emptyStands = 0;
|
||
|
||
stands.forEach(stand => {
|
||
db.get(sqlSchedule, [stand.id, dateNow], (err, row) => {
|
||
if (err) {
|
||
console.error('Schedule error:', err.message);
|
||
return;
|
||
}
|
||
|
||
if (!row) emptyStands++;
|
||
|
||
checked++;
|
||
|
||
if (checked === stands.length) {
|
||
console.log('Empty stands:', emptyStands);
|
||
|
||
if (emptyStands > 0) {
|
||
Notification.sendStandAdd({
|
||
title: 'Додайте нові дні служіння',
|
||
body: `${emptyStands} з ${stands.length} стендів потребують додавання днів служіння.`,
|
||
page: '/stand'
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
async check_entries() {
|
||
try {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
// 1. Отримуємо вісників і одразу робимо Map для швидкого пошуку
|
||
const sheeps = await dbAll(`SELECT id, name FROM sheeps`);
|
||
if (!sheeps.length) return console.log('There are no sheeps');
|
||
|
||
const sheepMap = new Map(sheeps.map(s => [s.id, s.name]));
|
||
|
||
const startTomorrow = today.getTime() + 86400000;
|
||
const startDayAfterTomorrow = today.getTime() + 86400000 * 2;
|
||
|
||
const sqlStands = `
|
||
SELECT
|
||
stand_schedule.*,
|
||
(SELECT stand_list.title FROM stand_list WHERE stand_list.id = stand_schedule.stand_id) AS title
|
||
FROM
|
||
stand_schedule
|
||
WHERE
|
||
stand_schedule.date >= ? AND stand_schedule.date < ?
|
||
ORDER BY
|
||
stand_schedule.stand_id
|
||
`;
|
||
|
||
const schedule = await dbAll(sqlStands, [startTomorrow, startDayAfterTomorrow]);
|
||
if (!schedule.length) return console.log('No active schedule');
|
||
|
||
// 2. Угруповання даних (Transform)
|
||
const standsData = schedule.reduce((acc, item) => {
|
||
if (!acc[item.stand_id]) {
|
||
acc[item.stand_id] = { id: item.stand_id, title: item.title || `Стенд ${sId}`, hours: {}, maxSheepIdx: 0, date: item.date };
|
||
}
|
||
if (!acc[item.stand_id].hours[item.hour]) {
|
||
acc[item.stand_id].hours[item.hour] = {};
|
||
}
|
||
acc[item.stand_id].hours[item.hour][item.number_sheep] = item.sheep_id;
|
||
acc[item.stand_id].maxSheepIdx = Math.max(acc[item.stand_id].maxSheepIdx, item.number_sheep);
|
||
return acc;
|
||
}, {});
|
||
|
||
// 3. Генерація
|
||
for (const standId in standsData) {
|
||
const data = standsData[standId];
|
||
const title = data.title;
|
||
const sortedHours = Object.keys(data.hours).map(Number).sort((a, b) => a - b);
|
||
|
||
// Визначаємо крок (мінімальна різниця між годинником або 1)
|
||
let step = 1;
|
||
if (sortedHours.length > 1) {
|
||
step = sortedHours[1] - sortedHours[0];
|
||
}
|
||
|
||
const sheepIdsMatrix = sortedHours.map(h => {
|
||
const row = [];
|
||
for (let i = 0; i <= data.maxSheepIdx; i++) {
|
||
row.push(data.hours[h][i] || null);
|
||
}
|
||
return row;
|
||
});
|
||
|
||
const formattedDate = new Date(data.date).toLocaleDateString('ru-RU');
|
||
|
||
const result = await generateSchedule({
|
||
title: title,
|
||
date: formattedDate,
|
||
options: {
|
||
start: sortedHours[0],
|
||
quantity: data.maxSheepIdx + 1,
|
||
step: step
|
||
},
|
||
sheep_ids: sheepIdsMatrix,
|
||
sheepMap // Передаємо Map для імен
|
||
});
|
||
|
||
if (result && result.buffer) {
|
||
try {
|
||
let isNotFull = result.isNotFull ? '\n\n✏️ Ще є вільні місця, встигніть записатись' : '';
|
||
|
||
// 1. Надсилаємо нове фото
|
||
const sentMessage = await bot.sendPhoto(STAND_CHAT_ID, result.buffer, {
|
||
caption: `📍 *${title}*\n📅 ${formattedDate}${isNotFull}`,
|
||
parse_mode: 'Markdown'
|
||
}, {
|
||
filename: result.fileName,
|
||
contentType: 'image/png'
|
||
});
|
||
|
||
// 2. Зберігаємо ID нового повідомлення у базі
|
||
await dbRun(
|
||
`INSERT INTO sent_messages (last_message_id, created_at) VALUES (?, ?)`, [sentMessage.message_id, Date.now()]
|
||
);
|
||
|
||
console.log(`✅ Обновлено: ${title}`);
|
||
|
||
} catch (err) {
|
||
console.error('Помилка відправки в Telegram:', err.message);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Logic error:', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// Генерування зображення графіка
|
||
async function generateSchedule({
|
||
title, date, options, sheep_ids, sheepMap
|
||
}) {
|
||
const { start, step, quantity } = options;
|
||
const rowsCount = sheep_ids.length;
|
||
|
||
const rowHeight = 50;
|
||
const timeWidth = 120;
|
||
const gap = 10;
|
||
const padding = 10;
|
||
|
||
const totalWidth = 200 + (200 * quantity);
|
||
const headerHeight = 85;
|
||
const totalHeight = 136 + (rowHeight * (rowsCount - 1));
|
||
|
||
const mainRectWidth = totalWidth - (padding * 2);
|
||
const availableForBlocks = mainRectWidth - timeWidth - 5;
|
||
const blockWidth = (availableForBlocks - (gap * (quantity - 1))) / quantity;
|
||
|
||
let isNotFull = false;
|
||
|
||
const formatTime = (h) => {
|
||
const hh = Math.floor(h).toString().padStart(2, '0');
|
||
const mm = (h % 1 * 60).toString().padStart(2, '0');
|
||
return `${hh}:${mm}`;
|
||
};
|
||
|
||
let rowsSvg = '';
|
||
sheep_ids.forEach((rowSheepIds, i) => {
|
||
const y = headerHeight + (i * rowHeight);
|
||
const hStart = formatTime(start + (i * step));
|
||
const hEnd = formatTime(start + ((i + 1) * step));
|
||
const isEven = i % 2 === 0;
|
||
|
||
// Фон рядка
|
||
rowsSvg += `<rect x="${padding}" y="${y}" width="${totalWidth - padding * 2}" height="40" rx="8" fill="${isEven ? '#fbfbfb' : '#e7e7e1'}" />`;
|
||
|
||
// Текст часу
|
||
rowsSvg += `<text x="${padding + 7}" y="${y + 27}" font-family="Roboto Mono" font-size="15" fill="#313131">${hStart}-${hEnd}</text>`;
|
||
|
||
// Блоки з іменами
|
||
rowSheepIds.forEach((id, j) => {
|
||
const x = padding + timeWidth + (j * (blockWidth + gap));
|
||
const name = sheepMap.get(id) || '';
|
||
if(!sheepMap.get(id)) isNotFull = true; // Якщо є вільне місце
|
||
|
||
rowsSvg += `
|
||
<g>
|
||
<rect x="${x}" y="${y + 5}" width="${blockWidth}" height="30" rx="5" fill="${isEven ? '#e7e7e1' : '#fbfbfb'}" />
|
||
<text x="${x + 5}" y="${y + 25}" font-family="Roboto Mono" font-size="15" fill="#313131">${name}</text>
|
||
</g>`;
|
||
});
|
||
});
|
||
|
||
const finalSvg = `
|
||
<svg width="${totalWidth * 2}" height="${totalHeight * 2}" viewBox="0 0 ${totalWidth} ${totalHeight}" xmlns="http://www.w3.org/2000/svg">
|
||
<rect height="${totalHeight}" width="${totalWidth}" fill="#e7e7e1"/>
|
||
<text font-weight="bold" text-anchor="middle" font-family="'Roboto Mono'" font-size="24" y="52" x="${totalWidth / 2}" fill="#313131">
|
||
${date} • ${title}
|
||
</text>
|
||
${rowsSvg}
|
||
</svg>`;
|
||
|
||
try {
|
||
const buffer = await sharp(Buffer.from(finalSvg))
|
||
.png()
|
||
.toBuffer();
|
||
|
||
return { buffer, fileName: `${title}_${date}.png`, isNotFull };
|
||
} catch (e) {
|
||
console.error('Sharp error:', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
module.exports = new Stands(); |