feat(quest): add full CRUD and image handling for hunt quests

This commit is contained in:
2026-07-31 01:03:54 +02:00
parent 77becc453e
commit 24632742ef
15 changed files with 1228 additions and 7 deletions
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { parseError } from '#shared/utils/error';
const props = defineProps<{
huntId: number;
}>();
const { t } = useI18n();
const { confirm } = useModal();
const { init } = useToast();
const {
data: quests,
refresh: refreshQuests,
pending,
execute
} = await useFetch(`/api/admin/hunt/${props.huntId}/quest`, {
lazy: true,
immediate: false,
default: () => [] as never[]
});
type QuestListItem = {
id: number;
title_de: string;
title_en: string;
order: number;
points: number;
optional: boolean;
pictureRequired: boolean;
textRequired: boolean;
_count: { answers: number };
};
const questModalOpen = ref(false);
const editingQuestId = ref<number | null>(null);
const questLoading = ref(false);
function openCreateQuest() {
editingQuestId.value = null;
questModalOpen.value = true;
}
function openEditQuest(questId: number) {
editingQuestId.value = questId;
questModalOpen.value = true;
}
async function deleteQuest(quest: QuestListItem) {
const ok = await confirm({
title: t('page.quest_admin.delete_title'),
message: t('page.quest_admin.delete_confirm'),
okText: t('auth.accept')
});
if (!ok) {
return;
}
questLoading.value = true;
try {
await $fetch(`/api/admin/hunt/${props.huntId}/quest/${quest.id}`, {
method: 'DELETE'
});
init({ message: 'Quest deleted!', color: 'success' });
await refreshQuests();
} catch (e) {
init({
title: 'Error',
color: 'danger',
// @ts-expect-error weird error stuff
message: e?.message || e?.statusMessage || t(parseError(e))
});
}
questLoading.value = false;
}
async function reorderQuest(quest: QuestListItem, direction: 'up' | 'down') {
questLoading.value = true;
try {
await $fetch(`/api/admin/hunt/${props.huntId}/quest/${quest.id}/reorder`, {
method: 'PATCH',
body: { direction }
});
await refreshQuests();
} catch (e) {
init({
title: 'Error',
color: 'danger',
// @ts-expect-error weird error stuff
message: e?.message || e?.statusMessage || t(parseError(e))
});
}
questLoading.value = false;
}
</script>
<template>
<div class="flex items-center justify-between">
<h2 class="text-2xl">{{ t('page.quest_admin.quests_title') }}</h2>
<VaButton
icon="add"
color="success"
:disabled="questLoading"
@click="openCreateQuest"
>{{ t('page.quest_admin.add_quest') }}</VaButton
>
</div>
<VaCollapse
class="mt-2"
color="primary"
header="Quest list"
@update:model-value="(isOpen: boolean) => isOpen && execute()"
>
<p v-if="pending">pending...</p>
<div v-if="quests.length > 0" class="mt-4 flex flex-col gap-2">
<VaCard
v-for="(quest, index) in quests"
:key="quest.id"
class="flex items-center"
>
<VaCardContent class="flex w-full items-center gap-2">
<div class="flex flex-col gap-1">
<VaButton
icon="arrow_upward"
size="small"
preset="plain"
:disabled="index === 0 || questLoading"
:title="t('page.quest_admin.move_up')"
@click="reorderQuest(quest, 'up')"
/>
<VaButton
icon="arrow_downward"
size="small"
preset="plain"
:disabled="index === quests.length - 1 || questLoading"
:title="t('page.quest_admin.move_down')"
@click="reorderQuest(quest, 'down')"
/>
</div>
<VaButton
icon="edit"
preset="plain"
:disabled="questLoading"
@click="openEditQuest(quest.id)"
/>
<VaImage
v-if="quest.picture"
:src="quest.picture.url"
lazy
alt="Quest picture"
class="h-16 w-24 rounded object-cover"
><template #loader><VaProgressCircle indeterminate /></template
></VaImage>
<div class="flex-1">
<p>
<span class="font-bold">{{ quest.title_de }}</span
><span class="text-secondary font-light"> | </span
><span class="font-bold">{{ quest.title_en }}</span>
</p>
<p class="text-sm text-gray-500">
{{ quest.points }} {{ t('page.quest_admin.points') }} ·
{{ t('page.quest_admin.answers', quest._count.answers) }}
</p>
</div>
<div class="flex gap-1">
<VaChip v-if="quest.optional" size="small" color="secondary"
>Opt</VaChip
>
<VaChip v-if="quest.pictureRequired" size="small" color="info"
>Pic!</VaChip
>
<VaChip v-if="quest.textRequired" size="small" color="warning"
>Txt!</VaChip
>
</div>
<VaButton
icon="edit"
size="small"
preset="plain"
:disabled="questLoading"
@click="openEditQuest(quest.id)"
/>
<VaButton
icon="delete"
size="small"
preset="plain"
color="danger"
:disabled="questLoading"
@click="deleteQuest(quest)"
/>
</VaCardContent>
</VaCard>
</div>
<p v-else class="mt-4 text-gray-500">
{{ t('page.quest_admin.no_quests') }}
</p>
</VaCollapse>
<AdminQuestModal
v-model="questModalOpen"
:hunt-id="Number(props.huntId)"
:quest-id="editingQuestId"
@saved="refreshQuests"
/>
</template>
<style scoped></style>
+381
View File
@@ -0,0 +1,381 @@
<script setup lang="ts">
import type { TextType } from '#shared/generated/prisma/enums';
import { useForm } from 'vuestic-ui';
const props = defineProps<{
huntId: number;
questId?: number | null;
}>();
const emit = defineEmits<{
saved: [];
}>();
const { t } = useI18n();
const { init } = useToast();
const validation = useValidation();
const { validate } = useForm('formRef');
const showModal = defineModel<boolean>('modelValue', { default: false });
const textTypeOptions: { value: TextType }[] = [
{ value: 'TEXT' },
{ value: 'WORD' },
{ value: 'CHAR' },
{ value: 'NUMBER' },
{ value: 'INTEGER' }
];
const defaultForm = () => ({
title_de: '',
title_en: '',
description_de: '',
description_en: '',
question_de: '',
question_en: '',
location: '',
locationLink: '',
optional: true,
pictureRequired: false,
textRequired: false,
textType: 'TEXT' as TextType,
textUnit: '',
textSolution: '',
points: 10,
extraPoints: null as number | null,
extraText_de: '',
extraText_en: ''
});
const form = reactive(defaultForm());
const loading = ref(false);
const formError = ref('');
const isEdit = computed(() => !!props.questId);
// Image management
const currentPicture = ref<{ id: number; url: string } | null>(null);
const newImage = ref<File>();
const uploading = ref(false);
const imageError = ref('');
watch(showModal, async (visible) => {
if (visible && props.questId) {
// Load quest data for editing
loading.value = true;
formError.value = '';
try {
const quest = await $fetch(
`/api/admin/hunt/${props.huntId}/quest/${props.questId}`
);
Object.assign(form, {
title_de: quest.title_de,
title_en: quest.title_en,
description_de: quest.description_de,
description_en: quest.description_en,
question_de: quest.question_de ?? '',
question_en: quest.question_en ?? '',
location: quest.location ?? '',
locationLink: quest.locationLink ?? '',
optional: quest.optional,
pictureRequired: quest.pictureRequired,
textRequired: quest.textRequired,
textType: quest.textType,
textUnit: quest.textUnit ?? '',
textSolution: quest.textSolution ?? '',
points: quest.points,
extraPoints: quest.extraPoints,
extraText_de: quest.extraText_de ?? '',
extraText_en: quest.extraText_en ?? ''
});
currentPicture.value = quest.picture;
} catch (e) {
formError.value = parseError(e);
}
loading.value = false;
} else if (visible) {
// Reset form for creating
Object.assign(form, defaultForm());
formError.value = '';
currentPicture.value = null;
newImage.value = undefined;
imageError.value = '';
}
});
async function submit() {
loading.value = true;
formError.value = '';
// Convert empty strings to null for optional fields
const body = {
...form,
question_de: form.question_de || null,
question_en: form.question_en || null,
location: form.location || null,
locationLink: form.locationLink || null,
textUnit: form.textUnit || null,
textSolution: form.textSolution || null,
extraText_de: form.extraText_de || null,
extraText_en: form.extraText_en || null
};
try {
if (isEdit.value) {
await $fetch(`/api/admin/hunt/${props.huntId}/quest/${props.questId}`, {
method: 'PUT',
body
});
} else {
await $fetch(`/api/admin/hunt/${props.huntId}/quest`, {
method: 'POST',
body
});
}
init({
message: isEdit.value ? 'Quest updated!' : 'Quest created!',
color: 'success'
});
showModal.value = false;
emit('saved');
} catch (e) {
formError.value = parseError(e);
}
loading.value = false;
}
async function uploadImage() {
if (!newImage.value || !props.questId) {
return;
}
uploading.value = true;
imageError.value = '';
const body = new FormData();
body.set('image', newImage.value);
try {
await $fetch(
`/api/admin/hunt/${props.huntId}/quest/${props.questId}/image`,
{ method: 'POST', body }
);
// Reload quest to get updated picture
const quest = await $fetch(
`/api/admin/hunt/${props.huntId}/quest/${props.questId}`
);
currentPicture.value = quest.picture;
newImage.value = undefined;
} catch (e) {
imageError.value = parseError(e);
}
uploading.value = false;
}
async function deleteImage() {
if (!props.questId) {
return;
}
uploading.value = true;
imageError.value = '';
try {
await $fetch(
`/api/admin/hunt/${props.huntId}/quest/${props.questId}/image`,
{ method: 'DELETE' }
);
currentPicture.value = null;
newImage.value = undefined;
} catch (e) {
imageError.value = parseError(e);
}
uploading.value = false;
}
</script>
<template>
<VaModal
v-model="showModal"
:title="
isEdit
? t('page.quest_admin.edit_title')
: t('page.quest_admin.create_title')
"
size="large"
:loading="loading"
hide-default-actions
>
<template #footer>
<div class="flex flex-row gap-2">
<VaButton
preset="secondary"
color="secondary"
@click="() => (showModal = false)"
>{{ t('page.common.cancel') }}</VaButton
>
<VaButton color="primary" @click="validate() && submit()">{{
t('page.quest_admin.save')
}}</VaButton>
</div>
</template>
<VaForm ref="formRef" class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<h3 class="hidden text-center md:block">
{{ t('page.create_hunt.de') }}
</h3>
<h3 class="hidden text-center md:block">
{{ t('page.create_hunt.en') }}
</h3>
<VaInput
v-model="form.title_de"
:label="t('page.quest_admin.title_de')"
:rules="[validation.required(t('page.quest_admin.title_de'))]"
/>
<VaInput
v-model="form.title_en"
:label="t('page.quest_admin.title_en')"
:rules="[validation.required(t('page.quest_admin.title_en'))]"
/>
<VaTextarea
v-model="form.description_de"
:label="t('page.quest_admin.description_de')"
/>
<VaTextarea
v-model="form.description_en"
:label="t('page.quest_admin.description_en')"
/>
<VaTextarea
v-model="form.question_de"
:label="t('page.quest_admin.question_de')"
/>
<VaTextarea
v-model="form.question_en"
:label="t('page.quest_admin.question_en')"
/>
</div>
<VaInput
v-model="form.location"
:label="t('page.quest_admin.location')"
/>
<VaInput
v-model="form.locationLink"
:label="t('page.quest_admin.location_link')"
type="url"
/>
<VaDivider />
<div class="flex flex-row gap-2">
<VaInput
v-model.number="form.points"
:label="t('page.quest_admin.points')"
type="number"
/>
<VaInput
v-model.number="form.extraPoints"
:label="t('page.quest_admin.extra_points')"
type="number"
/>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<VaInput
v-model="form.extraText_de"
:label="t('page.quest_admin.extra_text_de')"
/>
<VaInput
v-model="form.extraText_en"
:label="t('page.quest_admin.extra_text_en')"
/>
</div>
<VaDivider />
<div class="flex flex-col gap-2">
<VaCheckbox
v-model="form.optional"
:label="t('page.quest_admin.optional')"
/>
<VaCheckbox
v-model="form.pictureRequired"
:label="t('page.quest_admin.picture_required')"
/>
<VaCheckbox
v-model="form.textRequired"
:label="t('page.quest_admin.text_required')"
/>
</div>
<VaSelect
v-model="form.textType"
:label="t('page.quest_admin.text_type')"
:options="textTypeOptions"
track-by="value"
value-by="value"
:text-by="
(opt) =>
t(
`enums.text_type.${(opt as (typeof textTypeOptions)[number]).value}`
)
"
/>
<VaInput
v-model="form.textUnit"
:label="t('page.quest_admin.text_unit')"
/>
<VaInput
v-model="form.textSolution"
:label="t('page.quest_admin.text_solution')"
/>
<VaDivider />
<div v-if="isEdit" class="flex flex-col gap-2">
<h3 class="text-lg font-bold">{{ t('page.quest_admin.picture') }}</h3>
<div v-if="currentPicture" class="flex items-center gap-4">
<img
:src="currentPicture.url"
alt="Quest picture"
class="h-24 w-32 rounded object-cover"
/>
<VaButton
color="danger"
preset="secondary"
:loading="uploading"
@click="deleteImage"
>{{ t('page.quest_admin.delete_picture') }}</VaButton
>
</div>
<p v-else class="text-sm text-gray-500">
{{ t('page.quest_admin.no_picture') }}
</p>
<VaFileUpload
v-model="newImage"
type="single"
file-types="jpg,png,jpeg"
:disabled="uploading"
/>
<VaButton
v-if="newImage"
color="primary"
:loading="uploading"
@click="uploadImage"
>{{ t('page.quest_admin.upload_picture') }}</VaButton
>
<VaAlert v-if="imageError" color="danger" class="w-full text-center">{{
t(imageError)
}}</VaAlert>
</div>
<VaAlert
v-if="formError.length > 0"
color="danger"
class="w-full text-center"
>{{ t(formError) }}</VaAlert
>
</VaForm>
</VaModal>
</template>
+37 -1
View File
@@ -49,6 +49,7 @@
} }
}, },
"error": { "error": {
"400": "Falsche Daten",
"401": "Bitte melde dich vorher an!", "401": "Bitte melde dich vorher an!",
"404": "Nicht gefunden", "404": "Nicht gefunden",
"default": "Unbekannter Fehler", "default": "Unbekannter Fehler",
@@ -190,6 +191,41 @@
"upload_title": "Antwort wird hochgeladen...", "upload_title": "Antwort wird hochgeladen...",
"your_answer": "Deine Antwort" "your_answer": "Deine Antwort"
}, },
"quest_admin": {
"add_quest": "Quest hinzufügen",
"answers": "{n} Antworten",
"create_title": "Quest erstellen",
"delete_confirm": "Möchtest du diese Quest wirklich löschen? Alle Antworten gehen verloren!",
"delete_picture": "Bild löschen",
"delete_title": "Quest löschen",
"description_de": "Beschreibung (Deutsch)",
"description_en": "Beschreibung (Englisch)",
"edit_title": "Quest bearbeiten",
"extra_points": "Extrapunkte",
"extra_text_de": "Extra-Text (Deutsch)",
"extra_text_en": "Extra-Text (Englisch)",
"location": "Ort",
"location_link": "Orts-Link (URL)",
"move_down": "Nach unten",
"move_up": "Nach oben",
"no_picture": "Kein Bild hochgeladen",
"no_quests": "Noch keine Quests. Füge deine erste Quest hinzu!",
"optional": "Optionale Quest",
"picture": "Quest-Bild",
"picture_required": "Bild erforderlich",
"points": "Punkte",
"question_de": "Frage (Deutsch)",
"question_en": "Frage (Englisch)",
"quests_title": "Quests",
"save": "Speichern",
"text_required": "Textantwort erforderlich",
"text_solution": "Text-Lösung",
"text_type": "Text-Typ",
"text_unit": "Text-Einheit",
"title_de": "Titel (Deutsch)",
"title_en": "Titel (Englisch)",
"upload_picture": "Bild hochladen"
},
"showcase": { "showcase": {
"answers": "Antworten", "answers": "Antworten",
"crew": "Crew", "crew": "Crew",
@@ -295,4 +331,4 @@
"uploadFile": "Datei hochladen", "uploadFile": "Datei hochladen",
"voteRating": "vote rating {value} of {max}" "voteRating": "vote rating {value} of {max}"
} }
} }
+37 -1
View File
@@ -49,6 +49,7 @@
} }
}, },
"error": { "error": {
"400": "Wrong data",
"401": "Please login!", "401": "Please login!",
"404": "Not found", "404": "Not found",
"default": "Unknown error", "default": "Unknown error",
@@ -190,6 +191,41 @@
"upload_title": "Uploading answer...", "upload_title": "Uploading answer...",
"your_answer": "Your answer" "your_answer": "Your answer"
}, },
"quest_admin": {
"add_quest": "Add Quest",
"answers": "{n} answers",
"create_title": "Create Quest",
"delete_confirm": "Do you really want to delete this quest? All answers will be lost!",
"delete_picture": "Delete picture",
"delete_title": "Delete Quest",
"description_de": "Description (German)",
"description_en": "Description (English)",
"edit_title": "Edit Quest",
"extra_points": "Extra points",
"extra_text_de": "Extra text (German)",
"extra_text_en": "Extra text (English)",
"location": "Location",
"location_link": "Location link (URL)",
"move_down": "Move down",
"move_up": "Move up",
"no_picture": "No picture uploaded",
"no_quests": "No quests yet. Add your first quest!",
"optional": "Optional quest",
"picture": "Quest picture",
"picture_required": "Picture required",
"points": "Points",
"question_de": "Question (German)",
"question_en": "Question (English)",
"quests_title": "Quests",
"save": "Save",
"text_required": "Text answer required",
"text_solution": "Text solution",
"text_type": "Text type",
"text_unit": "Text unit",
"title_de": "Title (German)",
"title_en": "Title (English)",
"upload_picture": "Upload picture"
},
"showcase": { "showcase": {
"answers": "Answers", "answers": "Answers",
"crew": "Crew", "crew": "Crew",
@@ -295,4 +331,4 @@
"uploadFile": "Upload file", "uploadFile": "Upload file",
"voteRating": "vote rating {value} of {max}" "voteRating": "vote rating {value} of {max}"
} }
} }
+7 -3
View File
@@ -73,8 +73,12 @@ async function submit() {
<VaCardContent> <VaCardContent>
<VaForm class="flex flex-col gap-4"> <VaForm class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2"> <div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<h3 class="text-center">{{ t('page.create_hunt.de') }}</h3> <h3 class="hidden text-center md:block">
<h3 class="text-center">{{ t('page.create_hunt.en') }}</h3> {{ t('page.create_hunt.de') }}
</h3>
<h3 class="hidden text-center md:block">
{{ t('page.create_hunt.en') }}
</h3>
<VaInput <VaInput
v-model="form.name_de" v-model="form.name_de"
:label="t('page.create_hunt.name_de')" :label="t('page.create_hunt.name_de')"
@@ -126,7 +130,7 @@ async function submit() {
v-model="form.revealAnswers" v-model="form.revealAnswers"
:label="t('page.create_hunt.reveal_answers')" :label="t('page.create_hunt.reveal_answers')"
/> />
<hr /> <VaDivider />
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<VaCheckbox <VaCheckbox
:label="t('page.create_hunt.has_start')" :label="t('page.create_hunt.has_start')"
+4 -2
View File
@@ -8,8 +8,7 @@ import { useTitleStore } from '~/stores/title';
import { fullDate } from '~~/server/utils/date'; import { fullDate } from '~~/server/utils/date';
definePageMeta({ definePageMeta({
middleware: ['auth'], middleware: ['auth']
keepalive: false
}); });
const app = useNuxtApp(); const app = useNuxtApp();
@@ -380,6 +379,9 @@ async function deleteImage() {
</VaButtonGroup> </VaButtonGroup>
</div> </div>
<VaDivider />
<AdminQuestList :hunt-id="id" />
<VaDivider /> <VaDivider />
<h2 class="text-2xl">Hunt admin members:</h2> <h2 class="text-2xl">Hunt admin members:</h2>
<div <div
@@ -0,0 +1,48 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId, questId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
questId: zh.numAsString
})
);
// Verify membership
const quest = await prisma.huntQuest.findUnique({
where: {
id: questId,
huntId,
hunt: {
members: {
some: {
memberId: userId
}
}
}
},
select: {
id: true
}
});
if (!quest) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
await prisma.huntQuest.update({
where: {
id: questId
},
data: {
pictureId: null
}
});
return { success: true };
});
@@ -0,0 +1,81 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import { randomUUID } from 'node:crypto';
import prisma from '~~/lib/prisma';
import { minioClient, s3Bucket, s3Host } from '~~/lib/s3';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId, questId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
questId: zh.numAsString
})
);
// Verify membership
const quest = await prisma.huntQuest.findUnique({
where: {
id: questId,
huntId,
hunt: {
members: {
some: {
memberId: userId
}
}
}
},
select: {
id: true
}
});
if (!quest) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
const body = await readFormData(event);
const formDataObj = Object.fromEntries(body.entries());
const { image } = z
.object({
image: z
.file()
.mime(['image/png', 'image/jpeg', 'image/jpg'])
.max(10_000_000)
})
.parse(formDataObj);
const uuid = randomUUID();
await minioClient.putObject(
s3Bucket,
uuid,
Buffer.from(await image.arrayBuffer()),
undefined,
{
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Type': image.type
}
);
await prisma.huntQuest.update({
where: {
id: questId
},
data: {
picture: {
create: {
creatorId: userId,
url: `https://${s3Bucket}.${s3Host}/${uuid}`,
uuid,
questId
}
}
}
});
return { success: true };
});
@@ -0,0 +1,62 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId, questId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
questId: zh.numAsString
})
);
// Verify membership and get quest order
const quest = await prisma.huntQuest.findUnique({
where: {
id: questId,
huntId,
hunt: {
members: {
some: {
memberId: userId
}
}
}
},
select: {
id: true,
order: true
}
});
if (!quest) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
// Delete the quest
await prisma.huntQuest.delete({
where: {
id: questId
}
});
// Reorder remaining quests
await prisma.huntQuest.updateMany({
where: {
huntId,
order: {
gt: quest.order
}
},
data: {
order: {
decrement: 1
}
}
});
return { success: true };
});
@@ -0,0 +1,63 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId, questId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
questId: zh.numAsString
})
);
const quest = await prisma.huntQuest.findUnique({
where: {
id: questId,
huntId,
hunt: {
members: {
some: {
memberId: userId
}
}
}
},
select: {
id: true,
title_de: true,
title_en: true,
description_de: true,
description_en: true,
question_de: true,
question_en: true,
location: true,
locationLink: true,
order: true,
optional: true,
pictureRequired: true,
textRequired: true,
textType: true,
textUnit: true,
textSolution: true,
points: true,
extraPoints: true,
extraText_de: true,
extraText_en: true,
picture: {
select: {
id: true,
url: true
}
}
}
});
if (!quest) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
return quest;
});
@@ -0,0 +1,76 @@
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId, questId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
questId: zh.numAsString
})
);
// Verify membership
const quest = await prisma.huntQuest.findUnique({
where: {
id: questId,
huntId,
hunt: {
members: {
some: {
memberId: userId
}
}
}
},
select: {
id: true
}
});
if (!quest) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
const body = await useValidatedBody(
event,
z.object({
title_de: z.string().min(1).max(256).trim(),
title_en: z.string().min(1).max(256).trim(),
description_de: z.string().max(2000).trim().default(''),
description_en: z.string().max(2000).trim().default(''),
question_de: z.string().max(1000).trim().nullish(),
question_en: z.string().max(1000).trim().nullish(),
location: z.string().max(256).trim().nullish(),
locationLink: z.string().url().nullish(),
optional: z.boolean().default(true),
pictureRequired: z.boolean().default(false),
textRequired: z.boolean().default(false),
textType: z
.enum(['TEXT', 'WORD', 'CHAR', 'NUMBER', 'INTEGER'])
.default('TEXT'),
textUnit: z.string().max(64).trim().nullish(),
textSolution: z.string().max(256).trim().nullish(),
points: z.number().int().min(0).max(32767).default(10),
extraPoints: z.number().int().min(0).max(32767).nullish(),
extraText_de: z.string().max(256).trim().nullish(),
extraText_en: z.string().max(256).trim().nullish()
})
);
return await prisma.huntQuest.update({
where: {
id: questId
},
data: body,
select: {
id: true,
title_de: true,
title_en: true,
order: true
}
});
});
@@ -0,0 +1,81 @@
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId, questId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
questId: zh.numAsString
})
);
const { direction } = await useValidatedBody(
event,
z.object({
direction: z.enum(['up', 'down'])
})
);
// Verify membership and get quest
const quest = await prisma.huntQuest.findUnique({
where: {
id: questId,
huntId,
hunt: {
members: {
some: {
memberId: userId
}
}
}
},
select: {
id: true,
order: true
}
});
if (!quest) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
const targetOrder = direction === 'up' ? quest.order - 1 : quest.order + 1;
if (targetOrder < 0) {
throw createError({ statusCode: 400, statusMessage: 'Already at top!' });
}
// Find the quest to swap with
const swapQuest = await prisma.huntQuest.findFirst({
where: {
huntId,
order: targetOrder
},
select: {
id: true,
order: true
}
});
if (!swapQuest) {
throw createError({ statusCode: 400, statusMessage: 'Already at bottom!' });
}
// Swap orders in a transaction
await prisma.$transaction([
prisma.huntQuest.update({
where: { id: quest.id },
data: { order: swapQuest.order }
}),
prisma.huntQuest.update({
where: { id: swapQuest.id },
data: { order: quest.order }
})
]);
return { success: true };
});
@@ -0,0 +1,63 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
// Verify membership
const hunt = await prisma.hunt.findUnique({
where: {
id: huntId,
members: {
some: {
memberId: userId
}
}
},
select: {
id: true
}
});
if (!hunt) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
return await prisma.huntQuest.findMany({
where: {
huntId
},
select: {
id: true,
title_de: true,
title_en: true,
order: true,
points: true,
optional: true,
pictureRequired: true,
textRequired: true,
picture: {
select: {
id: true,
url: true
}
},
_count: {
select: {
answers: true
}
}
},
orderBy: {
order: 'asc'
}
});
});
@@ -0,0 +1,78 @@
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
// Verify membership
const hunt = await prisma.hunt.findUnique({
where: {
id: huntId,
members: {
some: {
memberId: userId
}
}
},
select: {
id: true,
_count: {
select: {
quests: true
}
}
}
});
if (!hunt) {
throw createError({ statusCode: 404, statusMessage: 'Not found!' });
}
const body = await useValidatedBody(
event,
z.object({
title_de: z.string().min(1).max(256).trim(),
title_en: z.string().min(1).max(256).trim(),
description_de: z.string().max(2000).trim().default(''),
description_en: z.string().max(2000).trim().default(''),
question_de: z.string().max(1000).trim().nullish(),
question_en: z.string().max(1000).trim().nullish(),
location: z.string().max(256).trim().nullish(),
locationLink: z.string().url().nullish(),
optional: z.boolean().default(true),
pictureRequired: z.boolean().default(false),
textRequired: z.boolean().default(false),
textType: z
.enum(['TEXT', 'WORD', 'CHAR', 'NUMBER', 'INTEGER'])
.default('TEXT'),
textUnit: z.string().max(64).trim().nullish(),
textSolution: z.string().max(256).trim().nullish(),
points: z.number().int().min(0).max(32767).default(10),
extraPoints: z.number().int().min(0).max(32767).nullish(),
extraText_de: z.string().max(256).trim().nullish(),
extraText_en: z.string().max(256).trim().nullish()
})
);
return await prisma.huntQuest.create({
data: {
...body,
huntId,
order: hunt._count.quests
},
select: {
id: true,
title_de: true,
title_en: true,
order: true
}
});
});
+2
View File
@@ -5,6 +5,8 @@ export function parseError(e: unknown): string {
return 'error.401'; return 'error.401';
case 404: case 404:
return 'error.404'; return 'error.404';
case 400:
return 'error.400';
} }
} }
return 'error.default'; return 'error.default';