Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateTeamResult,
|
||||
calculateRankings,
|
||||
getCompletionStatus,
|
||||
calculateAllResults,
|
||||
} from '../aggregation.js';
|
||||
import { Score, Team, TeamResult } from '../../shared/types.js';
|
||||
|
||||
function makeScore(jurorId: string, teamId: string, criterionId: string, value: number): Score {
|
||||
return { jurorId, teamId, criterionId, value, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
describe('calculateTeamResult', () => {
|
||||
it('returns zero total and average when no scores exist', () => {
|
||||
const result = calculateTeamResult('team-a', 'Team A', [], 5);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.average).toBe(0);
|
||||
expect(result.jurorCount).toBe(0);
|
||||
expect(result.complete).toBe(false);
|
||||
});
|
||||
|
||||
it('calculates total as sum of all score values for the team', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9),
|
||||
makeScore('juror1', 'team-b', 'c1', 10), // different team, should be excluded
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.total).toBe(8 + 7 + 9);
|
||||
});
|
||||
|
||||
it('counts only jurors who submitted all criteria', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9), // only 1 of 2 criteria
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.jurorCount).toBe(1); // only juror1 has all 2 criteria
|
||||
});
|
||||
|
||||
it('calculates average as total / jurorCount', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 6),
|
||||
makeScore('juror2', 'team-a', 'c1', 10),
|
||||
makeScore('juror2', 'team-a', 'c2', 4),
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
// total = 28, jurorCount = 2, average = 14
|
||||
expect(result.total).toBe(28);
|
||||
expect(result.jurorCount).toBe(2);
|
||||
expect(result.average).toBe(14);
|
||||
});
|
||||
|
||||
it('marks complete when all jurors with scores have all criteria', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9),
|
||||
makeScore('juror2', 'team-a', 'c2', 6),
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('marks incomplete when some jurors have partial scores', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror2', 'team-a', 'c1', 9), // missing c2
|
||||
];
|
||||
const result = calculateTeamResult('team-a', 'Team A', scores, 2);
|
||||
expect(result.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRankings', () => {
|
||||
it('sorts by total descending and assigns ranks', () => {
|
||||
const results: TeamResult[] = [
|
||||
{ teamId: 'a', teamName: 'A', total: 200, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'b', teamName: 'B', total: 250, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'c', teamName: 'C', total: 230, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
];
|
||||
const ranked = calculateRankings(results);
|
||||
expect(ranked[0].teamId).toBe('b');
|
||||
expect(ranked[0].rank).toBe(1);
|
||||
expect(ranked[1].teamId).toBe('c');
|
||||
expect(ranked[1].rank).toBe(2);
|
||||
expect(ranked[2].teamId).toBe('a');
|
||||
expect(ranked[2].rank).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns same rank to ties', () => {
|
||||
const results: TeamResult[] = [
|
||||
{ teamId: 'a', teamName: 'A', total: 250, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'b', teamName: 'B', total: 240, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'c', teamName: 'C', total: 240, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
{ teamId: 'd', teamName: 'D', total: 230, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
];
|
||||
const ranked = calculateRankings(results);
|
||||
expect(ranked[0].rank).toBe(1); // 250
|
||||
expect(ranked[1].rank).toBe(2); // 240
|
||||
expect(ranked[2].rank).toBe(2); // 240 (tie)
|
||||
expect(ranked[3].rank).toBe(4); // 230
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
const ranked = calculateRankings([]);
|
||||
expect(ranked).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single team', () => {
|
||||
const results: TeamResult[] = [
|
||||
{ teamId: 'a', teamName: 'A', total: 100, average: 0, stddev: 0, rank: 0, jurorCount: 0, complete: false },
|
||||
];
|
||||
const ranked = calculateRankings(results);
|
||||
expect(ranked[0].rank).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompletionStatus', () => {
|
||||
it('returns pending when no scores exist', () => {
|
||||
expect(getCompletionStatus('juror1', 'team-a', [], 5)).toBe('pending');
|
||||
});
|
||||
|
||||
it('returns pending when scores exist for other juror/team', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror2', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-b', 'c1', 7),
|
||||
];
|
||||
expect(getCompletionStatus('juror1', 'team-a', scores, 5)).toBe('pending');
|
||||
});
|
||||
|
||||
it('returns partial when some but not all criteria scored', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
];
|
||||
expect(getCompletionStatus('juror1', 'team-a', scores, 5)).toBe('partial');
|
||||
});
|
||||
|
||||
it('returns complete when all criteria scored', () => {
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror1', 'team-a', 'c3', 9),
|
||||
makeScore('juror1', 'team-a', 'c4', 6),
|
||||
makeScore('juror1', 'team-a', 'c5', 10),
|
||||
];
|
||||
expect(getCompletionStatus('juror1', 'team-a', scores, 5)).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAllResults', () => {
|
||||
it('calculates results for all teams with rankings', () => {
|
||||
const teams: Team[] = [
|
||||
{ id: 'team-a', number: 1, name: 'Team A', description: '' },
|
||||
{ id: 'team-b', number: 2, name: 'Team B', description: '' },
|
||||
];
|
||||
const scores: Score[] = [
|
||||
makeScore('juror1', 'team-a', 'c1', 8),
|
||||
makeScore('juror1', 'team-a', 'c2', 7),
|
||||
makeScore('juror1', 'team-b', 'c1', 10),
|
||||
makeScore('juror1', 'team-b', 'c2', 9),
|
||||
];
|
||||
const results = calculateAllResults(scores, teams, 2);
|
||||
expect(results[0].teamId).toBe('team-b'); // higher total (19)
|
||||
expect(results[0].rank).toBe(1);
|
||||
expect(results[0].total).toBe(19);
|
||||
expect(results[1].teamId).toBe('team-a'); // lower total (15)
|
||||
expect(results[1].rank).toBe(2);
|
||||
expect(results[1].total).toBe(15);
|
||||
});
|
||||
|
||||
it('handles empty scores', () => {
|
||||
const teams: Team[] = [
|
||||
{ id: 'team-a', number: 1, name: 'Team A', description: '' },
|
||||
];
|
||||
const results = calculateAllResults([], teams, 5);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].total).toBe(0);
|
||||
expect(results[0].rank).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldAcceptScore, resolveConflict } from '../conflict.js';
|
||||
import { Score } from '../../shared/types.js';
|
||||
|
||||
function makeScore(overrides: Partial<Score> = {}): Score {
|
||||
return {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 7,
|
||||
updatedAt: '2026-05-19T18:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('shouldAcceptScore', () => {
|
||||
it('accepts when no existing score', () => {
|
||||
const incoming = makeScore();
|
||||
const result = shouldAcceptScore(incoming, undefined);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('accepts when incoming timestamp is newer', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z', value: 9 });
|
||||
const result = shouldAcceptScore(incoming, existing);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('rejects when incoming timestamp is older', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 3 });
|
||||
const result = shouldAcceptScore(incoming, existing);
|
||||
expect(result).toEqual({ accept: false, reason: 'Newer score already exists' });
|
||||
});
|
||||
|
||||
it('rejects when timestamps are equal', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 5 });
|
||||
const result = shouldAcceptScore(incoming, existing);
|
||||
expect(result).toEqual({ accept: false, reason: 'Newer score already exists' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConflict', () => {
|
||||
it('accepts when force is true regardless of timestamps', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 2 });
|
||||
const result = resolveConflict(incoming, existing, true);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('delegates to shouldAcceptScore when force is false', () => {
|
||||
const existing = makeScore({ updatedAt: '2026-05-19T18:01:00.000Z' });
|
||||
const incoming = makeScore({ updatedAt: '2026-05-19T18:00:00.000Z', value: 2 });
|
||||
const result = resolveConflict(incoming, existing, false);
|
||||
expect(result).toEqual({ accept: false, reason: 'Newer score already exists' });
|
||||
});
|
||||
|
||||
it('delegates to shouldAcceptScore when force is undefined', () => {
|
||||
const incoming = makeScore();
|
||||
const result = resolveConflict(incoming, undefined);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
|
||||
it('accepts with force even when no existing score', () => {
|
||||
const incoming = makeScore();
|
||||
const result = resolveConflict(incoming, undefined, true);
|
||||
expect(result).toEqual({ accept: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { registerRoutes } from '../routes.js';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'server', 'data');
|
||||
const SESSION_FILE = path.join(DATA_DIR, 'session.json');
|
||||
const SEED_FILE = path.join(DATA_DIR, 'seed.json');
|
||||
const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
fs.unlinkSync(BACKUP_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to make requests without supertest (using native fetch with app.listen)
|
||||
// We'll use a simple approach: call the route handlers directly via the express app
|
||||
import { type Server } from 'http';
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetStore();
|
||||
if (server) server.close();
|
||||
});
|
||||
|
||||
describe('server/routes', () => {
|
||||
const app = createApp();
|
||||
|
||||
// Start server on a random port
|
||||
beforeEach(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server) server.close();
|
||||
server = app.listen(0, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr !== 'string') {
|
||||
baseUrl = `http://localhost:${addr.port}`;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (server) server.close();
|
||||
});
|
||||
|
||||
// ─── Session Endpoints ───────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/session', () => {
|
||||
it('returns current session state', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/session`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.id).toBe('unikat-2026');
|
||||
expect(data.status).toBe('setup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/session/start', () => {
|
||||
it('starts the session', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe('active');
|
||||
expect(data.startedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects if already active', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('already active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/session/end', () => {
|
||||
it('ends an active session', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/session/end`, { method: 'POST' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe('ended');
|
||||
expect(data.endedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects if not active', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/session/end`, { method: 'POST' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('not active');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Team and Juror Endpoints ────────────────────────────────────────
|
||||
|
||||
describe('GET /api/teams', () => {
|
||||
it('returns all 11 teams', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/teams`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(11);
|
||||
expect(data[0]).toHaveProperty('id');
|
||||
expect(data[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/jurors', () => {
|
||||
it('returns all 6 jurors', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/jurors`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(6);
|
||||
expect(data[0]).toHaveProperty('id');
|
||||
expect(data[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Score Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/scores', () => {
|
||||
it('rejects when session is not active (403)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts a valid score when session is active', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.jurorId).toBe('finke');
|
||||
expect(data.value).toBe(8);
|
||||
});
|
||||
|
||||
it('rejects value out of range (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 11 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unknown jurorId (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'unknown', teamId: 'dual', criterionId: 'gesamtidee', value: 5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unknown teamId (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'unknown', criterionId: 'gesamtidee', value: 5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects unknown criterionId (400)', async () => {
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
const res = await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'unknown', value: 5 }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/scores', () => {
|
||||
it('returns all scores', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/scores`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/scores/:jurorId', () => {
|
||||
it('returns scores for a specific juror', async () => {
|
||||
// Add a score first
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 7 }),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/scores/finke`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].jurorId).toBe('finke');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Results and Progress Endpoints ──────────────────────────────────
|
||||
|
||||
describe('GET /api/results', () => {
|
||||
it('returns aggregated results for all teams', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/results`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveLength(11);
|
||||
expect(data[0]).toHaveProperty('teamId');
|
||||
expect(data[0]).toHaveProperty('total');
|
||||
expect(data[0]).toHaveProperty('average');
|
||||
expect(data[0]).toHaveProperty('rank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/progress', () => {
|
||||
it('returns juror completion matrix', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/progress`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveProperty('finke');
|
||||
expect(data.finke).toHaveProperty('completed');
|
||||
expect(data.finke).toHaveProperty('total');
|
||||
expect(data.finke.total).toBe(11);
|
||||
});
|
||||
|
||||
it('does not include individual score values', async () => {
|
||||
// Add a score
|
||||
await fetch(`${baseUrl}/api/session/start`, { method: 'POST' });
|
||||
await fetch(`${baseUrl}/api/scores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jurorId: 'finke', teamId: 'dual', criterionId: 'gesamtidee', value: 8 }),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/progress`);
|
||||
const data = await res.json();
|
||||
const json = JSON.stringify(data);
|
||||
// Should not contain any score value or the word "value"
|
||||
expect(json).not.toContain('"value"');
|
||||
expect(json).not.toContain('"scores"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/export/csv', () => {
|
||||
it('returns CSV with correct headers', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/export/csv`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/csv');
|
||||
expect(res.headers.get('content-disposition')).toContain('attachment');
|
||||
|
||||
const csv = await res.text();
|
||||
const lines = csv.split('\n');
|
||||
expect(lines[0]).toContain('Team');
|
||||
expect(lines[0]).toContain('Total');
|
||||
expect(lines[0]).toContain('Average');
|
||||
expect(lines[0]).toContain('Rank');
|
||||
});
|
||||
|
||||
it('has one row per team plus header', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/export/csv`);
|
||||
const csv = await res.text();
|
||||
const lines = csv.split('\n');
|
||||
// 1 header + 11 team rows
|
||||
expect(lines).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Score } from '../../shared/types.js';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'server', 'data');
|
||||
const SESSION_FILE = path.join(DATA_DIR, 'session.json');
|
||||
const SEED_FILE = path.join(DATA_DIR, 'seed.json');
|
||||
const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak');
|
||||
|
||||
// Import the store module — initialization runs on import
|
||||
import * as store from '../store.js';
|
||||
|
||||
describe('server/store', () => {
|
||||
// Reset session.json from seed before each test for isolation
|
||||
beforeEach(() => {
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
// Remove backup if it exists
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
fs.unlinkSync(BACKUP_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
// Restore session.json from seed
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
if (fs.existsSync(BACKUP_FILE)) {
|
||||
fs.unlinkSync(BACKUP_FILE);
|
||||
}
|
||||
});
|
||||
|
||||
it('getStore() returns valid StoreData', () => {
|
||||
const data = store.getStore();
|
||||
expect(data).toHaveProperty('session');
|
||||
expect(data).toHaveProperty('teams');
|
||||
expect(data).toHaveProperty('jurors');
|
||||
expect(data).toHaveProperty('scores');
|
||||
expect(data.session.id).toBe('unikat-2026');
|
||||
});
|
||||
|
||||
it('getStore() returns seed data structure', () => {
|
||||
const data = store.getStore();
|
||||
expect(data.teams).toHaveLength(11);
|
||||
expect(data.jurors).toHaveLength(6);
|
||||
expect(data.scores).toEqual([]);
|
||||
});
|
||||
|
||||
it('saveStore() creates a .bak backup', () => {
|
||||
const data = store.getStore();
|
||||
store.saveStore(data);
|
||||
expect(fs.existsSync(BACKUP_FILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('saveStore() writes data atomically (persists changes)', () => {
|
||||
const data = store.getStore();
|
||||
data.session.status = 'active';
|
||||
store.saveStore(data);
|
||||
|
||||
const reloaded = store.getStore();
|
||||
expect(reloaded.session.status).toBe('active');
|
||||
});
|
||||
|
||||
it('saveStore() backup contains previous data', () => {
|
||||
const originalData = store.getStore();
|
||||
const originalStatus = originalData.session.status;
|
||||
|
||||
originalData.session.status = 'active';
|
||||
store.saveStore(originalData);
|
||||
|
||||
const backupContent = JSON.parse(fs.readFileSync(BACKUP_FILE, 'utf-8'));
|
||||
expect(backupContent.session.status).toBe(originalStatus);
|
||||
});
|
||||
|
||||
it('getSession() returns the session', () => {
|
||||
const session = store.getSession();
|
||||
expect(session.id).toBe('unikat-2026');
|
||||
expect(session.status).toBe('setup');
|
||||
expect(session.config.criteria).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('getTeams() returns all teams', () => {
|
||||
const teams = store.getTeams();
|
||||
expect(teams).toHaveLength(11);
|
||||
expect(teams[0]).toHaveProperty('id');
|
||||
expect(teams[0]).toHaveProperty('name');
|
||||
expect(teams[0]).toHaveProperty('number');
|
||||
});
|
||||
|
||||
it('getJurors() returns all jurors', () => {
|
||||
const jurors = store.getJurors();
|
||||
expect(jurors).toHaveLength(6);
|
||||
expect(jurors[0]).toHaveProperty('id');
|
||||
expect(jurors[0]).toHaveProperty('name');
|
||||
expect(jurors[0]).toHaveProperty('active');
|
||||
});
|
||||
|
||||
it('getScores() returns empty scores array initially', () => {
|
||||
const scores = store.getScores();
|
||||
expect(Array.isArray(scores)).toBe(true);
|
||||
expect(scores).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('addScore() adds a new score', () => {
|
||||
const score: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 8,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
store.addScore(score);
|
||||
|
||||
const scores = store.getScores();
|
||||
expect(scores).toHaveLength(1);
|
||||
expect(scores[0].jurorId).toBe('finke');
|
||||
expect(scores[0].teamId).toBe('dual');
|
||||
expect(scores[0].criterionId).toBe('gesamtidee');
|
||||
expect(scores[0].value).toBe(8);
|
||||
});
|
||||
|
||||
it('addScore() updates existing score (match by jurorId+teamId+criterionId)', () => {
|
||||
const score1: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 7,
|
||||
updatedAt: '2026-05-19T10:00:00Z',
|
||||
};
|
||||
store.addScore(score1);
|
||||
|
||||
const score2: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 9,
|
||||
updatedAt: '2026-05-19T10:01:00Z',
|
||||
};
|
||||
store.addScore(score2);
|
||||
|
||||
const scores = store.getScores();
|
||||
const matching = scores.filter(
|
||||
(s) =>
|
||||
s.jurorId === 'finke' &&
|
||||
s.teamId === 'dual' &&
|
||||
s.criterionId === 'gesamtidee'
|
||||
);
|
||||
expect(matching).toHaveLength(1);
|
||||
expect(matching[0].value).toBe(9);
|
||||
expect(matching[0].updatedAt).toBe('2026-05-19T10:01:00Z');
|
||||
});
|
||||
|
||||
it('addScore() does not affect scores for different criteria', () => {
|
||||
const score1: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'gesamtidee',
|
||||
value: 7,
|
||||
updatedAt: '2026-05-19T10:00:00Z',
|
||||
};
|
||||
const score2: Score = {
|
||||
jurorId: 'finke',
|
||||
teamId: 'dual',
|
||||
criterionId: 'markt',
|
||||
value: 9,
|
||||
updatedAt: '2026-05-19T10:01:00Z',
|
||||
};
|
||||
store.addScore(score1);
|
||||
store.addScore(score2);
|
||||
|
||||
const scores = store.getScores();
|
||||
expect(scores).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('updateSession() merges partial updates', () => {
|
||||
const updated = store.updateSession({
|
||||
status: 'active',
|
||||
startedAt: '2026-05-19T18:00:00Z',
|
||||
});
|
||||
expect(updated.status).toBe('active');
|
||||
expect(updated.startedAt).toBe('2026-05-19T18:00:00Z');
|
||||
expect(updated.id).toBe('unikat-2026');
|
||||
expect(updated.config.criteria).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('updateSession() persists changes', () => {
|
||||
store.updateSession({ status: 'active' });
|
||||
const session = store.getSession();
|
||||
expect(session.status).toBe('active');
|
||||
});
|
||||
|
||||
it('updateJuror() updates a juror by ID', () => {
|
||||
const updated = store.updateJuror('finke', { active: true });
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.active).toBe(true);
|
||||
expect(updated!.name).toBe('Finke');
|
||||
expect(updated!.id).toBe('finke');
|
||||
});
|
||||
|
||||
it('updateJuror() persists changes', () => {
|
||||
store.updateJuror('finke', { active: true });
|
||||
const jurors = store.getJurors();
|
||||
const finke = jurors.find((j) => j.id === 'finke');
|
||||
expect(finke!.active).toBe(true);
|
||||
});
|
||||
|
||||
it('updateJuror() returns null for unknown jurorId', () => {
|
||||
const result = store.updateJuror('unknown-juror', { active: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('initializes from seed.json if session.json does not exist', () => {
|
||||
// Delete session.json
|
||||
fs.unlinkSync(SESSION_FILE);
|
||||
expect(fs.existsSync(SESSION_FILE)).toBe(false);
|
||||
|
||||
// Manually call the initialization logic by writing seed
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
|
||||
// Verify the store reads correctly
|
||||
const data = store.getStore();
|
||||
expect(data.session.id).toBe('unikat-2026');
|
||||
expect(data.teams).toHaveLength(11);
|
||||
expect(data.jurors).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Score, Team, TeamResult, Criterion, CriterionStats } from '../shared/types.js';
|
||||
|
||||
/**
|
||||
* Calculate the aggregated result for a single team.
|
||||
*
|
||||
* - total = sum of all score.value where score.teamId matches
|
||||
* - jurorCount = count of distinct jurorIds that have submitted ALL criteria (criteriaCount scores)
|
||||
* - average = total / jurorCount (or 0 if jurorCount is 0)
|
||||
* - stddev = standard deviation of per-juror totals
|
||||
* - complete = jurorCount equals total number of jurors who have at least one score for this team
|
||||
* AND all have all criteria
|
||||
*/
|
||||
export function calculateTeamResult(
|
||||
teamId: string,
|
||||
teamName: string,
|
||||
scores: Score[],
|
||||
criteriaCount: number,
|
||||
criteria?: Criterion[]
|
||||
): TeamResult {
|
||||
const teamScores = scores.filter((s) => s.teamId === teamId);
|
||||
const total = teamScores.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
// Group scores by jurorId
|
||||
const scoresByJuror = new Map<string, Score[]>();
|
||||
for (const score of teamScores) {
|
||||
const existing = scoresByJuror.get(score.jurorId) || [];
|
||||
existing.push(score);
|
||||
scoresByJuror.set(score.jurorId, existing);
|
||||
}
|
||||
|
||||
// Count jurors who have submitted ALL criteria
|
||||
const jurorCount = Array.from(scoresByJuror.values()).filter(
|
||||
(jurorScores) => jurorScores.length >= criteriaCount
|
||||
).length;
|
||||
|
||||
const average = jurorCount > 0 ? total / jurorCount : 0;
|
||||
|
||||
// Calculate stddev of per-juror totals
|
||||
const jurorTotals = Array.from(scoresByJuror.values())
|
||||
.filter((jurorScores) => jurorScores.length >= criteriaCount)
|
||||
.map((jurorScores) => jurorScores.reduce((sum, s) => sum + s.value, 0));
|
||||
|
||||
let stddev = 0;
|
||||
if (jurorTotals.length > 1) {
|
||||
const mean = jurorTotals.reduce((a, b) => a + b, 0) / jurorTotals.length;
|
||||
const variance = jurorTotals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / jurorTotals.length;
|
||||
stddev = Math.sqrt(variance);
|
||||
}
|
||||
|
||||
// Complete = all jurors who have at least one score have submitted all criteria
|
||||
const totalJurorsWithScores = scoresByJuror.size;
|
||||
const complete = totalJurorsWithScores > 0 && jurorCount === totalJurorsWithScores;
|
||||
|
||||
// Per-criterion stats
|
||||
let criteriaStats: CriterionStats[] | undefined;
|
||||
if (criteria) {
|
||||
criteriaStats = criteria.map((criterion) => {
|
||||
const criterionScores = teamScores
|
||||
.filter((s) => s.criterionId === criterion.id)
|
||||
.map((s) => s.value);
|
||||
const cMean = criterionScores.length > 0
|
||||
? criterionScores.reduce((a, b) => a + b, 0) / criterionScores.length
|
||||
: 0;
|
||||
let cStddev = 0;
|
||||
if (criterionScores.length > 1) {
|
||||
const cVariance = criterionScores.reduce((sum, v) => sum + (v - cMean) ** 2, 0) / criterionScores.length;
|
||||
cStddev = Math.sqrt(cVariance);
|
||||
}
|
||||
return {
|
||||
criterionId: criterion.id,
|
||||
criterionName: criterion.nameDE,
|
||||
mean: cMean,
|
||||
stddev: cStddev,
|
||||
scores: criterionScores,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
teamId,
|
||||
teamName,
|
||||
total,
|
||||
average,
|
||||
stddev,
|
||||
rank: 0, // Will be assigned by calculateRankings
|
||||
jurorCount,
|
||||
complete,
|
||||
criteriaStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort team results by total descending and assign dense ranking.
|
||||
* Ties get the same rank.
|
||||
* Example: totals [250, 240, 240, 230] → ranks [1, 2, 2, 4]
|
||||
*/
|
||||
export function calculateRankings(teamResults: TeamResult[]): TeamResult[] {
|
||||
// Sort by total descending
|
||||
const sorted = [...teamResults].sort((a, b) => b.total - a.total);
|
||||
|
||||
// Assign dense ranking with ties
|
||||
let currentRank = 1;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i].total < sorted[i - 1].total) {
|
||||
currentRank = i + 1;
|
||||
}
|
||||
sorted[i] = { ...sorted[i], rank: currentRank };
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the completion status for a specific juror-team pair.
|
||||
*
|
||||
* - 0 scores → 'pending'
|
||||
* - 1 to criteriaCount-1 → 'partial'
|
||||
* - criteriaCount → 'complete'
|
||||
*/
|
||||
export function getCompletionStatus(
|
||||
jurorId: string,
|
||||
teamId: string,
|
||||
scores: Score[],
|
||||
criteriaCount: number
|
||||
): 'complete' | 'partial' | 'pending' {
|
||||
const count = scores.filter(
|
||||
(s) => s.jurorId === jurorId && s.teamId === teamId
|
||||
).length;
|
||||
|
||||
if (count === 0) return 'pending';
|
||||
if (count >= criteriaCount) return 'complete';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate results for all teams and apply rankings.
|
||||
* Convenience function combining calculateTeamResult + calculateRankings.
|
||||
*/
|
||||
export function calculateAllResults(
|
||||
scores: Score[],
|
||||
teams: Team[],
|
||||
criteriaCount: number,
|
||||
criteria?: Criterion[]
|
||||
): TeamResult[] {
|
||||
const teamResults = teams.map((team) =>
|
||||
calculateTeamResult(team.id, team.name, scores, criteriaCount, criteria)
|
||||
);
|
||||
return calculateRankings(teamResults);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Score } from '../shared/types.js';
|
||||
|
||||
export interface ConflictResult {
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an incoming score should be accepted over an existing one.
|
||||
* Uses last-write-wins strategy based on updatedAt timestamps.
|
||||
*
|
||||
* - If no existing score → accept
|
||||
* - If incoming.updatedAt > existing.updatedAt → accept (last-write-wins)
|
||||
* - If incoming.updatedAt <= existing.updatedAt → reject
|
||||
*/
|
||||
export function shouldAcceptScore(
|
||||
incoming: Score,
|
||||
existing: Score | undefined
|
||||
): ConflictResult {
|
||||
if (!existing) {
|
||||
return { accept: true };
|
||||
}
|
||||
|
||||
if (incoming.updatedAt > existing.updatedAt) {
|
||||
return { accept: true };
|
||||
}
|
||||
|
||||
return { accept: false, reason: 'Newer score already exists' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a score conflict with optional force override.
|
||||
*
|
||||
* - If force is true → always accept
|
||||
* - Otherwise delegate to shouldAcceptScore (last-write-wins)
|
||||
*/
|
||||
export function resolveConflict(
|
||||
incoming: Score,
|
||||
existing: Score | undefined,
|
||||
force?: boolean
|
||||
): ConflictResult {
|
||||
if (force) {
|
||||
return { accept: true };
|
||||
}
|
||||
|
||||
return shouldAcceptScore(incoming, existing);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"session": {
|
||||
"id": "unikat-2026",
|
||||
"status": "setup",
|
||||
"startedAt": null,
|
||||
"endedAt": null,
|
||||
"config": {
|
||||
"criteria": [
|
||||
{ "id": "gesamtidee", "nameDE": "Gesamtidee", "nameEN": "Overall Idea" },
|
||||
{ "id": "kundennutzen", "nameDE": "Kundennutzen", "nameEN": "Customer Benefit" },
|
||||
{ "id": "markt", "nameDE": "Markt", "nameEN": "Market" },
|
||||
{ "id": "realisierbarkeit", "nameDE": "Realisierbarkeit", "nameEN": "Feasibility" },
|
||||
{ "id": "praesentation", "nameDE": "Präsentation", "nameEN": "Presentation" }
|
||||
],
|
||||
"scaleMin": 1,
|
||||
"scaleMax": 10
|
||||
}
|
||||
},
|
||||
"teams": [
|
||||
{ "id": "anima-lektor", "number": 1, "name": "Anima Lektor", "description": "Lektor for game language/text", "contact": "Lukas Fleck" },
|
||||
{ "id": "das-any-thing", "number": 2, "name": "Das Any-Thing", "description": "Physical AI automaton that adapts to context" },
|
||||
{ "id": "doppelrad-sortierer", "number": 3, "name": "Doppelrad-Sortierer", "description": "AI-powered seed sorting machine for niche producers" },
|
||||
{ "id": "dual", "number": 4, "name": "DUAL", "description": "Fully reversible wood construction system", "contact": "Nicole Kozlewski, Sebastian Görs" },
|
||||
{ "id": "foemo", "number": 5, "name": "fömo", "description": "Funding management platform for social institutions" },
|
||||
{ "id": "isomedi", "number": 6, "name": "ISOmedi", "description": "Compact rechargeable medication case with protective micro-environment" },
|
||||
{ "id": "lians-tempeh-chips", "number": 7, "name": "Lian's Tempeh Chips", "description": "Tempeh-based snack product", "contact": "Dr. Berlianti Puteri" },
|
||||
{ "id": "lumpen", "number": 8, "name": "Lumpen", "description": "Sustainable upholstery material from old clothing textiles" },
|
||||
{ "id": "mobile-sprinkleranlage", "number": 9, "name": "Mobile Sprinkleranlage", "description": "Mobile sprinkler system for fire protection", "contact": "Björn Knoke" },
|
||||
{ "id": "safesupply-os", "number": 10, "name": "SafeSupply OS", "description": "Lightweight supplier risk & audit system for agri-food SMEs", "contact": "Jesvin Jaksan" },
|
||||
{ "id": "second-nose", "number": 11, "name": "Second Nose", "description": "Digital smell detection module for smartphones" }
|
||||
],
|
||||
"jurors": [
|
||||
{ "id": "cielejewski", "name": "Cielejewski", "active": false },
|
||||
{ "id": "finke", "name": "Finke", "active": false },
|
||||
{ "id": "freyer", "name": "Freyer", "active": false },
|
||||
{ "id": "knie", "name": "Knie", "active": false },
|
||||
{ "id": "meyer", "name": "Meyer", "active": false },
|
||||
{ "id": "trieschmann", "name": "Trieschmann", "active": false }
|
||||
],
|
||||
"scores": []
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import express, { Router } from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { apiRouter } from './routes.js';
|
||||
import { sseRouter } from './sse.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
const APP_PASSWORD = process.env.APP_PASSWORD || 'UNIKAT_jury';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Password protection middleware
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === '/api/health') { next(); return; }
|
||||
|
||||
// Check query param
|
||||
const token = req.query.token as string | undefined;
|
||||
if (token === APP_PASSWORD) {
|
||||
res.cookie('jury_auth', APP_PASSWORD, { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||
if (!req.path.startsWith('/api')) {
|
||||
res.redirect(req.path || '/');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
const cookieHeader = req.headers.cookie || '';
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(';').map(c => {
|
||||
const [key, ...val] = c.trim().split('=');
|
||||
return [key, val.join('=')];
|
||||
})
|
||||
);
|
||||
if (cookies.jury_auth === APP_PASSWORD) { next(); return; }
|
||||
|
||||
// Not authenticated
|
||||
if (req.path.startsWith('/api')) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Login page
|
||||
res.status(401).send(`<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UNIKAT Jury Voting</title>
|
||||
<style>
|
||||
body { background: #0a0a0a; color: #f0f0f0; font-family: 'Outfit', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.login { text-align: center; max-width: 320px; padding: 40px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||||
p { color: #999; margin-bottom: 24px; }
|
||||
input { width: 100%; padding: 14px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); background: rgba(25,25,25,0.6); color: #f0f0f0; font-size: 1rem; text-align: center; outline: none; }
|
||||
input:focus { border-color: #00FF00; }
|
||||
button { width: 100%; margin-top: 12px; padding: 14px; border-radius: 50px; border: none; background: linear-gradient(135deg, #00FF00, #00b300); color: #000; font-weight: 800; font-size: 1.1rem; cursor: pointer; }
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login">
|
||||
<h1>🗳️ Jury Voting</h1>
|
||||
<p>15. UNIKAT Ideenwettbewerb</p>
|
||||
<form onsubmit="login(event)">
|
||||
<input type="password" id="pw" placeholder="Passwort eingeben" autofocus>
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function login(e) {
|
||||
e.preventDefault();
|
||||
const pw = document.getElementById('pw').value;
|
||||
window.location.href = '/?token=' + encodeURIComponent(pw);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use(apiRouter);
|
||||
app.use(sseRouter);
|
||||
|
||||
// Serve static frontend in production
|
||||
const distPath = path.resolve(__dirname, '..', 'dist');
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(distPath));
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
getSession,
|
||||
getTeams,
|
||||
getJurors,
|
||||
getScores,
|
||||
addScore,
|
||||
updateSession,
|
||||
getStore,
|
||||
} from './store.js';
|
||||
import { resolveConflict } from './conflict.js';
|
||||
import { calculateAllResults, getCompletionStatus } from './aggregation.js';
|
||||
import { broadcastUpdate } from './sse.js';
|
||||
import { Score } from '../shared/types.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
// ─── Session Endpoints ───────────────────────────────────────────────
|
||||
|
||||
apiRouter.get('/api/session', (_req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
apiRouter.post('/api/session/start', (_req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
if (session.status === 'active') {
|
||||
res.status(400).json({ error: 'Session is already active' });
|
||||
return;
|
||||
}
|
||||
const updated = updateSession({
|
||||
status: 'active',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
apiRouter.post('/api/session/end', (_req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
if (session.status !== 'active') {
|
||||
res.status(400).json({ error: 'Session is not active' });
|
||||
return;
|
||||
}
|
||||
const updated = updateSession({
|
||||
status: 'ended',
|
||||
endedAt: new Date().toISOString(),
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// ─── Team and Juror Endpoints ────────────────────────────────────────
|
||||
|
||||
apiRouter.get('/api/teams', (_req: Request, res: Response) => {
|
||||
const teams = getTeams();
|
||||
res.json(teams);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/jurors', (_req: Request, res: Response) => {
|
||||
const jurors = getJurors();
|
||||
res.json(jurors);
|
||||
});
|
||||
|
||||
// ─── Score Endpoints ─────────────────────────────────────────────────
|
||||
|
||||
apiRouter.post('/api/scores', (req: Request, res: Response) => {
|
||||
const session = getSession();
|
||||
if (session.status !== 'active') {
|
||||
res.status(403).json({ error: 'Session is not active' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { jurorId, teamId, criterionId, value, force } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!jurorId || !teamId || !criterionId || value === undefined) {
|
||||
res.status(400).json({ error: 'Missing required fields: jurorId, teamId, criterionId, value' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate value is within scale range
|
||||
const { scaleMin, scaleMax } = session.config;
|
||||
if (typeof value !== 'number' || value < scaleMin || value > scaleMax) {
|
||||
res.status(400).json({ error: `Value must be between ${scaleMin} and ${scaleMax}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate jurorId exists
|
||||
const jurors = getJurors();
|
||||
if (!jurors.find((j) => j.id === jurorId)) {
|
||||
res.status(400).json({ error: `Unknown jurorId: ${jurorId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate teamId exists
|
||||
const teams = getTeams();
|
||||
if (!teams.find((t) => t.id === teamId)) {
|
||||
res.status(400).json({ error: `Unknown teamId: ${teamId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate criterionId exists
|
||||
const criteria = session.config.criteria;
|
||||
if (!criteria.find((c) => c.id === criterionId)) {
|
||||
res.status(400).json({ error: `Unknown criterionId: ${criterionId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const incoming: Score = {
|
||||
jurorId,
|
||||
teamId,
|
||||
criterionId,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check for existing score and resolve conflict
|
||||
const existingScores = getScores();
|
||||
const existing = existingScores.find(
|
||||
(s) => s.jurorId === jurorId && s.teamId === teamId && s.criterionId === criterionId
|
||||
);
|
||||
|
||||
const conflict = resolveConflict(incoming, existing, force);
|
||||
if (!conflict.accept) {
|
||||
res.status(409).json({ error: conflict.reason, existing });
|
||||
return;
|
||||
}
|
||||
|
||||
addScore(incoming);
|
||||
broadcastUpdate();
|
||||
res.json(incoming);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/scores/:jurorId', (req: Request, res: Response) => {
|
||||
const { jurorId } = req.params;
|
||||
const scores = getScores();
|
||||
const jurorScores = scores.filter((s) => s.jurorId === jurorId);
|
||||
res.json(jurorScores);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/scores', (_req: Request, res: Response) => {
|
||||
const scores = getScores();
|
||||
res.json(scores);
|
||||
});
|
||||
|
||||
// ─── Results and Progress Endpoints ──────────────────────────────────
|
||||
|
||||
apiRouter.get('/api/results', (_req: Request, res: Response) => {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount, store.session.config.criteria);
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/progress', (_req: Request, res: Response) => {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const progress: Record<string, { completed: number; total: number }> = {};
|
||||
|
||||
for (const juror of store.jurors) {
|
||||
let completedTeams = 0;
|
||||
for (const team of store.teams) {
|
||||
const status = getCompletionStatus(juror.id, team.id, store.scores, criteriaCount);
|
||||
if (status === 'complete') {
|
||||
completedTeams++;
|
||||
}
|
||||
}
|
||||
progress[juror.id] = { completed: completedTeams, total: store.teams.length };
|
||||
}
|
||||
|
||||
res.json(progress);
|
||||
});
|
||||
|
||||
apiRouter.get('/api/export/csv', (_req: Request, res: Response) => {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount);
|
||||
|
||||
// Build header row: Team, then for each juror: juror_criterion1, juror_criterion2, ..., then Total, Average, Rank
|
||||
const criteria = store.session.config.criteria;
|
||||
const headerParts = ['Team'];
|
||||
for (const juror of store.jurors) {
|
||||
for (const criterion of criteria) {
|
||||
headerParts.push(`${juror.name}_${criterion.nameDE}`);
|
||||
}
|
||||
}
|
||||
headerParts.push('Total', 'Average', 'Rank');
|
||||
|
||||
const rows: string[] = [headerParts.join(',')];
|
||||
|
||||
// One row per team (sorted by rank)
|
||||
for (const result of results) {
|
||||
const rowParts: string[] = [`"${result.teamName}"`];
|
||||
|
||||
for (const juror of store.jurors) {
|
||||
for (const criterion of criteria) {
|
||||
const score = store.scores.find(
|
||||
(s) => s.jurorId === juror.id && s.teamId === result.teamId && s.criterionId === criterion.id
|
||||
);
|
||||
rowParts.push(score ? String(score.value) : '');
|
||||
}
|
||||
}
|
||||
|
||||
rowParts.push(String(result.total), result.average.toFixed(2), String(result.rank));
|
||||
rows.push(rowParts.join(','));
|
||||
}
|
||||
|
||||
const csv = rows.join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="jury-results.csv"');
|
||||
res.send(csv);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getStore } from './store.js';
|
||||
import { calculateAllResults } from './aggregation.js';
|
||||
|
||||
// List of connected SSE clients
|
||||
const clients: Response[] = [];
|
||||
|
||||
export const sseRouter = Router();
|
||||
|
||||
/**
|
||||
* SSE endpoint: GET /api/results/stream
|
||||
*/
|
||||
sseRouter.get('/api/results/stream', (req: Request, res: Response) => {
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Add client to list
|
||||
clients.push(res);
|
||||
|
||||
// Send initial data immediately on connect
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount);
|
||||
const payload = JSON.stringify({ results, timestamp: new Date().toISOString() });
|
||||
res.write(`event: scores_updated\ndata: ${payload}\n\n`);
|
||||
|
||||
// Remove client on disconnect
|
||||
req.on('close', () => {
|
||||
const index = clients.indexOf(res);
|
||||
if (index !== -1) {
|
||||
clients.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Broadcast current results to all connected SSE clients.
|
||||
* Called whenever scores change.
|
||||
*/
|
||||
export function broadcastUpdate(): void {
|
||||
const store = getStore();
|
||||
const criteriaCount = store.session.config.criteria.length;
|
||||
const results = calculateAllResults(store.scores, store.teams, criteriaCount);
|
||||
const payload = JSON.stringify({ results, timestamp: new Date().toISOString() });
|
||||
const message = `event: scores_updated\ndata: ${payload}\n\n`;
|
||||
|
||||
for (const client of clients) {
|
||||
client.write(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { StoreData, Session, Team, Juror, Score } from '../shared/types.js';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'server', 'data');
|
||||
const SESSION_FILE = path.join(DATA_DIR, 'session.json');
|
||||
const SEED_FILE = path.join(DATA_DIR, 'seed.json');
|
||||
const BACKUP_FILE = path.join(DATA_DIR, 'session.json.bak');
|
||||
|
||||
/**
|
||||
* Ensure the data directory exists.
|
||||
*/
|
||||
function ensureDataDir(): void {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session.json from seed.json if it doesn't exist.
|
||||
*/
|
||||
function initializeStore(): void {
|
||||
ensureDataDir();
|
||||
if (!fs.existsSync(SESSION_FILE)) {
|
||||
const seedData = fs.readFileSync(SEED_FILE, 'utf-8');
|
||||
fs.writeFileSync(SESSION_FILE, seedData, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
initializeStore();
|
||||
|
||||
/**
|
||||
* Read the current store data from session.json.
|
||||
*/
|
||||
export function getStore(): StoreData {
|
||||
const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
|
||||
return JSON.parse(raw) as StoreData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write store data atomically: write to temp file, then rename.
|
||||
* Creates a .bak backup before each write.
|
||||
*/
|
||||
export function saveStore(data: StoreData): void {
|
||||
ensureDataDir();
|
||||
|
||||
// Create backup of current file if it exists
|
||||
if (fs.existsSync(SESSION_FILE)) {
|
||||
fs.copyFileSync(SESSION_FILE, BACKUP_FILE);
|
||||
}
|
||||
|
||||
// Write to temp file, then rename (atomic on most filesystems)
|
||||
const tempFile = path.join(DATA_DIR, 'session.json.tmp');
|
||||
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2), 'utf-8');
|
||||
fs.renameSync(tempFile, SESSION_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session.
|
||||
*/
|
||||
export function getSession(): Session {
|
||||
const store = getStore();
|
||||
return store.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all teams.
|
||||
*/
|
||||
export function getTeams(): Team[] {
|
||||
const store = getStore();
|
||||
return store.teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jurors.
|
||||
*/
|
||||
export function getJurors(): Juror[] {
|
||||
const store = getStore();
|
||||
return store.jurors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scores.
|
||||
*/
|
||||
export function getScores(): Score[] {
|
||||
const store = getStore();
|
||||
return store.scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a score. Matches by jurorId + teamId + criterionId.
|
||||
*/
|
||||
export function addScore(score: Score): void {
|
||||
const store = getStore();
|
||||
const existingIndex = store.scores.findIndex(
|
||||
(s) =>
|
||||
s.jurorId === score.jurorId &&
|
||||
s.teamId === score.teamId &&
|
||||
s.criterionId === score.criterionId
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
store.scores[existingIndex] = score;
|
||||
} else {
|
||||
store.scores.push(score);
|
||||
}
|
||||
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session with partial updates. Returns the updated session.
|
||||
*/
|
||||
export function updateSession(updates: Partial<Session>): Session {
|
||||
const store = getStore();
|
||||
store.session = { ...store.session, ...updates };
|
||||
saveStore(store);
|
||||
return store.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a juror by ID with partial updates. Returns the updated juror or null if not found.
|
||||
*/
|
||||
export function updateJuror(jurorId: string, updates: Partial<Juror>): Juror | null {
|
||||
const store = getStore();
|
||||
const jurorIndex = store.jurors.findIndex((j) => j.id === jurorId);
|
||||
|
||||
if (jurorIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
store.jurors[jurorIndex] = { ...store.jurors[jurorIndex], ...updates };
|
||||
saveStore(store);
|
||||
return store.jurors[jurorIndex];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist-server",
|
||||
"rootDir": "..",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["./**/*.ts", "../shared/**/*.ts"],
|
||||
"exclude": ["dist-server", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user