Додані повідомлення та перепрацьована структура застосунку та api

This commit is contained in:
2026-03-15 00:25:10 +02:00
parent 85483b85bb
commit 4bc9c11512
101 changed files with 5763 additions and 2546 deletions

View File

@@ -8,4 +8,8 @@ RUN npm install
COPY . .
COPY ./fonts/ /usr/share/fonts/truetype/roboto/
RUN fc-cache -f -v
CMD npm start

View File

@@ -1,13 +1,57 @@
const cron = require("node-cron");
const Backup = require("./tasks/backup");
const Stand = require("./tasks/stands");
const Messages = require('./tasks/messages');
const Rept = require('./tasks/rept');
// Завдання: резервна копія БД кожен день в 22:30
// 1. Резервна копія БД щодня о 22:35
cron.schedule("30 22 * * *", () => {
Backup.database();
const now = new Date().toLocaleString();
console.log(`[${now}] Завдання «Backup» виконане!`);
console.log(`[${new Date().toLocaleString()}] Завдання «Backup» виконане!`);
});
// 2. Перевірка стендів без графіку щосуботи о 18:00
cron.schedule("0 18 * * 6", async () => {
console.log(`[${new Date().toLocaleString()}] Запуск перевірки стендів без графіку...`);
try {
await Stand.check_add();
console.log(`[${new Date().toLocaleString()}] Завдання «Stand check_add» виконане!`);
} catch (err) {
console.error("Помилка Stand check_add:", err.message);
}
});
// 3. Відправка графіку стендів щодня о 18:00
cron.schedule("0 18 * * *", async () => {
console.log(`[${new Date().toLocaleString()}] Запуск відправки графіків...`);
try {
await Stand.check_entries();
console.log(`[${new Date().toLocaleString()}] Завдання «Stand check_entries» виконане!`);
} catch (err) {
console.error("Помилка Stand check_entries:", err.message);
}
});
// 4. Очищення старих повідомлень щодня о 17:59
cron.schedule('59 17 * * *', async () => {
console.log(`[${new Date().toLocaleString()}] Запуск очищення старих повідомлень...`);
try {
await Messages.cleanup_old();
console.log(`[${new Date().toLocaleString()}] Завдання «Messages cleanup_old» виконане успішно!`);
} catch (err) {
console.error(`[${new Date().toLocaleString()}] Помилка Messages cleanup_old:`, err.message);
}
});
// 5. Нагадування про звіт щомісяця о 10:00
cron.schedule('00 10 1 * *', async () => {
console.log(`[${new Date().toLocaleString()}] Запуск відправки повідомлень...`);
try {
await Rept.send_notification();
console.log(`[${new Date().toLocaleString()}] Завдання Rept send_notification виконане успішно!`);
} catch (err) {
console.error(`[${new Date().toLocaleString()}] Помилка Rept send_notification:`, err.message);
}
});
console.log("Cron-завдання запущено.");

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2026
cron/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@
"license": "ISC",
"description": "",
"dependencies": {
"node-cron": "^4.2.1",
"sqlite3": "^5.1.7",
"web-push": "^3.6.7",
"@aws-sdk/client-s3": "^3.1004.0",
"dotenv": "^17.2.0",
"node-telegram-bot-api": "^0.66.0"
"node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"sharp": "^0.34.5",
"sqlite3": "^5.1.7",
"web-push": "^3.6.7"
}
}

View File

