Skip to content

Commit

Permalink
Merge pull request #8 from STF-Webdev/saldo_upload
Browse files Browse the repository at this point in the history
Saldo upload
  • Loading branch information
backjonas authored Apr 8, 2024
2 parents a34a8c2 + 5c91937 commit 23a2096
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 93 deletions.
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
9 changes: 1 addition & 8 deletions backend/src/admin/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyWizardSession>
import { ContextWithScenes } from './scene.js'

const bot = new Composer<ContextWithScenes>()

Expand Down
186 changes: 161 additions & 25 deletions backend/src/admin/saldo.ts
Original file line number Diff line number Diff line change
@@ -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<ContextWithScenes>()

const exportCommand = bot.command('exportera', async (ctx) => {
const res = await exportTransactions()
Expand All @@ -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) => {
Expand All @@ -52,8 +48,148 @@ const saldoTemplateCommand = bot.command('saldo_template', async (ctx) => {
})
})

const saldoUploadScene = new Scenes.WizardScene<ContextWithScenes>(
'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,
])
12 changes: 12 additions & 0 deletions backend/src/admin/scene.ts
Original file line number Diff line number Diff line change
@@ -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<MyWizardSession>
2 changes: 1 addition & 1 deletion backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { number, z } from 'zod'
import { z } from 'zod'
import 'dotenv/config'

const processEnvSchema = z.object({
Expand Down
10 changes: 7 additions & 3 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
}

Expand All @@ -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)}`
Expand Down
14 changes: 9 additions & 5 deletions backend/src/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,28 @@ export const getBalanceForMember = async (userId: number) => {
export const exportTransactions = async (): Promise<
QueryResult<Transaction>
> => {
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<QueryResult<Transaction>> => {
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 () => {
Expand Down
20 changes: 18 additions & 2 deletions backend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ export const createCsv = (queryResult: QueryResult<any>) => {
.map((header) => {
return (String(row[header]) ?? '').replace(',', '')
})
.join(', ')
.join(',')
})
return `${headers.join(', ')}
return `${headers.join(',')}
${rows.join('\n')}`
}

Expand Down Expand Up @@ -68,3 +68,19 @@ export function formatButtonArray<T>(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}`
)
}
Loading

0 comments on commit 23a2096

Please sign in to comment.