diff --git a/App.tsx b/App.tsx index cbfe1bf..52680ce 100644 --- a/App.tsx +++ b/App.tsx @@ -46,6 +46,7 @@ function App() { startQuizGen, startManualCreation, finalizeManualQuiz, + loadSavedQuiz, joinGame, startGame, handleAnswer, @@ -76,6 +77,7 @@ function App() { ` modal when open - - [ ] Handle quiz load: call `onLoadQuiz` prop with loaded quiz +- [x] Modify `components/Landing.tsx`: + - [x] Add "My Quizzes" button (only visible when authenticated) + - [x] Add state for quiz library modal visibility + - [x] Render `` modal when open + - [x] Handle quiz load: call `onLoadQuiz` prop with loaded quiz ### 4.4 Types Update -- [ ] Modify `src/types.ts`: - - [ ] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) - - [ ] Add `QuizListItem` interface (for list view) - - [ ] Add `QuizSource` type: `'manual' | 'ai_generated'` +- [x] Modify `types.ts`: + - [x] Add `SavedQuiz` interface (extends `Quiz` with id, source, dates) + - [x] Add `QuizListItem` interface (for list view) + - [x] Add `QuizSource` type: `'manual' | 'ai_generated'` + +### 4.5 Game Hook Integration +- [x] Modify `hooks/useGame.ts`: + - [x] Add `loadSavedQuiz(quiz)` function + - [x] Export for App.tsx to consume --- @@ -403,7 +406,7 @@ kaboot/ | Phase 1 | **COMPLETE** | Docker Compose, .env, setup script, Authentik docs | | Phase 2 | **COMPLETE** | Backend API with Express, SQLite, JWT auth, Quiz CRUD | | Phase 3 | **COMPLETE** | OIDC config, AuthProvider, AuthButton, useAuthenticatedFetch | -| Phase 4 | Not Started | | +| Phase 4 | **COMPLETE** | useQuizLibrary hook, QuizLibrary modal, Landing integration | | Phase 5 | Not Started | | | Phase 6 | Not Started | | | Phase 7 | Not Started | | diff --git a/components/Landing.tsx b/components/Landing.tsx index de17fdc..c552466 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,21 +1,36 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen } from 'lucide-react'; +import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; +import { QuizLibrary } from './QuizLibrary'; +import { useQuizLibrary } from '../hooks/useQuizLibrary'; +import type { Quiz } from '../types'; interface LandingProps { onGenerate: (topic: string) => void; onCreateManual: () => void; + onLoadQuiz: (quiz: Quiz) => void; onJoin: (pin: string, name: string) => void; isLoading: boolean; error: string | null; } -export const Landing: React.FC = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => { +export const Landing: React.FC = ({ onGenerate, onCreateManual, onLoadQuiz, onJoin, isLoading, error }) => { + const auth = useAuth(); const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST'); const [topic, setTopic] = useState(''); const [pin, setPin] = useState(''); const [name, setName] = useState(''); + const [libraryOpen, setLibraryOpen] = useState(false); + + const { quizzes, loading: libraryLoading, error: libraryError, fetchQuizzes, loadQuiz, deleteQuiz } = useQuizLibrary(); + + useEffect(() => { + if (libraryOpen && auth.isAuthenticated) { + fetchQuizzes(); + } + }, [libraryOpen, auth.isAuthenticated, fetchQuizzes]); const handleHostSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -27,6 +42,12 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on if (pin.trim() && name.trim()) onJoin(pin, name); }; + const handleLoadQuiz = async (id: string) => { + const quiz = await loadQuiz(id); + setLibraryOpen(false); + onLoadQuiz(quiz); + }; + return (
@@ -93,6 +114,15 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on > Create Manually + + {auth.isAuthenticated && ( + + )}
) : (
@@ -126,6 +156,16 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on )} + + setLibraryOpen(false)} + quizzes={quizzes} + loading={libraryLoading} + error={libraryError} + onLoadQuiz={handleLoadQuiz} + onDeleteQuiz={deleteQuiz} + />
); }; diff --git a/components/QuizLibrary.tsx b/components/QuizLibrary.tsx new file mode 100644 index 0000000..9a0e4b7 --- /dev/null +++ b/components/QuizLibrary.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Trash2, Play, BrainCircuit, PenTool, Loader2, Calendar } from 'lucide-react'; +import { QuizListItem } from '../types'; + +interface QuizLibraryProps { + isOpen: boolean; + onClose: () => void; + quizzes: QuizListItem[]; + loading: boolean; + error: string | null; + onLoadQuiz: (id: string) => void; + onDeleteQuiz: (id: string) => void; +} + +export const QuizLibrary: React.FC = ({ + isOpen, + onClose, + quizzes, + loading, + error, + onLoadQuiz, + onDeleteQuiz, +}) => { + const [deletingId, setDeletingId] = useState(null); + + const handleDeleteClick = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + setDeletingId(id); + }; + + const confirmDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + if (deletingId) { + onDeleteQuiz(deletingId); + setDeletingId(null); + } + }; + + const cancelDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + setDeletingId(null); + }; + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } catch (e) { + return dateString; + } + }; + + return ( + + {isOpen && ( + <> + + e.stopPropagation()} + className="bg-white w-full max-w-2xl max-h-[80vh] flex flex-col rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] border-4 border-white/50 relative overflow-hidden" + > +
+
+

My Library

+

Select a quiz to play

+
+ +
+ +
+ {loading && ( +
+ +

Loading your quizzes...

+
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && quizzes.length === 0 && ( +
+
+ +
+

No saved quizzes yet

+

Create or generate a quiz to save it here!

+
+ )} + + {!loading && !error && quizzes.map((quiz) => ( + onLoadQuiz(quiz.id)} + > +
+
+
+ {quiz.source === 'ai_generated' ? ( + + AI + + ) : ( + + Manual + + )} + + {formatDate(quiz.createdAt)} + +
+ +

+ {quiz.title} +

+ +

+ {quiz.questionCount} question{quiz.questionCount !== 1 ? 's' : ''} + {quiz.aiTopic && • Topic: {quiz.aiTopic}} +

+
+ +
+ {deletingId === quiz.id ? ( +
e.stopPropagation()}> + + +
+ ) : ( + <> + +
+ +
+ + )} +
+
+
+ ))} +
+
+
+ + )} +
+ ); +}; diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md index 60a6694..940c1b9 100644 --- a/docs/AUTHENTIK_SETUP.md +++ b/docs/AUTHENTIK_SETUP.md @@ -93,20 +93,73 @@ Note these endpoints (you'll need them for frontend configuration): 2. You should see a JSON response with all OIDC endpoints -## Step 6: Create a Test User (Optional) +## Step 6: Create a Test User + +Create a regular user for manual browser testing. 1. Go to **Directory** > **Users** 2. Click **Create** 3. Fill in user details: - - Username: `testuser` - - Name: `Test User` - - Email: `test@example.com` + | Field | Value | + |-------|-------| + | Username | `kaboottest` | + | Name | `Kaboot Test` | + | Email | `kaboottest@test.com` | 4. After creation, click on the user and go to the **Credentials** tab -5. Click **Set password** to create a password +5. Click **Set password** and set it to `kaboottest` + +6. **Bind the user to the Kaboot application**: + - Go to **Applications** > **Applications** > **Kaboot** + - Click the **Policy / Group / User Bindings** tab + - Click **Bind existing user** + - Select `kaboottest` and click **Bind** + +## Step 7: Create a Service Account for API Testing + +Create a service account that can obtain tokens programmatically for automated tests. + +1. Go to **Directory** > **Users** + +2. Click **Create Service Account** + +3. Fill in details: + | Field | Value | + |-------|-------| + | Username | `kaboot-test-service` | + | Create group | Unchecked | + +4. Click **Create** + +5. **Create an App Password** for the service account: + - Click on the newly created `kaboot-test-service` user + - Go to the **App passwords** tab + - Click **Create App Password** + - Name it `api-tests` + - Copy the generated password (you won't see it again!) + +6. **Bind the service account to the Kaboot application**: + - Go to **Applications** > **Applications** > **Kaboot** + - Click the **Policy / Group / User Bindings** tab + - Click **Bind existing user** + - Select `kaboot-test-service` and click **Bind** + +7. **Save credentials to `server/.env.test`**: + ```bash + TEST_USERNAME=kaboot-test-service + TEST_PASSWORD= + ``` + +8. **Verify token generation works**: + ```bash + cd server + npm run test:get-token + ``` + + You should see "Token obtained successfully" and the access token printed. ## Environment Variables diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 2b9b6ac..63df37e 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -64,6 +64,10 @@ export const useGame = () => { initializeHostGame(manualQuiz); }; + const loadSavedQuiz = (savedQuiz: Quiz) => { + initializeHostGame(savedQuiz); + }; + // We use a ref to hold the current handleHostData function // This prevents stale closures in the PeerJS event listeners const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {}); @@ -379,6 +383,6 @@ export const useGame = () => { return { role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, - startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion + startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion }; }; \ No newline at end of file diff --git a/hooks/useQuizLibrary.ts b/hooks/useQuizLibrary.ts new file mode 100644 index 0000000..da54024 --- /dev/null +++ b/hooks/useQuizLibrary.ts @@ -0,0 +1,103 @@ +import { useState, useCallback } from 'react'; +import { useAuthenticatedFetch } from './useAuthenticatedFetch'; +import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types'; + +interface UseQuizLibraryReturn { + quizzes: QuizListItem[]; + loading: boolean; + error: string | null; + fetchQuizzes: () => Promise; + loadQuiz: (id: string) => Promise; + saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise; + deleteQuiz: (id: string) => Promise; +} + +export const useQuizLibrary = (): UseQuizLibraryReturn => { + const { authFetch, isAuthenticated } = useAuthenticatedFetch(); + const [quizzes, setQuizzes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchQuizzes = useCallback(async () => { + if (!isAuthenticated) return; + + setLoading(true); + setError(null); + + try { + const response = await authFetch('/api/quizzes'); + if (!response.ok) { + throw new Error('Failed to fetch quizzes'); + } + const data = await response.json(); + setQuizzes(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch quizzes'); + } finally { + setLoading(false); + } + }, [authFetch, isAuthenticated]); + + const loadQuiz = useCallback(async (id: string): Promise => { + const response = await authFetch(`/api/quizzes/${id}`); + if (!response.ok) { + throw new Error('Failed to load quiz'); + } + return response.json(); + }, [authFetch]); + + const saveQuiz = useCallback(async ( + quiz: Quiz, + source: QuizSource, + aiTopic?: string + ): Promise => { + const response = await authFetch('/api/quizzes', { + method: 'POST', + body: JSON.stringify({ + title: quiz.title, + source, + aiTopic, + questions: quiz.questions.map(q => ({ + text: q.text, + timeLimit: q.timeLimit, + options: q.options.map(o => ({ + text: o.text, + isCorrect: o.isCorrect, + shape: o.shape, + color: o.color, + reason: o.reason, + })), + })), + }), + }); + + if (!response.ok) { + throw new Error('Failed to save quiz'); + } + + const data = await response.json(); + return data.id; + }, [authFetch]); + + const deleteQuiz = useCallback(async (id: string): Promise => { + const response = await authFetch(`/api/quizzes/${id}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + throw new Error('Failed to delete quiz'); + } + + setQuizzes(prev => prev.filter(q => q.id !== id)); + }, [authFetch]); + + return { + quizzes, + loading, + error, + fetchQuizzes, + loadQuiz, + saveQuiz, + deleteQuiz, + }; +}; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 4b31592..69d9d79 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -168,6 +168,264 @@ async function runTests() { await request('GET', `/api/quizzes/${createdQuizId}`, undefined, 404); }); + console.log('\nQuiz Validation Tests:'); + await test('POST /api/quizzes without title returns 400', async () => { + const invalidQuiz = { + source: 'manual', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes without source returns 400', async () => { + const invalidQuiz = { + title: 'Missing Source Quiz', + questions: [ + { + text: 'Question?', + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes without questions returns 400', async () => { + const invalidQuiz = { + title: 'No Questions Quiz', + source: 'manual', + questions: [], + }; + await request('POST', '/api/quizzes', invalidQuiz, 400); + }); + + await test('POST /api/quizzes with empty body returns 400', async () => { + await request('POST', '/api/quizzes', {}, 400); + }); + + console.log('\nQuiz Not Found Tests:'); + await test('GET /api/quizzes/:id with non-existent ID returns 404', async () => { + await request('GET', '/api/quizzes/non-existent-uuid-12345', undefined, 404); + }); + + await test('PUT /api/quizzes/:id with non-existent ID returns 404', async () => { + const quiz = { + title: 'Update Non-Existent', + questions: [ + { + text: 'Q?', + options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }], + }, + ], + }; + await request('PUT', '/api/quizzes/non-existent-uuid-12345', quiz, 404); + }); + + await test('DELETE /api/quizzes/:id with non-existent ID returns 404', async () => { + await request('DELETE', '/api/quizzes/non-existent-uuid-12345', undefined, 404); + }); + + console.log('\nQuiz Source Types Tests:'); + let aiQuizId: string | null = null; + + await test('POST /api/quizzes with ai_generated source and aiTopic', async () => { + const aiQuiz = { + title: 'AI Generated Quiz', + source: 'ai_generated', + aiTopic: 'Space Exploration', + questions: [ + { + text: 'What planet is known as the Red Planet?', + timeLimit: 20, + options: [ + { text: 'Venus', isCorrect: false, shape: 'triangle', color: 'red' }, + { text: 'Mars', isCorrect: true, shape: 'diamond', color: 'blue' }, + { text: 'Jupiter', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'Saturn', isCorrect: false, shape: 'square', color: 'green' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', aiQuiz, 201); + const result = data as { id: string }; + if (!result.id) throw new Error('Missing quiz id'); + aiQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns aiTopic for AI quiz', async () => { + if (!aiQuizId) throw new Error('No AI quiz created'); + const { data } = await request('GET', `/api/quizzes/${aiQuizId}`); + const quiz = data as Record; + if (quiz.source !== 'ai_generated') throw new Error('Wrong source'); + if (quiz.aiTopic !== 'Space Exploration') throw new Error('Missing or wrong aiTopic'); + }); + + await test('GET /api/quizzes list includes source and questionCount', async () => { + const { data } = await request('GET', '/api/quizzes'); + const quizzes = data as Record[]; + if (quizzes.length === 0) throw new Error('Expected at least one quiz'); + const quiz = quizzes.find((q) => q.id === aiQuizId); + if (!quiz) throw new Error('AI quiz not in list'); + if (quiz.source !== 'ai_generated') throw new Error('Missing source in list'); + if (typeof quiz.questionCount !== 'number') throw new Error('Missing questionCount'); + if (quiz.questionCount !== 1) throw new Error('Wrong questionCount'); + }); + + await test('DELETE cleanup AI quiz', async () => { + if (!aiQuizId) throw new Error('No AI quiz to delete'); + await request('DELETE', `/api/quizzes/${aiQuizId}`, undefined, 204); + }); + + console.log('\nQuiz with Multiple Questions Tests:'); + let multiQuizId: string | null = null; + + await test('POST /api/quizzes with multiple questions', async () => { + const multiQuiz = { + title: 'Multi-Question Quiz', + source: 'manual', + questions: [ + { + text: 'Question 1?', + timeLimit: 15, + options: [ + { text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }, + { text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' }, + ], + }, + { + text: 'Question 2?', + timeLimit: 25, + options: [ + { text: 'X', isCorrect: false, shape: 'circle', color: 'yellow' }, + { text: 'Y', isCorrect: true, shape: 'square', color: 'green' }, + ], + }, + { + text: 'Question 3?', + timeLimit: 30, + options: [ + { text: 'P', isCorrect: false, shape: 'triangle', color: 'red', reason: 'Wrong because...' }, + { text: 'Q', isCorrect: true, shape: 'diamond', color: 'blue', reason: 'Correct because...' }, + ], + }, + ], + }; + + const { data } = await request('POST', '/api/quizzes', multiQuiz, 201); + const result = data as { id: string }; + multiQuizId = result.id; + }); + + await test('GET /api/quizzes/:id returns all questions with correct order', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz created'); + const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); + const quiz = data as { questions: { text: string; timeLimit: number; options: { reason?: string }[] }[] }; + if (quiz.questions.length !== 3) throw new Error(`Expected 3 questions, got ${quiz.questions.length}`); + if (quiz.questions[0].text !== 'Question 1?') throw new Error('Wrong order for Q1'); + if (quiz.questions[1].text !== 'Question 2?') throw new Error('Wrong order for Q2'); + if (quiz.questions[2].text !== 'Question 3?') throw new Error('Wrong order for Q3'); + if (quiz.questions[0].timeLimit !== 15) throw new Error('Wrong timeLimit for Q1'); + if (quiz.questions[2].options[1].reason !== 'Correct because...') throw new Error('Missing reason field'); + }); + + await test('GET /api/quizzes shows correct questionCount for multi-question quiz', async () => { + const { data } = await request('GET', '/api/quizzes'); + const quizzes = data as Record[]; + const quiz = quizzes.find((q) => q.id === multiQuizId); + if (!quiz) throw new Error('Multi-question quiz not in list'); + if (quiz.questionCount !== 3) throw new Error(`Expected questionCount 3, got ${quiz.questionCount}`); + }); + + await test('PUT /api/quizzes/:id replaces all questions', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz created'); + const updatedQuiz = { + title: 'Updated Multi Quiz', + questions: [ + { + text: 'Only One Question Now', + timeLimit: 10, + options: [ + { text: 'Solo', isCorrect: true, shape: 'triangle', color: 'red' }, + ], + }, + ], + }; + + await request('PUT', `/api/quizzes/${multiQuizId}`, updatedQuiz); + const { data } = await request('GET', `/api/quizzes/${multiQuizId}`); + const quiz = data as { title: string; questions: unknown[] }; + if (quiz.title !== 'Updated Multi Quiz') throw new Error('Title not updated'); + if (quiz.questions.length !== 1) throw new Error(`Expected 1 question after update, got ${quiz.questions.length}`); + }); + + await test('DELETE cleanup multi-question quiz', async () => { + if (!multiQuizId) throw new Error('No multi-question quiz to delete'); + await request('DELETE', `/api/quizzes/${multiQuizId}`, undefined, 204); + }); + + console.log('\nTimestamp Tests:'); + let timestampQuizId: string | null = null; + + await test('POST /api/quizzes returns quiz with timestamps', async () => { + const quiz = { + title: 'Timestamp Test Quiz', + source: 'manual', + questions: [ + { + text: 'Timestamp Q?', + options: [{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' }], + }, + ], + }; + + const { data: createData } = await request('POST', '/api/quizzes', quiz, 201); + timestampQuizId = (createData as { id: string }).id; + + const { data } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const result = data as Record; + if (!result.createdAt) throw new Error('Missing createdAt'); + if (!result.updatedAt) throw new Error('Missing updatedAt'); + }); + + await test('PUT /api/quizzes/:id updates updatedAt timestamp', async () => { + if (!timestampQuizId) throw new Error('No timestamp quiz created'); + + const { data: beforeData } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const beforeUpdatedAt = (beforeData as Record).updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + await request('PUT', `/api/quizzes/${timestampQuizId}`, { + title: 'Updated Timestamp Quiz', + questions: [ + { + text: 'Updated Q?', + options: [{ text: 'B', isCorrect: true, shape: 'diamond', color: 'blue' }], + }, + ], + }); + + const { data: afterData } = await request('GET', `/api/quizzes/${timestampQuizId}`); + const afterUpdatedAt = (afterData as Record).updatedAt; + + if (beforeUpdatedAt === afterUpdatedAt) throw new Error('updatedAt should have changed'); + }); + + await test('DELETE cleanup timestamp quiz', async () => { + if (!timestampQuizId) throw new Error('No timestamp quiz to delete'); + await request('DELETE', `/api/quizzes/${timestampQuizId}`, undefined, 204); + }); + console.log('\n=== Results ==='); const passed = results.filter((r) => r.passed).length; const failed = results.filter((r) => !r.passed).length; diff --git a/types.ts b/types.ts index ea2601a..6fc1adc 100644 --- a/types.ts +++ b/types.ts @@ -31,6 +31,26 @@ export interface Quiz { questions: Question[]; } +export type QuizSource = 'manual' | 'ai_generated'; + +export interface SavedQuiz extends Quiz { + id: string; + source: QuizSource; + aiTopic?: string; + createdAt: string; + updatedAt: string; +} + +export interface QuizListItem { + id: string; + title: string; + source: QuizSource; + aiTopic?: string; + questionCount: number; + createdAt: string; + updatedAt: string; +} + export interface Player { id: string; name: string;