Move review to separate model

This commit is contained in:
2025-08-13 23:53:36 +02:00
parent befc678bdb
commit 900414727f
17 changed files with 403 additions and 155 deletions
+4 -2
View File
@@ -7,7 +7,7 @@
- [x] Start/Stop
- [ ] Add Reviews/Score
- [x] View best Answers and Pictures
- [ ] All/Best Images gallery
- [x] All/Best Images gallery
- [x] Manage teams (remove, kick, add)
- [x] Manage admins (kick, add)
- **Hunt User**
@@ -18,7 +18,7 @@
- [x] Change/Update Answer and image
- [x] View Answers of other members, check for collisions!
- [x] View own reviews and score
- [ ] Mark submission as final
- [x] Mark submission as final
- **Nice to have**
- **Auth**
- [ ] Login with LDAP
@@ -46,6 +46,8 @@
- [ ] Copy team from different hunt
- **General**
- [x] Pretty slugified urls
- [ ] All static texts translated
- [ ] Pretty
- [ ] SEO, Meta, Preview
- [ ] Deployment `.on.hs-bremen.de`??
+3 -3
View File
@@ -8,7 +8,7 @@ const props = defineProps<{
points: number;
extraPoints: number | null;
answer?: {
score: number | null;
review?: { score: number | null } | null;
} | null;
};
}>();
@@ -18,8 +18,8 @@ const props = defineProps<{
<span>{{ quest.pictureRequired ? '📸' : '📷' }}</span>
<span title="aaa">{{ quest.textRequired ? '❓' : '❔' }}</span>
<span class="mx-2">{{ quest.title }}</span>
<VaChip v-if="quest.answer?.score" color="success" class="float-right"
>{{ quest.answer?.score || 0 }} / {{ quest.points
<VaChip v-if="quest.answer?.review?.score" color="success" class="float-right"
>{{ quest.answer?.review?.score || 0 }} / {{ quest.points
}}{{ quest.extraPoints ? `+${quest.extraPoints}` : '' }}</VaChip
>
<VaChip v-else class="float-right"
+20 -13
View File
@@ -6,13 +6,16 @@ const props = defineProps<{
text: string | null;
updatedAt: string;
team: { id: number; name: string };
score: number | null;
review: string | null;
reviewer: { name: string; id: number } | null;
member: { name: string; id: number } | null;
final: boolean;
review: {
internalNote: string | null;
rankingValue: number | null;
showcase: boolean;
score: number | null;
review: string | null;
reviewer: { name: string; id: number } | null;
} | null;
member: { name: string; id: number } | null;
picture: {
url: string;
createdAt: string;
@@ -35,23 +38,26 @@ const header = computed(() => {
props.answer.picture?.url ? '📸✅' : '📷❌',
props.answer.text ? '📝✅' : '📝❌',
props.answer.review ? '🔍✅' : '🔍❌',
props.answer.score ?? 'No points',
props.answer.review?.score ?? 'No points',
d(props.answer.updatedAt, {
dateStyle: 'medium',
timeStyle: 'medium'
}),
props.answer.rankingValue,
props.answer.showcase ? '★' : null
props.answer.review?.rankingValue,
props.answer.review?.showcase ? '★' : null
];
return parts.filter((s) => !!s).join(' ');
});
const style = computed<[string, string]>(() => {
if (props.answer.review && props.answer.score !== null) {
if (props.answer.review?.review && props.answer.review?.score !== null) {
return ['success', 'check'];
}
if (props.answer.review || props.answer.score !== null) {
if (!props.answer.final) {
return ['', ''];
}
if (props.answer.review?.review || props.answer.review?.score !== null) {
return ['danger', 'priority_high'];
}
if (
@@ -71,6 +77,7 @@ const style = computed<[string, string]>(() => {
<div
class="mb-2 grid grid-cols-3 justify-between gap-8 rounded-lg bg-gray-200 p-4 font-bold md:grid-cols-6"
>
<p v-if="!answer.final">NOT FINAL</p>
<p>
{{
answer.picture?.url ? '📸✅ Picture done' : '📷❌ Picture missing'
@@ -80,14 +87,14 @@ const style = computed<[string, string]>(() => {
<p>
{{ answer.review ? '🔍✅ Review done' : '🔍❌ Review missing' }}
</p>
<p v-if="answer.rankingValue">
<p v-if="answer.review?.rankingValue">
<VaIcon name="leaderboard" color="primary" />
{{ answer.rankingValue }}
{{ answer.review?.rankingValue }}
</p>
<p v-if="answer.showcase">
<p v-if="answer.review?.showcase">
<VaIcon name="auto_awesome" color="warning" /> Showcase
</p>
<p>{{ answer.score ?? 'No' }} points</p>
<p>{{ answer.review?.score ?? 'No' }} points</p>
</div>
<ClickableImage
v-if="answer.picture?.url"
+1 -1
View File
@@ -21,7 +21,7 @@ const newUser = ref<User>();
watchDebounced(
search,
(newSearch) => {
if (newSearch && newSearch.length >= 3) {
if (newSearch) {
safeSearch.value = newSearch;
}
},
+74 -20
View File
@@ -33,6 +33,7 @@ useHead({
});
const answer = ref(data.value?.answer?.text);
const final = ref(data.value?.answer?.final || false);
const textType = {
TEXT: 'text',
@@ -44,8 +45,10 @@ const textType = {
const newImage = ref<File>();
const uploading = ref(false);
const submitError = ref('');
async function submit() {
uploading.value = true;
submitError.value = '';
const body = new FormData();
@@ -55,21 +58,38 @@ async function submit() {
if (answer.value) {
body.set('answer', answer.value);
}
if (final.value) {
body.set('final', 'true');
}
if (newImage.value) {
body.set('image', newImage.value);
}
const result = await $fetch(`/api/quest/${questSlug?.id}/submit`, {
try {
await $fetch(`/api/quest/${questSlug?.id}/submit`, {
method: 'POST',
body
});
console.log(result);
await refresh();
uploading.value = false;
answer.value = data.value?.answer?.text;
newImage.value = undefined;
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 418) {
submitError.value = 'Answer is already finalized, no update allowed!';
await refresh();
} else if (
e instanceof Error &&
'statusCode' in e &&
e.statusCode === 409
) {
submitError.value = 'Conflict: Please refresh';
} else {
submitError.value = parseError(e);
}
}
uploading.value = false;
}
const createImageURL = (file: File) => URL.createObjectURL(file);
@@ -83,7 +103,10 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
>Back to Hunt</VaButton
>
<VaButton
@click="refresh"
@click="
submitError = '';
refresh();
"
icon="refresh"
:loading="pending"
color="success"
@@ -105,7 +128,9 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
<VaCard
:stripe="data.answer !== null"
:stripe-color="
data.answer?.score && data.answer?.score > 0 ? 'success' : 'primary'
data.answer?.review?.score && data.answer?.review?.score > 0
? 'success'
: 'primary'
"
>
<VaCardTitle><QuestTags :quest="data" /></VaCardTitle>
@@ -132,26 +157,34 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
<VaCard color="info" v-if="data.answer?.review">
<VaCardTitle>Review</VaCardTitle>
<VaCardContent>
<p v-if="data.answer.score" class="mb-2">
<VaChip color="success"
>Answer type: {{ data.answer.score }}</VaChip
>
<span v-if="data.answer.reviewer" class="text-sm">
by {{ data.answer.reviewer.name }}</span
<p v-if="data.answer.review?.score" class="mb-2">
Score:
<VaChip color="success">{{ data.answer.review.score }}</VaChip>
<span v-if="data.answer.review.reviewer" class="text-sm">
by {{ data.answer.review.reviewer.name }} at
{{
$d(data.answer.updatedAt, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}</span
>
</p>
<p>{{ data.answer.review }}</p>
<p class="italic">{{ data.answer.review.review }}</p>
</VaCardContent>
</VaCard>
<VaCard color="primary" v-if="user && user.role !== 'ADMIN'">
<VaCard
stripe
:stripe-color="data.answer?.final ? 'success' : 'primary'"
v-if="user && user.role !== 'ADMIN'"
>
<VaCardTitle>Submission</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-4">
<VaInput
:label="data.question"
:disabled="uploading"
:disabled="uploading || data.answer?.final"
v-if="data.question"
color="onPrimary"
placeholder="Your answer"
:required-mark="data.textRequired"
v-model="answer"
@@ -187,8 +220,7 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
></VaImage>
<VaFileUpload
v-model="newImage"
:disabled="uploading"
color="onPrimary"
:disabled="uploading || data.answer?.final"
type="single"
file-types="jpg,png,jpeg"
/>
@@ -206,13 +238,35 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
}}
by {{ data.answer.member.name }}
</p>
<VaCheckbox
:disabled="data.answer?.final"
label="Finalize answer"
messages="This allows the admins to review your answer and give you points. You cannot change your answer afterwards!"
v-model="final"
/>
<VaAlert
v-if="submitError.length > 0"
color="danger"
class="w-full text-center"
>{{ $t(submitError) }}</VaAlert
>
<VaButton
:disabled="!(newImage || data.answer?.text !== answer)"
color="onPrimary"
v-if="!data.answer?.final"
:disabled="
!(
newImage ||
data.answer?.text !== answer ||
data.answer?.final !== final
)
"
:loading="uploading"
@click="submit"
>Update</VaButton
>
<p v-else class="font-bold">
Your final answer has already been submitted!
</p>
</div>
</VaCardContent>
</VaCard>
+1 -1
View File
@@ -55,7 +55,7 @@ const newUser = ref<User>();
watchDebounced(
search,
(newSearch) => {
if (newSearch && newSearch.length >= 3) {
if (newSearch) {
safeSearch.value = newSearch;
}
},
+8 -3
View File
@@ -143,9 +143,14 @@ async function invite() {
v-for="quest in data.quests"
:key="`q-${quest.id}`"
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}`)"
:stripe="quest.answer !== null"
stripe
:stripe-color="
quest.answer?.score && quest.answer?.score > 0 ? 'success' : 'primary'
(quest.answer?.review?.score && quest.answer?.review?.score > 0) ||
quest.answer?.final
? 'success'
: !!quest.answer
? 'warning'
: 'primary'
"
>
<VaCardTitle><QuestTags :quest="quest" /></VaCardTitle>
@@ -172,7 +177,7 @@ async function invite() {
<VaButton
color="danger"
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}/admin`)"
>Admin</VaButton
>Admin ({{ quest.unreviewed || 0 }} unreviewed)</VaButton
>
</VaCardActions>
</VaCard>
@@ -0,0 +1,62 @@
-- CreateTable
CREATE TABLE "public"."Review"
(
"id" SERIAL NOT NULL,
"score" SMALLINT,
"review" TEXT,
"internalNote" TEXT,
"rankingValue" INTEGER,
"showcase" BOOLEAN NOT NULL DEFAULT false,
"answerId" INTEGER NOT NULL,
"reviewerId" INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Review_answerId_key" ON "public"."Review" ("answerId");
-- AddForeignKey
ALTER TABLE "public"."Review"
ADD CONSTRAINT "Review_answerId_fkey" FOREIGN KEY ("answerId") REFERENCES "public"."QuestAnswer" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Review"
ADD CONSTRAINT "Review_reviewerId_fkey" FOREIGN KEY ("reviewerId") REFERENCES "public"."User" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
insert into public."Review" ("internalNote",
"rankingValue",
review,
score,
showcase,
"reviewerId",
"answerId",
"updatedAt",
"createdAt")
select "internalNote",
"rankingValue",
review,
score,
showcase,
"reviewerId",
id,
"updatedAt",
"updatedAt"
from "QuestAnswer"
where "reviewerId" is not null;
-- DropForeignKey
ALTER TABLE "public"."QuestAnswer"
DROP CONSTRAINT "QuestAnswer_reviewerId_fkey";
-- AlterTable
ALTER TABLE "public"."QuestAnswer"
DROP COLUMN "internalNote",
DROP COLUMN "rankingValue",
DROP COLUMN "review",
DROP COLUMN "reviewerId",
DROP COLUMN "score",
DROP COLUMN "showcase",
ADD COLUMN "final" BOOLEAN NOT NULL DEFAULT false;
+20 -7
View File
@@ -28,7 +28,7 @@ model User {
teamsOwner HuntTeam[]
teamsMember TeamMember[]
answers QuestAnswer[] @relation("AnswerMember")
reviews QuestAnswer[] @relation("AnswerReview")
reviews Review[]
uploadedFiles File[]
}
@@ -192,6 +192,7 @@ model QuestAnswer {
location String?
lat Decimal? @db.Decimal(10, 8)
lon Decimal? @db.Decimal(10, 8)
final Boolean @default(false)
team HuntTeam @relation(fields: [teamId], references: [id], onUpdate: Cascade, onDelete: Cascade)
teamId Int
@@ -208,8 +209,17 @@ model QuestAnswer {
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
reviewer User? @relation("AnswerReview", fields: [reviewerId], references: [id], onUpdate: Cascade, onDelete: Cascade)
reviewerId Int?
review Review?
oldFiles File[] @relation("OldAnswerFiles")
@@index([teamId])
@@index([memberId])
@@index([questId])
}
model Review {
id Int @id @default(autoincrement())
/// Final score for this answer including bonus
score Int? @db.SmallInt
@@ -222,9 +232,12 @@ model QuestAnswer {
/// If true, this answer will be featured in the end showcase
showcase Boolean @default(false)
oldFiles File[] @relation("OldAnswerFiles")
answer QuestAnswer @relation(fields: [answerId], references: [id], onUpdate: Cascade, onDelete: Cascade)
answerId Int @unique
@@index([teamId])
@@index([memberId])
@@index([questId])
reviewer User @relation(fields: [reviewerId], references: [id], onUpdate: Cascade, onDelete: Cascade)
reviewerId Int
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
}
+83 -52
View File
@@ -236,12 +236,47 @@ async function main() {
const team3 = hunt.teams[2];
const images = [
'/img/dummy/moin1.jpeg',
'/img/dummy/musikanten1.jpg',
'/img/dummy/aussicht1.jpeg',
'https://pichunt.s3.swarm.syma.dev/0197ff6a-ce49-7000-a5b0-f32f1ee1b4ee',
'https://pichunt.s3.swarm.syma.dev/0197ff1a-3c8e-7000-8ba6-fa9c06548f82',
'https://pichunt.s3.swarm.syma.dev/0197ff5b-f74d-7000-864c-20f531481fcc'
'https://pichunt.s3.swarm.syma.dev/0197ff5b-f74d-7000-864c-20f531481fcc',
'/img/dummy/moin1.jpeg',
'/img/dummy/musikanten1.jpg'
];
const reviews = [
{
reviewerId: admin.id,
score: 10,
review: 'Schönes Bild!',
internalNote: 'Wow!'
},
{
score: 17,
reviewerId: huntMember.id,
review: 'Höchstes Team, aber leider gabs andere im AB!',
rankingValue: 10
},
{
score: 13,
reviewerId: admin.id,
review: 'Ihr wart 612m weit weg!',
rankingValue: 612,
internalNote: '612m'
},
{
score: 21,
reviewerId: admin.id,
review: 'Ihr wart 2110m weit weg!',
rankingValue: 2110,
internalNote: 'Bisher am weitesten'
},
{
score: 18,
reviewerId: huntMember.id,
review: 'Ihr wart 945m weit weg!',
rankingValue: 945,
showcase: true
}
];
const answers = await tx.questAnswer.createManyAndReturn({
data: [
@@ -253,10 +288,35 @@ async function main() {
location: 'An der Haltestelle Herdentor',
lat: 53.079138,
lon: 8.809734,
reviewerId: admin.id,
score: 10,
review: 'Schönes Bild!',
internalNote: 'Wow!'
final: true
},
{
teamId: team.id,
memberId: teamMember.id,
questId: hunt.quests[2].id,
text: 'Neustadtswall AB-Gebäude Etage 10',
final: true
},
{
teamId: team.id,
memberId: teamMember.id,
questId: hunt.quests[3].id,
text: 'Theater am Leibnizplatz',
final: true
},
{
teamId: team2.id,
memberId: teamMember2.id,
questId: hunt.quests[3].id,
text: 'Bürgerpark',
final: true
},
{
teamId: team3.id,
memberId: teamMember3.id,
questId: hunt.quests[3].id,
text: 'Am Brill',
final: true
},
{
teamId: team.id,
@@ -264,52 +324,13 @@ async function main() {
questId: hunt.quests[1].id,
text: '14',
location: 'Marktplatz',
score: 10
},
{
teamId: team.id,
memberId: teamMember.id,
questId: hunt.quests[2].id,
text: 'Neustadtswall AB-Gebäude Etage 10',
score: 17,
reviewerId: huntMember.id,
review: 'Höchstes Team, aber leider gabs andere im AB!',
rankingValue: 10
},
{
teamId: team.id,
memberId: teamMember.id,
questId: hunt.quests[3].id,
text: 'Theater am Leibnizplatz',
score: 13,
reviewerId: admin.id,
review: 'Ihr wart 612m weit weg!',
rankingValue: 612,
internalNote: '612m'
},
{
teamId: team2.id,
memberId: teamMember2.id,
questId: hunt.quests[3].id,
text: 'Bürgerpark',
score: 21,
reviewerId: admin.id,
review: 'Ihr wart 2110m weit weg!',
rankingValue: 2110,
internalNote: 'Bisher am weitesten'
},
{
teamId: team3.id,
memberId: teamMember3.id,
questId: hunt.quests[3].id,
text: 'Am Brill',
score: 18,
reviewerId: huntMember.id,
review: 'Ihr wart 945m weit weg!',
rankingValue: 945,
showcase: true
final: false
}
],
select: {
id: true,
memberId: true
}
]
});
await Promise.all(
@@ -325,6 +346,16 @@ async function main() {
})
)
);
await Promise.all(
reviews.map((r, i) =>
tx.review.create({
data: {
answerId: answers[i].id,
...r
}
})
)
);
console.log(JSON.stringify(hunt, null, 2));
});
@@ -45,23 +45,30 @@ export default defineEventHandler(async (event) => {
if (!answer) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const { score, review } = await useValidatedBody(
const data = await useValidatedBody(
event,
z.object({
score: z.int().min(0).max(32767).nullable(),
review: z.string().max(256).nullable()
review: z.string().max(256).nullable(),
showcase: z.boolean().nullable().default(false),
internalNote: z.string().max(256).nullable(),
rankingValue: z.int().nullable()
})
);
await prisma.questAnswer.update({
await prisma.review.upsert({
where: {
id: answerId
answerId
},
data: {
score,
review,
update: {
...data,
reviewerId: userId
},
create: {
...data,
reviewerId: userId
}
});
return answer;
});
+28 -4
View File
@@ -56,23 +56,31 @@ export default defineEventHandler(async (event) => {
members: true,
answers: {
where: {
review: {
score: { gt: 0 }
}
}
}
}
},
id: true,
name: true,
description: true,
answers: {
select: {
review: {
select: {
score: true
}
}
},
where: {
review: {
score: { not: null }
}
}
}
}
},
quests: {
@@ -88,7 +96,11 @@ export default defineEventHandler(async (event) => {
select: {
id: true,
text: true,
score: true,
review: {
select: {
score: true
}
},
picture: {
select: {
@@ -110,11 +122,21 @@ export default defineEventHandler(async (event) => {
}
},
where: {
review: {
showcase: true
}
},
orderBy: [
{ rankingValue: { sort: 'asc', nulls: 'last' } },
{ score: { sort: 'asc', nulls: 'last' } },
{
review: {
rankingValue: { sort: 'asc', nulls: 'last' }
}
},
{
review: {
score: { sort: 'asc', nulls: 'last' }
}
},
{ createdAt: 'asc' }
]
}
@@ -124,10 +146,12 @@ export default defineEventHandler(async (event) => {
{
answers: {
some: {
review: {
showcase: true
}
}
}
}
// TODO: showcase solutions to quests
// {
// textSolution: { not: null }
@@ -155,7 +179,7 @@ export default defineEventHandler(async (event) => {
id,
description,
answers: _count.answers,
score: answers.reduce((sum, a) => sum + (a.score || 0), 0)
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0)
}))
.toSorted((a, b) => b.score - a.score);
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
const { email } = await useValidatedQuery(
event,
z.object({
email: z.string().min(3).trim()
email: z.string().trim().default('')
})
);
@@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => {
select: {
id: true,
text: true,
final: true,
updatedAt: true,
team: {
select: {
@@ -58,6 +59,8 @@ export default defineEventHandler(async (event) => {
name: true
}
},
review: {
select: {
score: true,
review: true,
internalNote: true,
@@ -68,6 +71,8 @@ export default defineEventHandler(async (event) => {
name: true,
id: true
}
}
}
},
member: {
select: {
@@ -83,8 +88,9 @@ export default defineEventHandler(async (event) => {
}
},
orderBy: [
{ rankingValue: { sort: 'desc', nulls: 'last' } },
{ score: { sort: 'desc', nulls: 'last' } },
{ final: 'desc' },
{ review: { rankingValue: { sort: 'desc', nulls: 'last' } } },
{ review: { score: { sort: 'desc', nulls: 'last' } } },
{
updatedAt: 'desc'
}
+28 -5
View File
@@ -96,20 +96,42 @@ export default defineEventHandler(async (event) => {
textUnit: true,
_count: {
select: {
answers: true
answers: {
where: {
final: true,
OR: [
{
review: null
},
{
review: { score: null }
},
{
review: {
review: null
}
}
]
}
}
}
},
answers: {
select: {
id: true,
text: true,
final: true,
picture: {
select: {
url: true
}
},
review: {
select: {
score: true,
review: true,
review: true
}
},
member: {
select: {
name: true
@@ -160,13 +182,14 @@ export default defineEventHandler(async (event) => {
const isMember = hunt.creator.id === userId || members.length > 0;
const ownTeam = teams.length > 0 ? teams[0] : null;
const mappedQuests = quests.map(({ answers, ...q }) => ({
const mappedQuests = quests.map(({ answers, _count, ...q }) => ({
...q,
unreviewed: isMember ? _count.answers : undefined,
answer: answers.length > 0 ? answers[0] : null
}));
const totalScore = mappedQuests.reduce(
(acc, q) => acc + (q.answer?.score ?? 0),
(acc, q) => acc + (q.answer?.review?.score ?? 0),
0
);
@@ -176,7 +199,7 @@ export default defineEventHandler(async (event) => {
ownTeam,
loggedIn: !!user.user,
totalScore: ownTeam !== null ? totalScore : undefined,
quests: hunt.revealQuests ? mappedQuests : null,
quests: hunt.revealQuests || isMember ? mappedQuests : null,
...hunt
};
});
+11 -5
View File
@@ -50,18 +50,24 @@ export default defineEventHandler(async (event) => {
url: true
}
},
review: {
select: {
score: true,
review: true,
member: {
select: {
name: true
}
},
reviewer: {
select: {
name: true
}
},
updatedAt: true
}
},
member: {
select: {
name: true
}
},
final: true,
createdAt: true,
updatedAt: true
},
+15 -7
View File
@@ -46,7 +46,8 @@ export default defineEventHandler(async (event) => {
id: true,
text: true,
picture: true,
updatedAt: true
updatedAt: true,
final: true
},
where: {
team: {
@@ -75,6 +76,9 @@ export default defineEventHandler(async (event) => {
if (quest.hunt.teams.length <= 0) {
throw createError({ status: 403, statusText: 'No team yet!' });
}
if (quest.answers.length > 0 && quest.answers[0].final) {
throw createError({ status: 418, statusText: 'Already marked as final!' });
}
const team = quest.hunt.teams[0];
@@ -89,7 +93,8 @@ export default defineEventHandler(async (event) => {
.file()
.mime(['image/png', 'image/jpeg', 'image/jpg'])
.max(10_000_000)
.optional()
.optional(),
final: zh.boolAsString.optional()
})
.parse(formDataObj);
@@ -113,7 +118,6 @@ export default defineEventHandler(async (event) => {
});
}
// TODO: prevent updating reviewed answers
// TODO: respect end of hunt
let picture: { creatorId: number; url: string; uuid: string } | undefined =
@@ -155,7 +159,8 @@ export default defineEventHandler(async (event) => {
data: {
memberId: userId,
text: data.answer,
pictureId: file.id
pictureId: file.id,
final: data.final
}
});
} else {
@@ -164,7 +169,8 @@ export default defineEventHandler(async (event) => {
teamId: team.id,
memberId: userId,
questId: quest.id,
text: data.answer
text: data.answer,
final: data.final
},
select: { id: true }
});
@@ -187,7 +193,8 @@ export default defineEventHandler(async (event) => {
},
data: {
memberId: userId,
text: data.answer
text: data.answer,
final: data.final
}
});
} else {
@@ -196,7 +203,8 @@ export default defineEventHandler(async (event) => {
teamId: team.id,
memberId: userId,
questId: quest.id,
text: data.answer
text: data.answer,
final: data.final
}
});
}