From 321d8ee6504823128daaa4f2ce8fe5313e8723b6 Mon Sep 17 00:00:00 2001 From: christiansegercrantz <44305721+christiansegercrantz@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:06:44 +0200 Subject: [PATCH] Add user transaction history (#4) * Added transaction history * Changed to cum_sum calc in SQL * Added limit of transactions * Changed formatting of table * Fixed/undid async and typo errors * Remove cli-table * Fix PR comments * Remov command interface * Fix formatting --- .devcontainer/devcontainer.json | 3 +- backend/src/index.ts | 111 ++++++++++++++++++++++++++++++-- backend/src/transactions.ts | 33 +++++++++- package-lock.json | 50 +++++++++++++- 4 files changed, 186 insertions(+), 11 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a274963..1fedd14 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,8 @@ "esbenp.prettier-vscode", "ms-azuretools.vscode-docker", "eamodio.gitlens", - "ms-azuretools.vscode-docker" + "ms-azuretools.vscode-docker", + "qufiwefefwoyn.inline-sql-syntax" ] }, "settings": { diff --git a/backend/src/index.ts b/backend/src/index.ts index 7991af1..3c708e0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,10 +2,12 @@ import { Context, Telegraf } from 'telegraf' import { config } from './config.js' import { exportTransactions, + exportTransactionsForOneUser, getBalanceForMember, purchaseItemForMember, } from './transactions.js' import { Message, Update } from '@telegraf/types' +import { QueryResult } from 'pg' /* Toiveiden tynnyri: @@ -71,7 +73,12 @@ const addPurchaseOption = (itemDescription: string, itemPriceCents: string) => { } } } -const commands = [ + +const products: { + command: string + description: string + priceCents: string +}[] = [ { command: 'patron', description: 'Patron', @@ -98,7 +105,7 @@ const commands = [ priceCents: '-200', }, ] -commands.forEach(({ command, description, priceCents }) => { +products.forEach(({ command, description, priceCents }) => { bot.command(command, addPurchaseOption(description, priceCents)) }) @@ -115,11 +122,69 @@ bot.command('start', async (ctx) => { return ctx.reply(info_message) }) +bot.command('historia', async (ctx) => { + const history = await exportTransactionsForOneUser(ctx.from.id) + + const parsedHistory = history.rows.map( + ({ created_at, description, amount_cents }) => { + return { + created_at, + description, + amount_cents, + } + } + ) + const saldo = await getBalanceForMember(ctx.from.id) + var res = `Ditt nuvarande saldo är ${saldo}. Här är din historia:\`\`\`` + parsedHistory.forEach((row) => { + res += + `\n${formatDateToString( + row.created_at + )} ${row.created_at.toLocaleTimeString('sv-fi')}, ` + + `${centsToEuroString(-row.amount_cents)}, ` + + `${row.description}` + }) + res += '```' + return ctx.reply(res, { parse_mode: 'Markdown' }) +}) + +bot.command('historia_all', async (ctx) => { + if (!(await isAdminUser(ctx))) { + return ctx.reply('Nå huhhu, håll dig ti ditt egna dåkande!') + } + 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 + )} ${row.created_at.toLocaleTimeString('sv-fi')}, ` + + `${centsToEuroString(-row.amount_cents)}, ` + + `${row.description}` + }) + res += '```' + return ctx.reply(res, { parse_mode: 'Markdown' }) +}) + bot.command('exportera', async (ctx) => { - if (!(await isChatMember(ctx.from.id, config.adminChatId))) { + if (!(await isAdminUser(ctx))) { return ctx.reply('sii dej i reven, pleb!') } - const res = await exportTransactions() + // Temp solution until I or Bäck fixes the error this produces + const res: QueryResult = await exportTransactions() const headers = res.fields.map((field) => field.name) const rows = res.rows.map((row) => { return headers @@ -137,7 +202,7 @@ bot.command('exportera', async (ctx) => { }) bot.telegram.setMyCommands([ - ...commands.map(({ command, description, priceCents }) => ({ + ...products.map(({ command, description, priceCents }) => ({ command, description: `Köp 1 st ${description} för ${( Number(priceCents) / -100 @@ -145,6 +210,7 @@ bot.telegram.setMyCommands([ })), { command: 'saldo', description: 'Kontrollera saldo' }, { command: 'info', description: 'Visar information om bottens användning' }, + { command: 'historia', description: 'Se din egna transaktionshistorik' }, ]) bot.launch() @@ -177,3 +243,38 @@ const formatName = ({ const formattedUserName = username ? ` (${username})` : `` return `${first_name}${formattedLastName}${formattedUserName}` } +/** + * Formats from number of cent to a string in euro. I.e. -350 becomes "-3.5€" + * @param amountInCents + * @returns + */ +function centsToEuroString(amountInCents: number): string { + var euro = (amountInCents / -100).toFixed(2).toString() + if (euro[0] !== '-') { + euro = ' ' + euro + } + return euro + '€' +} + +/** + * Checks if the user is in the admin chat + * @param ctx + * @returns + */ +const isAdminUser = async ( + ctx: Context<{ + message: Update.New & Update.NonChannel & Message.TextMessage + update_id: number + }> & + Omit, keyof Context> +) => { + const res: Promise = isChatMember(ctx.from.id, config.adminChatId) + return res +} +function formatDateToString(date: Date) { + return `${date.toLocaleDateString('sv-fi', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + })} ${date.toLocaleDateString('sv-fi', { weekday: 'short' })}` +} diff --git a/backend/src/transactions.ts b/backend/src/transactions.ts index a2c6a5e..8ae6f6a 100644 --- a/backend/src/transactions.ts +++ b/backend/src/transactions.ts @@ -1,7 +1,17 @@ import { z } from 'zod' import { pool } from './db.js' +import { QueryResult } from 'pg' -interface Transaction { +export interface Transaction { + id: number + created_at: Date + userId: number + user_name: string + description: string + amount_cents: number +} + +export interface TransactionInsert { userId: number userName: string description: string @@ -13,7 +23,7 @@ export const purchaseItemForMember = async ({ userName, description, amountCents, -}: Transaction) => { +}: TransactionInsert) => { await pool.query( `INSERT INTO transactions( user_id, @@ -42,11 +52,28 @@ export const getBalanceForMember = async (userId: number) => { } } -export const exportTransactions = async () => { +export const exportTransactions = async (): Promise< + QueryResult +> => { const res = await pool.query(`SELECT * FROM transactions`) return res } +export const exportTransactionsForOneUser = async ( + userId: number +): Promise> => { + const res = await pool.query( + `--sql + SELECT * + FROM transactions + WHERE user_id = $1 + ORDER BY id DESC + LIMIT 30`, + [userId] + ) + return res +} + const BalanceResponseSchema = z .array( z.object({ diff --git a/package-lock.json b/package-lock.json index 119debc..3e39ad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "dotenv": "^16.3.1", "pg": "^8.11.3", "telegraf": "^4.14.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "cli-table": "^0.3.11" }, "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" @@ -41,6 +43,12 @@ "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", @@ -156,6 +164,25 @@ "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", @@ -689,6 +716,12 @@ "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", @@ -785,6 +818,19 @@ "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", @@ -1178,4 +1224,4 @@ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" } } -} +} \ No newline at end of file