From 3713c7eaba9339b10c3b4365bf59f9115139b2b1 Mon Sep 17 00:00:00 2001
From: bytedream <bytedream@protonmail.com>
Date: Sat, 30 Sep 2023 01:01:26 +0200
Subject: [PATCH] add option to create a report via the admin panel

---
 src/lib/components/Input/Search.svelte        |  78 ++++++++++
 src/routes/admin/reports/+page.svelte         |  52 ++++---
 src/routes/admin/reports/+server.ts           |  47 ++++++
 src/routes/admin/reports/HeaderBar.svelte     |  33 +++++
 .../admin/reports/NewReportModal.svelte       | 137 ++++++++++++++++++
 src/routes/admin/users/+server.ts             |  28 +++-
 src/routes/report/+server.ts                  |   7 +-
 7 files changed, 353 insertions(+), 29 deletions(-)
 create mode 100644 src/lib/components/Input/Search.svelte
 create mode 100644 src/routes/admin/reports/HeaderBar.svelte
 create mode 100644 src/routes/admin/reports/NewReportModal.svelte

diff --git a/src/lib/components/Input/Search.svelte b/src/lib/components/Input/Search.svelte
new file mode 100644
index 0000000..e0588d5
--- /dev/null
+++ b/src/lib/components/Input/Search.svelte
@@ -0,0 +1,78 @@
+<script lang="ts">
+	export let id: string | null = null;
+	export let value = '';
+	export let suggestionRequired = false;
+	export let searchSuggestionFunc: (
+		input: string
+	) => Promise<{ name: string; value: string }[]> = () => Promise.resolve([]);
+	export let size: 'xs' | 'sm' | 'md' | 'lg' = 'md';
+	export let label: string | null = null;
+	export let required = false;
+
+	let inputValue: string;
+	let searchSuggestions: { name: string; value: string }[] = [];
+	$: if (!suggestionRequired) value = inputValue;
+</script>
+
+<div class="relative">
+	<div>
+		{#if label}
+			<label class="label" for={id}>
+				<span class="label-text">
+					{label}
+					{#if required}
+						<span class="text-red-700">*</span>
+					{/if}
+				</span>
+			</label>
+		{/if}
+		<input
+			type="search"
+			class="input input-bordered w-full"
+			class:input-xs={size === 'xs'}
+			class:input-sm={size === 'sm'}
+			class:input-md={size === 'md'}
+			class:input-lg={size === 'md'}
+			{id}
+			{required}
+			bind:value={inputValue}
+			on:input={() => {
+				value = '';
+				searchSuggestionFunc(inputValue).then((v) => {
+					searchSuggestions = v;
+					let searchSuggestionValue = v.find((v) => v.name === inputValue);
+					if (searchSuggestionValue !== undefined) {
+						value = searchSuggestionValue.value;
+						searchSuggestions = [];
+					}
+				});
+			}}
+			pattern={suggestionRequired ? `${value ? inputValue : 'a^'}` : null}
+		/>
+	</div>
+
+	{#if inputValue && searchSuggestions.length !== 0}
+		<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
+			{#each searchSuggestions as searchSuggestion}
+				<li class="w-full text-left">
+					<button
+						class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
+						title="{searchSuggestion.name} ({searchSuggestion.value})"
+						on:click|preventDefault={() => {
+							inputValue = searchSuggestion.name;
+							value = searchSuggestion.value;
+							searchSuggestions = [];
+						}}>{searchSuggestion.name}</button
+					>
+				</li>
+			{/each}
+		</ul>
+	{/if}
+</div>
+
+{#if inputValue && searchSuggestions.length !== 0}
+	<button
+		class="absolute top-0 left-0 z-10 w-full h-full cursor-default"
+		on:click={() => (searchSuggestions = [])}
+	/>
+{/if}
diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte
index e667846..66ffcb8 100644
--- a/src/routes/admin/reports/+page.svelte
+++ b/src/routes/admin/reports/+page.svelte
@@ -8,6 +8,9 @@
 	import Input from '$lib/components/Input/Input.svelte';
 	import Textarea from '$lib/components/Input/Textarea.svelte';
 	import { reportCount } from '$lib/stores';
+	import HeaderBar from './HeaderBar.svelte';
+	import { IconOutline } from 'svelte-heros-v2';
+	import NewReportModal from './NewReportModal.svelte';
 
 	export let data: PageData;
 
@@ -47,29 +50,12 @@
 	}
 
 	let saveActiveReportChangesModal: HTMLDialogElement;
+	let newReportModal: HTMLDialogElement;
 </script>
 
 <div class="h-screen flex flex-row">
 	<div class="w-full flex flex-col">
-		<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
-			<Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter}>
-				<span slot="label">Report Ersteller</span>
-			</Input>
-			<Input size="sm" placeholder="Alle" bind:value={reportFilter.reported}>
-				<span slot="label">Reportete Spieler</span>
-			</Input>
-			<Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status}>
-				<option value="none">Unbearbeitet</option>
-				<option value="review">In Bearbeitung</option>
-				<option value={null}>Unbearbeitet & In Bearbeitung</option>
-				<option value="reviewed">Bearbeitet</option>
-			</Select>
-			<Select label="Reportstatus" size="sm" bind:value={reportFilter.draft}>
-				<option value={false}>Erstellt</option>
-				<option value={true}>Entwurf</option>
-				<option value={null}>Erstellt & Entwurf</option>
-			</Select>
-		</form>
+		<HeaderBar bind:reportFilter />
 		<hr class="divider my-1 mx-8 border-none" />
 		<table class="table table-fixed h-fit">
 			<colgroup>
@@ -124,6 +110,16 @@
 						<td>{report.draft ? 'Entwurf' : 'Erstellt'}</td>
 					</tr>
 				{/each}
+				<tr>
+					<td colspan="100">
+						<div class="flex justify-center items-center">
+							<button class="btn btn-sm" on:click={() => newReportModal.show()}>
+								<IconOutline name="plus-outline" />
+								<span>Neuer Report</span>
+							</button>
+						</div>
+					</td>
+				</tr>
 			</tbody>
 		</table>
 	</div>
@@ -162,7 +158,7 @@
 				>
 					<Textarea
 						label="Interne Notizen"
-						readonly={activeReport.auditor === null && activeReport.notice === null}
+						readonly={activeReport.status === 'none'}
 						rows={1}
 						bind:value={activeReport.notice}
 					/>
@@ -175,13 +171,15 @@
 				>
 					<Textarea
 						label="(Öffentliche) Report Antwort"
-						readonly={activeReport.auditor === null && activeReport.notice === null}
+						readonly={activeReport.status === 'none'}
 						rows={3}
 						bind:value={activeReport.statement}
 					/>
 				</div>
 				<Select label="Bearbeitungsstatus" size="sm" bind:value={activeReport.status}>
-					<option value="none" disabled={activeReport.auditor != null || activeReport.notice}
+					<option
+						value="none"
+						disabled={activeReport.auditor != null || activeReport.notice || activeReport.statement}
 						>Unbearbeitet</option
 					>
 					<option value="review">In Bearbeitung</option>
@@ -227,3 +225,13 @@
 		<button>close</button>
 	</form>
 </dialog>
+
+<dialog class="modal" bind:this={newReportModal}>
+	<NewReportModal
+		on:submit={(e) => {
+			if (!e.detail.draft) $reportCount += 1;
+			currentPageReports = [e.detail, ...currentPageReports];
+			activeReport = currentPageReports[0];
+		}}
+	/>
+</dialog>
diff --git a/src/routes/admin/reports/+server.ts b/src/routes/admin/reports/+server.ts
index 784d161..420fa26 100644
--- a/src/routes/admin/reports/+server.ts
+++ b/src/routes/admin/reports/+server.ts
@@ -5,6 +5,7 @@ import { Admin, Report, User } from '$lib/server/database';
 import type { Attributes } from 'sequelize';
 import { Op } from 'sequelize';
 import { env } from '$env/dynamic/private';
+import crypto from 'crypto';
 
 export const POST = (async ({ request, cookies }) => {
 	if (getSession(cookies, { permissions: [Permissions.ReportRead] }) == null) {
@@ -104,3 +105,49 @@ export const PATCH = (async ({ request, cookies }) => {
 
 	return new Response();
 }) satisfies RequestHandler;
+
+export const PUT = (async ({ request, cookies, url }) => {
+	if (getSession(cookies, { permissions: [Permissions.ReportWrite] }) == null) {
+		return new Response(null, {
+			status: 401
+		});
+	}
+
+	const data: {
+		reporter: string;
+		reported: string;
+		reason: string;
+		body: string | null;
+	} = await request.json();
+
+	if (
+		data.reporter == null ||
+		data.reported == null ||
+		data.reason == null ||
+		data.body === undefined
+	)
+		return new Response(null, { status: 400 });
+
+	const reporter = await User.findOne({ where: { uuid: data.reporter } });
+	const reported = await User.findOne({ where: { uuid: data.reported } });
+
+	if (reporter == null || reported == null) return new Response(null, { status: 400 });
+
+	const report = await Report.create({
+		subject: data.reason,
+		body: data.body,
+		draft: data.body === null,
+		status: 'none',
+		url_hash: crypto.randomBytes(18).toString('hex'),
+		completed: false,
+		reporter_id: reporter.id,
+		reported_id: reported.id
+	});
+	report.dataValues.reporter = await User.findOne({ where: { id: report.reporter_id } });
+	report.dataValues.reported = await User.findOne({ where: { id: report.reported_id } });
+	report.dataValues.auditor = null;
+
+	return new Response(JSON.stringify(report), {
+		status: 201
+	});
+}) satisfies RequestHandler;
diff --git a/src/routes/admin/reports/HeaderBar.svelte b/src/routes/admin/reports/HeaderBar.svelte
new file mode 100644
index 0000000..945019f
--- /dev/null
+++ b/src/routes/admin/reports/HeaderBar.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+	import Select from '$lib/components/Input/Select.svelte';
+	import Input from '$lib/components/Input/Input.svelte';
+
+	export let reportFilter = {
+		reporter: null,
+		reported: null,
+		status: null,
+		draft: false
+	};
+</script>
+
+<div class="flex flex-row justify-center items-end">
+	<form class="flex flex-row justify-center space-x-4 mx-4 my-2">
+		<Input size="sm" placeholder="Alle" bind:value={reportFilter.reporter}>
+			<span slot="label">Report Ersteller</span>
+		</Input>
+		<Input size="sm" placeholder="Alle" bind:value={reportFilter.reported}>
+			<span slot="label">Reportete Spieler</span>
+		</Input>
+		<Select label="Bearbeitungsstatus" size="sm" bind:value={reportFilter.status}>
+			<option value="none">Unbearbeitet</option>
+			<option value="review">In Bearbeitung</option>
+			<option value={null}>Unbearbeitet & In Bearbeitung</option>
+			<option value="reviewed">Bearbeitet</option>
+		</Select>
+		<Select label="Reportstatus" size="sm" bind:value={reportFilter.draft}>
+			<option value={false}>Erstellt</option>
+			<option value={true}>Entwurf</option>
+			<option value={null}>Erstellt & Entwurf</option>
+		</Select>
+	</form>
+</div>
diff --git a/src/routes/admin/reports/NewReportModal.svelte b/src/routes/admin/reports/NewReportModal.svelte
new file mode 100644
index 0000000..0c91750
--- /dev/null
+++ b/src/routes/admin/reports/NewReportModal.svelte
@@ -0,0 +1,137 @@
+<script lang="ts">
+	import Input from '$lib/components/Input/Input.svelte';
+	import { env } from '$env/dynamic/public';
+	import Textarea from '$lib/components/Input/Textarea.svelte';
+	import Search from '$lib/components/Input/Search.svelte';
+	import { createEventDispatcher } from 'svelte';
+
+	const dispatch = createEventDispatcher();
+
+	let reporter: string;
+	let reported: string;
+	let reason = '';
+	let body = '';
+
+	async function usernameSuggestions(username: string): Promise<{ name: string; value: string }[]> {
+		const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/users`, {
+			method: 'POST',
+			body: JSON.stringify({
+				limit: 6,
+				search: username,
+				slim: true
+			})
+		});
+		const json: { username: string; uuid: string }[] = await response.json();
+		return json.map((v) => {
+			return { name: v.username, value: v.uuid };
+		});
+	}
+
+	async function newReport() {
+		const response = await fetch(`${env.PUBLIC_BASE_PATH}/admin/reports`, {
+			method: 'PUT',
+			body: JSON.stringify({
+				reporter: reporter,
+				reported: reported,
+				reason: reason,
+				body: body || null
+			})
+		});
+		if (response.ok) dispatch('submit', await response.json());
+	}
+
+	let globalCloseForm: HTMLFormElement;
+
+	let reportForm: HTMLFormElement;
+	let confirmDialog: HTMLDialogElement;
+</script>
+
+<form method="dialog" class="modal-box" bind:this={reportForm}>
+	<button
+		class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
+		on:click|preventDefault={() => globalCloseForm.submit()}>✕</button
+	>
+	<h3 class="font-roboto text-xl">Neuer Report</h3>
+	<div class="space-y-2 mt-2 px-1 max-h-[70vh] overflow-y-scroll">
+		<div>
+			<Search
+				size="sm"
+				suggestionRequired={true}
+				searchSuggestionFunc={usernameSuggestions}
+				label="Reporter"
+				required={true}
+				bind:value={reporter}
+			/>
+			<Search
+				size="sm"
+				suggestionRequired={true}
+				searchSuggestionFunc={usernameSuggestions}
+				label="Reporteter User"
+				required={true}
+				bind:value={reported}
+			/>
+		</div>
+		<div class="divider mx-4 pt-3" />
+		<Input type="text" bind:value={reason} required={true} pickyWidth={false}>
+			<span slot="label">Report Grund</span>
+		</Input>
+		<div>
+			<Textarea rows={4} label="Details über den Report Grund" bind:value={body} />
+		</div>
+	</div>
+	<div class="flex flex-row space-x-2 mt-6">
+		<Input
+			type="submit"
+			value="Erstellen"
+			on:click={(e) => {
+				if (reportForm.checkValidity()) {
+					e.detail.preventDefault();
+					confirmDialog.show();
+				}
+			}}
+		/>
+		<Input
+			type="submit"
+			value="Abbrechen"
+			on:click={(e) => {
+				e.detail.preventDefault();
+				globalCloseForm.submit();
+			}}
+		/>
+	</div>
+</form>
+<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]" bind:this={globalCloseForm}>
+	<button>close</button>
+</form>
+
+<dialog class="modal" bind:this={confirmDialog}>
+	<form method="dialog" class="modal-box">
+		<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
+		<h3 class="font-roboto text-xl mb-2">Report Erstellen?</h3>
+		{#if body}
+			<p>
+				Dadurch, dass bereits Details über den Report Grund hinzugefügt wurden, ist es nach dem
+				Erstellen nicht mehr möglich, den Report Inhalt zu ändern
+			</p>
+		{:else}
+			<p>
+				Der Report wird als Entwurf gespeichert und kann nach dem Erstellen über den Report-Link
+				bearbeitet werden
+			</p>
+		{/if}
+		<div class="flex flex-row space-x-2 mt-6">
+			<Input
+				type="submit"
+				value="Erstellen"
+				on:click={async () => {
+					await newReport();
+					globalCloseForm.submit();
+				}}
+			/>
+			<Input type="submit" value="Abbrechen" />
+		</div>
+	</form>
+	<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
+		<button>close</button>
+	</form>
+</dialog>
diff --git a/src/routes/admin/users/+server.ts b/src/routes/admin/users/+server.ts
index 62ee1f7..ff659f4 100644
--- a/src/routes/admin/users/+server.ts
+++ b/src/routes/admin/users/+server.ts
@@ -1,7 +1,8 @@
 import { getSession } from '$lib/server/session';
 import { Permissions } from '$lib/permissions';
 import type { RequestHandler } from '@sveltejs/kit';
-import { Admin, User } from '$lib/server/database';
+import { User } from '$lib/server/database';
+import { type Attributes, Op } from 'sequelize';
 
 export const POST = (async ({ request, cookies }) => {
 	if (getSession(cookies, { permissions: [Permissions.UserRead] }) == null) {
@@ -10,11 +11,28 @@ export const POST = (async ({ request, cookies }) => {
 		});
 	}
 
-	const data = await request.json();
-	const limit = data['limit'] || 100;
-	const from = data['from'] || 0;
+	const data: {
+		limit: number | null;
+		from: number | null;
+		search: string | null;
+		slim: boolean | null;
+	} = await request.json();
 
-	const users = await User.findAll({ offset: from, limit: limit });
+	const usersFindOptions: Attributes<User> = {};
+	if (data.search) {
+		Object.assign(usersFindOptions, {
+			[Op.or]: {
+				username: { [Op.like]: `%${data.search}%` },
+				uuid: { [Op.like]: `%${data.search}%` }
+			}
+		});
+	}
+	const users = await User.findAll({
+		where: usersFindOptions,
+		attributes: data.slim ? ['username', 'uuid'] : undefined,
+		offset: data.from || 0,
+		limit: data.limit || 100
+	});
 
 	return new Response(JSON.stringify(users));
 }) satisfies RequestHandler;
diff --git a/src/routes/report/+server.ts b/src/routes/report/+server.ts
index 9bc074f..e72f9c6 100644
--- a/src/routes/report/+server.ts
+++ b/src/routes/report/+server.ts
@@ -1,11 +1,12 @@
 import type { RequestHandler } from '@sveltejs/kit';
 import { Report, User } from '$lib/server/database';
 import * as crypto from 'crypto';
+import { env } from '$env/dynamic/public';
 
 export const POST = (async ({ request, url }) => {
 	const data: { reporter: string; reported: string; reason: string } = await request.json();
 
-	if (data.reporter == undefined || data.reported == undefined || data.reason == undefined)
+	if (data.reporter == null || data.reported == null || data.reason == null)
 		return new Response(null, { status: 400 });
 
 	const reporter = await User.findOne({ where: { uuid: data.reporter } });
@@ -25,7 +26,9 @@ export const POST = (async ({ request, url }) => {
 	});
 
 	return new Response(
-		JSON.stringify({ url: `${url.toString().replace(/\/$/, '')}/${report.url_hash}` }),
+		JSON.stringify({
+			url: `${url.protocol}//${url.host}${env.PUBLIC_BASE_PATH || ''}/report/${report.url_hash}`
+		}),
 		{
 			status: 201
 		}