diff --git a/README.md b/README.md index 4b4f304..7542cf0 100644 --- a/README.md +++ b/README.md @@ -99,22 +99,28 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down | Variable | Required | Default | Description | | --- | :---: | --- | --- | | `BOT_TOKEN` | Yes | — | Telegram token issued by BotFather. | -| `POSTGRES_PASSWORD` | Recommended | `telebot` | Password for the `telebot` database user. | +| `POSTGRES_PASSWORD` | No | `telebot` | Password for the `telebot` database user. Set a strong value outside local development. | | `BACKEND_PORT` | No | `3000` | Backend port exposed on the host. | -| `BACKEND_URL` | No | `http://backend:3000` | API URL used by the bot outside the standard Compose setup. | +| `BACKEND_URL` | No | `http://localhost:3000` outside Compose | API URL used by the bot. Both Compose files set it to `http://backend:3000`. | | `ADMIN_PANEL_PORT` | No | `3001` | Admin panel port exposed on the host. | | `ADMINER_PORT` | No | `8080` | Adminer port exposed on the host. | -| `VITE_BACKEND_URL` | No | `http://localhost:3000` | Browser-visible API URL baked into the admin build. | -| `ALLOWED_USERS` | No | Empty | Comma-separated Telegram user IDs allowed by backend rules. | -| `ADMIN_USERS` | No | Empty | Comma-separated Telegram user IDs with admin status. | -| `CHAT_ID` | No | — | Legacy fallback Telegram chat ID. | -| `BILLS_THREAD_ID` | No | — | Legacy fallback topic for new bills. | -| `PAID_THREAD_ID` | No | — | Legacy fallback topic for payment confirmations. | +| `VITE_BACKEND_URL` | No | `http://localhost:3000` in development | Browser-visible API URL compiled into a development admin build. | +| `ALLOWED_USERS` | No | Empty (allow all new users) | Comma-separated Telegram IDs used to set `is_allowed` when a user is first registered. | +| `ADMIN_USERS` | No | Empty | Comma-separated Telegram IDs used to set `is_admin` when a user is first registered. | +| `CHAT_ID` | No | — | Legacy chat ID consumed by the unused `constant_payment` helper. | +| `BILLS_THREAD_ID` | No | — | Fallback topic for new bills when the selected installation has no configured topic. | +| `PAID_THREAD_ID` | No | — | Fallback topic for payment confirmations when the selected installation has no configured topic. | | `LOG` | No | `false` | Enables additional logging when set to `true`. | `VITE_BACKEND_URL` is used by the visitor's browser, so it must be reachable from that browser. A Docker-internal hostname such as `backend` will not work -for users outside the Compose network. +for users outside the Compose network. Vite replaces this value at build time, +not when the container starts. The development Compose overlay supplies it to +the Vite build; the prebuilt production image uses the value with which that +image was published. + +`ALLOWED_USERS` and `ADMIN_USERS` do not overwrite existing database records on +restart. Change an existing user's flags from the admin panel or API. ## Bot Commands @@ -128,6 +134,7 @@ for users outside the Compose network. | `/delete` | Delete a registered bill. | | `/cancel` | Cancel the current operation. | | `/help` | Show the available commands. | +| `/ia` | Display the bot's informational AI placeholder message. | To create a bill, a user must first be seen in a group or channel where the bot is present. The flow then asks for the target chat, bill details, amount, and @@ -276,9 +283,11 @@ The schema is defined in `backend/src/db/schema.sql` and contains: - `bot_installation_topics` - `bot_installation_users` -The backend applies the schema during startup. The development setup stores -PostgreSQL data in the `postgres_data` Docker volume. To discard all local -development data and rebuild the database, run: +The backend applies the idempotent schema during every startup. PostgreSQL also +runs the mounted schema automatically when it initializes an empty data +directory. The development setup stores PostgreSQL data in the `postgres_data` +Docker volume. To discard all local development data and rebuild the database, +run: ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml down -v @@ -293,33 +302,36 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ## Run Services Without Docker -Use Node.js 20 or newer and start PostgreSQL separately. Set `DATABASE_URL` for -the backend and use separate terminals for each service. +Use Node.js 20.19 or newer (or Node.js 22.12 or newer) and start PostgreSQL +separately. Set `DATABASE_URL` for the backend and use separate terminals for +each service. Run `npm ci` in each directory for a reproducible install. ```bash # Backend cd backend -npm install +npm ci DATABASE_URL=postgres://telebot:password@localhost:5432/telebot npm run dev ``` ```bash # Telegram bot cd tele_bot -npm install +npm ci BOT_TOKEN=your_token BACKEND_URL=http://localhost:3000 npm run dev ``` ```bash # Admin panel cd admin_panel -npm install +npm ci VITE_BACKEND_URL=http://localhost:3000 npm run dev ``` -## Production +## Deployment with Compose -The production Compose file uses images published to GitHub Container Registry: +The base Compose file uses prebuilt images from GitHub Container Registry. The +current image tags are `:dev`, despite the services running with +`NODE_ENV=production`: ```bash docker compose pull @@ -328,6 +340,11 @@ docker compose ps ``` The admin image is built with Vite and served by nginx on container port `80`. +Its backend URL is fixed when the image is built; setting `VITE_BACKEND_URL` on +the running production container does not change it. The PostgreSQL service +bind-mounts `/Kojo/Docker/tele_bot_node/postgres`, so adjust that host path in +`docker-compose.yml` before deploying on a different machine. + To inspect a service, use `docker compose logs -f backend`, `tele_bot`, or `admin_panel`. diff --git a/backend/src/repositories/agenda.repository.js b/backend/src/repositories/agenda.repository.js index 3e0ae38..aad7cac 100644 --- a/backend/src/repositories/agenda.repository.js +++ b/backend/src/repositories/agenda.repository.js @@ -44,6 +44,10 @@ function uniqueByTelegramId(users) { return uniqueUsers; } +/** + * @param {Object} data - The data to resolve the responsible users. + * @return {Array|null} The responsible users or null. + */ function explicitResponsibleUsers(data) { const explicit = data.responsibleUserIds || data.responsibleTelegramIds || data.members; @@ -65,6 +69,10 @@ function explicitResponsibleUsers(data) { return null; } +/** + * @param {number|string} value - The value to parse as money. + * @return {number} The parsed money. + */ function parseMoney(value) { if (typeof value === "number") { return value; @@ -79,6 +87,10 @@ function parseMoney(value) { return Number.isFinite(amount) ? amount : 0; } +/** + * @param {number|string} value - The value to parse as due date. + * @return {string} The parsed due date. + */ function parseDueDate(value) { const text = String(value ?? ""); const brazilianDate = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); @@ -90,6 +102,10 @@ function parseDueDate(value) { return text; } +/** + * @param {Object} row - The row to map the agenda. + * @return {Object} The mapped agenda. + */ function mapAgenda(row) { if (!row) { return row; @@ -123,6 +139,11 @@ function mapAgenda(row) { }; } +/** + * @param {Object} data - The data to build the update. + * @param {Object} columnMap - The column map. + * @return {Object|null} The build update. + */ function buildUpdate(data, columnMap) { const entries = Object.entries(data) .filter(([field]) => @@ -154,6 +175,12 @@ function buildUpdate(data, columnMap) { }; } +/** + * @param {Object} db - The database client. + * @param {Object} data - The data to resolve the responsible users. + * @param {Object} creator - The creator user. + * @return {Promise} The resolved responsible users. + */ async function resolveResponsibleUsers(db, data, creator) { const explicitUsers = explicitResponsibleUsers(data); if (explicitUsers) { @@ -203,6 +230,12 @@ async function resolveResponsibleUsers(db, data, creator) { return creator ? [creator] : []; } +/** + * @param {Object} db - The database client. + * @param {number} telegramUserId - The Telegram user id. + * @param {number} chatId - The chat id. + * @return {Promise} True if the user belongs to the chat, false otherwise. + */ async function userBelongsToBotInstallationChat(db, telegramUserId, chatId) { const result = await db.query( `SELECT 1 @@ -220,6 +253,12 @@ async function userBelongsToBotInstallationChat(db, telegramUserId, chatId) { return result.rowCount > 0; } +/** + * @param {Object} db - The database client. + * @param {number} telegramChatId - The Telegram chat id. + * @param {Array} users - The users to filter. + * @return {Promise} The filtered users. + */ async function filterUsersByBotInstallationChatId(db, telegramChatId, users) { const telegramIds = uniqueByTelegramId(users).map((user) => Number(user.telegram_id) @@ -248,6 +287,11 @@ async function filterUsersByBotInstallationChatId(db, telegramChatId, users) { return result.rows; } +/** + * @param {Object} db - The database client. + * @param {Object} data - The data to resolve the bank. + * @return {Promise} The resolved bank or null. + */ async function resolveBank(db, data) { if (data.bankId) { const result = await db.query( @@ -274,6 +318,11 @@ async function resolveBank(db, data) { return null; } +/** + * @param {Object} db - The database client. + * @param {number} id - The id of the agenda. + * @return {Promise} The agenda row or null. + */ async function getAgendaRow(db, id) { const result = await db.query( `SELECT @@ -317,6 +366,7 @@ async function getAgendaRow(db, id) { * Inserts a new agenda payment. * @param {Object} data - The data to insert. * @param {Function} next - Callback called with the inserted agenda or false. + * @return {Promise} The inserted agenda or false. */ async function insetAgendaPayment(data, next) { const db = await getClient(); @@ -441,7 +491,7 @@ async function insetAgendaPayment(data, next) { /** * Retrieves unpaid agenda payments by responsible Telegram user. * @param {number} senderId - The Telegram user id to search for. - * @return {Promise} Matching agenda payments or false. + * @return {Promise} The matching agenda payments or false. */ async function getAllAgendaPaymentBySender(senderId) { try { diff --git a/backend/src/repositories/users.repository.js b/backend/src/repositories/users.repository.js index bb0ccc7..96a93ac 100644 --- a/backend/src/repositories/users.repository.js +++ b/backend/src/repositories/users.repository.js @@ -2,12 +2,9 @@ import { getClient, query } from "../db/postgres.js"; function telegramDisplayName(user = {}) { return user.displayName || - user.display_name || - [user.firstName || user.first_name, user.lastName || user.last_name].filter( - Boolean, - ).join(" ") || + [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username || - String(user.telegramId || user.telegram_id || user.senderId || ""); + String(user.firstName || ""); } function userPayloadFromData(data = {}) { diff --git a/backend/src/services/agenda.service.js b/backend/src/services/agenda.service.js index 5b74ded..effbb5a 100644 --- a/backend/src/services/agenda.service.js +++ b/backend/src/services/agenda.service.js @@ -7,30 +7,55 @@ import { updateAgendaPayment, } from "../repositories/agenda.repository.js"; +/** + * @param {Object} data - The data to create the agenda payment. + * @return {Promise} The created agenda payment. + */ function createAgendaPayment(data) { return new Promise((resolve) => { insetAgendaPayment(data, resolve); }); } +/** + * @return {Promise} The all agenda payments. + */ function getAllAgendaPayments() { return new Promise((resolve) => { getAllAgendaPayment(resolve); }); } +/** + * @param {number} telegramId - The Telegram user id. + * @return {Promise} The agenda payments by user. + */ async function getAgendaPaymentsByUser(telegramId) { return getAllAgendaPaymentBySender(telegramId); } +/** + * @param {number} id - The id of the agenda payment. + * @return {Promise} The agenda payment by id. + */ async function getAgendaPayment(id) { return getAgendaPaymentById(id); } +/** + * @param {number} id - The id of the agenda payment. + * @param {Object} data - The data to update the agenda payment. + * @param {number|null} telegramId - The Telegram user id. + * @return {Promise} The updated agenda payment. + */ async function updateAgenda(id, data, telegramId = null) { return updateAgendaPayment(id, data, telegramId); } +/** + * @param {number} id - The id of the agenda payment. + * @return {Promise} The deleted agenda payment. + */ async function deleteAgenda(id) { return deleteAgendaPayment(id); } diff --git a/tele_bot/src/agendas/agenda_payment.js b/tele_bot/src/agendas/agenda_payment.js index b0e2bea..998631d 100644 --- a/tele_bot/src/agendas/agenda_payment.js +++ b/tele_bot/src/agendas/agenda_payment.js @@ -1,4 +1,8 @@ import moment from "moment"; +import agendaFormatter from "../utilities/agenda_formatter.js"; +import getMemberName from "../utilities/get_member_name.js"; +import generateBankKeyboard from "../utilities/generate_banks_keyboard.js"; +import getUserPixBySenderBankAsKeyboard from "../utilities/get_all_pix_as_keyboard.js"; import { getBotInstallation, getBotInstallationUsers, @@ -6,11 +10,7 @@ import { insetAgendaPayment, insetPix, } from "../api/backendClient.js"; -import agendaFormatter, { - getAgendaMemberDisplayName, -} from "../utilities/agenda_formatter.js"; -import generateBankKeyboard from "../utilities/generate_banks_keyboard.js"; -import getUserPixBySenderBankAsKeyboard from "../utilities/get_all_pix_as_keyboard.js"; + function parseCurrency(value) { if (typeof value === "number") { @@ -263,7 +263,7 @@ class AgendaPayment { } memberLabel(member) { - return getAgendaMemberDisplayName(member); + return getMemberName(member); } generateMembersKeyboard() { @@ -420,6 +420,8 @@ class AgendaPayment { ); return true; } + console.log(isInseted); + const paymentSummary = agendaFormatter(isInseted); // Send the summary message this.bot.sendMessage(isInseted.chatId, paymentSummary, { diff --git a/tele_bot/src/agendas/paid.js b/tele_bot/src/agendas/paid.js index a6cb66b..b07b7c8 100644 --- a/tele_bot/src/agendas/paid.js +++ b/tele_bot/src/agendas/paid.js @@ -1,12 +1,9 @@ -// const allAgendaAsKeyboard = require("../utilities/all_agenda_as_keyboard"); -// const agendaFormatter = require("../utilities/agenda_formatter"); - +import allAgendaAsKeyboard from "../utilities/all_agenda_as_keyboard.js"; +import agendaFormatter from "../utilities/agenda_formatter.js"; import { getBotInstallation, updateAgendaPayment, } from "../api/backendClient.js"; -import allAgendaAsKeyboard from "../utilities/all_agenda_as_keyboard.js"; -import agendaFormatter from "../utilities/agenda_formatter.js"; class paid { constructor(bot) { diff --git a/tele_bot/src/agendas/someone_paid.js b/tele_bot/src/agendas/someone_paid.js index 502fad4..4e73fbd 100644 --- a/tele_bot/src/agendas/someone_paid.js +++ b/tele_bot/src/agendas/someone_paid.js @@ -1,16 +1,11 @@ -// const {updateAgendaPayment, getAgendaPaymentById } = require('../api/backendClient.js'); -// const agendaFormatter = require('../utilities/agenda_formatter.js'); -// const allAgendaAsKeyboard = require('../utilities/all_agenda_as_keyboard.js'); - +import agendaFormatter from "../utilities/agenda_formatter.js"; +import allAgendaAsKeyboard from "../utilities/all_agenda_as_keyboard.js"; +import getMemberName from "../utilities/get_member_name.js"; import { getAgendaPaymentById, getBotInstallation, updateAgendaPayment, } from "../api/backendClient.js"; -import agendaFormatter, { - getAgendaMemberDisplayName, -} from "../utilities/agenda_formatter.js"; -import allAgendaAsKeyboard from "../utilities/all_agenda_as_keyboard.js"; class SomeonePaid { /** @@ -76,7 +71,7 @@ class SomeonePaid { const memberButtons = this.members .filter((member) => member.isResponsible !== false) .map((member) => [{ - text: `${getAgendaMemberDisplayName(member)} ${ + text: `${getMemberName(member)} ${ member.isPaid ? "✅" : "❌" }`, callback_data: `member_${member.telegramId}`, diff --git a/tele_bot/src/server.js b/tele_bot/src/server.js index a14676b..9ab061b 100644 --- a/tele_bot/src/server.js +++ b/tele_bot/src/server.js @@ -5,6 +5,8 @@ import SchedulesEveryday from "./schedules/schedules_everyday.js"; import Paid from "./agendas/paid.js"; import Pix from "./agendas/add_pix_to_db.js"; import DeleteAgenda from "./agendas/delete_agenda.js"; +import { registerTelegramUserAndMembership } from "./utilities/registerTelegramUserAndMembership.js"; +import { handleUserMessage } from "./handlers/handleUserMessage.js"; import { agendaUsersState, deleteAgendaState, @@ -12,8 +14,6 @@ import { paidState, pixState, } from "./state/memoryState.js"; -import { registerTelegramUserAndMembership } from "./utilities/registerTelegramUserAndMembership.js"; -import { handleUserMessage } from "./handlers/handleUserMessage.js"; /** * Creates a new instance of the bot. @@ -26,6 +26,12 @@ new SchedulesEveryday(bot); const trackedRegistrationMessages = new Set(); +/** + * Generates a unique key for a Telegram registration message. + * + * @param {Object} msg - The Telegram message object. + * @returns {string} The generated key. + */ function registrationKey(msg = {}) { return [ msg.chat?.id || "no-chat", @@ -34,6 +40,11 @@ function registrationKey(msg = {}) { ].join(":"); } +/** + * Registers a Telegram user and their membership in a bot installation. + * + * @param {Object} msg - The Telegram message object. + */ function trackTelegramUserAndMembership(msg) { const key = registrationKey(msg); if (trackedRegistrationMessages.has(key)) { diff --git a/tele_bot/src/utilities/agenda_formatter.js b/tele_bot/src/utilities/agenda_formatter.js index 780ffec..0a960ce 100644 --- a/tele_bot/src/utilities/agenda_formatter.js +++ b/tele_bot/src/utilities/agenda_formatter.js @@ -1,61 +1,10 @@ -function cleanText(value) { - const text = String(value ?? "").trim(); - return text && text !== "null" && text !== "undefined" ? text : null; -} - -function getAgendaMemberDisplayName(member = {}) { - const username = cleanText( - member.username || - member.telegramUsername || - member.user?.username || - member.user?.telegramUsername, - ); - - const firstName = cleanText( - member.firstName || - member.first_name || - member.user?.firstName || - member.user?.first_name, - ); - - const lastName = cleanText( - member.lastName || - member.last_name || - member.user?.lastName || - member.user?.last_name, - ); - - const displayName = cleanText( - member.displayName || - member.display_name || - member.user?.displayName || - member.user?.display_name, - ); - - if (displayName) { - return displayName; - } - - const fullName = [firstName, lastName].filter(Boolean).join(" ").trim(); - if (fullName) { - return fullName; - } - - if (username) { - return username.startsWith("@") ? username : `@${username}`; - } - - return String( - member.telegramId || - member.telegram_id || - member.user?.telegramId || - member.user?.telegram_id || - member.user_id || - member.id || - "Unknown", - ); -} +import getMemberName from "./get_member_name"; +/** + * Checks if a member has paid. + * @param {Object} member - The member object. + * @returns {boolean} True if the member has paid, false otherwise. + */ function memberPaid(member = {}) { return Boolean( member.isPaid || @@ -70,13 +19,13 @@ function memberPaid(member = {}) { * @param {Object} agenda - The agenda object containing the payment information. * @returns {string} The formatted string. */ -function agendaFormatter(agenda) { +export default function agendaFormatter(agenda) { const members = Array.isArray(agenda.members) ? agenda.members : []; const memberLines = members.length > 0 ? members .filter((member) => member.isResponsible !== false) .map((member) => - `${getAgendaMemberDisplayName(member)} ${memberPaid(member) ? " ✅" : " ❌"}` + `${getMemberName(member)} ${memberPaid(member) ? " ✅" : " ❌"}` ) .join(" \n") : `Pago ${agenda.isPaid ? " ✅" : " ❌"}`; @@ -90,8 +39,4 @@ function agendaFormatter(agenda) { `--------------------------------\n` + `${memberLines}\n` + `--------------------------------\n\n`; -} - -export { getAgendaMemberDisplayName }; - -export default agendaFormatter; +} \ No newline at end of file diff --git a/tele_bot/src/utilities/get_member_name.js b/tele_bot/src/utilities/get_member_name.js new file mode 100644 index 0000000..2f2601f --- /dev/null +++ b/tele_bot/src/utilities/get_member_name.js @@ -0,0 +1,68 @@ +/** + * Clean text + * @param {*} value + * @returns + */ +function cleanText(value) { + const text = String(value ?? "").trim(); + return text && text !== "null" && text !== "undefined" ? text : null; +} + +/** + * Get member name + * @param {Object} member + * @returns {string} member name + */ +export default function getMemberName(member = {}) { + comsole.log(member); + const username = cleanText( + member.username || + member.telegramUsername || + member.user?.username || + member.user?.telegramUsername, + ); + + const firstName = cleanText( + member.firstName || + member.first_name || + member.user?.firstName || + member.user?.first_name, + ); + + const lastName = cleanText( + member.lastName || + member.last_name || + member.user?.lastName || + member.user?.last_name, + ); + + const displayName = cleanText( + member.displayName || + member.display_name || + member.user?.displayName || + member.user?.display_name, + ); + + if (displayName) { + return displayName; + } + + const fullName = [firstName, lastName].filter(Boolean).join(" ").trim(); + if (fullName) { + return fullName; + } + + if (username) { + return username.startsWith("@") ? username : `@${username}`; + } + + return String( + member.telegramId || + member.telegram_id || + member.user?.telegramId || + member.user?.telegram_id || + member.user_id || + member.id || + "Unknown", + ); +} \ No newline at end of file diff --git a/tele_bot/src/utilities/registerTelegramUserAndMembership.js b/tele_bot/src/utilities/registerTelegramUserAndMembership.js index f1dc45a..fba841c 100644 --- a/tele_bot/src/utilities/registerTelegramUserAndMembership.js +++ b/tele_bot/src/utilities/registerTelegramUserAndMembership.js @@ -1,6 +1,11 @@ import { upsertBotInstallationUser, upsertUser } from "../api/backendClient.js"; import { registerBotInstallation } from "./registerBotInstallation.js"; +/** + * Registers a Telegram user and their membership in a bot installation. + * @param {Message} msg - The Telegram message object. + * @returns {Object} An object containing the registered installation, user, and membership. + */ async function registerTelegramUserAndMembership(msg) { const installation = await registerBotInstallation(msg); const telegramUser = msg?.from;