Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s

This commit is contained in:
Torsten Schulz (local)
2026-05-27 23:53:41 +02:00
parent 2e7cf0c28d
commit e57cdc6ad8
25 changed files with 156689 additions and 171 deletions

View 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();

View File

@@ -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);

View File

@@ -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}`;

View File

@@ -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),
});
}

View File

@@ -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

View 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"}');
});
});

View 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);
}