Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
This commit is contained in:
38
backend/scripts/checkDrawingData.js
Normal file
38
backend/scripts/checkDrawingData.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
const rows = await PredefinedActivity.findAll({ attributes: ['id', 'name', 'drawingData'] });
|
||||
const invalid = [];
|
||||
for (const r of rows) {
|
||||
const id = r.id;
|
||||
const name = r.name;
|
||||
const raw = r.drawingData;
|
||||
if (raw == null) continue;
|
||||
if (typeof raw !== 'string') {
|
||||
invalid.push({ id, name, reason: 'not-string', raw: String(raw).slice(0, 200) });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
} catch (e) {
|
||||
// If raw looks like an object literal without quotes, consider invalid
|
||||
invalid.push({ id, name, reason: 'invalid-json', error: e.message, raw: raw.slice(0, 200) });
|
||||
}
|
||||
}
|
||||
if (invalid.length === 0) {
|
||||
console.log('No invalid drawingData found.');
|
||||
} else {
|
||||
console.log('Invalid drawingData rows:');
|
||||
invalid.forEach(i => console.log(JSON.stringify(i)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Script error', err);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
@@ -268,6 +268,45 @@ app.use(express.urlencoded({ extended: true, verify: captureRawBody }));
|
||||
// Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne
|
||||
app.use(requestLoggingMiddleware);
|
||||
|
||||
// Normalisierungs-Middleware: Wenn `drawingData` als Objekt ankommt, immer als JSON-String speichern.
|
||||
// Vermeidet Typ-Mismatches zwischen Client (Objekt) und DB/Code (stringified JSON).
|
||||
function normalizeDrawingDataInObject(obj) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
if (Array.isArray(obj)) return obj.forEach(normalizeDrawingDataInObject);
|
||||
|
||||
// Direkte Felder
|
||||
try {
|
||||
if (obj.drawingData && typeof obj.drawingData === 'object') {
|
||||
obj.drawingData = JSON.stringify(obj.drawingData);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// häufiges Pattern: predefinedActivity.drawingData
|
||||
try {
|
||||
if (obj.predefinedActivity && obj.predefinedActivity.drawingData && typeof obj.predefinedActivity.drawingData === 'object') {
|
||||
obj.predefinedActivity.drawingData = JSON.stringify(obj.predefinedActivity.drawingData);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Rekursiv durch alle Keys gehen
|
||||
for (const k of Object.keys(obj)) {
|
||||
try { normalizeDrawingDataInObject(obj[k]); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
try {
|
||||
if (req.body) normalizeDrawingDataInObject(req.body);
|
||||
if (req.query) normalizeDrawingDataInObject(req.query);
|
||||
} catch (e) {
|
||||
// Never fail the request because of normalization
|
||||
console.error('[normalizeDrawingData] Error:', e && e.message ? e.message : e);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Globale Fehlerbehandlung, damit der Server bei unerwarteten Fehlern nicht hart abstürzt
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err);
|
||||
|
||||
@@ -237,6 +237,11 @@ class DiaryDateActivityService {
|
||||
const gpImages = await PredefinedActivityImage.findAll({ where: { predefinedActivityId: gpJson.id }, order: [['createdAt','ASC']] });
|
||||
gpJson.images = gpImages.map(i => { const ii = i.toJSON(); try { if (ii.drawingData) ii.drawingData = JSON.parse(ii.drawingData); } catch (e){}; return ii; });
|
||||
const firstImg = gpImages[0];
|
||||
if (gpJson.drawingData) {
|
||||
try { if (typeof gpJson.drawingData === 'string') gpJson.drawingData = JSON.parse(gpJson.drawingData); } catch (e) {}
|
||||
} else if (firstImg && firstImg.drawingData) {
|
||||
try { gpJson.drawingData = JSON.parse(firstImg.drawingData); } catch (e) {}
|
||||
}
|
||||
if (firstImg) {
|
||||
gpJson.imageUrl = `/api/predefined-activities/${gpJson.id}/image/${firstImg.id}`;
|
||||
gpJson.imageLink = `/api/predefined-activities/${gpJson.id}/image/${firstImg.id}`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import GroupActivity from '../models/GroupActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import sequelize from '../database.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { normalizeStoredDrawingData } from '../utils/drawingData.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
class PredefinedActivityService {
|
||||
@@ -16,7 +17,7 @@ class PredefinedActivityService {
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
excludeFromStats: Boolean(data.excludeFromStats),
|
||||
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
|
||||
drawingData: normalizeStoredDrawingData(data.drawingData),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ class PredefinedActivityService {
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
excludeFromStats: Boolean(data.excludeFromStats),
|
||||
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
|
||||
drawingData: normalizeStoredDrawingData(data.drawingData),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,34 @@ function nextPowerOfTwo(n) {
|
||||
return p;
|
||||
}
|
||||
|
||||
function knockoutEntrantKey(entrant) {
|
||||
return `${entrant.isExternal ? 'E' : 'M'}:${entrant.id}`;
|
||||
}
|
||||
|
||||
function compareKnockoutEntrants(a, b) {
|
||||
const comparisons = [
|
||||
['place', 1],
|
||||
['points', -1],
|
||||
['setDiff', -1],
|
||||
['setsWon', -1],
|
||||
['pointsDiff', -1],
|
||||
['pointsWon', -1],
|
||||
];
|
||||
|
||||
for (const [property, direction] of comparisons) {
|
||||
const aValue = Number(a[property] ?? 0);
|
||||
const bValue = Number(b[property] ?? 0);
|
||||
if (aValue !== bValue) {
|
||||
return direction * (aValue - bValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (!!a.isExternal !== !!b.isExternal) {
|
||||
return a.isExternal ? 1 : -1;
|
||||
}
|
||||
return Number(a.id) - Number(b.id);
|
||||
}
|
||||
|
||||
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
|
||||
class TournamentService {
|
||||
// -------- Multi-Stage (Runden) V1 --------
|
||||
@@ -255,9 +283,15 @@ class TournamentService {
|
||||
// - Für interne Teilnehmer brauchen wir die ClubMember-ID (Member.id / TournamentMember.clubMemberId),
|
||||
// nicht die TournamentMember.id.
|
||||
// - Für externe Teilnehmer ist `id` die ExternalTournamentParticipant.id (bestehende Logik).
|
||||
participants: (g.participants || []).map(p => ({
|
||||
participants: (g.participants || []).map((p, index) => ({
|
||||
id: p.isExternal ? p.id : (p.clubMemberId ?? p.member?.id ?? p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
place: Number(p.position ?? index + 1),
|
||||
points: Number(p.points ?? 0),
|
||||
setDiff: Number(p.setDiff ?? 0),
|
||||
setsWon: Number(p.setsWon ?? 0),
|
||||
pointsDiff: Number(p.pointsDiff ?? 0),
|
||||
pointsWon: Number(p.pointsWon ?? 0),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -289,7 +323,7 @@ class TournamentService {
|
||||
if ((grp.classId ?? null) !== classId) continue;
|
||||
for (const place of fromPlaces) {
|
||||
const p = getByPlace(grp, Number(place));
|
||||
if (p) items.push({ ...p, classId, place: Number(place) });
|
||||
if (p) items.push({ ...p, classId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,16 +489,21 @@ class TournamentService {
|
||||
const entrants = classItems.map(p => ({
|
||||
id: Number(p.id),
|
||||
isExternal: !!p.isExternal,
|
||||
place: p.place || 999, // Platz-Information behalten
|
||||
place: p.place || 999,
|
||||
points: p.points ?? 0,
|
||||
setDiff: p.setDiff ?? 0,
|
||||
setsWon: p.setsWon ?? 0,
|
||||
pointsDiff: p.pointsDiff ?? 0,
|
||||
pointsWon: p.pointsWon ?? 0,
|
||||
}));
|
||||
|
||||
// Dedupliziere (falls jemand in mehreren Regeln landet)
|
||||
// Wenn jemand mehrfach vorkommt, nehme den besten Platz
|
||||
const seen = new Map();
|
||||
for (const e of entrants) {
|
||||
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
|
||||
const key = knockoutEntrantKey(e);
|
||||
const existing = seen.get(key);
|
||||
if (!existing || (e.place < existing.place)) {
|
||||
if (!existing || compareKnockoutEntrants(e, existing) < 0) {
|
||||
seen.set(key, e);
|
||||
}
|
||||
}
|
||||
@@ -472,13 +511,22 @@ class TournamentService {
|
||||
|
||||
const thirdPlace = wantsThirdPlace;
|
||||
if (uniqueEntrants.length >= 2) {
|
||||
// Sortiere nach Platz: beste Plätze zuerst, dann schlechtere
|
||||
// Wenn mehrere Teilnehmer den gleichen Platz haben, behalte die ursprüngliche Reihenfolge
|
||||
uniqueEntrants.sort((a, b) => {
|
||||
const placeA = a.place || 999;
|
||||
const placeB = b.place || 999;
|
||||
return placeA - placeB;
|
||||
});
|
||||
// Ein KO-Feld wird voll besetzt, sofern weitere Gruppenteilnehmer vorhanden sind.
|
||||
// Beispiel: 3 Gruppen x 2 Qualifikanten ergeben sechs Starter; die beiden
|
||||
// besten Drittplatzierten vervollständigen das Viertelfinale.
|
||||
const targetBracketSize = nextPowerOfTwo(uniqueEntrants.length);
|
||||
const requiredFillers = targetBracketSize - uniqueEntrants.length;
|
||||
if (requiredFillers > 0) {
|
||||
const selectedKeys = new Set(uniqueEntrants.map(knockoutEntrantKey));
|
||||
const remainingEntrants = perGroupRanked
|
||||
.filter(group => (group.classId ?? null) === classId)
|
||||
.flatMap(group => group.participants)
|
||||
.filter(entrant => !selectedKeys.has(knockoutEntrantKey(entrant)))
|
||||
.sort(compareKnockoutEntrants);
|
||||
uniqueEntrants.push(...remainingEntrants.slice(0, requiredFillers));
|
||||
}
|
||||
|
||||
uniqueEntrants.sort(compareKnockoutEntrants);
|
||||
|
||||
// Paare: Bester gegen Schlechtesten, Zweiter gegen Vorletzten, etc.
|
||||
// Reverse die zweite Hälfte, um das gewünschte Pairing zu erreichen
|
||||
|
||||
20
backend/tests/predefinedActivityDrawingData.test.js
Normal file
20
backend/tests/predefinedActivityDrawingData.test.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeStoredDrawingData } from '../utils/drawingData.js';
|
||||
|
||||
describe('predefined activity drawing data', () => {
|
||||
it('stores drawing objects as JSON once', () => {
|
||||
expect(normalizeStoredDrawingData({ selectedStartPosition: 'AS1' }))
|
||||
.toBe('{"selectedStartPosition":"AS1"}');
|
||||
});
|
||||
|
||||
it('does not double encode JSON text sent by mobile clients', () => {
|
||||
expect(normalizeStoredDrawingData('{"strokeType":"VH"}'))
|
||||
.toBe('{"strokeType":"VH"}');
|
||||
});
|
||||
|
||||
it('repairs previously double encoded JSON text', () => {
|
||||
expect(normalizeStoredDrawingData('"{\\"targetPosition\\":\\"5\\"}"'))
|
||||
.toBe('{"targetPosition":"5"}');
|
||||
});
|
||||
});
|
||||
12
backend/utils/drawingData.js
Normal file
12
backend/utils/drawingData.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export function normalizeStoredDrawingData(value) {
|
||||
if (!value) return null;
|
||||
let normalized = value;
|
||||
for (let pass = 0; pass < 2 && typeof normalized === 'string'; pass += 1) {
|
||||
try {
|
||||
normalized = JSON.parse(normalized);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return typeof normalized === 'string' ? normalized : JSON.stringify(normalized);
|
||||
}
|
||||
Reference in New Issue
Block a user