Skip to content
Snippets Groups Projects
Commit 464149f8 authored by Aaron Dötsch's avatar Aaron Dötsch
Browse files

Extract user history into its own component

parent 4f211179
No related branches found
No related tags found
No related merge requests found
<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}
import * as devalue from "devalue";
import { getPriceModifier } from "../../admin/api/articles"; import { getPriceModifier } from "../../admin/api/articles";
import { Flags } from "$lib/flags"; import { Flags } from "$lib/flags";
import { getArticles, getBuyableVouchers } from "../articles"; import { getArticles, getBuyableVouchers } from "../articles";
import { buyArticles, transferMoney, useVoucher } from "../transactions"; import { buyArticles, fetchTransactions, transferMoney, useVoucher } from "../transactions";
import { getUserById } from "../users"; import { getUserById } from "../users";
import { Codes } from "$lib/customcodes"; import { Codes } from "$lib/customcodes";
import { AllOrNothingRateLimiter, SlidingWindowRateLimiter } from "../../../lib/ratelimit"; import { AllOrNothingRateLimiter, SlidingWindowRateLimiter } from "../../../lib/ratelimit";
...@@ -112,7 +113,30 @@ export async function GET(req){ ...@@ -112,7 +113,30 @@ export async function GET(req){
switch(req.params.slug){ switch(req.params.slug){
case "items": case "items":
return getArticles().then(articles => new Response(JSON.stringify(articles), {status: 200})); 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: default:
return ""; return new Response("Unknown endpoint", {status: 400});
} }
} }
...@@ -127,3 +127,63 @@ export async function getUsedVouchers(usedById, before=Date.now(), limit=100) { ...@@ -127,3 +127,63 @@ export async function getUsedVouchers(usedById, before=Date.now(), limit=100) {
orderBy: { usedAt: "desc" } 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};
}
import { getMoneyTransactions, getItemTransactions, getMoneyTransfers, getUsedVouchers } from "../api/transactions"; import { fetchTransactions } 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};
}
export async function load(event) { export async function load(event) {
const userId = event.locals.user.id; const userId = event.locals.user.id;
const balance = event.locals.user.balance; const balance = event.locals.user.balance;
const {transactions, hasMore} = await fetchData(userId, balance, Date.now(), 100); const {transactions, hasMore} = await fetchTransactions(userId, balance, Date.now(), 100);
return { transactions, hasMore }; 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};
}
}
<script> <script>
import ItemIcon from '../../components/ItemIcon.svelte'; import UserHistory from "../../components/UserHistory.svelte";
import IntersectionObserver from "svelte-intersection-observer";
import { deserialize } from "$app/forms";
import { onMount } from "svelte";
export let data; 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> </script>
<style> <UserHistory initialData={data} userId={data.userId} />
.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}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment