- fix graph rendering issues & add server-side logging

This commit is contained in:
Markus Brueckner 2024-12-15 09:11:40 +01:00
parent 94ccd14689
commit 2050c715dc
8 changed files with 100 additions and 32 deletions

27
package-lock.json generated
View file

@ -17,6 +17,7 @@
"d3-array": "^3.2.4", "d3-array": "^3.2.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"debug": "^4.4.0",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
"layerchart": "^0.59.1", "layerchart": "^0.59.1",
"mysql2": "^3.11.4" "mysql2": "^3.11.4"
@ -29,6 +30,7 @@
"@types/d3-array": "^3.2.1", "@types/d3-array": "^3.2.1",
"@types/d3-scale": "^4.0.8", "@types/d3-scale": "^4.0.8",
"@types/d3-shape": "^3.1.6", "@types/d3-shape": "^3.1.6",
"@types/debug": "^4.1.12",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.28.1",
@ -2364,6 +2366,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@ -2388,6 +2400,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": {
"version": "0.7.34",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.9.0", "version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
@ -3425,10 +3444,9 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.7", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -5347,7 +5365,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {

View file

@ -19,6 +19,7 @@
"@types/d3-array": "^3.2.1", "@types/d3-array": "^3.2.1",
"@types/d3-scale": "^4.0.8", "@types/d3-scale": "^4.0.8",
"@types/d3-shape": "^3.1.6", "@types/d3-shape": "^3.1.6",
"@types/debug": "^4.1.12",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.28.1",
@ -46,6 +47,7 @@
"d3-array": "^3.2.4", "d3-array": "^3.2.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"debug": "^4.4.0",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
"layerchart": "^0.59.1", "layerchart": "^0.59.1",
"mysql2": "^3.11.4" "mysql2": "^3.11.4"

View file

@ -2,10 +2,14 @@ import type { Handle } from "@sveltejs/kit";
import { deleteSessionTokenCookie, setSessionTokenCookie, validateSession } from "./lib/session/session"; import { deleteSessionTokenCookie, setSessionTokenCookie, validateSession } from "./lib/session/session";
import debug from 'debug';
const log = debug('hooks');
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get("session") ?? null; const token = event.cookies.get("session") ?? null;
if (token === null) { if (token === null) {
console.log("No session available") log("No session available")
event.locals.userId = null; event.locals.userId = null;
event.locals.session = null; event.locals.session = null;
return resolve(event); return resolve(event);

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { scaleBand } from 'd3-scale'; import { scaleBand } from 'd3-scale';
import { curveLinearClosed } from 'd3-shape'; import { curveLinear, curveLinearClosed } from 'd3-shape';
import { flatGroup } from 'd3-array'; import { flatGroup } from 'd3-array';
import { Axis, Chart, Points, Spline, Svg } from 'layerchart'; import { Axis, Chart, Points, Spline, Svg } from 'layerchart';
@ -10,23 +10,29 @@
data: { skill: string; participant: number; rating: number | undefined }[]; data: { skill: string; participant: number; rating: number | undefined }[];
} = $props(); } = $props();
const averageValues = flatGroup(data, (d) => d.skill).map(([skill, ratings]) => { const averageValues = flatGroup(data, (d) => d.skill)
.map(([skill, ratings]) => {
const actualRatings = ratings.filter((r) => r.rating !== undefined); const actualRatings = ratings.filter((r) => r.rating !== undefined);
const avg = actualRatings.reduce((a, b) => a + (b.rating ?? 0), 0) / actualRatings.length; const avg = actualRatings.reduce((a, b) => a + (b.rating ?? 0), 0) / actualRatings.length;
return { skill, rating: avg }; return { skill, rating: avg };
}); })
// .filter((d) => !isNaN(d.rating));
.map((d) => ({
...d,
rating: isNaN(d.rating) ? undefined : d.rating
}));
const colors = [ const colors = [
'stroke-slate-300', { stroke: 'stroke-slate-300', fill: 'fill-slate-400' },
'stroke-violet-300', { stroke: 'stroke-violet-300', fill: 'fill-violet-400' },
'stroke-red-300', { stroke: 'stroke-red-300', fill: 'fill-red-400' },
'stroke-lime-300', { stroke: 'stroke-lime-300', fill: 'fill-lime-400' },
'stroke-blue-300', { stroke: 'stroke-blue-300', fill: 'fill-blue-400' },
'stroke-amber-300', { stroke: 'stroke-amber-300', fill: 'fill-amber-400' },
'stroke-stone-300', { stroke: 'stroke-stone-300', fill: 'fill-stone-400' },
'stroke-emerald-300', { stroke: 'stroke-emerald-300', fill: 'fill-emerald-400' },
'stroke-cyan-300', { stroke: 'stroke-cyan-300', fill: 'fill-cyan-400' },
'stroke-pink-300' { stroke: 'stroke-pink-300', fill: 'fill-pink-400' }
]; ];
</script> </script>
@ -51,13 +57,22 @@
<Axis placement="angle" grid={{ class: 'stroke-slate-950' }} /> <Axis placement="angle" grid={{ class: 'stroke-slate-950' }} />
{#each flatGroup(data, (d) => d.participant) as [participant, ratings], idx} {#each flatGroup(data, (d) => d.participant) as [participant, ratings], idx}
<Spline <Spline
data={[...ratings, ratings[0]]}
curve={curveLinear}
class="{colors[idx % colors.length].stroke} stroke-[2px]"
/>
<Points
data={ratings} data={ratings}
curve={curveLinearClosed} class="{colors[idx % colors.length].stroke} {colors[idx % colors.length]
class="{colors[idx % colors.length]} stroke-[2px]" .fill} stroke-[2px]"
/> />
{/each} {/each}
<Spline data={averageValues} curve={curveLinearClosed} class="stroke-lime-600 stroke-[4px]" /> <Spline
<!-- <Points class="fill-red-900 stroke-slate-950" /> --> data={[...averageValues, averageValues[0]]}
curve={curveLinear}
class="stroke-lime-600 stroke-[4px]"
/>
<Points data={averageValues} class="fill-lime-500 stroke-lime-600 stroke-[2px]" />
</Svg> </Svg>
</Chart> </Chart>
</div> </div>

View file

@ -22,7 +22,7 @@ export async function loadSurvey(id: number, ownerId: number) {
accessToken: participant.accessToken, accessToken: participant.accessToken,
answers: answers.filter(answer => answer.participantId === participant.id).map(answer => ({ answers: answers.filter(answer => answer.participantId === participant.id).map(answer => ({
skillId: answer.skillId, skillId: answer.skillId,
rating: answer.rating rating: answer.rating / 10 // convert back to original vallue. The DB stores integer values only to prevent rounding and matching errors
})) }))
})), })),
skills: skills.map(skill => ({ skills: skills.map(skill => ({

View file

@ -2,18 +2,25 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { loadSurvey } from '$lib/queries'; import { loadSurvey } from '$lib/queries';
import debug from 'debug';
const log = debug('survey:admin');
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const surveyId = parseInt(params.surveyId); const surveyId = parseInt(params.surveyId);
if (isNaN(surveyId)) { if (isNaN(surveyId)) {
log('Invalid survey ID %s', params.surveyId);
error(400, 'Invalid survey ID'); error(400, 'Invalid survey ID');
} }
if (!locals.userId) { if (!locals.userId) {
log('User is not logged in');
error(403, 'User is not logged in'); error(403, 'User is not logged in');
} }
const surveyData = await loadSurvey(surveyId, locals.userId); const surveyData = await loadSurvey(surveyId, locals.userId);
if (!surveyData) { if (!surveyData) {
log('Survey not found or user (%s) does not have access: %s', locals.userId, params.surveyId);
error(404, 'Survey not found'); error(404, 'Survey not found');
} }
return surveyData; return surveyData;

View file

@ -5,6 +5,11 @@ import type { PageServerLoad } from './$types';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import debug from 'debug';
const log_load = debug('survey:load');
const log_store = debug('survey:store');
export const load: PageServerLoad = async ({ params, url }) => { export const load: PageServerLoad = async ({ params, url }) => {
const results = await db.select() const results = await db.select()
.from(surveyAccessTable) .from(surveyAccessTable)
@ -13,10 +18,12 @@ export const load: PageServerLoad = async ({ params, url }) => {
.limit(1); .limit(1);
if (results.length === 0) { if (results.length === 0) {
log_load('Survey not found: %s', params.accessToken);
error(404, 'Survey not found'); error(404, 'Survey not found');
} }
if (await db.$count(surveyAnswersTable, eq(surveyAnswersTable.participantId, results[0].survey_access_table.id)) > 0) { if (await db.$count(surveyAnswersTable, eq(surveyAnswersTable.participantId, results[0].survey_access_table.id)) > 0) {
log_load('Answers already submitted: %s', params.accessToken);
error(400, 'Answers already submitted'); error(400, 'Answers already submitted');
} }
@ -44,10 +51,12 @@ export const actions = {
.limit(1); .limit(1);
if (results.length === 0) { if (results.length === 0) {
log_store('Survey not found: %s', params.accessToken);
error(404, 'Survey not found'); error(404, 'Survey not found');
} }
if (await db.$count(surveyAnswersTable, eq(surveyAnswersTable.participantId, results[0].survey_access_table.id)) > 0) { if (await db.$count(surveyAnswersTable, eq(surveyAnswersTable.participantId, results[0].survey_access_table.id)) > 0) {
log_store('Answers already submitted: %s', params.accessToken);
error(400, 'Answers already submitted'); error(400, 'Answers already submitted');
} }
@ -56,20 +65,29 @@ export const actions = {
const formData = await request.formData(); const formData = await request.formData();
// validate that the form doesn't contain invalid skill IDs // validate that the form doesn't contain invalid skill IDs
const skillEntries = [...formData.entries()].filter(([key, _]) => key.startsWith('disable-')); const skillEntries = [...formData.entries()].filter(([key, _]) => !key.startsWith('disable-'));
const allIdsValid = skillEntries.every(([key, _]) => skills.some(skill => skill.id.toString() === key)); const allIdsValid = skillEntries.every(([key, _]) => skills.some(skill => skill.id.toString() === key));
if (!allIdsValid || skillEntries.length !== skills.length) { const deselectedIds = [...formData.entries().filter(([key, _]) => key.startsWith('disable-')).map(([key, _]) => parseInt(key.replace('disable-', '')))];
if (!allIdsValid || skillEntries.length + deselectedIds.length !== skills.length) {
let missing_ids = skills.filter(skill => !skillEntries.some(([key, _]) => key === skill.id.toString()) && !deselectedIds.includes(skill.id));
let invalid_ids = skillEntries.filter(([key, _]) => !skills.some(skill => skill.id.toString() === key)).map(([key, _]) => key);
log_store.log("Invalid (%o) or missing (%o) skill IDs", invalid_ids, missing_ids);
error(400, 'Invalid skill ID'); error(400, 'Invalid skill ID');
} }
const answers = skillEntries.map(([key, value]) => ({ const answers = skillEntries.map(([key, value]) => ({
participantId: results[0].survey_access_table.id, participantId: results[0].survey_access_table.id,
skillId: parseInt(key), skillId: parseInt(key),
rating: parseFloat(value.toString()) rating: parseFloat(value.toString()) * 10 // convert to int to store in the database without rounding issues etc.
})); }));
if (answers.some(answer => isNaN(answer.rating) || answer.rating < 0 || answer.rating > 3)) { if (answers.some(answer => isNaN(answer.rating) || answer.rating < 0 || answer.rating > 30)) {
log_store(
"Invalid answer: %o",
answers.filter(answer => isNaN(answer.rating) || answer.rating < 0 || answer.rating > 30)
);
error(400, 'Invalid answer'); error(400, 'Invalid answer');
} }
await db.insert(surveyAnswersTable).values([...answers]); await db.insert(surveyAnswersTable).values([...answers]);
redirect(303, `/${params.accessToken}/thanks`); redirect(303, `/${params.accessToken}/thanks`);

View file

@ -8,17 +8,21 @@ import { error } from '@sveltejs/kit';
import { verify } from '@node-rs/argon2'; import { verify } from '@node-rs/argon2';
import { generateRandomToken } from '$lib/randomToken'; import { generateRandomToken } from '$lib/randomToken';
import debug from 'debug';
let log = debug('login');
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
const formData = await event.request.formData(); const formData = await event.request.formData();
const email = formData.get('email')?.toString(); const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString(); const password = formData.get('password')?.toString();
if (!email || !password) { if (!email || !password) {
log('Email and password are required');
error(400, 'Email and password are required'); error(400, 'Email and password are required');
} }
const userCredentials = await db.select().from(usersTable).where(eq(usersTable.email, email)); const userCredentials = await db.select().from(usersTable).where(eq(usersTable.email, email));
console.log(userCredentials, formData.get('email'));
if (userCredentials.length === 0) { if (userCredentials.length === 0) {
error(403, 'Invalid credentials'); error(403, 'Invalid credentials');
} }
@ -38,6 +42,7 @@ export const actions = {
} }
} }
else { else {
log('Invalid credentials for %s', email);
error(403, 'Invalid credentials'); error(403, 'Invalid credentials');
} }
} }