diff --git a/backend/src/admin/index.ts b/backend/src/admin/index.ts index b010acd..40b3eb7 100644 --- a/backend/src/admin/index.ts +++ b/backend/src/admin/index.ts @@ -2,6 +2,7 @@ import { Composer } from 'telegraf' import { isChatMember } from '../index.js' import { config } from '../config.js' import saldoCommands from './saldo.js' +import productCommands from './product.js' const bot = new Composer() @@ -15,4 +16,20 @@ const adminMiddleware = bot.use(async (ctx, next) => { await next() }) -export default Composer.compose([adminMiddleware, saldoCommands]) +bot.command('admin', async (ctx) => { + const admin_message = + 'Följande admin kommandon existerar:\n' + + '/add_product För att lägga till en produkt\n' + + '/edit_product För att ändra en produkt\n' + + '/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' + return ctx.reply(admin_message) +}) + +export default Composer.compose([ + adminMiddleware, + saldoCommands, + productCommands, +]) diff --git a/backend/src/admin/product.ts b/backend/src/admin/product.ts new file mode 100644 index 0000000..d7efeba --- /dev/null +++ b/backend/src/admin/product.ts @@ -0,0 +1,385 @@ +//#region Imports & Init +import { Composer, Markup, Scenes } from 'telegraf' +import { + Product, + ProductIn, + addProduct, + deleteProduct, + editProduct, + getProductById, + 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 + +const bot = new Composer() + +export const productsToArray = async (): Promise => { + const productQuery = await getProducts() + return productQuery.rows.map(({ id, name, description, price_cents }) => { + return { + id, + name, + description, + price_cents, + } + }) as Product[] +} + +const skipButtonKeyboard = Markup.inlineKeyboard([ + Markup.button.callback('Skip (värdet uppdateras inte)', 'skip'), +]) + +//endregion + +//#region Delete +const deleteCommand = bot.command('delete_product', async (ctx) => { + const products = await productsToArray() + const priceList = products.map(({ description, price_cents }) => { + return `\n${description} - ${Number(price_cents) / -100}€` + }) + const keyboard_array = products.map(({ id, description }) => { + return Markup.button.callback( + description, + `delete_productname_${id}_${description}` + ) + }) + + const abortButton = Markup.button.callback( + 'Avbryt', + 'delete_productname_abort' + ) + + return ctx.reply(`Vilken produkt vill du ta bort?${priceList}`, { + ...Markup.inlineKeyboard( + formatButtonArray([...keyboard_array, abortButton]) + ), + }) +}) + +const deleteCommandFollowUp = bot.action( + /delete_productname_(\d*)_(.*)/, + async (ctx) => { + const productId = Number(ctx.match[1]) + const productDescription = ctx.match[2] + try { + const deletedRowsCount = (await deleteProduct(productId))['rowCount'] + if (deletedRowsCount > 1) { + console.log( + `${deletedRowsCount} rows were deleted, only 1 should have been.` + ) + } + console.log( + `Removed product with id ${productId} and description "${productDescription}"` + ) + return ctx.editMessageText( + `Raderingen av produkt "${productDescription}" lyckades!` + ) + } catch (e) { + console.log( + `Failed to remove product with id ${productId} and description "${productDescription}"`, + e + ) + return ctx.editMessageText( + `Raderingen av produkt ${productId} misslyckades! Klaga till nån!` + ) + } + } +) + +const deleteCommandAbort = bot.action( + 'delete_productname_abort', + async (ctx) => { + return ctx.editMessageText('Raderingen av produkter avbröts') + } +) +//#endregion + +//#region Add + +const addProductScene = new Scenes.WizardScene( + 'add_product_scene', + async (ctx) => { + ctx.reply('Produkt namn? (Blir automatisk små bokstäver)') + ctx.scene.session.newProduct = { + name: '', + description: '', + priceCents: '', + } + return ctx.wizard.next() + }, + async (ctx) => { + if (ctx.message && 'text' in ctx.message) { + ctx.scene.session.newProduct.name = ctx.message.text.toLowerCase() + ctx.reply('Produkt beskrivning?') + return ctx.wizard.next() + } else { + ctx.reply('Du måst skriva en text') + } + }, + async (ctx) => { + if (ctx.message && 'text' in ctx.message) { + ctx.scene.session.newProduct.description = ctx.message.text + ctx.reply('Produktens pris (i positiva cent)?') + return ctx.wizard.next() + } else { + ctx.reply('Du måst skriva en text') + } + }, + async (ctx) => { + if (ctx.message && 'text' in ctx.message) { + if (Number(ctx.message.text) < 0) { + return ctx.reply( + 'Priset måste vara positivt, det läggs sedan in i databasen som negativt!' + ) + } + ctx.scene.session.newProduct.priceCents = `-` + ctx.message.text + + const newProduct = ctx.scene.session.newProduct + confirmOrAbortReplyForAdd(ctx, newProduct) + return ctx.wizard.next() + } + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + const decision = ctx.callbackQuery.data + if (decision === 'confirm') { + const product = { + name: ctx.scene.session.newProduct.name, + description: ctx.scene.session.newProduct.description, + priceCents: ctx.scene.session.newProduct.priceCents, + } + try { + await addProduct(product) + console.log( + 'The following product has been added:\n' + + `name:"${ctx.scene.session.newProduct.name}"\n` + + `description:"${ctx.scene.session.newProduct.description}"\n` + + `priceCents:"${ctx.scene.session.newProduct.priceCents}"\n` + ) + ctx.reply('Produkten har lagts till!') + } catch (e) { + console.log( + 'The following product could not be added:\n' + + `name:"${ctx.scene.session.newProduct.name}"\n` + + `description:"${ctx.scene.session.newProduct.description}"\n` + + `priceCents:"${ctx.scene.session.newProduct.priceCents}"\n`, + e + ) + } + return ctx.scene.leave() + } else if (decision === 'abort') { + ctx.reply('Tillägning av produkt avbruten.') + return ctx.scene.leave() + } + } else { + ctx.reply('Du måst trycka på endera alternativ') + } + } +) + +function confirmOrAbortReplyForAdd(ctx: ContextWithScenes, product: ProductIn) { + ctx.reply( + `Följande product kommer att läggas till:\n` + + `\t${product.name}\n` + + `\t${product.description}\n` + + `\t${-product.priceCents}\n`, + { + ...confirmOrAbortButton, + } + ) +} + +//#endregion + +//#region Edit + +const editProductScene = new Scenes.WizardScene( + 'edit_product_scene', + async (ctx: ContextWithScenes) => { + const products = await productsToArray() + const priceList = products.map(({ description, price_cents }) => { + return `\n${description} - ${Number(price_cents) / -100}€` + }) + const keyboard_array = formatButtonArray( + products.map(({ id, description }) => { + return Markup.button.callback(description, String(id)) + }) + ) + + await ctx.reply(`Vilken produkt vill du editera? ${priceList}`, { + ...Markup.inlineKeyboard(keyboard_array), + }) + return ctx.wizard.next() + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + const productId = Number(ctx.callbackQuery.data) + ctx.scene.session.product = (await getProductById(productId)).rows[0] + ctx.reply( + `Produktens nya namn? Nu heter produkten "${ctx.scene.session.product.name}"`, + { + ...skipButtonKeyboard, + } + ) + return ctx.wizard.next() + } + ctx.reply('Välj en produkt från knapparna!') + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + if (ctx.callbackQuery.data === 'skip') { + ctx.reply( + `Produktens nya beskrivning? Nu har den beskrviningen "${ctx.scene.session.product.description}"`, + { + ...skipButtonKeyboard, + } + ) + return ctx.wizard.next() + } + } + if (ctx.message && 'text' in ctx.message) { + ctx.scene.session.product.name = ctx.message.text + ctx.reply( + `Produktens nya beskrivning? Nu har den beskrviningen "${ctx.scene.session.product.description}"`, + { + ...skipButtonKeyboard, + } + ) + return ctx.wizard.next() + } else { + ctx.reply('Du måst skriva en text') + } + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + if (ctx.callbackQuery.data === 'skip') { + ctx.reply( + `Produktens nya pris (i positiva cent)? Nu är det ${-ctx.scene.session + .product.price_cents}`, + { + ...skipButtonKeyboard, + } + ) + return ctx.wizard.next() + } + } + if (ctx.message && 'text' in ctx.message) { + ctx.scene.session.product.description = ctx.message.text + ctx.reply( + `Produktens nya pris (i positiva cent)? Nu är det ${-ctx.scene.session + .product.price_cents}`, + { + ...skipButtonKeyboard, + } + ) + return ctx.wizard.next() + } else { + ctx.reply('Du måst skriva en text') + } + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + if (ctx.callbackQuery.data === 'skip') { + const updatedProduct = ctx.scene.session.product + await confirmOrAbortButtonForEdit(ctx, updatedProduct) + + return ctx.wizard.next() + } + } else if (ctx.message && 'text' in ctx.message) { + if (Number(ctx.message.text) < 0) { + return ctx.reply( + 'Priset måste vara positivt, det läggs sedan in i databasen som negativt!' + ) + } + ctx.scene.session.product.price_cents = `-` + ctx.message.text + + const updatedProduct = ctx.scene.session.product + + await confirmOrAbortButtonForEdit(ctx, updatedProduct) + + return ctx.wizard.next() + } else { + ctx.reply('Du måst skriva en text') + } + }, + async (ctx) => { + if (ctx.callbackQuery && 'data' in ctx.callbackQuery) { + const decision = ctx.callbackQuery.data + if (decision === 'confirm') { + try { + await editProduct(ctx.scene.session.product) + console.log( + `Product "${ctx.scene.session.product.name}" with id ${ctx.scene.session.product.id} has been updated!` + ) + ctx.reply('Produkten har uppdaterats!') + } catch (e) { + ctx.reply('Produkten kunde inte uppdaterats!') + console.log( + `Product "${ctx.scene.session.product.name}" with id ${ctx.scene.session.product.id} could not be edited:`, + e + ) + } + return ctx.scene.leave() + } else if (decision === 'abort') { + ctx.reply('Uppdatering av produkten avbruten.') + return ctx.scene.leave() + } + } else { + ctx.reply('Du måst trycka på endera alternativ') + } + } +) + +async function confirmOrAbortButtonForEdit( + ctx: ContextWithScenes, + product: Product +) { + const originalProduct = (await getProductById(product.id)).rows[0] + ctx.reply( + `Följande product kommer att uppdateras:\n` + + `\t${originalProduct.name} --> ${product.name}\n` + + `\t${originalProduct.description} --> ${product.description}\n` + + `\t${-originalProduct.price_cents} --> ${-product.price_cents}\n`, + { + ...confirmOrAbortButton, + } + ) +} + +//#endregion + +//#region Misc & Export + +const confirmOrAbortButton = Markup.inlineKeyboard([ + Markup.button.callback('Godkänn', 'confirm'), + Markup.button.callback('Avbryt', 'abort'), +]) + +const stage = new Scenes.Stage([addProductScene, editProductScene]) +bot.use(stage.middleware()) + +const addCommand = bot.command('add_product', async (ctx) => { + await ctx.scene.enter('add_product_scene') +}) + +const editCommand = bot.command('edit_product', async (ctx) => { + await ctx.scene.enter('edit_product_scene') +}) + +export default Composer.compose([ + addCommand, + editCommand, + deleteCommand, + deleteCommandFollowUp, + deleteCommandAbort, +]) + +//#endregion diff --git a/backend/src/db.ts b/backend/src/db_setup.ts similarity index 72% rename from backend/src/db.ts rename to backend/src/db_setup.ts index c82d435..5028986 100644 --- a/backend/src/db.ts +++ b/backend/src/db_setup.ts @@ -18,3 +18,13 @@ await pool.query( ); ` ) + +await pool.query( + `CREATE TABLE IF NOT EXISTS products( + id SERIAL, + name TEXT, + description TEXT, + price_cents INTEGER + ); + ` +) diff --git a/backend/src/index.ts b/backend/src/index.ts index 811a00a..17c22ed 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,5 @@ -import { Context, Markup, Telegraf } from 'telegraf' +//#region Imports & Init +import { Context, Markup, Telegraf, session } from 'telegraf' import { config } from './config.js' import { exportTransactionsForOneUser, @@ -6,19 +7,25 @@ import { purchaseItemForMember, } from './transactions.js' import { Message, Update } from '@telegraf/types' -import { formatDateToString, centsToEuroString } from './utils.js' import adminCommands from './admin/index.js' +import { ContextWithScenes, productsToArray } from './admin/product.js' +import productCommands from './admin/product.js' +import { + centsToEuroString, + formatButtonArray, + formatDateToString, + formatName, +} from './utils.js' /* Toiveiden tynnyri: -- Admin interface, lägg till/ta bort produkter, lägg till/ta bort saldo +- Admin interface, lägg till/ta bort saldo - Skamlistan, posta alla med negativt i chatten med en @ - En 'vapaa myynti' command med description och summa - "Undo" funktionalitet -- Transaktionshistorik för användare */ -const bot = new Telegraf(config.botToken) +const bot = new Telegraf(config.botToken) const info_message = `Hej, välkommen till STF spik bot! Här kan du köra köp och kolla ditt saldo. @@ -44,34 +51,19 @@ bot.use(async (ctx, next) => { await next() }) -const products = [ - { - command: 'patron', - description: 'Patron', - priceCents: '-1200', - }, - { - command: 'kalja', - description: 'Öl', - priceCents: '-150', - }, - { - command: 'cigarr', - description: 'Cigarr', - priceCents: '-600', - }, - { - command: 'cognac', - description: 'Cognac', - priceCents: '-200', - }, - { - command: 'snaps', - description: 'Snaps', - priceCents: '-200', - }, -] +bot.use(session()) +// addAdminCommands(bot) + +// bot.use(admin middleware) +// bot.command... +bot.use(productCommands) +//endregion + +//#region Products +const products = await productsToArray() +//endregion +//#region Buying from commands const addPurchaseOption = (itemDescription: string, itemPriceCents: string) => { return async ( ctx: Context<{ @@ -102,10 +94,14 @@ const addPurchaseOption = (itemDescription: string, itemPriceCents: string) => { } } -products.forEach(({ command, description, priceCents }) => { - bot.command(command, addPurchaseOption(description, priceCents)) +products.forEach(({ name, description, price_cents }) => { + bot.command(name, addPurchaseOption(description, price_cents)) }) +//endregion + +//#region Buying inline + const addPurchaseOptionFromInline = ( itemDescription: string, itemPriceCents: string @@ -143,13 +139,14 @@ const addPurchaseOptionFromInline = ( } } -bot.command('meny', (ctx) => { - const priceList = products.map(({ command, description, priceCents }) => { - return `\n${description} - ${Number(priceCents) / -100}€` +bot.command('meny', async (ctx) => { + const products = await productsToArray() + const priceList = products.map(({ description, price_cents }) => { + return `\n${description} - ${Number(price_cents) / -100}€` }) const keyboard_array = formatButtonArray( - products.map(({ command, description }) => { - return Markup.button.callback(description, command) + products.map(({ name, description }) => { + return Markup.button.callback(description, name) }) ) @@ -158,22 +155,13 @@ bot.command('meny', (ctx) => { }) }) -products.forEach(({ command, description, priceCents }) => { - bot.action(command, addPurchaseOptionFromInline(description, priceCents)) +products.forEach(({ name, description, price_cents }) => { + bot.action(name, addPurchaseOptionFromInline(description, price_cents)) }) -bot.command('saldo', async (ctx) => { - const balance = await getBalanceForMember(ctx.from.id) - return ctx.reply(`Ditt saldo är ${balance}€`) -}) +//endregion -bot.command('info', async (ctx) => { - return ctx.reply(info_message) -}) - -bot.command('start', async (ctx) => { - return ctx.reply(info_message) -}) +//#region History bot.command('historia', async (ctx) => { const history = await exportTransactionsForOneUser(ctx.from.id) @@ -201,11 +189,28 @@ bot.command('historia', async (ctx) => { return ctx.reply(res, { parse_mode: 'Markdown' }) }) +//endregion + +//#region Misc commands + +bot.command('saldo', async (ctx) => { + const balance = await getBalanceForMember(ctx.from.id) + return ctx.reply(`Ditt saldo är ${balance}€`) +}) + +bot.command('info', async (ctx) => { + return ctx.reply(info_message) +}) + +bot.command('start', async (ctx) => { + return ctx.reply(info_message) +}) + bot.telegram.setMyCommands([ - ...products.map(({ command, description, priceCents }) => ({ - command, + ...products.map(({ name, description, price_cents }) => ({ + command: name, description: `Köp 1 st ${description} för ${( - Number(priceCents) / -100 + Number(price_cents) / -100 ).toFixed(2)}€`, })), { command: 'saldo', description: 'Kontrollera saldo' }, @@ -217,6 +222,10 @@ bot.telegram.setMyCommands([ // Admin middleware is used for all commands added after this line! bot.use(adminCommands) +//endregion + +//#region Launch bot & misc + bot.launch() // Enable graceful stop @@ -234,29 +243,4 @@ export const isChatMember = async (userId: number, chatId: number) => { } } -const formatName = ({ - first_name, - last_name, - username, -}: { - first_name: string - last_name?: string - username?: string -}) => { - const formattedLastName = last_name ? ` ${last_name}` : `` - const formattedUserName = username ? ` (${username})` : `` - return `${first_name}${formattedLastName}${formattedUserName}` -} - -/** - * Splits a array into an array of arrays with max n elements per subarray - * - * n defaults to 3 - */ -function formatButtonArray(array: any[], n: number = 3): any[][] { - const result = [] - for (let i = 0; i < array.length; i += n) { - result.push(array.slice(i, i + n)) - } - return result -} +//endregion diff --git a/backend/src/products.ts b/backend/src/products.ts new file mode 100644 index 0000000..fa7d16f --- /dev/null +++ b/backend/src/products.ts @@ -0,0 +1,66 @@ +import { pool } from './db_setup.js' +import { QueryResult } from 'pg' + +export interface ProductIn { + name: string + description: string + priceCents: string +} + +export interface Product { + id: number + name: string + description: string + price_cents: string +} + +export const addProduct = async ({ + name, + description, + priceCents: amountCents, +}: ProductIn): Promise => { + await pool.query( + `INSERT INTO products( + name, + description, + price_cents + ) VALUES ( + $1, $2, $3 + )`, + [name, description, amountCents] + ) +} + +export const deleteProduct = async (id: number): Promise => { + return await pool.query( + `DELETE FROM products + where id = $1`, + [id] + ) +} + +export const editProduct = async ({ + id, + name, + description, + price_cents: amountCents, +}: Product): Promise => { + await pool.query( + `UPDATE products + SET name = $2, + description = $3, + price_cents = $4 + WHERE id = $1;`, + [id, name, description, amountCents] + ) +} + +export const getProducts = async (): Promise> => { + return await pool.query(`SELECT * FROM products`) +} + +export const getProductById = async ( + id: number +): Promise> => { + return await pool.query(`SELECT * FROM products WHERE id = $1;`, [id]) +} diff --git a/backend/src/transactions.ts b/backend/src/transactions.ts index 79bf4e3..5a390fd 100644 --- a/backend/src/transactions.ts +++ b/backend/src/transactions.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { pool } from './db.js' +import { pool } from './db_setup.js' import { QueryResult } from 'pg' export interface Transaction { diff --git a/backend/src/utils.ts b/backend/src/utils.ts index cb196fe..9145e97 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -33,3 +33,28 @@ export const centsToEuroString = (amountInCents: number): string => { } return euro + '€' } +export const formatName = ({ + first_name, + last_name, + username, +}: { + first_name: string + last_name?: string + username?: string +}) => { + const formattedLastName = last_name ? ` ${last_name}` : `` + const formattedUserName = username ? ` (${username})` : `` + return `${first_name}${formattedLastName}${formattedUserName}` +} +/** + * Splits a array into an array of arrays with max n elements per subarray + * + * n defaults to 3 + */ +export function formatButtonArray(array: T[], n: number = 3): T[][] { + const result = [] + for (let i = 0; i < array.length; i += n) { + result.push(array.slice(i, i + n)) + } + return result +}