From dd2e939d762eeff6c2f9de22954ff9c5e899c8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aaron=20D=C3=B6tsch?= <aaron@fsmpi.rwth-aachen.de> Date: Sun, 7 May 2023 16:12:21 +0200 Subject: [PATCH] Add voucher functionality --- .../migration.sql | 18 +++++ prisma/schema.prisma | 12 +++ src/lib/customcodes.js | 3 +- src/routes/+page.svelte | 40 ++++++++-- src/routes/admin/+page.svelte | 1 + src/routes/admin/api/transactions.js | 25 +++++++ src/routes/admin/user/[id]/+page.svelte | 2 +- src/routes/admin/vouchers/+page.server.js | 30 ++++++++ src/routes/admin/vouchers/+page.svelte | 9 +++ src/routes/admin/vouchers/VoucherItem.svelte | 32 ++++++++ src/routes/admin/vouchers/VoucherList.svelte | 74 +++++++++++++++++++ src/routes/api/[slug]/+server.js | 25 ++++++- src/routes/api/transactions.js | 31 ++++++++ 13 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20230430160007_add_voucher_table/migration.sql create mode 100644 src/routes/admin/vouchers/+page.server.js create mode 100644 src/routes/admin/vouchers/+page.svelte create mode 100644 src/routes/admin/vouchers/VoucherItem.svelte create mode 100644 src/routes/admin/vouchers/VoucherList.svelte diff --git a/prisma/migrations/20230430160007_add_voucher_table/migration.sql b/prisma/migrations/20230430160007_add_voucher_table/migration.sql new file mode 100644 index 0000000..71b893c --- /dev/null +++ b/prisma/migrations/20230430160007_add_voucher_table/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "Voucher" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "usedAt" TIMESTAMP(3), + "usedById" INTEGER, + + CONSTRAINT "Voucher_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Voucher_code_key" ON "Voucher"("code"); + +-- AddForeignKey +ALTER TABLE "Voucher" ADD CONSTRAINT "Voucher_usedById_fkey" FOREIGN KEY ("usedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f31357..154aefa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { cards UserCard[] moneyTransfersSent MoneyTransfer[] @relation("from") moneyTransfersReceived MoneyTransfer[] @relation("to") + vouchersUsed Voucher[] @relation("vouchersUsed") updatedAt DateTime @updatedAt comment String @default("") } @@ -99,3 +100,14 @@ model MoneyTransfer { toId Int card String @default("") } + +model Voucher { + id Int @id @default(autoincrement()) + code String @unique + amount Int + expiresAt DateTime? + createdAt DateTime @default(now()) + usedAt DateTime? + usedById Int? + usedBy User? @relation("vouchersUsed", fields: [usedById], references: [id]) +} diff --git a/src/lib/customcodes.js b/src/lib/customcodes.js index df64f71..67bc543 100644 --- a/src/lib/customcodes.js +++ b/src/lib/customcodes.js @@ -1,5 +1,6 @@ export const Codes = { Pfand: "PFAND", Custom: "CUSTOM", - MoneyTransfer: "TRANSFER" + MoneyTransfer: "TRANSFER", + Voucher: "VOUCHER", }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0600b5c..1a5397f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -23,8 +23,12 @@ let isFetchingBuy = false; let cart = []; - // if string, then it is a code, else it is a custom item - $: cartItems = cart.map(code => typeof code === "string" ? items.find(item => item.code === code) : code); + $: cartItems = cart.map(item => { + if(item.type === "article") return Object.assign({type: "article"}, items.find(i => i.code === item.code)); + else if(item.type === "custom") return {type: "custom", name: "Manuell", price: item.price, premium: 0, code: item.code}; + else if(item.type === "pfand") return {type: "pfand", name: "Pfand", price: item.price, premium: 0, code: item.code}; + else return addMessage(MessageType.ERROR, "Unbekannter Artikeltyp: " + item.type); + }); function emptyCart(){ if(isFetchingBuy) return addMessage(MessageType.ERROR, "Der Kauf wird gerade ausgeführt. Bitte warten..."); cart = []; @@ -104,7 +108,7 @@ } isFetchingBuy = true; if(browser && timer) clearInterval(timer); - const items = cartItems.map(item => ({code: item.code, price: item.price, premium: item.premium})); + const items = cartItems.map(item => ({code: item.code, price: item.price, premium: item.premium})); // TODO (i forgot what this todo is supposed to mean) fetch("/api/buy", { method: "POST", body: JSON.stringify({items}), @@ -128,30 +132,49 @@ body: JSON.stringify({target, amount}), headers: { "Content-Type": "application/json" } }).then(r => r.json()).then(r => { + console.log(r); // TODO check if correct / then remove if(r.message){ addMessage(MessageType.ERROR, r.message); } else { // Überweisung erfolgreich + // TODO Empfängername und Betrag anzeigen addMessage(MessageType.SUCCESS, "Überweisung erfolgreich"); invalidateAll(); } }); } + function handleVoucher(code){ + fetch("/api/voucher", { + method: "POST", + body: JSON.stringify({code}), + headers: { "Content-Type": "application/json" } + }).then(r => r.json()).then(r => { + if(r.message){ + addMessage(MessageType.ERROR, r.message); + } else { + // Gutschein erfolgreich eingelöst + // TODO Gutscheinwert anzeigen + addMessage(MessageType.SUCCESS, "Gutschein erfolgreich eingelöst"); + invalidateAll(); + } + }); + } + function handleCode(code){ if(specialCodes[code]){ specialCodes[code](); }else { const item = items.find(item => item.code === code); if(item){ - addToCart(item.code); + addToCart({type: "article", code: item.code}); }else if(code === usercard){ buy(); }else if(code.startsWith("#" + Codes.Custom + "-")){ const price = parseInt(code.substring(2 + Codes.Custom.length)); if(!isNaN(price)){ if(price > 0) - addToCart({code: Codes.Custom, name: "Manuell (" + (price/100).toFixed(2) + "€)", price, premium: 0}); + addToCart({type: "custom", code: Codes.Custom, price}); else addMessage(MessageType.ERROR, "Der Preis muss größer als 0 sein."); } @@ -159,7 +182,7 @@ const price = parseInt(code.substring(2 + Codes.Pfand.length)); if(!isNaN(price)){ if(price > 0) - addToCart({code: Codes.Pfand, name: "Pfand (" + (price/100).toFixed(2) + "€)", price, premium: 0}); + addToCart({type: "pfand", code: Codes.Pfand, price}); else addMessage(MessageType.ERROR, "Der Preis muss größer als 0 sein."); } @@ -174,6 +197,9 @@ if(data.user.perms & Flags.CANT_TRANSFER) return addMessage(MessageType.ERROR, "Du hast keine Berechtigung für Geldüberweisungen"); // TODO add confirmation transferMoney(to, amount); + }else if(code.startsWith("#" + Codes.Voucher + "-")){ + const voucher = code.substring(2 + Codes.Voucher.length); + handleVoucher(voucher); }else{ addMessage(MessageType.INFO, "Unbekannter Code"); } @@ -270,7 +296,7 @@ {#each categories as category, i} <h2 bind:this={categoryElements[i].element}>{category.name}</h2> <div class="item-list"> - <ItemList items={items.filter(item=>item.categoryId===category.id)} onClick={item=>addToCart(item.code)} priceModifier={x=>x} bind:inView={elementsInView[i]} /> + <ItemList items={items.filter(item=>item.categoryId===category.id)} onClick={item=>addToCart({type: "article", code: item.code})} priceModifier={x=>x} bind:inView={elementsInView[i]} /> </div> {/each} </div> diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 830a6e7..3edce41 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -2,6 +2,7 @@ <a href="/admin/product">Produkte verwalten</a><br> <a href="/admin/history">Transaktionsverlauf</a><br> <a href="/admin/restock">Nachschub</a><br> +<a href="/admin/vouchers">Gutscheine</a><br> <br> <a href="/logout">Abmelden</a> diff --git a/src/routes/admin/api/transactions.js b/src/routes/admin/api/transactions.js index 99754ee..b77c1c6 100644 --- a/src/routes/admin/api/transactions.js +++ b/src/routes/admin/api/transactions.js @@ -72,3 +72,28 @@ export async function deleteItemTransaction(id){ }) ]); } + +export async function getVouchers(){ + return db.voucher.findMany({ orderBy: { createdAt: "desc" }, include: { usedBy: true } }); +} + +export async function createVoucher(code, amount, expiresAt){ + return db.voucher.create({ + data: { + code, + amount, + expiresAt + } + }); +} + +export async function editVoucher(id, code, amount, expiresAt){ + return db.voucher.update({ + where: {id}, + data: { + code, + amount, + expiresAt + } + }); +} diff --git a/src/routes/admin/user/[id]/+page.svelte b/src/routes/admin/user/[id]/+page.svelte index f5ee716..6d01637 100644 --- a/src/routes/admin/user/[id]/+page.svelte +++ b/src/routes/admin/user/[id]/+page.svelte @@ -26,7 +26,7 @@ import { addMessage, MessageType } from "$lib/messages"; if(result.type === "success"){ balance = result.data.user.balance; form.reset(); - addMessage(MessageType.SUCCESS, "Transaktion wurde erstellt"); + addMessage(MessageType.SUCCESS, "Transaktion wurde erstellt"); // TODO show amount }else{ addMessage(MessageType.ERROR, "Transaktion konnte nicht erstellt werden"); console.error(result); diff --git a/src/routes/admin/vouchers/+page.server.js b/src/routes/admin/vouchers/+page.server.js new file mode 100644 index 0000000..0f7057d --- /dev/null +++ b/src/routes/admin/vouchers/+page.server.js @@ -0,0 +1,30 @@ +import { createVoucher, editVoucher, getVouchers } from "../api/transactions" + +export const load = async ()=>{ + const vouchers = await getVouchers(); + return { vouchers }; +} + +export const actions = { + create: async event => { + const data = await event.request.formData(); + const code = data.get('code'); + const amount = parseInt(data.get('amount')); + const expiresAt = data.get('expiresAt'); + const expiresDate = expiresAt ? new Date(expiresAt) : null; + if(!code || !amount || Number.isNaN(amount) || amount < 0 || isNaN(expiresDate)) return { error: "Invalid form data" }; + const voucher = await createVoucher(code, amount, expiresDate); + return { voucher }; + }, + edit: async event => { + const data = await event.request.formData(); + const id = parseInt(data.get('id')); + const code = data.get('code'); + const amount = parseInt(data.get('amount')); + const expiresAt = data.get('expiresAt'); + const expiresDate = expiresAt ? new Date(expiresAt) : null; + if(!id || !code || !amount || Number.isNaN(amount) || amount < 0 || isNaN(expiresDate)) return { error: "Invalid form data" }; + const voucher = await editVoucher(id, code, amount, expiresDate); + return { voucher }; + } +}; diff --git a/src/routes/admin/vouchers/+page.svelte b/src/routes/admin/vouchers/+page.svelte new file mode 100644 index 0000000..ef64a11 --- /dev/null +++ b/src/routes/admin/vouchers/+page.svelte @@ -0,0 +1,9 @@ +<script> + import VoucherList from "./VoucherList.svelte"; + + export let data; +</script> + +<a href="/admin">Zurück</a> + +<VoucherList bind:vouchers={data.vouchers} /> diff --git a/src/routes/admin/vouchers/VoucherItem.svelte b/src/routes/admin/vouchers/VoucherItem.svelte new file mode 100644 index 0000000..057a213 --- /dev/null +++ b/src/routes/admin/vouchers/VoucherItem.svelte @@ -0,0 +1,32 @@ +<script> + export let id, code, amount, expiresAt, usedAt, usedBy; + export let edit; +</script> + +{#if edit} +<tr> + <td><input form={"form" + id} name="code" value={code} required /></td> + <td><input form={"form" + id} name="amount" value={amount} required type="number" /></td> + <td><input form={"form" + id} name="expiresAt" value={expiresAt} type="datetime-local" /></td> <!-- TODO fix pre-filled value --> + <td>{usedAt ?? ""}</td> + <td> + {#if usedBy} + <a href="/admin/user/{usedBy.id}">{usedBy.name}</a> + {/if} + </td> + <td><button type="submit" form={"form" + id}>Speichern</button><button on:click={()=>edit=false}>Abbrechen</button></td> +</tr> +{:else} +<tr> + <td>{code}</td> + <td>{(amount/100).toFixed(2)}€</td> + <td>{expiresAt?.toLocaleString() ?? ""}</td> + <td>{usedAt?.toLocaleString() ?? ""}</td> + <td> + {#if usedBy} + <a href="/admin/user/{usedBy.id}">{usedBy.name}</a> + {/if} + </td> + <td><button on:click={()=>edit=true} disabled={!!usedBy}>Bearbeiten</button></td> +</tr> +{/if} diff --git a/src/routes/admin/vouchers/VoucherList.svelte b/src/routes/admin/vouchers/VoucherList.svelte new file mode 100644 index 0000000..0a940dd --- /dev/null +++ b/src/routes/admin/vouchers/VoucherList.svelte @@ -0,0 +1,74 @@ +<script> + import { enhance } from "$app/forms"; + import { addMessage, MessageType } from "$lib/messages"; + import VoucherItem from "./VoucherItem.svelte"; + + export let vouchers; + let edit = vouchers.map(() => false); +</script> + +<table> + <thead> + <tr> + <th>Code</th> + <th>Betrag</th> + <th>Gültig bis</th> + <th>Eingelöst am</th> + <th>Eingelöst von</th> + <th></th> + </tr> + </thead> + <tbody> + {#each vouchers as voucher, i} + <VoucherItem {...voucher} bind:edit={edit[i]} /> + {/each} + <tr> + <td><input form="form-new" name="code" required /></td> + <td><input form="form-new" name="amount" required type="number" /></td> + <td><input form="form-new" name="expiresAt" type="datetime-local" /></td> + <td></td> + <td></td> + <td><button type="submit" form="form-new">Erstellen</button></td> + </tr> + </tbody> +</table> + +{#each vouchers as voucher, i} + <form method="post" action="?/edit" id={"form" + voucher.id} use:enhance={({form, data, cancel})=>{ + const expiresAt = data.get("expiresAt"); + if(expiresAt){ + data.set("expiresAt", new Date(expiresAt).toISOString()); + } + return async ({result})=>{ + if(result.type === "success"){ + form.reset(); + vouchers[i] = result.data.voucher; + edit[i] = false; + addMessage(MessageType.SUCCESS, "Gutschein wurde bearbeitet"); + }else{ + addMessage(MessageType.ERROR, "Gutschein konnte nicht bearbeitet werden"); + console.error(result); + } + }; + }}> + <input type="hidden" name="id" value={voucher.id} /> + <input type="hidden" name="timezone" value={""} /> + </form> +{/each} +<form method="post" action="?/create" id="form-new" use:enhance={({form, data, cancel})=>{ + const expiresAt = data.get("expiresAt"); + if(expiresAt){ + data.set("expiresAt", new Date(expiresAt).toISOString()); + } + return async ({result})=>{ + if(result.type === "success"){ + form.reset(); + vouchers = [...vouchers, result.data.voucher]; + edit = [...edit, false]; + addMessage(MessageType.SUCCESS, "Gutschein wurde erstellt"); + }else{ + addMessage(MessageType.ERROR, "Gutschein konnte nicht erstellt werden"); + console.error(result); + } + }; +}}></form> diff --git a/src/routes/api/[slug]/+server.js b/src/routes/api/[slug]/+server.js index 202e8b2..cb109ed 100644 --- a/src/routes/api/[slug]/+server.js +++ b/src/routes/api/[slug]/+server.js @@ -1,9 +1,10 @@ import { getPriceModifier } from "../../admin/api/articles"; import { Flags } from "$lib/flags"; import { getArticles } from "../articles"; -import { buyArticles, transferMoney } from "../transactions"; +import { buyArticles, transferMoney, useVoucher } from "../transactions"; import { getUserById } from "../users"; import { Codes } from "$lib/customcodes"; +import { AllOrNothingRateLimiter, SlidingWindowRateLimiter } from "../../../lib/ratelimit"; function isValidCart(cart, articles, priceModifier){ @@ -16,6 +17,12 @@ function isValidCart(cart, articles, priceModifier){ }); } +const voucherRedeemRateLimiter = new AllOrNothingRateLimiter( + new SlidingWindowRateLimiter({duration: 60*1000, max: 5}), // 5 per minute + new SlidingWindowRateLimiter({duration: 60*60*1000, max: 20}), // 20 per hour + new SlidingWindowRateLimiter({duration: 24*60*60*1000, max: 50}) // 50 per day +); + export async function POST(event) { switch(event.params.slug){ case "buy": { @@ -50,6 +57,22 @@ export async function POST(event) { return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}}); } } + case "voucher": { + const remaining = voucherRedeemRateLimiter.consume(event.locals.user.id); + if(remaining < 0) return new Response(JSON.stringify({message: "Rate limit exceeded"}), {headers: {'content-type': 'application/json', 'status': 429}}); + try{ + const data = await event.request.json(); + const voucherCode = data.code; + if(!voucherCode) return new Response(JSON.stringify({message: "Missing required fields"}), {headers: {'content-type': 'application/json', 'status': 400}}); + if(typeof voucherCode !== "string" || voucherCode.length === 0) return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}}); + const result = await useVoucher(event.locals.user.id, voucherCode); + if(!result) return new Response(JSON.stringify({message: "Invalid voucher"}), {headers: {'content-type': 'application/json', 'status': 400}}); + return new Response(JSON.stringify(result), {headers: {'content-type': 'application/json', 'status': 200}}); + }catch(e){ + console.error(e); + return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}}); + } + } default: return new Response("Unknown endpoint", {status: 400}); } diff --git a/src/routes/api/transactions.js b/src/routes/api/transactions.js index 059cc81..af09378 100644 --- a/src/routes/api/transactions.js +++ b/src/routes/api/transactions.js @@ -58,6 +58,29 @@ export async function transferMoney(fromId, card, toId, amount){ return { to, from }; } +/** + * Warning! This function returns null if the voucher doesn't exist, is already used, or is expired. + */ +export async function useVoucher(userId, voucherCode){ + const voucher = await db.voucher.findUnique({ where: { code: voucherCode } }); + // if voucher doesn't exist, is already used, or is expired, return null + if(!voucher || voucher.usedById || (voucher.expiresAt && voucher.expiresAt <= new Date())) return null; + return db.voucher.update({ + where: { code: voucherCode }, + data: { + usedAt: new Date(), + usedBy: { + connect: { id: userId }, + update: { + balance: { + increment: voucher.amount + } + } + } + } + }); +} + export async function getMoneyTransactions(userId, before=Date.now(), limit=100) { return db.transaction.findMany({ take: limit, @@ -83,3 +106,11 @@ export async function getMoneyTransfers(userId, before=Date.now(), limit=100) { include: { from: true, to: true } }); } + +export async function getUsedVouchers(usedById, before=Date.now(), limit=100) { + return db.voucher.findMany({ + take: limit, + where: { usedById, createdAt: { lt: new Date(before) } }, + orderBy: { usedAt: "desc" } + }); +} -- GitLab