diff --git a/backend/package.json b/backend/package.json index 8d9c9d8..e20a72a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "typescript": "^5.2.2" }, "dependencies": { + "csv-parse": "^5.5.5", "dotenv": "^16.3.1", "pg": "^8.11.3", "telegraf": "^4.14.0", diff --git a/backend/src/admin/index.ts b/backend/src/admin/index.ts index 40b3eb7..f435e88 100644 --- a/backend/src/admin/index.ts +++ b/backend/src/admin/index.ts @@ -24,7 +24,7 @@ bot.command('admin', async (ctx) => { '/delete_product För att ta bort en produkt\n' + '/exportera CSV-dump av alla transaktioner\n' + '/historia_all Se de senaste händelserna för alla användare\n' + - '/saldo_template Exportera template för att manuellt ändra på användares saldo\n' + '/saldo_upload För att lägga till transaktioner manuellt\n' return ctx.reply(admin_message) }) diff --git a/backend/src/admin/product.ts b/backend/src/admin/product.ts index d7efeba..582361f 100644 --- a/backend/src/admin/product.ts +++ b/backend/src/admin/product.ts @@ -10,14 +10,7 @@ import { getProducts, } from '../products.js' import { formatButtonArray } from '../utils.js' - -interface MyWizardSession extends Scenes.WizardSessionData { - // available in scene context under ctx.scene.session - newProduct: ProductIn - product: Product -} - -export type ContextWithScenes = Scenes.WizardContext +import { ContextWithScenes } from './scene.js' const bot = new Composer() diff --git a/backend/src/admin/saldo.ts b/backend/src/admin/saldo.ts index 2ff9258..bc637ae 100644 --- a/backend/src/admin/saldo.ts +++ b/backend/src/admin/saldo.ts @@ -1,13 +1,15 @@ -import { Composer } from 'telegraf' -import { isChatMember } from '../index.js' +import { Composer, Markup, Scenes } from 'telegraf' +import { parse } from 'csv-parse/sync' import { exportTransactions, exportTransactionTemplate, + purchaseItemForMember, } from '../transactions.js' -import { createCsv, formatDateToString, centsToEuroString } from '../utils.js' +import { createCsv, formatTransaction } from '../utils.js' import { config } from '../config.js' +import { ContextWithScenes } from './scene.js' -const bot = new Composer() +const bot = new Composer() const exportCommand = bot.command('exportera', async (ctx) => { const res = await exportTransactions() @@ -21,27 +23,21 @@ const exportCommand = bot.command('exportera', async (ctx) => { const allHistoryCommand = bot.command('historia_all', async (ctx) => { const history = await exportTransactions() - const parsedHistory = history.rows.map( - ({ user_name, created_at, description, amount_cents }) => { - return { - user_name, - created_at, - description, - amount_cents, - } - } - ) - - var res = `\`\`\`` - parsedHistory.forEach((row) => { - res += - `\n${row.user_name.split(' ').slice(0, -1).join(' ')}, ` + - `${formatDateToString(row.created_at, true)}, ` + - `${centsToEuroString(-row.amount_cents)}, ` + - `${row.description}` - }) - res += '```' - return ctx.reply(res, { parse_mode: 'Markdown' }) + const historyString = + '```' + + history.rows + .map((row) => + formatTransaction( + row.user_name, + row.description, + row.amount_cents, + row.created_at + ) + ) + .join('') + + '```' + + return ctx.reply(historyString, { parse_mode: 'Markdown' }) }) const saldoTemplateCommand = bot.command('saldo_template', async (ctx) => { @@ -52,8 +48,148 @@ const saldoTemplateCommand = bot.command('saldo_template', async (ctx) => { }) }) +const saldoUploadScene = new Scenes.WizardScene( + 'saldo_upload_scene', + async (ctx) => { + ctx.reply( + `Skicka en csv fil med transaktioner du vill lägga till. +Laddningen kan avbrytas med /exit. +En csv template för transaktioner kan skapas med /saldo_template. + +Saldo templaten kan antingen editeras rakt i en text editor eller importeras till Excel (eller motsvarande). +Ifall excel används, se till att exportera med "," eller ";" som delimiter. + +Från templaten behöver endast kolumnerna "amount_cents" och "description" ändras. +En transaktion skapas för varje rad i csv-filen, ta alltså bort rader där summan hålls som 0. + +"amount_cents" är storleken på transaktionen i cent. +Observera att en inbetalning ska ha ett positiv tal och att en kostnad ska ha ett negativt tal! + +Ifall någon person saknas från csv templaten kan user ID:s hittas genom att öppna en chat med personen i telegram web. +Siffran i slutet av URLen (efter #) är user ID:n. +ID:n kan vara positiv eller negativ, ta alltså också med "-" från URL:en om ett sånt finns. +User ID:n fungerar som primary key, kom alltså ihåg att ändra den om du manuellt lägger till fler rader till csv:n! +` + ) + return ctx.wizard.next() + }, + async (ctx) => { + if (ctx.message !== undefined && 'document' in ctx.message) { + const document = ctx.message.document + if (document.mime_type !== 'text/csv') { + return ctx.reply('Botten tar bara emot csv-filer.') + } + + const { file_path } = await ctx.telegram.getFile(document.file_id) + + if (file_path === undefined) { + return ctx.reply('Filen kunde inte laddas.') + } + + const res = await fetch( + `https://api.telegram.org/file/bot${config.botToken}/${file_path}` + ) + const fileContent = await res.text() + + if (!fileContent.includes(';') && !fileContent.includes(',')) { + return ctx.reply( + 'Filen du laddade upp hade fel format. Delimitern bör vara ";" eller ","' + ) + } + + const delimiter = fileContent.includes(';') ? ';' : ',' + const parsedContent = parse(fileContent, { + delimiter, + from: 1, + }) as string[][] + + // Ensure that the csv file contains the correct headers + const headers = parsedContent.shift() + if ( + headers?.length !== 4 || + headers[0] !== 'user_id' || + headers[1] !== 'user_name' || + headers[2] !== 'description' || + headers[3] !== 'amount_cents' + ) { + return ctx.reply( + 'Filen du laddade upp hade fel format. Filens kolumner bör vara "user_id, user_name, description, amount_cents" ' + ) + } + + try { + ctx.scene.session.transactions = parsedContent.map((row) => { + return { + userId: Number(row[0]), + userName: row[1], + description: row[2], + amountCents: row[3], + } + }) + } catch (error) { + console.log('Error loading transactions:', error) + ctx.reply( + `Något gick fel när dokumentet laddades. Inga transaktioner har lagts till. + ${error}` + ) + } + + const transactions = ctx.scene.session.transactions + transactions.forEach( + (t) => (t.description = `Manuell transaktion: ${t.description}`) + ) + + const confirmationMessage = + 'Följande transaktioner kommer läggas till:\n```' + + transactions + .map((t) => + formatTransaction(t.userName, t.description, Number(t.amountCents)) + ) + .join('') + + '```' + ctx.reply(confirmationMessage, { + parse_mode: 'Markdown', + ...Markup.inlineKeyboard([ + Markup.button.callback('Godkänn', 'confirm'), + Markup.button.callback('Avbryt', 'abort'), + ]), + }) + } + return ctx.wizard.next() + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + if (ctx.callbackQuery.data === 'confirm') { + const transactions = ctx.scene.session.transactions + for (const t of transactions) { + await purchaseItemForMember(t) + } + ctx.scene.leave() + return ctx.reply(`Saldoladdningen lyckades med en insättning av ${transactions.length} nya transaktioner. +Använd /historia_all eller /exportera för att inspektera de nya transaktionerna.`) + } else { + ctx.scene.leave() + return ctx.reply('Saldoladdningen avbröts') + } + } + } +) + +saldoUploadScene.command('exit', async (ctx) => { + ctx.reply('Saldouppladningen avbröts') + return ctx.scene.leave() +}) + +const stage = new Scenes.Stage([saldoUploadScene]) +bot.use(stage.middleware()) + +const saldoUploadCommand = bot.command('saldo_upload', async (ctx) => { + await ctx.scene.enter('saldo_upload_scene') +}) + export default Composer.compose([ exportCommand, allHistoryCommand, saldoTemplateCommand, + saldoUploadCommand, ]) diff --git a/backend/src/admin/scene.ts b/backend/src/admin/scene.ts new file mode 100644 index 0000000..867bfd9 --- /dev/null +++ b/backend/src/admin/scene.ts @@ -0,0 +1,12 @@ +import { Scenes } from 'telegraf' +import { Product, ProductIn } from '../products.js' +import { TransactionInsert } from '../transactions.js' + +interface MyWizardSession extends Scenes.WizardSessionData { + // available in scene context under ctx.scene.session + newProduct: ProductIn + product: Product + transactions: TransactionInsert[] +} + +export type ContextWithScenes = Scenes.WizardContext diff --git a/backend/src/config.ts b/backend/src/config.ts index 0c12d1c..f09d724 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,4 +1,4 @@ -import { number, z } from 'zod' +import { z } from 'zod' import 'dotenv/config' const processEnvSchema = z.object({ diff --git a/backend/src/index.ts b/backend/src/index.ts index f6d9a6a..2ad7b44 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,7 +9,8 @@ import { } from './transactions.js' import { Message, Update } from '@telegraf/types' import adminCommands from './admin/index.js' -import { ContextWithScenes, productsToArray } from './admin/product.js' +import { productsToArray } from './admin/product.js' +import { ContextWithScenes } from './admin/scene.js' import productCommands from './admin/product.js' import { centsToEuroString, @@ -197,7 +198,10 @@ bot.command('undo', async (ctx) => { const latestTransaction = queryResult.rows[0] const description = latestTransaction.description - if (description.includes('_undo') || description.includes('Manuell_')) { + if ( + description.endsWith('_undo') || + description.startsWith('Manuell transaktion: ') + ) { return ctx.reply('Din senaste händelse är redan ångrad') } @@ -215,7 +219,7 @@ bot.command('undo', async (ctx) => { await purchaseItemForMember(productUndone) const message = - 'Följande transaction har ångrats: \n' + + 'Följande transaktion har ångrats: \n' + `\t\tTid: ${formatDateToString(latestTransaction.created_at, true)}\n` + `\t\tProdukt: ${latestTransaction.description}\n` + `\t\tPris: ${centsToEuroString(latestTransaction.amount_cents)}` diff --git a/backend/src/transactions.ts b/backend/src/transactions.ts index d8f48ad..7cd0ea5 100644 --- a/backend/src/transactions.ts +++ b/backend/src/transactions.ts @@ -55,24 +55,28 @@ export const getBalanceForMember = async (userId: number) => { export const exportTransactions = async (): Promise< QueryResult > => { - const res = await pool.query(`SELECT * FROM transactions`) - return res + return await pool.query( + `--sql + SELECT * + FROM transactions + ORDER BY created_at DESC + LIMIT 30` + ) } export const exportTransactionsForOneUser = async ( userId: number, transactionCount: number ): Promise> => { - const res = await pool.query( + return await pool.query( `--sql SELECT * FROM transactions WHERE user_id = $1 - ORDER BY id DESC + ORDER BY created_at DESC LIMIT $2`, [userId, transactionCount] ) - return res } export const exportTransactionTemplate = async () => { diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 60bdd38..2b63271 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -7,9 +7,9 @@ export const createCsv = (queryResult: QueryResult) => { .map((header) => { return (String(row[header]) ?? '').replace(',', '') }) - .join(', ') + .join(',') }) - return `${headers.join(', ')} + return `${headers.join(',')} ${rows.join('\n')}` } @@ -68,3 +68,19 @@ export function formatButtonArray(array: T[], n: number = 3): T[][] { } return result } + +export const formatTransaction = ( + user_name: string, + description: string, + amount_cents: number, + created_at?: Date +) => { + const timeString = + created_at !== undefined ? `${formatDateToString(created_at, true)}, ` : '' + return ( + `\n${user_name.split(' ').slice(0, -1).join(' ')}, ` + + timeString + + `${centsToEuroString(-amount_cents)}, ` + + `${description}` + ) +} diff --git a/package-lock.json b/package-lock.json index 3e39ad4..bc1c216 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,16 +18,15 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "csv-parse": "^5.5.5", "dotenv": "^16.3.1", "pg": "^8.11.3", "telegraf": "^4.14.0", - "zod": "^3.22.4", - "cli-table": "^0.3.11" + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20.8.4", "@types/pg": "^8.10.5", - "@types/cli-table": "^0.3.4", "prettier": "3.0.3", "tsc-watch": "^6.0.4", "typescript": "^5.2.2" @@ -43,12 +42,6 @@ "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-6.9.1.tgz", "integrity": "sha512-bzqwhicZq401T0e09tu8b1KvGfJObPmzKU/iKCT5V466AsAZZWQrBYQ5edbmD1VZuHLEwopoOVY5wPP4HaLtug==" }, - "node_modules/@types/cli-table": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.4.tgz", - "integrity": "sha512-GsALrTL69mlwbAw/MHF1IPTadSLZQnsxe7a80G8l4inN/iEXCOcVeT/S7aRc6hbhqzL9qZ314kHPDQnQ3ev+HA==", - "dev": true - }, "node_modules/@types/node": { "version": "20.8.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz", @@ -164,25 +157,6 @@ "node": ">=4" } }, - "node_modules/cli-table": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", - "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", - "dependencies": { - "colors": "1.0.3" - }, - "engines": { - "node": ">= 0.2.0" - } - }, - "node_modules/colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -197,6 +171,11 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.5.tgz", + "integrity": "sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -716,12 +695,6 @@ "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-6.9.1.tgz", "integrity": "sha512-bzqwhicZq401T0e09tu8b1KvGfJObPmzKU/iKCT5V466AsAZZWQrBYQ5edbmD1VZuHLEwopoOVY5wPP4HaLtug==" }, - "@types/cli-table": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.4.tgz", - "integrity": "sha512-GsALrTL69mlwbAw/MHF1IPTadSLZQnsxe7a80G8l4inN/iEXCOcVeT/S7aRc6hbhqzL9qZ314kHPDQnQ3ev+HA==", - "dev": true - }, "@types/node": { "version": "20.8.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz", @@ -818,19 +791,6 @@ "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" }, - "cli-table": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", - "integrity": "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ==", - "requires": { - "colors": "1.0.3" - } - }, - "colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==" - }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -842,6 +802,11 @@ "which": "^2.0.1" } }, + "csv-parse": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.5.tgz", + "integrity": "sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -1099,6 +1064,7 @@ "requires": { "@types/node": "^20.8.4", "@types/pg": "^8.10.5", + "csv-parse": "^5.5.5", "dotenv": "^16.3.1", "pg": "^8.11.3", "prettier": "3.0.3", @@ -1224,4 +1190,4 @@ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" } } -} \ No newline at end of file +}