diff --git a/src/components/UserHistory.svelte b/src/components/UserHistory.svelte new file mode 100644 index 0000000000000000000000000000000000000000..6ff874d7fc7988dc73e9336d9322acec60e8ba99 --- /dev/null +++ b/src/components/UserHistory.svelte @@ -0,0 +1,98 @@ +<script> + import ItemIcon from "./ItemIcon.svelte"; + import IntersectionObserver from "svelte-intersection-observer"; + import { onMount } from "svelte"; + import * as devalue from "devalue"; + + export let initialData, userId; + let transactions = initialData.transactions || []; + let hasMoreTransactions = initialData.hasMore ?? true; + let isFetching = false; + + function hasScrollBar() { + const rootElement = document.getElementById("main"); + return rootElement.scrollHeight > rootElement.clientHeight; + } + + function fetchMoreTransactions(){ + if(!hasMoreTransactions || isFetching) return; + isFetching = true; + fetch("/api/transactions?" + new URLSearchParams({ + userId, + before: transactions[transactions.length-1].createdAt.getTime(), + balance: transactions[transactions.length-1].balance-transactions[transactions.length-1].difference + }), { + method: "GET" + }).then(r=>r.text()).then(devalue.parse).then(({transactions: prevTransactions, hasMore})=>{ + if(prevTransactions.length > 0) transactions = [...transactions, ...prevTransactions]; + hasMoreTransactions = hasMore; + isFetching = false; + if(!hasScrollBar()) fetchMoreTransactions(); + }); + } + + onMount(()=>hasScrollBar()||fetchMoreTransactions()); + + let observerElement; +</script> + +<style> + .gain { + color: green; + } + .loss { + color: red; + } + table { + width: 100%; + margin-bottom: .5rem; + margin-top: 1rem; + } + tr:nth-child(1) { + position: sticky; + top: 0; + z-index: 10; + } + td:not(:nth-child(2)) { + text-align: right; + } + td:nth-child(1) { + display: flex; + justify-content: center; + align-items: center; + } + th > span, td > span { + display: inline-block; + padding: 0 1.5em; + } +</style> + +<!-- TODO add way to navigate back --> + +<table> + <tr> + <th></th> + <th><span>Aktion</span></th> + <th><span>Preis</span></th> + <th><span>Guthaben</span></th> + <th><span>Datum</span></th> + </tr> + {#each transactions as transaction} + <tr> + <td>{#if transaction.type==="item"}<ItemIcon icon={transaction.image} size="40px" name={transaction.name} />{/if}</td> + <td><span>{transaction.name}</span></td> + <td class="{transaction.difference > 0 ? "gain" : transaction.difference < 0 ? "loss" : ""}"><span>{#if transaction.difference>0}+{/if}{(transaction.difference/100).toFixed(2)}€</span></td> <!-- TODO show premium on item transcations --> + <td><span>{(transaction.balance/100).toFixed(2)}€</span></td> + <td><span>{transaction.createdAt.toLocaleString()}</span></td> + </tr> + {/each} +</table> +{#if transactions.length === 0} + <p>Keine Transaktionen gefunden.</p> +{:else} + <IntersectionObserver element={observerElement} rootMargin="100px 0px 0px 0px" on:intersect={fetchMoreTransactions}> + <div bind:this={observerElement} style="margin-bottom: 50px;height:1em;{hasMoreTransactions ? "" : "visibility: hidden;"}"> + {#if isFetching}Lade weitere Transaktionen...{/if} + </div> + </IntersectionObserver> +{/if} diff --git a/src/routes/api/[slug]/+server.js b/src/routes/api/[slug]/+server.js index c49ead2686aff7320b627bab8d1876c3a8a67fc6..d8362ed8896e6c0fffb0dd3c1ff4b492065ad02d 100644 --- a/src/routes/api/[slug]/+server.js +++ b/src/routes/api/[slug]/+server.js @@ -1,7 +1,8 @@ +import * as devalue from "devalue"; import { getPriceModifier } from "../../admin/api/articles"; import { Flags } from "$lib/flags"; import { getArticles, getBuyableVouchers } from "../articles"; -import { buyArticles, transferMoney, useVoucher } from "../transactions"; +import { buyArticles, fetchTransactions, transferMoney, useVoucher } from "../transactions"; import { getUserById } from "../users"; import { Codes } from "$lib/customcodes"; import { AllOrNothingRateLimiter, SlidingWindowRateLimiter } from "../../../lib/ratelimit"; @@ -112,7 +113,30 @@ export async function GET(req){ switch(req.params.slug){ case "items": return getArticles().then(articles => new Response(JSON.stringify(articles), {status: 200})); + case "transactions": { + try { + const userId = parseInt(req.url.searchParams.get("userId")); + const balance = parseInt(req.url.searchParams.get("balance")); + const before = parseInt(req.url.searchParams.get("before")); + if (isNaN(userId) || isNaN(balance) || isNaN(before)) { + return new Response(JSON.stringify({ message: "Invalid fields" }), { status: 400 }); + } + if(userId !== req.locals.user.id && !(req.locals.user.perms & Flags.ADMIN)){ + return new Response(JSON.stringify({ message: "You can't view other users' transactions" }), { status: 403 }); + } + try { + const transactions = await fetchTransactions(userId, balance, before, 100); + // use devalue because JSON.stringify/JSON.parse doesn't really support Date + return new Response(devalue.stringify(transactions), { status: 200 }); + } catch (error) { + console.error(error); + return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500 }); + } + } catch (e) { + return new Response(JSON.stringify({ message: "Invalid data" }), { headers: { 'content-type': 'application/json', 'status': 400 } }); + } + } default: - return ""; + return new Response("Unknown endpoint", {status: 400}); } } diff --git a/src/routes/api/transactions.js b/src/routes/api/transactions.js index 878835565ae31f64e1ca314520dfc9ed4e443012..850ec9015f396a111a6ec621949e2a36dd3ced30 100644 --- a/src/routes/api/transactions.js +++ b/src/routes/api/transactions.js @@ -127,3 +127,63 @@ export async function getUsedVouchers(usedById, before=Date.now(), limit=100) { orderBy: { usedAt: "desc" } }); } + +// TODO somehow fix this +// this skips transactions if `before` is the same as the createdAt date of the previous transaction +// e.g. you could sort by id instead of createdAt and provide the last id of each transaction type as +// replacement for `before` +export async function fetchTransactions(userId, balanceAfterwards, before, limit) { + /* + For every type of transaction that changes the user's balance the limit of transactions to return + is used. This means at most we fetch 4*limit transactions from the database. + Since we don't know how many transactions of each type are within the last [limit] transactions + we have to fetch all of them and then sort them by date. Afterwards we only take the first [limit] + transactions. We can't take more than [limit] transactions because we don't know if we have fetched + the correct next transaction. + + (That is not entirely true. We could return transactions until we have [limit] of one type. We + could do that, but I don't think it's worth it and it would mean that the function gets a + "limit" parameter and returns more than [limit] transactions. I'd rather have data be fetched + multiple times but have the function behave as expected.) + */ + /* + [limit]+1 items are fetched because then we can check if there are more items to fetch. + */ + const moneyTransactions = (await getMoneyTransactions(userId, before, limit+1)).map(transaction => ({ + type: "transaction", + name: transaction.amount > 0 ? "Aufladen" : "Auszahlung", + difference: transaction.amount, + createdAt: transaction.createdAt, + verified: !!transaction.verifiedById + })); + const itemTransactions = (await getItemTransactions(userId, before, limit+1)).map(transaction => ({ + type: "item", + name: transaction.item.name, + difference: -(transaction.price + transaction.premium), + premium: transaction.premium, + createdAt: transaction.createdAt, + image: transaction.item.image, + })); + const moneyTransfers = (await getMoneyTransfers(userId, before, limit+1)).map(transfer => ({ + type: "transfer", + name: transfer.fromId === userId ? `Gesendet an ${transfer.to.name}` : `Empfangen von ${transfer.from.name}`, + difference: transfer.fromId === userId ? -transfer.amount : transfer.amount, + createdAt: transfer.createdAt + })); + const voucherUses = (await getUsedVouchers(userId, before, limit+1)).map(voucher => ({ + type: "voucher", + name: `Gutschein ${voucher.code} eingelöst`, + difference: voucher.amount, + createdAt: voucher.usedAt // use usedAt because voucher creation time is not relevant + })); + let transactions = [...moneyTransactions, ...itemTransactions, ...moneyTransfers, ...voucherUses] + .sort((a, b) => b.createdAt - a.createdAt); + const hasMore = transactions.length > limit; + if(transactions.length > limit) transactions = transactions.filter((_,i)=>i<limit); + if (transactions.length > 0) { + transactions[0].balance = balanceAfterwards; + for (let i = 1; i < transactions.length; i++) + transactions[i].balance = transactions[i - 1].balance - transactions[i - 1].difference; + } + return {transactions, hasMore}; +} diff --git a/src/routes/history/+page.server.js b/src/routes/history/+page.server.js index 1aeb8cc01610d13fdf9971e5e1947c4ad758f013..6a1fe6a7e9ff92b2fb1fe46b5eca8cee275b3b24 100644 --- a/src/routes/history/+page.server.js +++ b/src/routes/history/+page.server.js @@ -1,76 +1,8 @@ -import { getMoneyTransactions, getItemTransactions, getMoneyTransfers, getUsedVouchers } from "../api/transactions"; - -async function fetchData(userId, balanceAfterwards, before, limit) { - /* - For every type of transaction that changes the user's balance the limit of transactions to return - is used. This means at most we fetch 3*limit transactions from the database. - Since we don't know how many transactions of each type are within the last [limit] transactions - we have to fetch all of them and then sort them by date. Afterwards we only take the first [limit] - transactions. We can't take more than [limit] transactions because we don't know if we have fetched - the correct next transaction. - - (That is not entirely true. We could return transactions until we have [limit] of one type. We - could do that, but I don't think it's worth it and it would mean that the function gets a - "limit" parameter and returns more than [limit] transactions. I'd rather have data be fetched - multiple times but have the function behave as expected.) - */ - /* - [limit]+1 items are fetched because then we can check if there are more items to fetch. - */ - const moneyTransactions = (await getMoneyTransactions(userId, before, limit+1)).map(transaction => ({ - type: "transaction", - name: transaction.amount > 0 ? "Aufladen" : "Auszahlung", - difference: transaction.amount, - createdAt: transaction.createdAt, - verified: !!transaction.verifiedById - })); - const itemTransactions = (await getItemTransactions(userId, before, limit+1)).map(transaction => ({ - type: "item", - name: transaction.item.name, - difference: -(transaction.price + transaction.premium), - premium: transaction.premium, - createdAt: transaction.createdAt, - image: transaction.item.image, - })); - const moneyTransfers = (await getMoneyTransfers(userId, before, limit+1)).map(transfer => ({ - type: "transfer", - name: transfer.fromId === userId ? `Gesendet an ${transfer.to.name}` : `Empfangen von ${transfer.from.name}`, - difference: transfer.fromId === userId ? -transfer.amount : transfer.amount, - createdAt: transfer.createdAt - })); - const voucherUses = (await getUsedVouchers(userId, before, limit+1)).map(voucher => ({ - type: "voucher", - name: `Gutschein ${voucher.code} eingelöst`, - difference: voucher.amount, - createdAt: voucher.usedAt // use usedAt because voucher creation time is not relevant - })); - let transactions = [...moneyTransactions, ...itemTransactions, ...moneyTransfers, ...voucherUses] - .sort((a, b) => b.createdAt - a.createdAt); - const hasMore = transactions.length > limit; - if(transactions.length > limit) transactions = transactions.filter((_,i)=>i<limit); - if (transactions.length > 0) { - transactions[0].balance = balanceAfterwards; - for (let i = 1; i < transactions.length; i++) - transactions[i].balance = transactions[i - 1].balance - transactions[i - 1].difference; - } - return {transactions, hasMore}; -} +import { fetchTransactions } from "../api/transactions"; export async function load(event) { const userId = event.locals.user.id; const balance = event.locals.user.balance; - const {transactions, hasMore} = await fetchData(userId, balance, Date.now(), 100); - return { transactions, hasMore }; + const {transactions, hasMore} = await fetchTransactions(userId, balance, Date.now(), 100); + return { transactions, hasMore, userId }; }; - -export const actions = { - transactions: async event=>{ - const data = await event.request.formData(); - const userId = event.locals.user.id; - const balance = parseInt(data.get("balance")); - const before = parseInt(data.get("before")); - if(!Number.isInteger(balance) || !Number.isInteger(before)) return { error: "Invalid data" }; - const {transactions, hasMore} = await fetchData(userId, balance, before, 100); - return {transactions, hasMore}; - } -} diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte index 679bfe3ddd90033032e2458ceceb889f19b375dc..ede05ee94c5af52b380869a5a719b7ff03d5c634 100644 --- a/src/routes/history/+page.svelte +++ b/src/routes/history/+page.svelte @@ -1,98 +1,7 @@ <script> - import ItemIcon from '../../components/ItemIcon.svelte'; - import IntersectionObserver from "svelte-intersection-observer"; - import { deserialize } from "$app/forms"; - import { onMount } from "svelte"; + import UserHistory from "../../components/UserHistory.svelte"; export let data; - let transactions = data.transactions || []; - let hasMoreTransactions = data.hasMore; - let isFetching = false; - - function hasScrollBar() { - const rootElement = document.getElementById("main"); - return rootElement.scrollHeight > rootElement.clientHeight; - } - - function fetchMoreTransactions(){ - if(!hasMoreTransactions || isFetching) return; - isFetching = true; - const fd = new FormData(); - fd.append("before", transactions[transactions.length-1].createdAt.getTime()); - fd.append("balance", transactions[transactions.length-1].balance-transactions[transactions.length-1].difference); - fetch("?/transactions", { - method: "POST", - body: fd - }).then(r=>r.text()).then(deserialize).then(({data: {transactions: prevTransactions, hasMore}}) => { - if(prevTransactions.length > 0) transactions = [...transactions, ...prevTransactions]; - hasMoreTransactions = hasMore; - isFetching = false; - if(!hasScrollBar()) fetchMoreTransactions(); - }); - } - - onMount(()=>hasScrollBar()||fetchMoreTransactions()); - - let observerElement; </script> -<style> - .gain { - color: green; - } - .loss { - color: red; - } - table { - width: 100%; - margin-bottom: .5rem; - margin-top: 1rem; - } - tr:nth-child(1) { - position: sticky; - top: 0; - z-index: 10; - } - td:not(:nth-child(2)) { - text-align: right; - } - td:nth-child(1) { - display: flex; - justify-content: center; - align-items: center; - } - th > span, td > span { - display: inline-block; - padding: 0 1.5em; - } -</style> - -<!-- TODO add way to navigate back --> - -<table> - <tr> - <th></th> - <th><span>Aktion</span></th> - <th><span>Preis</span></th> - <th><span>Guthaben</span></th> - <th><span>Datum</span></th> - </tr> - {#each transactions as transaction} - <tr> - <td>{#if transaction.type==="item"}<ItemIcon icon={transaction.image} size="40px" name={transaction.name} />{/if}</td> - <td><span>{transaction.name}</span></td> - <td class="{transaction.difference > 0 ? "gain" : transaction.difference < 0 ? "loss" : ""}"><span>{#if transaction.difference>0}+{/if}{(transaction.difference/100).toFixed(2)}€</span></td> <!-- TODO show premium on item transcations --> - <td><span>{(transaction.balance/100).toFixed(2)}€</span></td> - <td><span>{transaction.createdAt.toLocaleString()}</span></td> - </tr> - {/each} -</table> -{#if transactions.length === 0} - <p>Keine Transaktionen gefunden.</p> -{:else} - <IntersectionObserver element={observerElement} rootMargin="100px 0px 0px 0px" on:intersect={fetchMoreTransactions}> - <div bind:this={observerElement} style="margin-bottom: 50px;height:1em;{hasMoreTransactions ? "" : "visibility: hidden;"}"> - {#if isFetching}Lade weitere Transaktionen...{/if} - </div> - </IntersectionObserver> -{/if} +<UserHistory initialData={data} userId={data.userId} />