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 - [x] Start/Stop
- [ ] Add Reviews/Score - [ ] Add Reviews/Score
- [x] View best Answers and Pictures - [x] View best Answers and Pictures
- [ ] All/Best Images gallery - [x] All/Best Images gallery
- [x] Manage teams (remove, kick, add) - [x] Manage teams (remove, kick, add)
- [x] Manage admins (kick, add) - [x] Manage admins (kick, add)
- **Hunt User** - **Hunt User**
@@ -18,7 +18,7 @@
- [x] Change/Update Answer and image - [x] Change/Update Answer and image
- [x] View Answers of other members, check for collisions! - [x] View Answers of other members, check for collisions!
- [x] View own reviews and score - [x] View own reviews and score
- [ ] Mark submission as final - [x] Mark submission as final
- **Nice to have** - **Nice to have**
- **Auth** - **Auth**
- [ ] Login with LDAP - [ ] Login with LDAP
@@ -46,6 +46,8 @@
- [ ] Copy team from different hunt - [ ] Copy team from different hunt
- **General** - **General**
- [x] Pretty slugified urls - [x] Pretty slugified urls
- [ ] All static texts translated
- [ ] Pretty
- [ ] SEO, Meta, Preview - [ ] SEO, Meta, Preview
- [ ] Deployment `.on.hs-bremen.de`?? - [ ] Deployment `.on.hs-bremen.de`??
+3 -3
View File
@@ -8,7 +8,7 @@ const props = defineProps<{
points: number; points: number;
extraPoints: number | null; extraPoints: number | null;
answer?: { answer?: {
score: number | null; review?: { score: number | null } | null;
} | null; } | null;
}; };
}>(); }>();
@@ -18,8 +18,8 @@ const props = defineProps<{
<span>{{ quest.pictureRequired ? '📸' : '📷' }}</span> <span>{{ quest.pictureRequired ? '📸' : '📷' }}</span>
<span title="aaa">{{ quest.textRequired ? '❓' : '❔' }}</span> <span title="aaa">{{ quest.textRequired ? '❓' : '❔' }}</span>
<span class="mx-2">{{ quest.title }}</span> <span class="mx-2">{{ quest.title }}</span>
<VaChip v-if="quest.answer?.score" color="success" class="float-right" <VaChip v-if="quest.answer?.review?.score" color="success" class="float-right"
>{{ quest.answer?.score || 0 }} / {{ quest.points >{{ quest.answer?.review?.score || 0 }} / {{ quest.points
}}{{ quest.extraPoints ? `+${quest.extraPoints}` : '' }}</VaChip }}{{ quest.extraPoints ? `+${quest.extraPoints}` : '' }}</VaChip
> >
<VaChip v-else class="float-right" <VaChip v-else class="float-right"
+20 -13
View File
@@ -6,13 +6,16 @@ const props = defineProps<{
text: string | null; text: string | null;
updatedAt: string; updatedAt: string;
team: { id: number; name: string }; team: { id: number; name: string };
score: number | null; final: boolean;
review: string | null; review: {
reviewer: { name: string; id: number } | null;
member: { name: string; id: number } | null;
internalNote: string | null; internalNote: string | null;
rankingValue: number | null; rankingValue: number | null;
showcase: boolean; showcase: boolean;
score: number | null;
review: string | null;
reviewer: { name: string; id: number } | null;
} | null;
member: { name: string; id: number } | null;
picture: { picture: {
url: string; url: string;
createdAt: string; createdAt: string;
@@ -35,23 +38,26 @@ const header = computed(() => {
props.answer.picture?.url ? '📸✅' : '📷❌', props.answer.picture?.url ? '📸✅' : '📷❌',
props.answer.text ? '📝✅' : '📝❌', props.answer.text ? '📝✅' : '📝❌',
props.answer.review ? '🔍✅' : '🔍❌', props.answer.review ? '🔍✅' : '🔍❌',
props.answer.score ?? 'No points', props.answer.review?.score ?? 'No points',
d(props.answer.updatedAt, { d(props.answer.updatedAt, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'medium' timeStyle: 'medium'
}), }),
props.answer.rankingValue, props.answer.review?.rankingValue,
props.answer.showcase ? '★' : null props.answer.review?.showcase ? '★' : null
]; ];
return parts.filter((s) => !!s).join(' '); return parts.filter((s) => !!s).join(' ');
}); });
const style = computed<[string, string]>(() => { 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']; 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']; return ['danger', 'priority_high'];
} }
if ( if (
@@ -71,6 +77,7 @@ const style = computed<[string, string]>(() => {
<div <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" 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> <p>
{{ {{
answer.picture?.url ? '📸✅ Picture done' : '📷❌ Picture missing' answer.picture?.url ? '📸✅ Picture done' : '📷❌ Picture missing'
@@ -80,14 +87,14 @@ const style = computed<[string, string]>(() => {
<p> <p>
{{ answer.review ? '🔍✅ Review done' : '🔍❌ Review missing' }} {{ answer.review ? '🔍✅ Review done' : '🔍❌ Review missing' }}
</p> </p>
<p v-if="answer.rankingValue"> <p v-if="answer.review?.rankingValue">
<VaIcon name="leaderboard" color="primary" /> <VaIcon name="leaderboard" color="primary" />
{{ answer.rankingValue }} {{ answer.review?.rankingValue }}
</p> </p>
<p v-if="answer.showcase"> <p v-if="answer.review?.showcase">
<VaIcon name="auto_awesome" color="warning" /> Showcase <VaIcon name="auto_awesome" color="warning" /> Showcase
</p> </p>
<p>{{ answer.score ?? 'No' }} points</p> <p>{{ answer.review?.score ?? 'No' }} points</p>
</div> </div>
<ClickableImage <ClickableImage
v-if="answer.picture?.url" v-if="answer.picture?.url"
+1 -1
View File
@@ -21,7 +21,7 @@ const newUser = ref<User>();
watchDebounced( watchDebounced(
search, search,
(newSearch) => { (newSearch) => {
if (newSearch && newSearch.length >= 3) { if (newSearch) {
safeSearch.value = newSearch; safeSearch.value = newSearch;
} }
}, },
+74 -20
View File
@@ -33,6 +33,7 @@ useHead({
}); });
const answer = ref(data.value?.answer?.text); const answer = ref(data.value?.answer?.text);
const final = ref(data.value?.answer?.final || false);
const textType = { const textType = {
TEXT: 'text', TEXT: 'text',
@@ -44,8 +45,10 @@ const textType = {
const newImage = ref<File>(); const newImage = ref<File>();
const uploading = ref(false); const uploading = ref(false);
const submitError = ref('');
async function submit() { async function submit() {
uploading.value = true; uploading.value = true;
submitError.value = '';
const body = new FormData(); const body = new FormData();
@@ -55,21 +58,38 @@ async function submit() {
if (answer.value) { if (answer.value) {
body.set('answer', answer.value); body.set('answer', answer.value);
} }
if (final.value) {
body.set('final', 'true');
}
if (newImage.value) { if (newImage.value) {
body.set('image', 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', method: 'POST',
body body
}); });
console.log(result);
await refresh(); await refresh();
uploading.value = false;
answer.value = data.value?.answer?.text; answer.value = data.value?.answer?.text;
newImage.value = undefined; 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); const createImageURL = (file: File) => URL.createObjectURL(file);
@@ -83,7 +103,10 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
>Back to Hunt</VaButton >Back to Hunt</VaButton
> >
<VaButton <VaButton
@click="refresh" @click="
submitError = '';
refresh();
"
icon="refresh" icon="refresh"
:loading="pending" :loading="pending"
color="success" color="success"
@@ -105,7 +128,9 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
<VaCard <VaCard
:stripe="data.answer !== null" :stripe="data.answer !== null"
:stripe-color=" :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> <VaCardTitle><QuestTags :quest="data" /></VaCardTitle>
@@ -132,26 +157,34 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
<VaCard color="info" v-if="data.answer?.review"> <VaCard color="info" v-if="data.answer?.review">
<VaCardTitle>Review</VaCardTitle> <VaCardTitle>Review</VaCardTitle>
<VaCardContent> <VaCardContent>
<p v-if="data.answer.score" class="mb-2"> <p v-if="data.answer.review?.score" class="mb-2">
<VaChip color="success" Score:
>Answer type: {{ data.answer.score }}</VaChip <VaChip color="success">{{ data.answer.review.score }}</VaChip>
> <span v-if="data.answer.review.reviewer" class="text-sm">
<span v-if="data.answer.reviewer" class="text-sm"> by {{ data.answer.review.reviewer.name }} at
by {{ data.answer.reviewer.name }}</span {{
$d(data.answer.updatedAt, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}</span
> >
</p> </p>
<p>{{ data.answer.review }}</p> <p class="italic">{{ data.answer.review.review }}</p>
</VaCardContent> </VaCardContent>
</VaCard> </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> <VaCardTitle>Submission</VaCardTitle>
<VaCardContent> <VaCardContent>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<VaInput <VaInput
:label="data.question" :label="data.question"
:disabled="uploading" :disabled="uploading || data.answer?.final"
v-if="data.question" v-if="data.question"
color="onPrimary"
placeholder="Your answer" placeholder="Your answer"
:required-mark="data.textRequired" :required-mark="data.textRequired"
v-model="answer" v-model="answer"
@@ -187,8 +220,7 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
></VaImage> ></VaImage>
<VaFileUpload <VaFileUpload
v-model="newImage" v-model="newImage"
:disabled="uploading" :disabled="uploading || data.answer?.final"
color="onPrimary"
type="single" type="single"
file-types="jpg,png,jpeg" file-types="jpg,png,jpeg"
/> />
@@ -206,13 +238,35 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
}} }}
by {{ data.answer.member.name }} by {{ data.answer.member.name }}
</p> </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 <VaButton
:disabled="!(newImage || data.answer?.text !== answer)" v-if="!data.answer?.final"
color="onPrimary" :disabled="
!(
newImage ||
data.answer?.text !== answer ||
data.answer?.final !== final
)
"
:loading="uploading" :loading="uploading"
@click="submit" @click="submit"
>Update</VaButton >Update</VaButton
> >
<p v-else class="font-bold">
Your final answer has already been submitted!
</p>
</div> </div>
</VaCardContent> </VaCardContent>
</VaCard> </VaCard>
+1 -1
View File
@@ -55,7 +55,7 @@ const newUser = ref<User>();
watchDebounced( watchDebounced(
search, search,
(newSearch) => { (newSearch) => {
if (newSearch && newSearch.length >= 3) { if (newSearch) {
safeSearch.value = newSearch; safeSearch.value = newSearch;
} }
}, },
+8 -3
View File
@@ -143,9 +143,14 @@ async function invite() {
v-for="quest in data.quests" v-for="quest in data.quests"
:key="`q-${quest.id}`" :key="`q-${quest.id}`"
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}`)" :to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}`)"
:stripe="quest.answer !== null" stripe
:stripe-color=" :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> <VaCardTitle><QuestTags :quest="quest" /></VaCardTitle>
@@ -172,7 +177,7 @@ async function invite() {
<VaButton <VaButton
color="danger" color="danger"
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}/admin`)" :to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}/admin`)"
>Admin</VaButton >Admin ({{ quest.unreviewed || 0 }} unreviewed)</VaButton
> >
</VaCardActions> </VaCardActions>
</VaCard> </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[] teamsOwner HuntTeam[]
teamsMember TeamMember[] teamsMember TeamMember[]
answers QuestAnswer[] @relation("AnswerMember") answers QuestAnswer[] @relation("AnswerMember")
reviews QuestAnswer[] @relation("AnswerReview") reviews Review[]
uploadedFiles File[] uploadedFiles File[]
} }
@@ -192,6 +192,7 @@ model QuestAnswer {
location String? location String?
lat Decimal? @db.Decimal(10, 8) lat Decimal? @db.Decimal(10, 8)
lon 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) team HuntTeam @relation(fields: [teamId], references: [id], onUpdate: Cascade, onDelete: Cascade)
teamId Int teamId Int
@@ -208,8 +209,17 @@ model QuestAnswer {
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3)
reviewer User? @relation("AnswerReview", fields: [reviewerId], references: [id], onUpdate: Cascade, onDelete: Cascade) review Review?
reviewerId Int?
oldFiles File[] @relation("OldAnswerFiles")
@@index([teamId])
@@index([memberId])
@@index([questId])
}
model Review {
id Int @id @default(autoincrement())
/// Final score for this answer including bonus /// Final score for this answer including bonus
score Int? @db.SmallInt score Int? @db.SmallInt
@@ -222,9 +232,12 @@ model QuestAnswer {
/// If true, this answer will be featured in the end showcase /// If true, this answer will be featured in the end showcase
showcase Boolean @default(false) showcase Boolean @default(false)
oldFiles File[] @relation("OldAnswerFiles") answer QuestAnswer @relation(fields: [answerId], references: [id], onUpdate: Cascade, onDelete: Cascade)
answerId Int @unique
@@index([teamId]) reviewer User @relation(fields: [reviewerId], references: [id], onUpdate: Cascade, onDelete: Cascade)
@@index([memberId]) reviewerId Int
@@index([questId])
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 team3 = hunt.teams[2];
const images = [ const images = [
'/img/dummy/moin1.jpeg',
'/img/dummy/musikanten1.jpg',
'/img/dummy/aussicht1.jpeg', '/img/dummy/aussicht1.jpeg',
'https://pichunt.s3.swarm.syma.dev/0197ff6a-ce49-7000-a5b0-f32f1ee1b4ee', '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/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({ const answers = await tx.questAnswer.createManyAndReturn({
data: [ data: [
@@ -253,10 +288,35 @@ async function main() {
location: 'An der Haltestelle Herdentor', location: 'An der Haltestelle Herdentor',
lat: 53.079138, lat: 53.079138,
lon: 8.809734, lon: 8.809734,
reviewerId: admin.id, final: true
score: 10, },
review: 'Schönes Bild!', {
internalNote: 'Wow!' 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, teamId: team.id,
@@ -264,52 +324,13 @@ async function main() {
questId: hunt.quests[1].id, questId: hunt.quests[1].id,
text: '14', text: '14',
location: 'Marktplatz', location: 'Marktplatz',
score: 10 final: false
}, }
{ ],
teamId: team.id, select: {
memberId: teamMember.id, id: true,
questId: hunt.quests[2].id, memberId: true
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
} }
]
}); });
await Promise.all( 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)); console.log(JSON.stringify(hunt, null, 2));
}); });
@@ -45,23 +45,30 @@ export default defineEventHandler(async (event) => {
if (!answer) { if (!answer) {
throw createError({ status: 404, statusText: 'Not found!' }); throw createError({ status: 404, statusText: 'Not found!' });
} }
const { score, review } = await useValidatedBody( const data = await useValidatedBody(
event, event,
z.object({ z.object({
score: z.int().min(0).max(32767).nullable(), 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: { where: {
id: answerId answerId
}, },
data: { update: {
score, ...data,
review, reviewerId: userId
},
create: {
...data,
reviewerId: userId reviewerId: userId
} }
}); });
return answer; return answer;
}); });
+28 -4
View File
@@ -56,23 +56,31 @@ export default defineEventHandler(async (event) => {
members: true, members: true,
answers: { answers: {
where: { where: {
review: {
score: { gt: 0 } score: { gt: 0 }
} }
} }
} }
}
}, },
id: true, id: true,
name: true, name: true,
description: true, description: true,
answers: { answers: {
select: {
review: {
select: { select: {
score: true score: true
}
}
}, },
where: { where: {
review: {
score: { not: null } score: { not: null }
} }
} }
} }
}
}, },
quests: { quests: {
@@ -88,7 +96,11 @@ export default defineEventHandler(async (event) => {
select: { select: {
id: true, id: true,
text: true, text: true,
score: true, review: {
select: {
score: true
}
},
picture: { picture: {
select: { select: {
@@ -110,11 +122,21 @@ export default defineEventHandler(async (event) => {
} }
}, },
where: { where: {
review: {
showcase: true showcase: true
}
}, },
orderBy: [ 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' } { createdAt: 'asc' }
] ]
} }
@@ -124,10 +146,12 @@ export default defineEventHandler(async (event) => {
{ {
answers: { answers: {
some: { some: {
review: {
showcase: true showcase: true
} }
} }
} }
}
// TODO: showcase solutions to quests // TODO: showcase solutions to quests
// { // {
// textSolution: { not: null } // textSolution: { not: null }
@@ -155,7 +179,7 @@ export default defineEventHandler(async (event) => {
id, id,
description, description,
answers: _count.answers, 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); .toSorted((a, b) => b.score - a.score);
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
const { email } = await useValidatedQuery( const { email } = await useValidatedQuery(
event, event,
z.object({ z.object({
email: z.string().min(3).trim() email: z.string().trim().default('')
}) })
); );
@@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => {
select: { select: {
id: true, id: true,
text: true, text: true,
final: true,
updatedAt: true, updatedAt: true,
team: { team: {
select: { select: {
@@ -58,6 +59,8 @@ export default defineEventHandler(async (event) => {
name: true name: true
} }
}, },
review: {
select: {
score: true, score: true,
review: true, review: true,
internalNote: true, internalNote: true,
@@ -68,6 +71,8 @@ export default defineEventHandler(async (event) => {
name: true, name: true,
id: true id: true
} }
}
}
}, },
member: { member: {
select: { select: {
@@ -83,8 +88,9 @@ export default defineEventHandler(async (event) => {
} }
}, },
orderBy: [ orderBy: [
{ rankingValue: { sort: 'desc', nulls: 'last' } }, { final: 'desc' },
{ score: { sort: 'desc', nulls: 'last' } }, { review: { rankingValue: { sort: 'desc', nulls: 'last' } } },
{ review: { score: { sort: 'desc', nulls: 'last' } } },
{ {
updatedAt: 'desc' updatedAt: 'desc'
} }
+28 -5
View File
@@ -96,20 +96,42 @@ export default defineEventHandler(async (event) => {
textUnit: true, textUnit: true,
_count: { _count: {
select: { select: {
answers: true answers: {
where: {
final: true,
OR: [
{
review: null
},
{
review: { score: null }
},
{
review: {
review: null
}
}
]
}
}
} }
}, },
answers: { answers: {
select: { select: {
id: true, id: true,
text: true, text: true,
final: true,
picture: { picture: {
select: { select: {
url: true url: true
} }
}, },
review: {
select: {
score: true, score: true,
review: true, review: true
}
},
member: { member: {
select: { select: {
name: true name: true
@@ -160,13 +182,14 @@ export default defineEventHandler(async (event) => {
const isMember = hunt.creator.id === userId || members.length > 0; const isMember = hunt.creator.id === userId || members.length > 0;
const ownTeam = teams.length > 0 ? teams[0] : null; const ownTeam = teams.length > 0 ? teams[0] : null;
const mappedQuests = quests.map(({ answers, ...q }) => ({ const mappedQuests = quests.map(({ answers, _count, ...q }) => ({
...q, ...q,
unreviewed: isMember ? _count.answers : undefined,
answer: answers.length > 0 ? answers[0] : null answer: answers.length > 0 ? answers[0] : null
})); }));
const totalScore = mappedQuests.reduce( const totalScore = mappedQuests.reduce(
(acc, q) => acc + (q.answer?.score ?? 0), (acc, q) => acc + (q.answer?.review?.score ?? 0),
0 0
); );
@@ -176,7 +199,7 @@ export default defineEventHandler(async (event) => {
ownTeam, ownTeam,
loggedIn: !!user.user, loggedIn: !!user.user,
totalScore: ownTeam !== null ? totalScore : undefined, totalScore: ownTeam !== null ? totalScore : undefined,
quests: hunt.revealQuests ? mappedQuests : null, quests: hunt.revealQuests || isMember ? mappedQuests : null,
...hunt ...hunt
}; };
}); });
+11 -5
View File
@@ -50,18 +50,24 @@ export default defineEventHandler(async (event) => {
url: true url: true
} }
}, },
review: {
select: {
score: true, score: true,
review: true, review: true,
member: {
select: {
name: true
}
},
reviewer: { reviewer: {
select: { select: {
name: true name: true
} }
}, },
updatedAt: true
}
},
member: {
select: {
name: true
}
},
final: true,
createdAt: true, createdAt: true,
updatedAt: true updatedAt: true
}, },
+15 -7
View File
@@ -46,7 +46,8 @@ export default defineEventHandler(async (event) => {
id: true, id: true,
text: true, text: true,
picture: true, picture: true,
updatedAt: true updatedAt: true,
final: true
}, },
where: { where: {
team: { team: {
@@ -75,6 +76,9 @@ export default defineEventHandler(async (event) => {
if (quest.hunt.teams.length <= 0) { if (quest.hunt.teams.length <= 0) {
throw createError({ status: 403, statusText: 'No team yet!' }); 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]; const team = quest.hunt.teams[0];
@@ -89,7 +93,8 @@ export default defineEventHandler(async (event) => {
.file() .file()
.mime(['image/png', 'image/jpeg', 'image/jpg']) .mime(['image/png', 'image/jpeg', 'image/jpg'])
.max(10_000_000) .max(10_000_000)
.optional() .optional(),
final: zh.boolAsString.optional()
}) })
.parse(formDataObj); .parse(formDataObj);
@@ -113,7 +118,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
// TODO: prevent updating reviewed answers
// TODO: respect end of hunt // TODO: respect end of hunt
let picture: { creatorId: number; url: string; uuid: string } | undefined = let picture: { creatorId: number; url: string; uuid: string } | undefined =
@@ -155,7 +159,8 @@ export default defineEventHandler(async (event) => {
data: { data: {
memberId: userId, memberId: userId,
text: data.answer, text: data.answer,
pictureId: file.id pictureId: file.id,
final: data.final
} }
}); });
} else { } else {
@@ -164,7 +169,8 @@ export default defineEventHandler(async (event) => {
teamId: team.id, teamId: team.id,
memberId: userId, memberId: userId,
questId: quest.id, questId: quest.id,
text: data.answer text: data.answer,
final: data.final
}, },
select: { id: true } select: { id: true }
}); });
@@ -187,7 +193,8 @@ export default defineEventHandler(async (event) => {
}, },
data: { data: {
memberId: userId, memberId: userId,
text: data.answer text: data.answer,
final: data.final
} }
}); });
} else { } else {
@@ -196,7 +203,8 @@ export default defineEventHandler(async (event) => {
teamId: team.id, teamId: team.id,
memberId: userId, memberId: userId,
questId: quest.id, questId: quest.id,
text: data.answer text: data.answer,
final: data.final
} }
}); });
} }