@@ -1,6 +1,7 @@
const fs = require("fs");
const path = require("path");
const TelegramBot = require("node-telegram-bot-api");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const TOKEN = process.env.TELEGRAM_TOKEN;
const CHAT_ID = process.env.CHAT_ID;
@@ -9,6 +10,16 @@ const FILE = path.join(DB_PATH, "database.sqlite");
const bot = new TelegramBot(TOKEN, { polling: false });
// Настройки S3 / R2
const s3Client = new S3Client({
region: "auto",
endpoint: process.env.S3_ENDPOINT, // Напр: https://<id>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
class Backup {
async database() {
try {
@@ -17,13 +28,25 @@ class Backup {
return;
}
console.log(`📤 Надсилаю файл: ${FILE}`);
const fileName = `backup_${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
// 1. Отправка в Telegram
await bot.sendDocument(CHAT_ID, fs.createReadStream(FILE), {
caption: "📦 Резервна копія бази даних",
caption: `📦 Резервна копія бази даних`,
});
console.log("✅ Telegram: успішно надіслано");
console.log("✅ Файл успішно надіслано!");
// 2. Отправка в S3 (R2)
const fileBuffer = fs.readFileSync(FILE);
const uploadParams = {
Bucket: process.env.S3_BUCKET_NAME,
Key: `sheep-service.com/database/${fileName}`, // Путь внутри бакета
Body: fileBuffer,
ContentType: "application/x-sqlite3",
};
await s3Client.send(new PutObjectCommand(uploadParams));
console.log("✅ S3: успішно завантажено в хмару");
} catch (err) {
console.error("❌ Помилка при надсиланні файлу:", err.message);
}

55
cron/tasks/messages.js Normal file
View File

@@ -0,0 +1,55 @@
const db = require("../config/db");
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 Messages {
async cleanup_old() {
try {
// "Зараз мінус 24 години"
const oneDayAgo = Date.now() - (24 * 60 * 55 * 1000);
// 1. Отримуємо повідомлення, які старші за добу
const oldMessages = await dbAll(
`SELECT last_message_id FROM sent_messages WHERE created_at < ?`,
[oneDayAgo]
);
if (!oldMessages || oldMessages.length === 0) {
console.log('🧹 Застарілих повідомлень не знайдено.');
return;
}
console.log(`🧹 Знайдено ${oldMessages.length} повідомлень для видалення...`);
// 2. Видаляємо повідомлення з Telegram по черзі
for (const msg of oldMessages) {
try {
await bot.deleteMessage(STAND_CHAT_ID, msg.last_message_id);
await new Promise(resolve => setTimeout(resolve, 50)); // пауза 50мс
} catch (e) {
// Код помилки 400 зазвичай означає, що повідомлення вже видалено вручну
// або минуло понад 48 годин
console.log(`⚠️ Повідомлення ${msg.last_message_id} не знайдено в Telegram або застаре.`);
}
}
// 3. Видаляємо записи з бази даних одним махом
await dbRun(`DELETE FROM sent_messages WHERE created_at < ?`, [oneDayAgo]);
console.log(`✅ Очистка завершена.`);
} catch (err) {
console.error('❌ Помилка під час виконання cleanup_old:', err.message);
}
}
}
module.exports = new Messages();

28
cron/tasks/rept.js Normal file
View File

@@ -0,0 +1,28 @@
const Notification = require("../utils/notification");
class Rept {
async send_notification() {
let text = [
'Час підбивати підсумки! Не забудьте здати свій звіт про служіння за минулий місяць 📋',
'Нагадуємо про щомісячний звіт. Будь ласка, надішліть його сьогодні 😊',
'Кінець місяця вже тут, а це значить — пора здавати звіти про служіння 🕒',
'Ваш звіт дуже важливий! Не забудьте поділитися результатами служіння за цей місяць.',
'Маленьке нагадування: пора заповнити звіт про служіння за місяць. Дякуємо за вашу працю!',
'Ще не здали звіт? Саме час це зробити! 😉',
'Звітність — це порядок. Будь ласка, надішліть дані про своє служіння за минулий місяць.',
'Місяць завершився, тож не забудьте прозвітувати про ваші успіхи в служінні 📝',
'Чекаємо на ваш звіт! Це допоможе нам мати загальну картину нашого спільного служіння.',
'Пора здавати звіти! Дякуємо кожному за активність у цьому місяці ✨',
'Не відкладайте на потім — здайте звіт про служіння вже зараз 📋'
];
let randomMessage = text[Math.floor(Math.random() * text.length)];
Notification.sendAll({
title: "Звіт про служіння",
body: randomMessage,
page: `/`
});
}
}
module.exports = new Rept();

260
cron/tasks/stands.js Normal file
View File

@@ -0,0 +1,260 @@
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();

View File

@@ -11,6 +11,44 @@ webpush.setVapidDetails(
);
class Notification {
async sendAll({ title, body, page }) {
const sql = `
SELECT * FROM subscription
ORDER BY id
`;
db.all(sql, async (err, rows) => {
if (err) {
console.error('DB error:', err.message);
return;
}
if (!rows.length) {
console.log(`🐑 No subscriptions`);
return;
}
console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
const payload = JSON.stringify({
title: title ?? "Тестове повідомлення",
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
url: `https://${process.env.DOMAIN}${page ?? ""}`
});
const results = await Promise.allSettled(rows.map(row => {
const subscription = {
endpoint: row.endpoint,
keys: JSON.parse(row.keys),
};
return webpush.sendNotification(subscription, payload);
}));
const failed = results.filter(r => r.status === 'rejected').length;
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
});
}
async sendSheep({ sheep_id, title, body, page }) {
const sql = `
SELECT * FROM subscription
@@ -73,7 +111,7 @@ class Notification {
}
if (!rows.length) {
console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
console.log(`🐑 No subscriptions found for group_id: ${group_id}`);
return;
}
@@ -98,7 +136,7 @@ class Notification {
});
}
async sendStand({ title, body, page }) {
async sendStandAdd({title, body, page }) {
const sql = `
SELECT
subscription.*
@@ -111,7 +149,7 @@ class Notification {
possibilities
ON possibilities.sheep_id = sheeps.id
WHERE
possibilities.can_view_stand = '1'
possibilities.can_add_stand = '1'
ORDER BY
subscription.id;
`;
@@ -147,6 +185,22 @@ class Notification {
console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
});
}
async sendEndpoint({ endpoint, keys, title, body, page }) {
const payload = JSON.stringify({
title: title ?? "Тестове повідомлення",
body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
url: `https://${process.env.DOMAIN}${page ?? ""}`
});
const subscription = {
endpoint,
keys: typeof keys === "string" ? JSON.parse(keys) : keys
};
await webpush.sendNotification(subscription, payload);
console.log("✅ Push sent");
}
};
module.exports = new Notification();