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