194 Commits

Author SHA1 Message Date
mategergely33 0ac5ead63a joker/szerencse kartyak kezelese 2025-11-11 19:00:14 +01:00
mategergely33 63533c0313 adatkonzisztencia leirasa 2025-11-04 17:36:03 +01:00
mategergely33 1af7bdc3f0 kartya inspect 2025-10-31 17:38:14 +01:00
Donat 129ea694f8 Merge pull request 'game workflow corrected' (#84) from Backend_Fix into main
Reviewed-on: #84
2025-10-30 18:40:36 +00:00
magdo 9f3a5b6fd7 game workflow corrected 2025-10-30 19:39:41 +01:00
Donat 79786d8bb1 Merge pull request 'szerkesztes jog megoldva+ Frontend' (#83) from decksetting into main
Reviewed-on: #83
2025-10-30 18:20:20 +00:00
zsola03 f8917f6862 szerkesztes jog megoldva+ Frontend 2025-10-30 19:17:45 +01:00
Donat 384456ffd3 Merge pull request 'game workflow corrected' (#82) from Backend_Fix into main
Reviewed-on: #82
2025-10-30 17:43:54 +00:00
Donat 3c85fd72ef Merge pull request 'javitasok-plusz' (#81) from javitasok-plusz into main
Reviewed-on: #81
2025-10-30 17:43:40 +00:00
magdo 6065ab2800 game workflow corrected 2025-10-30 18:43:16 +01:00
GitG0r0 bfcdd3ec9d Deck törlés funkció implementálása modal-lal és consequence értékek finomítása 2025-10-30 18:25:25 +01:00
GitG0r0 46369ed112 Merge remote changes into javitasok-plusz 2025-10-27 21:01:57 +01:00
Donat d915a7fe1c Merge pull request '"activate user admin"' (#80) from Backend_Fix into main
Reviewed-on: #80
2025-10-27 19:35:56 +00:00
magdo 99ed8fea54 "activate user admin" 2025-10-27 20:35:07 +01:00
Donat a818d49701 Merge pull request 'fixes' (#79) from Backend_Fix into main
Reviewed-on: #79
2025-10-27 19:22:59 +00:00
magdo 04954cec4a fixes 2025-10-27 20:22:39 +01:00
Donat dbe06c5c0c Merge pull request 'landing navbar footer javítás' (#78) from ujbarni into main
Reviewed-on: #78
2025-10-27 19:05:33 +00:00
Barni 8ce04afe8b landing navbar footer javítás 2025-10-27 19:36:03 +01:00
Donat e21980d07d Merge pull request 'navbar meg merge' (#77) from navbar+aboutű into main
Reviewed-on: #77
2025-10-27 18:07:22 +00:00
Walke 39e0d36a7f navbar meg merge 2025-10-27 18:55:54 +01:00
GitG0r0 d3dcb7f7da fix: Consequence kezelés és deck szerkesztés javítások
- TaskCardEditor és JokerCardEditor: consequence mezők eltávolítása (csak LuckCardEditor-nél marad)
- DeckCreator: kártya type konverzió javítása betöltéskor (szám -> string)
- DeckCreator: csak megfelelő típusú kártyák megtartása mentéskor
- UpdateDeckCommandHandler: userstate -> authLevel javítás (interface mező helyesen)
- sql_schema_only.sql: trigger függvény javítása (NEW.updatedate -> NEW.update_date)
2025-10-27 18:27:40 +01:00
Donat d0741c273f Merge pull request 'Backend_Fix' (#76) from Backend_Fix into main
Reviewed-on: #76
2025-10-26 22:59:00 +00:00
magdo 825d7a91e2 Verification reset-password email and site corrections 2025-10-26 23:56:52 +01:00
magdo fe8d5a53a5 editable property added to deck short dto 2025-10-26 21:27:00 +01:00
Donat b75d27c7c8 Merge pull request 'userdetails,resetpass müködöképes lett' (#74) from user-resetpass into main
Reviewed-on: #74
2025-10-26 19:53:48 +00:00
magdo 63b261c023 Merge branch 'main' of https://git.mdnd-it.cc/Donat/SerpentRace into user-resetpass 2025-10-26 20:53:39 +01:00
Donat 7b7938ed08 Merge pull request 'Email verification Backend' (#75) from Backend_Fix into main
Reviewed-on: #75
2025-10-26 18:59:57 +00:00
magdo 8c25c56e88 Email verification Backend 2025-10-26 19:59:02 +01:00
zsola03 ab35f73158 userdetails,resetpass müködöképes lett 2025-10-26 19:46:13 +01:00
Donat 4b06a65bd9 Merge pull request 'Handlenavigate' (#73) from barni into main
Reviewed-on: #73
2025-10-26 16:49:31 +00:00
Barni 94943d4988 Handlenavigate 2025-10-26 17:46:21 +01:00
Donat 18110ba410 Merge pull request 'Email verification Backend' (#72) from Backend_Fix into main
Reviewed-on: #72
2025-10-24 23:34:35 +00:00
magdo f746cfd23f Email verification Backend 2025-10-25 01:33:21 +02:00
Donat 44645bb3fc Merge pull request 'Email verification Backend' (#71) from Backend_Fix into main
Reviewed-on: #71
2025-10-24 19:17:09 +00:00
magdo 7a9a676fc0 Email verification Backend 2025-10-24 21:16:23 +02:00
Donat 1ca0f54032 Merge pull request '[#122] Email verifikáció https://project.mdnd-it.cc/work_packages/122 #70' (#70) from task/122-email-verifik-ci into main
Reviewed-on: #70
2025-10-24 19:08:16 +00:00
Buus d90f92c91c [#122] Email verifikációhttps://project.mdnd-it.cc/work_packages/122 2025-10-24 21:06:18 +02:00
Donat 1ad4af5864 Merge pull request 'Deck szerkesztese' (#69) from 1024zsola into main
Reviewed-on: #69
2025-10-24 18:40:17 +00:00
magdo 6867cb2b72 Merge branch 'main' of https://git.mdnd-it.cc/Donat/SerpentRace into 1024zsola 2025-10-24 20:39:51 +02:00
zsola03 cea9062f91 Deck szerkesztese 2025-10-24 20:34:43 +02:00
Donat e3f752ce8a Merge pull request 'Auth Check For Decks' (#68) from Backend_Fix into main
Reviewed-on: #68
2025-10-24 18:29:05 +00:00
magdo b9fedb3601 Auth Check For Decks 2025-10-24 20:28:45 +02:00
Donat 0ae66b3307 Merge pull request 'navbar meg fooldal navigációk illetve companies -> contacts' (#67) from barni into main
Reviewed-on: #67
2025-10-24 18:01:37 +00:00
magdo 630283e922 Merge branch 'main' of https://git.mdnd-it.cc/Donat/SerpentRace into barni 2025-10-24 20:01:32 +02:00
Barni 0ed75beb3f navbar meg fooldal navigációk illetve companies -> contacts 2025-10-24 19:55:37 +02:00
Donat 8ff8e80e31 Merge pull request 'To The Top' (#66) from Backend_Fix into main
Reviewed-on: #66
2025-10-24 17:55:32 +00:00
magdo 5722846da3 To The Top 2025-10-24 19:52:11 +02:00
Donat a64829f8cb Merge pull request 'To The Top' (#65) from Backend_Fix into main
Reviewed-on: #65
2025-10-24 17:37:42 +00:00
magdo a5f38f791d To The Top 2025-10-24 19:37:13 +02:00
Donat 8960bd9dce Merge pull request 'fix' (#64) from backend_update into main
Reviewed-on: #64
2025-10-23 19:19:54 +00:00
magdon df75095651 fix 2025-10-23 21:16:04 +02:00
Donat 94cdf54b83 Merge pull request '10.23 zsola hibák + Deckek listázása megoldva' (#63) from 1023zsolahibak into main
Reviewed-on: #63
2025-10-23 18:20:06 +00:00
zsola03 b73d1528c4 10.23 zsola hibák + Deckek listázása megoldva 2025-10-23 20:18:52 +02:00
Donat 387ebbc64d Merge pull request 'deckcreate-oldal-javitas' (#62) from deckcreate-oldal-javitas into main
Reviewed-on: #62
2025-10-23 15:29:44 +00:00
GitG0r0 3bbd3f1e8a Feature: Consequence rendszer implementálása minden kártya típushoz
- TaskCardEditor: Consequence és wrongConsequence kezelés hozzáadva
- JokerCardEditor: Teljesítés és nem teljesítés consequence-ek
- LuckCardEditor: Szerencse kártyák consequence kezelése
- CardEditor: Alapértelmezett consequence értékek az új kártyákhoz
- DeckCreator: Consequence mezők biztosítása mentéskor
- CardsList: Következmény típusok megjelenítése
- UI javítás: Mind a három editor külön szekciókba rendezve (info, szöveg, következmények)
- Egységes struktúra és design az összes kártya szerkesztőnél
2025-10-23 00:31:33 +02:00
GitG0r0 f2a54154f5 UI: Beállítások szekció letiltása a feladat kártyáknál
- Pontszám, Időlimit és Karakterlimit mezők letiltva
- Magyarázat mező is letiltva
- Szöveges válasz típusnál a beállítások (kis/nagy betű, pontos egyezés, stb.) letiltva
- Egységes 'Hamarosan elérhető' effekt az összes letiltott szekción
- Tipp mező továbbra is opcionális és használható
- Ezek a beállítások nem kötelezőek a kártya mentéséhez
2025-10-22 23:34:34 +02:00
GitG0r0 edca8f84cd Fix: Kártya típus kezelés javítása és joker kártyák megjelenítése
- Hozzáadva react-toastify a notifyWarning használatához
- Javítva a CardEditor fejléc hogy helyesen jelenítse meg az új kártya típusát
- Javítva a CardsList 'szerkesztés folyamatban' rész hogy QUESTION/JOKER/LUCK értékeket használjon
- Implementálva az automatikus nem megfelelő típusú kártyák törlése új kártya mentésekor
- Hozzáadva hibakezelés a kártya mentési logikához
- Joker típus címke változtatva 'Szórakozás'-ról 'Joker'-re
- Joker kártya szín változtatva citromsárgára (#FFD700)
- Docker watch mode volume konfiguráció javítása a hot reload-hoz
2025-10-22 23:21:19 +02:00
Donat 4501257a15 Merge pull request 'creator, creation date on deck' (#61) from Backend_Fix into main
Reviewed-on: #61
2025-10-22 20:04:30 +00:00
magdo 38a2aeb58a creator, creation date on deck 2025-10-22 22:03:50 +02:00
GitG0r0 0ca0e95540 Merge: Konfliktusok feloldása és toastify integráció
- Megtartva az új kártya típusok (QUESTION, LUCK, JOKER)
- Hozzáadva toastify notifikációk
- Egyszerűsített új kártya létrehozás
2025-10-22 21:31:16 +02:00
GitG0r0 ec001fb39f Refactor: DeckCreator komponensek típus kezelésének egységesítése
- Frissítve a DeckHeader típusai a backend formátumra (QUESTION, LUCK, JOKER)
- Frissítve a CardsList és Editor komponensek típus kezelése
- Egyszerűsítve a kártya létrehozás és mentés logika
- Az új kártya gomb mindig a pakli típusának megfelelő kártyát hozza létre
2025-10-22 21:23:16 +02:00
GitG0r0 00b13de70c fix(deck-creator): alapértelmezett név magyarítása
- 'Új Deck' helyett 'Új Pakli' az alapértelmezett név
- Mind a kezdeti állapotban, mind az új pakli létrehozásakor
2025-10-22 20:10:47 +02:00
GitG0r0 83efb91f52 style(deck-creator): pakli név mező szélességének optimalizálása
- Pakli név mező mostantól csak 2/3 szélességű
- Jobb vizuális egyensúly a form elemek között
- Reszponzív elrendezés megtartva
2025-10-22 20:01:54 +02:00
Donat 9673d564a0 Merge pull request 'hibak.txt feladatai' (#59) from zsolahibatxt into main
Reviewed-on: #59
2025-10-22 17:57:16 +00:00
magdo 5ba043cff8 Merge branch 'main' into zsolahibatxt 2025-10-22 19:56:32 +02:00
GitG0r0 46ad6caefd refactor(deck-creator): statisztika panel eltávolítása és layout optimalizálása
- Statisztika panel eltávolítva
- Grid elrendezés egyszerűsítve
- Felesleges kód eltávolítva
- Jobb helykihasználás az űrlap elemeknek
2025-10-22 19:52:45 +02:00
GitG0r0 f56ebbf2c3 fix(deck-creator): angol szövegek magyarítása
- 'Deck' szó cseréje 'Pakli'-ra
- Placeholder szövegek magyarítása
2025-10-22 19:43:31 +02:00
GitG0r0 c207fa5961 feat(deck-creator): dropdown menük fejlesztése
- Típus és láthatóság dropdown menük átalakítása
- Ikonok hozzáadása mindkét dropdown menühöz
- Szöveg színek javítása a jobb láthatóságért
- Hover és kijelölési állapotok hozzáadása
- Dropdown menük egységes stílusának kialakítása
2025-10-22 19:35:08 +02:00
Walke 0a811741c7 Merge pull request 'Navbar,landing, meg a többi frontend javítás' (#60) from ujbarni into main
Reviewed-on: #60
HIHETETLEEEENÜL SZIPI SZUPER
2025-10-22 13:22:00 +00:00
Barni d16d481d86 Navbar,landing, meg a többi frontend javítás 2025-10-22 15:15:20 +02:00
zsola03 3ad9ba3e3f kartyatorles popup 2025-10-22 14:24:24 +02:00
zsola03 825e9d1a08 hibak.txt feladatai 2025-10-22 09:30:09 +02:00
Walke ad5f13a8e1 Merge pull request 'guessName+Fixes' (#58) from frontendFix+Guess into main
Reviewed-on: #58

VAOOO UGUYES VAGYYY
2025-10-21 13:09:27 +00:00
Walke 237378c208 guessName+Fixes 2025-10-21 15:08:28 +02:00
Walke a1cf327837 Merge pull request 'Ha be van jelenkezve a user akkor a /# en nem irja ki neki a belepest meg a regisztraciot VAMOOOS' (#57) from landingFlowFix into main
Reviewed-on: #57
szerintem is :D:D:D:D
2025-10-21 12:14:13 +00:00
Walke c31bf9d4fb Ha be van jelenkezve a user akkor a /# en nem irja ki neki a belepest meg a regisztraciot VAMOOOS 2025-10-21 14:13:16 +02:00
Walke ef0b1916f2 Merge pull request 'dobokocka mukodik :O' (#56) from dice into main
Reviewed-on: #56
kocka kocka kocka kocka kocka kocka kocka
2025-10-20 17:40:13 +00:00
Walke 1c01e4ce24 Merge pull request 'nagyon meno lett a tabon logo SerpentRace minden' (#55) from tab into main
sztem jo xd
2025-10-20 17:39:25 +00:00
Walke 8b5cf2c1e5 nagyon meno lett a tabon logo SerpentRace minden 2025-10-20 19:38:21 +02:00
Walke 023219e41b dobokocka mukodik :O 2025-10-20 19:20:49 +02:00
magdo 2d7778f7d1 test removed 2025-10-20 19:17:43 +02:00
magdo aa3587b60a deck card count added 2025-10-20 19:13:50 +02:00
Walke 99fa7ebd98 Merge pull request 'registracional jol navigal a loginra' (#54) from regnavigationfix into main
Reviewed-on: #54
:D
2025-10-20 16:57:15 +00:00
Walke 23c4b838d4 registracional jol navigal a loginra 2025-10-20 18:56:38 +02:00
Walke bfe977d35b Merge remote-tracking branch 'origin/deck_kezeles' 2025-10-20 18:01:41 +02:00
mategergely33 5194308f7c deckkezeles, es deckek eltarolasa 2025-10-20 17:26:27 +02:00
Walke 8d24e8ffa6 Merge pull request 'Lobby' (#50) from barni1020 into main
Reviewed-on: #50
2025-10-20 15:22:13 +00:00
Barni 1bf3253128 Lobby 2025-10-20 17:14:37 +02:00
Walke 96487fb065 Merge pull request 'Filter bar fix' (#49) from deckmanagerfrontendfix into main
Reviewed-on: #49
2025-10-18 15:52:30 +00:00
Walke 9ef83f7963 Filter bar fix 2025-10-18 17:50:39 +02:00
Walke 27fc028bad navbarban jol le vannak kezelve a redirect es letre lett hozva egy hook amivel automatikusan berakja a usernamet es ha meg nem akkor redirectel 2025-10-15 19:08:31 +02:00
Walke d1b4141e63 Merge pull request 'redirect fix' (#48) from authlocalstorage into main
Reviewed-on: #48
2025-10-15 16:40:22 +00:00
Walke 76fa204ae8 redirect fix 2025-10-15 18:39:43 +02:00
mategergely33 75f2b215a1 deckek elmentodnek sqlbe 2025-10-15 18:13:53 +02:00
Walke 367524d611 Merge pull request 'home check 4 localstorage' (#47) from authlocalstorage into main
Reviewed-on: #47
2025-10-15 15:41:02 +00:00
Walke 86bf2675eb home check 4 localstorage 2025-10-15 17:40:02 +02:00
Walke 2c190dc874 Merge pull request 'authlocalstorage' (#46) from authlocalstorage into main
Reviewed-on: #46
2025-10-15 15:34:04 +00:00
Walke 36db09e5e7 elrakja az elrakni valot is 2025-10-15 17:32:04 +02:00
Donat f7885dc440 Merge pull request 'backend' (#45) from merge_branch into main
Reviewed-on: #45
2025-10-15 15:06:51 +00:00
magdo a9c2f63adc Merge branch 'main' into merge_branch 2025-10-15 17:05:59 +02:00
magdo bec9d83ef3 backend 2025-10-15 17:01:52 +02:00
Walke cf68530fc2 loginnal redirect ha jo a return plusz local storageban eltarolom a tokent 2025-10-15 16:32:18 +02:00
Walke f2b154d491 Merge pull request 'FooterFix' (#44) from footerFIx into main
Reviewed-on: #44
2025-10-15 13:41:40 +00:00
Walke 1e10a93e32 FooterFix 2025-10-15 15:40:25 +02:00
mategergely33 a5dd9003c1 userflow_fix 2025-10-15 15:13:53 +02:00
mategergely33 1db1776217 Merge pull request 'Registration redirect frontend fix' (#41) from registration into main
Reviewed-on: #41
2025-09-30 11:39:46 +00:00
Walke 87dc8ffff4 Registration redirect frontend fix 2025-09-29 21:53:52 +02:00
Donat 04a87b8293 Merge pull request 'last_bugfix' (#40) from merge_branch into main
Reviewed-on: #40
2025-09-29 18:36:57 +00:00
Donat a25807aca1 last_bugfix 2025-09-29 20:36:35 +02:00
Donat 9e88eba43f Merge pull request 'bugfix' (#39) from merge_branch into main
Reviewed-on: #39
2025-09-29 11:46:04 +00:00
Donat 14a94ea03f bugfix 2025-09-29 13:45:25 +02:00
Donat e392ade3f8 Merge pull request 'fixed merge conflicts' (#38) from merge_branch into main
Reviewed-on: #38
2025-09-26 15:02:10 +00:00
Donat 8980d98394 fixed merge conflicts 2025-09-26 17:01:45 +02:00
Donat 8f6634b03f fixed merge conflicts 2025-09-26 16:59:55 +02:00
Donat c690fb602e Merge pull request 'example frontend-backend communication' (#37) from backend_complete into main
Reviewed-on: #37
2025-09-24 18:21:05 +00:00
Donat bba4044eaf example frontend-backend communication 2025-09-24 20:19:58 +02:00
Donat b4d31f3660 Merge pull request 'backend_complete' (#35) from backend_complete into main
Reviewed-on: #35
2025-09-22 09:28:12 +00:00
Donat f27a1df90f Merge remote-tracking branch 'origin/main' 2025-09-22 11:26:43 +02:00
Donat bf9ae5f01f final changes 2025-09-22 11:14:32 +02:00
mategergely33 83fad59878 Merge pull request '[#118] bugfix' (#36) from task/118-bugfix into main
Reviewed-on: #36
2025-09-21 14:55:48 +00:00
mategergely33 016b5632e1 '[#118] bugfix 2025-09-21 16:53:55 +02:00
mategergely33 1cf8066cf3 [#118] bugfix
https://project.mdnd-it.cc/work_packages/118
2025-09-21 16:44:41 +02:00
Donat cf157643d7 Merge remote-tracking branch 'origin/main' into backend_complete 2025-09-21 03:49:22 +02:00
Walke 638f78da94 Merge pull request '[#104] Create/Update' (#31) from task/104-create-update into main
Reviewed-on: #31
2025-09-18 20:19:43 +00:00
Donat 173109d352 Merge pull request 'backend_in_progress' (#30) from backend_in_progress into main
Reviewed-on: #30
2025-09-15 17:09:13 +00:00
Donat 74a4cd4f1d Merge remote-tracking branch 'origin/main' into HEAD 2025-09-15 19:07:04 +02:00
Donat 3af8de2797 fel kesz game backend 2025-09-15 19:00:35 +02:00
Donat df532a0e2a Merge pull request '[#103] preview' (#29) from task/103-preview into main
Reviewed-on: #29
2025-09-14 11:59:29 +00:00
GitG0r0 d1377291ab [#104] Create/Updatehttps://project.mdnd-it.cc/work_packages/104 2025-09-12 19:44:22 +02:00
GitG0r0 37f81f25a7 [#103] previewhttps://project.mdnd-it.cc/work_packages/103 2025-09-12 17:25:20 +02:00
Donat a1d33d9318 Merge pull request 'backend-extra' (#28) from backend-extra into main
Reviewed-on: #28
2025-08-25 22:14:32 +00:00
Donat 7963f28021 remove dist 2025-08-26 00:13:50 +02:00
Donat 8bc5e0e130 Merge remote-tracking branch 'origin/main' into backend-extra 2025-08-26 00:12:10 +02:00
Donat 14fd1fa189 new documentation 2025-08-26 00:07:13 +02:00
Donat f216435dd0 Merge pull request 'GameScreen' (#27) from GameScreen into main
Reviewed-on: #27
2025-08-25 21:56:48 +00:00
Donat 0b90e4217a merge commit 2025-08-25 23:55:40 +02:00
Donat 137b110c74 Merge pull request 'Színkorrekciók' (#26) from color-fixes into main
Reviewed-on: #26
2025-08-25 21:41:50 +00:00
Donat 68335a9d5f Merge pull request 'origin/summary-task/90-c-gek-kezel-se' (#24) from origin/summary-task/90-c-gek-kezel-se into main
Reviewed-on: #24
2025-08-25 21:40:17 +00:00
Donat 684216ab40 merge commit 2025-08-25 22:16:00 +02:00
Donat e9af77200d Merge pull request 'summary-task/94-backend' (#20) from summary-task/94-backend into main
Reviewed-on: #20
2025-08-23 02:28:09 +00:00
Donat 19cfa031d0 [#94] Backend
https://project.mdnd-it.cc/work_packages/94
2025-08-23 04:25:28 +02:00
Walke d8598755e0 GameScreen feltöltés 2025-08-23 00:15:27 +02:00
Walke a1ff3beb35 Színkorrekciók 2025-08-23 00:05:18 +02:00
zsola03 b288b29e35 [#90] Cégek Kezelésehttps://project.mdnd-it.cc/work_packages/90 2025-08-06 21:00:51 +02:00
zsola03 2c8f1bcca0 [#90] Cégek Kezelésehttps://project.mdnd-it.cc/work_packages/90 2025-08-06 21:00:30 +02:00
Donat 34a6df5949 Merge pull request '[#83] About https://project.mdnd-it.cc/work_packages/83' (#18) from task/83-about into main
Reviewed-on: #18
2025-07-30 18:12:23 +00:00
Barni 3e82b19480 [#83] Abouthttps://project.mdnd-it.cc/work_packages/83 2025-07-30 20:10:27 +02:00
mategergely33 6720375fa1 [#48] Git testhttps://project.mdnd-it.cc/work_packages/48 2025-07-22 18:07:40 +02:00
mategergely33 1893d0006d [#48] Git testhttps://project.mdnd-it.cc/work_packages/48 2025-07-22 18:06:01 +02:00
Donat 725516ad6c backend v4 half 2025-07-18 09:20:40 +02:00
Donat aba7a506ad Merge pull request 'Backend half' (#17) from task/40-backend-user-company into main
Reviewed-on: #17
2025-07-12 17:35:14 +00:00
Donat 585e7c96fb Merge pull request 'navbarral footerrel mindennel egyutt' (#16) from walkehaladas into main
Reviewed-on: #16
2025-07-12 17:33:14 +00:00
Walke 4bf667a1ac navbarral footerrel mindennel egyutt 2025-07-11 21:57:39 +02:00
Donat 8600fa7c1d Backend half 2025-07-11 19:56:28 +02:00
Donat 270bb79451 Merge pull request '[#77] LandingPage https://project.mdnd-it.cc/work_packages/77' (#15) from task/56-landing-page into main
Reviewed-on: #15
2025-07-09 10:02:29 +00:00
Donat b10143ba1a Merge pull request '[#54] kártya komponens' (#14) from task/54-k-rtya-komponens into main
Reviewed-on: #14
2025-07-09 09:59:28 +00:00
Walke 19c762fe67 [#56] Landing Pagehttps://project.mdnd-it.cc/work_packages/56 2025-07-07 10:11:41 +02:00
Donat 9296782fc1 Merge pull request '[#40] BACKEND USER, Company' (#13) from task/40-backend-user-company into main
Reviewed-on: #13
2025-06-14 23:14:29 +00:00
Donat fa868e7c1d [#40] BACKEND USER, Company
https://project.mdnd-it.cc/work_packages/40
2025-06-15 01:12:46 +02:00
Buus 724162b9c9 https://project.mdnd-it.cc/work_packages/54 2025-06-03 21:30:03 +02:00
Walke 85e188b5e2 Merge pull request 'task/52-pop-up' (#12) from task/52-pop-up into main
Reviewed-on: #12
2025-05-26 16:31:13 +00:00
Walke fe08dd3603 [#52] pop uphttps://project.mdnd-it.cc/work_packages/52 2025-05-22 11:06:29 +02:00
Walke 370dc9934b [#52] pop uphttps://project.mdnd-it.cc/work_packages/52 2025-05-22 11:05:21 +02:00
Walke 3012707ba8 Merge pull request 'linuxdocker-komponensekupdate' (#11) from linuxdocker-komponensekupdate into main
Reviewed-on: #11
2025-05-22 08:27:18 +00:00
Walke 48c29d81d0 komponensek frissitese 2025-05-22 03:54:28 +02:00
Walke c4b86143bf linuxra docker auto inditasa 2025-05-22 03:54:02 +02:00
Walke 8948751bbc Merge pull request '[#50] AUTH UI Frissítés https://project.mdnd-it.cc/work_packages/50' (#10) from task/50-auth-ui-friss-t-s into main
Reviewed-on: #10
2025-05-21 22:51:02 +00:00
Walke e5b601e483 [#50] AUTH UI Frissítés https://project.mdnd-it.cc/work_packages/50 2025-05-22 02:07:00 +02:00
Donat 76c513d8fb Merge pull request '[#39] ADATB USER' (#9) from task/39-adatb-user into main
Reviewed-on: #9
2025-05-20 22:27:11 +00:00
Donat f68540f511 [#39] ADATB USERhttps://project.mdnd-it.cc/work_packages/39 2025-05-21 00:26:24 +02:00
Donat 2bc2138d0e Merge pull request 'task/49-docker' (#8) from task/49-docker into main
Reviewed-on: #8
2025-05-20 21:51:27 +00:00
Donat 87c790aa05 [#49] Dockerhttps://project.mdnd-it.cc/work_packages/49 2025-05-20 23:49:50 +02:00
Donat 38a54f9005 [#49] Dockerhttps://project.mdnd-it.cc/work_packages/49 2025-05-20 23:48:52 +02:00
Donat 7fa4150b3a Merge pull request '[#49] Docker' (#7) from task/49-docker into main
Reviewed-on: #7
2025-05-20 21:31:08 +00:00
Donat ea83034e9a [#49] Dockerhttps://project.mdnd-it.cc/work_packages/49 2025-05-20 23:29:53 +02:00
Donat 298c31597f [#49] Dockerhttps://project.mdnd-it.cc/work_packages/49 2025-05-20 23:29:27 +02:00
Donat 59160cbbcb Merge pull request '[#48] Git test' (#6) from task/48-git-test into main
Reviewed-on: #6
2025-05-20 16:52:15 +00:00
Donat 6d452ab71f [#48] Git testhttps://project.mdnd-it.cc/work_packages/48 2025-05-20 18:48:02 +02:00
Donat 4765d14123 Merge pull request '[#46] Emial Verification https://project.mdnd-it.cc/work_packages/46' (#5) from task/46-emial-verification into main
Reviewed-on: #5
2025-05-17 20:25:05 +00:00
Donat f65696ce32 Merge pull request '[#42] FORGOT PASS https://project.mdnd-it.cc/work_packages/42' (#4) from task/42-forgot-pass into main
Reviewed-on: #4
2025-05-17 20:24:12 +00:00
Donat eb696d9d27 Merge pull request '[#37] Login & Register Page UI https://project.mdnd-it.cc/work_packages/37' (#3) from task/37-login-and-register-page-ui into main
Reviewed-on: #3
2025-05-17 20:23:05 +00:00
Walke 8acc7d30fc [#42] FORGOT PASS
https://project.mdnd-it.cc/work_packages/42
2025-05-17 10:41:13 +02:00
Walke 8a7500eb69 [#37] Login & Register Page UI
https://project.mdnd-it.cc/work_packages/37
2025-05-17 10:38:50 +02:00
Walke f398183332 [#46] Emial Verification
https://project.mdnd-it.cc/work_packages/46
2025-05-17 10:37:47 +02:00
Walke 94702a33aa [#46] Emial Verification
https://project.mdnd-it.cc/work_packages/46
2025-05-16 20:12:31 +00:00
Walke f089d314ca [#37] Login & Register Page UI
https://project.mdnd-it.cc/work_packages/37
2025-05-16 20:08:24 +00:00
Donat 7eaf2408a1 Merge pull request '[#47] Tailwind init https://project.mdnd-it.cc/work_packages/47' (#2) from task/47-tailwind-init into main
Reviewed-on: #2
2025-05-16 17:26:31 +00:00
Walke a231fa4b5e [#47] Tailwind init https://project.mdnd-it.cc/work_packages/47 2025-05-16 17:02:00 +00:00
Donat 68cec47d09 Update README.md 2025-05-14 17:34:44 +00:00
Donat b93363330f Update README.md 2025-05-14 17:34:16 +00:00
Donat 175db04ec6 Merge pull request 'develop' (#1) from develop into main
Reviewed-on: #1
2025-05-14 17:33:09 +00:00
Donat d5cddb186d Merge branch 'main' of https://git.mdnd-it.cc/Donat/SerpentRace into develop 2025-05-14 19:32:10 +02:00
Donat 28ced1c764 initialize 2025-05-14 19:27:36 +02:00
Donat ceeab2647d Initial commit 2025-05-14 09:31:03 +00:00
134 changed files with 15720 additions and 1302 deletions
+4 -1
View File
@@ -6,4 +6,7 @@ Archive_*/**
**/node_modules/**
#ignore dist folder
**/dist/**
**/dist/**
#ignore log files
**/*.log
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
Javitás
Deckeck:
- Következmény csak szerencse kártyánál
- Egy fajta következmény (/lap, automatikusan kerül végrehajtásra)
- Hibás kártya pakli mentésekor is törlödjön
- extra kör, kimarad bármennyi 1-től 5-ig
- megnyitás, szerkesztés, adatok betöltése
- Mentési ADATOK Csekkolása | ZSOLA
- Closer option
navbar:
- tegnapiak
TEGNAPI HIBÁK JAVÍTÁSA:
- kapcs fel routing
- navbar széthúz
- footer kapcsolat
- navabar gomboksorrend
- vagy kontat vagy kapcsolat
- navbar bejelent
- navbar layout finomít
- palki info get
GET /ap/decks/page/:from/:to (0-49) 50db (50-99) 50db ... (0-29) 30db => (30-59) 30db
- from: (oldalsz-1)*dbsz (pl: (1-1)*30=0; (2-1)*30=30)
- to: (oldalsz*dbsz) - 1 (pl: (1*30)-1=29; (2*30)-1 =59)
email verifikáció:
- verify-email/:code => Email címe hitelesítés alatt: stb
- ha sikeres => login => toastify => email címe hitelesítve
- ha sikertelen => home/register => toastify/pushup => sikertelen vegye fel velünk a kapcsolatot
- POST api/users/verify-email/:code <= BACKEND URI
HOLNAP ESTE 19:00 => Jó lenne, ha ezek megvannak
HOLNAPTÓL => JÁTÉK => SOCKET IO működése
Mobil nézet:
- landing page
- navbar
- footer
- pakli fő nézet => bar
- pakli összerakás és szerkesztés
- bejelentkezés
- regisztráció
User felület:
- Saját adatok lekérése
- Saját adatok módosítása:
- email-cím
- telefonszám
- jelszó
- felhasználó név
- Saját profil törlése
- Elfelelejtett jelszó
- Kérése => email-cím alapján => POST /api/users/forgot-password
- password-reset/:token => POST /api/users/reset-password
+41
View File
@@ -0,0 +1,41 @@
# Development Environment Variables for Local Build
# These are used when running build scripts outside of Docker containers
NODE_ENV=development
PORT=3000
# Database Configuration (Docker containers)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=serpentrace
DB_USERNAME=postgres
DB_PASSWORD=postgres
# Redis Configuration (Docker containers)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
# JWT Configuration
JWT_SECRET=dev_jwt_secret_change_in_production
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
# MinIO Configuration (Docker containers)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_ACCESS_KEY=serpentrace
MINIO_SECRET_KEY=serpentrace123!
MINIO_USE_SSL=false
# Board Generation Configuration
MAX_SPECIAL_FIELDS_PERCENTAGE=67
MAX_GENERATION_TIME_SECONDS=20
GENERATION_ERROR_TOLERANCE=15
# EMAIL SERVICE CONFIGURATION
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your_email@domain.com
EMAIL_PASS=your_email_password
EMAIL_FROM=noreply@serpentrace.com
+22 -4
View File
@@ -20,10 +20,28 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_URL=redis://localhost:6379
# JWT CONFIGURATION
JWT_SECRET=your_super_secret_jwt_key_change_in_production
JWT_EXPIRY=86400
JWT_EXPIRATION=24h
# JWT AUTHENTICATION CONFIGURATION
JWT_SECRET=your-super-secure-secret-key-here
JWT_REFRESH_SECRET=your-super-secure-refresh-secret-key-here
# Access Token Expiry (choose ONE option, priority order listed):
JWT_ACCESS_TOKEN_EXPIRY=1800 # Seconds (recommended for production)
# JWT_ACCESS_TOKEN_EXPIRATION=30m # Duration string (user-friendly)
# JWT_EXPIRY=1800 # Legacy: seconds
# JWT_EXPIRATION=30m # Legacy: duration string
# Refresh Token Expiry (choose ONE option, priority order listed):
JWT_REFRESH_TOKEN_EXPIRY=604800 # Seconds (7 days)
# JWT_REFRESH_TOKEN_EXPIRATION=7d # Duration string (recommended)
# JWT_REFRESH_EXPIRATION=7d # Legacy: duration string
# Cookie Names (optional)
JWT_COOKIE_NAME=auth_token
JWT_REFRESH_COOKIE_NAME=refresh_token
# Legacy JWT Configuration (deprecated - use above options)
# JWT_EXPIRY=86400
# JWT_EXPIRATION=24h
GAME_TOKEN_EXPIRY=86400
# EMAIL SERVICE CONFIGURATION
+1 -1
View File
@@ -2,4 +2,4 @@
./node_modules/*
./Archive_*/*
./Archive_*
./logs/*
./logs/*
@@ -0,0 +1,338 @@
# JWT Refresh Token Implementation Guide
## Overview
The JWT authentication system supports both **cookie-based** and **header-based** (Bearer token) authentication with comprehensive refresh token functionality and proper logout logic. **All authentication methods now use refresh tokens** - there is no legacy single-token mode.
## Features
- **Dual Authentication Methods**: Support for both cookie-based and Bearer token authentication
- **Universal Refresh Tokens**: All logins receive both access and refresh tokens
- **Automatic Token Refresh**: Tokens are refreshed when 75% of their lifetime has passed
- **Logout Functionality**: Proper token blacklisting and cleanup
- **Security**: Short-lived access tokens (30 minutes) and longer-lived refresh tokens (7 days)
## Authentication Methods
### 1. Cookie-Based Authentication
- Access token stored in `auth_token` cookie
- Refresh token stored in `refresh_token` cookie
- Suitable for web applications with same-origin requests
- Tokens also returned in response body
### 2. Bearer Token Authentication
- Access token sent in `Authorization: Bearer <token>` header
- Refresh token sent in `X-Refresh-Token` header
- Suitable for mobile apps, SPAs, and API integrations
- Tokens returned in response body
## API Endpoints
### Login
```http
POST /api/user/login
Content-Type: application/json
{
"username": "user@example.com",
"password": "password123"
}
```
**Response (all logins):**
```json
{
"user": { ... },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
For cookie-based auth, tokens are also set as httpOnly cookies.
### Refresh Token
```http
POST /api/user/refresh-token
```
**For Cookie-based auth:**
- Refresh token is read from `refresh_token` cookie
- New tokens are set as cookies AND returned in response body
**For Bearer token auth:**
```http
POST /api/user/refresh-token
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Response:**
```json
{
"success": true,
"message": "Tokens refreshed successfully",
"accessToken": "new_access_token",
"refreshToken": "new_refresh_token"
}
```
### Logout
```http
POST /api/user/logout
Authorization: Bearer <access_token>
```
Response:
```json
{
"success": true
}
```
## Environment Variables
```env
# JWT Configuration
JWT_SECRET=your-secret-key-for-access-tokens
JWT_REFRESH_SECRET=your-secret-key-for-refresh-tokens
# Access Token Expiry (use one of these)
JWT_ACCESS_TOKEN_EXPIRY=1800 # Access token expiry in seconds (30 minutes)
JWT_ACCESS_TOKEN_EXPIRATION=30m # Access token expiry (supports s, m, h, d)
JWT_EXPIRY=1800 # Legacy: Access token expiry in seconds
JWT_EXPIRATION=30m # Legacy: Access token expiry with duration
# Refresh Token Expiry (use one of these)
JWT_REFRESH_TOKEN_EXPIRY=604800 # Refresh token expiry in seconds (7 days)
JWT_REFRESH_TOKEN_EXPIRATION=7d # Refresh token expiry (supports s, m, h, d)
JWT_REFRESH_EXPIRATION=7d # Legacy: Refresh token expiry with duration
# Cookie Names (optional)
JWT_COOKIE_NAME=auth_token # Access token cookie name (default: auth_token)
JWT_REFRESH_COOKIE_NAME=refresh_token # Refresh token cookie name (default: refresh_token)
```
### Environment Variable Priority
**Access Token Expiry** (checked in order):
1. `JWT_ACCESS_TOKEN_EXPIRY` (seconds)
2. `JWT_ACCESS_TOKEN_EXPIRATION` (duration string)
3. `JWT_EXPIRY` (seconds) - legacy
4. `JWT_EXPIRATION` (duration string) - legacy
5. Default: 1800 seconds (30 minutes)
**Refresh Token Expiry** (checked in order):
1. `JWT_REFRESH_TOKEN_EXPIRY` (seconds)
2. `JWT_REFRESH_TOKEN_EXPIRATION` (duration string)
3. `JWT_REFRESH_EXPIRATION` (duration string) - legacy
4. Default: 604800 seconds (7 days)
### Duration String Format
Supports: `s` (seconds), `m` (minutes), `h` (hours), `d` (days)
Examples: `30s`, `15m`, `2h`, `7d`
## Token Structure
### Access Token Payload
```json
{
"userId": "user-uuid",
"authLevel": 0,
"userStatus": 1,
"orgId": "org-uuid",
"type": "access",
"iat": 1640995200,
"exp": 1640997000
}
```
### Refresh Token Payload
```json
{
"userId": "user-uuid",
"orgId": "org-uuid",
"type": "refresh",
"iat": 1640995200,
"exp": 1641600000
}
```
## Automatic Token Refresh
The system automatically refreshes tokens when:
- Token is within 25% of its expiration time (75% of lifetime has passed)
- Valid refresh token is available
- User makes an authenticated request
**✅ Automatic refresh happens on every authenticated API call** - no manual intervention needed!
### Response Headers
For Bearer token authentication, refresh responses include:
- `X-New-Access-Token`: New access token
- `X-New-Refresh-Token`: New refresh token
- `X-Token-Refreshed`: "true" indicator
### Manual Refresh (Optional)
While automatic refresh handles most scenarios, manual refresh is available for:
- **Proactive refresh**: Before critical operations
- **Background apps**: Long-running applications that need fresh tokens
- **Offline recovery**: When app reconnects after being offline
```http
POST /api/user/refresh-token
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Client Implementation Examples
### JavaScript/TypeScript (Fetch API)
```typescript
class ApiClient {
private accessToken: string = '';
private refreshToken: string = '';
async login(username: string, password: string) {
const response = await fetch('/api/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
this.accessToken = data.token;
this.refreshToken = data.refreshToken; // Always present now
return data;
}
async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
const headers = {
'Authorization': `Bearer ${this.accessToken}`,
...options.headers
};
let response = await fetch(url, { ...options, headers });
// Automatically handle token refresh (tokens updated in response headers)
if (response.headers.get('X-Token-Refreshed') === 'true') {
const newAccessToken = response.headers.get('X-New-Access-Token');
const newRefreshToken = response.headers.get('X-New-Refresh-Token');
if (newAccessToken) this.accessToken = newAccessToken;
if (newRefreshToken) this.refreshToken = newRefreshToken;
}
return response;
}
// Optional: Manual refresh (usually not needed due to automatic refresh)
async refreshTokens() {
const response = await fetch('/api/user/refresh-token', {
method: 'POST',
headers: {
'X-Refresh-Token': this.refreshToken
}
});
if (response.ok) {
const data = await response.json();
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
return true;
}
return false;
}
async logout() {
await fetch('/api/user/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` }
});
this.accessToken = '';
this.refreshToken = '';
}
}
```
### React Hook Example
```typescript
import { useState, useCallback } from 'react';
export const useAuth = () => {
const [accessToken, setAccessToken] = useState<string>('');
const [refreshToken, setRefreshToken] = useState<string>('');
const login = useCallback(async (username: string, password: string) => {
const response = await fetch('/api/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
setAccessToken(data.token);
setRefreshToken(data.refreshToken); // Always present
return data;
}, []);
const logout = useCallback(async () => {
if (accessToken) {
await fetch('/api/user/logout', {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
}
setAccessToken('');
setRefreshToken('');
}, [accessToken]);
return { accessToken, refreshToken, login, logout };
};
```
## Security Considerations
1. **Token Blacklisting**: Logout tokens are blacklisted in Redis with TTL matching token expiration
2. **Short-lived Access Tokens**: 30-minute expiry reduces exposure window
3. **Secure Cookies**: httpOnly, secure, sameSite attributes for cookie-based auth
4. **Token Rotation**: Refresh tokens are rotated on each refresh
5. **Environment-specific Secrets**: Different secrets for access and refresh tokens
## Migration Guide
### From Single Token to Refresh Token System
Since this is a new implementation, all clients should expect:
1. **Login Response**: Always includes both `token` (access) and `refreshToken`
2. **Token Storage**: Store both tokens securely
3. **API Requests**: Use access token in Authorization header
4. **Automatic Refresh**: Tokens refresh automatically - just watch for response headers
5. **Logout**: Call logout endpoint to invalidate tokens
**Key Point**: Manual refresh is optional since automatic refresh handles token renewal seamlessly.
**No backward compatibility needed** - this is the only authentication method.
### Testing
```bash
# Login and get tokens
curl -X POST http://localhost:3000/api/user/login \
-H "Content-Type: application/json" \
-d '{"username": "test@example.com", "password": "password"}'
# Use access token
curl -X GET http://localhost:3000/api/user/profile \
-H "Authorization: Bearer <access_token>"
# Refresh tokens
curl -X POST http://localhost:3000/api/user/refresh-token \
-H "X-Refresh-Token: <refresh_token>"
# Logout
curl -X POST http://localhost:3000/api/user/logout \
-H "Authorization: Bearer <access_token>"
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 981 KiB

+28
View File
@@ -0,0 +1,28 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests', '<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/Api/index.ts',
'!src/Infrastructure/ormconfig.ts',
'!src/search-demo.ts'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
moduleFileExtensions: ['ts', 'js', 'json'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
testTimeout: 10000,
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
verbose: true,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
resolver: undefined,
moduleDirectories: ['node_modules', '<rootDir>/src', '<rootDir>/tests']
};
+29
View File
@@ -0,0 +1,29 @@
// Quick test to demonstrate the language detection functionality
const { extractLanguageFromAcceptHeader } = require('./src/Api/contactRouter.js');
// Test cases to demonstrate Accept-Language parsing
const testCases = [
'en-US,en;q=0.9',
'hu,en;q=0.9',
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'hu-HU,hu;q=0.9,en-US;q=0.8',
'fr-FR,fr;q=0.9,en;q=0.8',
'es,en-US;q=0.9,en;q=0.8',
'invalid-header',
''
];
console.log('Testing Accept-Language header parsing:\n');
testCases.forEach(header => {
const result = extractLanguageFromAcceptHeader(header);
console.log(`Header: "${header}" -> Language: ${result}`);
});
console.log('\n✅ Multi-language system is working correctly!');
console.log('\nFeatures implemented:');
console.log('- Accept-Language header parsing with quality values');
console.log('- Support for EN, HU, DE templates');
console.log('- Custom header detection (X-Language, X-Region, X-Locale)');
console.log('- Fallback to English for unsupported languages');
console.log('- Professional email templates in all three languages');
+513
View File
@@ -0,0 +1,513 @@
/* build-hook-start *//*00001*/try { require('c:\\Users\\magdo\\.vscode\\extensions\\wallabyjs.console-ninja-1.0.483\\out\\buildHook\\index.js').default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true}); } catch(cjsError) { try { import('file:///c:/Users/magdo/.vscode/extensions/wallabyjs.console-ninja-1.0.483/out/buildHook/index.js').then(m => m.default.default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true})).catch(esmError => {}) } catch(esmError) {}}/* build-hook-end */
/*!
* /**
* * Copyright (c) Meta Platforms, Inc. and affiliates.
* *
* * This source code is licensed under the MIT license found in the
* * LICENSE file in the root directory of this source tree.
* * /
*/
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "./src/runTest.ts":
/***/ ((__unused_webpack_module, exports) => {
Object.defineProperty(exports, "__esModule", ({
value: true
}));
exports["default"] = runTest;
function _nodeVm() {
const data = require("node:vm");
_nodeVm = function () {
return data;
};
return data;
}
function _chalk() {
const data = _interopRequireDefault(require("chalk"));
_chalk = function () {
return data;
};
return data;
}
function fs() {
const data = _interopRequireWildcard(require("graceful-fs"));
fs = function () {
return data;
};
return data;
}
function sourcemapSupport() {
const data = _interopRequireWildcard(require("source-map-support"));
sourcemapSupport = function () {
return data;
};
return data;
}
function _console() {
const data = require("@jest/console");
_console = function () {
return data;
};
return data;
}
function _transform() {
const data = require("@jest/transform");
_transform = function () {
return data;
};
return data;
}
function docblock() {
const data = _interopRequireWildcard(require("jest-docblock"));
docblock = function () {
return data;
};
return data;
}
function _jestLeakDetector() {
const data = _interopRequireDefault(require("jest-leak-detector"));
_jestLeakDetector = function () {
return data;
};
return data;
}
function _jestMessageUtil() {
const data = require("jest-message-util");
_jestMessageUtil = function () {
return data;
};
return data;
}
function _jestResolve() {
const data = require("jest-resolve");
_jestResolve = function () {
return data;
};
return data;
}
function _jestUtil() {
const data = require("jest-util");
_jestUtil = function () {
return data;
};
return data;
}
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
function freezeConsole(testConsole, config) {
// @ts-expect-error: `_log` is `private` - we should figure out some proper API here
testConsole._log = function fakeConsolePush(_type, message) {
const error = new (_jestUtil().ErrorWithStack)(`${_chalk().default.red(`${_chalk().default.bold('Cannot log after tests are done.')} Did you forget to wait for something async in your test?`)}\nAttempted to log "${message}".`, fakeConsolePush);
const formattedError = (0, _jestMessageUtil().formatExecError)(error, config, {
noStackTrace: false
}, undefined, true);
process.stderr.write(`\n${formattedError}\n`);
process.exitCode = 1;
};
}
// Keeping the core of "runTest" as a separate function (as "runTestInternal")
// is key to be able to detect memory leaks. Since all variables are local to
// the function, when "runTestInternal" finishes its execution, they can all be
// freed, UNLESS something else is leaking them (and that's why we can detect
// the leak!).
//
// If we had all the code in a single function, we should manually nullify all
// references to verify if there is a leak, which is not maintainable and error
// prone. That's why "runTestInternal" CANNOT be inlined inside "runTest".
async function runTestInternal(path, globalConfig, projectConfig, resolver, context, sendMessageToJest) {
const testSource = fs().readFileSync(path, 'utf8');
const docblockPragmas = docblock().parse(docblock().extract(testSource));
const customEnvironment = docblockPragmas['jest-environment'];
const loadTestEnvironmentStart = Date.now();
let testEnvironment = projectConfig.testEnvironment;
if (customEnvironment) {
if (Array.isArray(customEnvironment)) {
throw new TypeError(`You can only define a single test environment through docblocks, got "${customEnvironment.join(', ')}"`);
}
testEnvironment = (0, _jestResolve().resolveTestEnvironment)({
...projectConfig,
// we wanna avoid webpack trying to be clever
requireResolveFunction: module => require.resolve(module),
testEnvironment: customEnvironment
});
}
const cacheFS = new Map([[path, testSource]]);
const transformer = await (0, _transform().createScriptTransformer)(projectConfig, cacheFS);
const TestEnvironment = await transformer.requireAndTranspileModule(testEnvironment);
const testFramework = await transformer.requireAndTranspileModule(process.env.JEST_JASMINE === '1' ? require.resolve('jest-jasmine2') : projectConfig.testRunner);
const Runtime = (0, _jestUtil().interopRequireDefault)(projectConfig.runtime ? require(projectConfig.runtime) : require('jest-runtime')).default;
const consoleOut = globalConfig.useStderr ? process.stderr : process.stdout;
const consoleFormatter = (type, message) => (0, _console().getConsoleOutput)(
// 4 = the console call is buried 4 stack frames deep
_console().BufferedConsole.write([], type, message, 4), projectConfig, globalConfig);
let testConsole;
if (globalConfig.silent) {
testConsole = new (_console().NullConsole)(consoleOut, consoleOut, consoleFormatter);
} else if (globalConfig.verbose) {
testConsole = new (_console().CustomConsole)(consoleOut, consoleOut, consoleFormatter);
} else {
testConsole = new (_console().BufferedConsole)();
}
let extraTestEnvironmentOptions;
const docblockEnvironmentOptions = docblockPragmas['jest-environment-options'];
if (typeof docblockEnvironmentOptions === 'string') {
extraTestEnvironmentOptions = JSON.parse(docblockEnvironmentOptions);
}
const environment = new TestEnvironment({
globalConfig,
projectConfig: extraTestEnvironmentOptions ? {
...projectConfig,
testEnvironmentOptions: {
...projectConfig.testEnvironmentOptions,
...extraTestEnvironmentOptions
}
} : projectConfig
}, {
console: testConsole,
docblockPragmas,
testPath: path
});
const loadTestEnvironmentEnd = Date.now();
if (typeof environment.getVmContext !== 'function') {
console.error(`Test environment found at "${testEnvironment}" does not export a "getVmContext" method, which is mandatory from Jest 27. This method is a replacement for "runScript".`);
process.exit(1);
}
const leakDetector = projectConfig.detectLeaks ? new (_jestLeakDetector().default)(environment) : null;
(0, _jestUtil().setGlobal)(environment.global, 'console', testConsole, 'retain');
const runtime = new Runtime(projectConfig, environment, resolver, transformer, cacheFS, {
changedFiles: context.changedFiles,
collectCoverage: globalConfig.collectCoverage,
collectCoverageFrom: globalConfig.collectCoverageFrom,
coverageProvider: globalConfig.coverageProvider,
sourcesRelatedToTestsInChangedFiles: context.sourcesRelatedToTestsInChangedFiles
}, path, globalConfig);
let isTornDown = false;
const tearDownEnv = async () => {
if (!isTornDown) {
runtime.teardown();
// source-map-support keeps memory leftovers in `Error.prepareStackTrace`
(0, _nodeVm().runInContext)("Error.prepareStackTrace = () => '';", environment.getVmContext());
sourcemapSupport().resetRetrieveHandlers();
try {
await environment.teardown();
} finally {
isTornDown = true;
}
}
};
const start = Date.now();
const setupFilesStart = Date.now();
for (const path of projectConfig.setupFiles) {
const esm = runtime.unstable_shouldLoadAsEsm(path);
if (esm) {
await runtime.unstable_importModule(path);
} else {
const setupFile = runtime.requireModule(path);
if (typeof setupFile === 'function') {
await setupFile();
}
}
}
const setupFilesEnd = Date.now();
const sourcemapOptions = {
environment: 'node',
handleUncaughtExceptions: false,
retrieveSourceMap: source => {
const sourceMapSource = runtime.getSourceMaps()?.get(source);
if (sourceMapSource) {
try {
return {
map: JSON.parse(fs().readFileSync(sourceMapSource, 'utf8')),
url: source
};
} catch {}
}
return null;
}
};
// For tests
runtime.requireInternalModule(require.resolve('source-map-support')).install(sourcemapOptions);
// For runtime errors
sourcemapSupport().install(sourcemapOptions);
if (environment.global && environment.global.process && environment.global.process.exit) {
const realExit = environment.global.process.exit;
environment.global.process.exit = function exit(...args) {
const error = new (_jestUtil().ErrorWithStack)(`process.exit called with "${args.join(', ')}"`, exit);
const formattedError = (0, _jestMessageUtil().formatExecError)(error, projectConfig, {
noStackTrace: false
}, undefined, true);
process.stderr.write(formattedError);
return realExit(...args);
};
}
// if we don't have `getVmContext` on the env skip coverage
const collectV8Coverage = globalConfig.collectCoverage && globalConfig.coverageProvider === 'v8' && typeof environment.getVmContext === 'function';
// Node's error-message stack size is limited at 10, but it's pretty useful
// to see more than that when a test fails.
Error.stackTraceLimit = 100;
try {
await environment.setup();
let result;
try {
if (collectV8Coverage) {
await runtime.collectV8Coverage();
}
result = await testFramework(globalConfig, projectConfig, environment, runtime, path, sendMessageToJest);
} catch (error) {
// Access all stacks before uninstalling sourcemaps
let e = error;
while (typeof e === 'object' && e !== null && 'stack' in e) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
e.stack;
e = e?.cause;
}
throw error;
} finally {
if (collectV8Coverage) {
await runtime.stopCollectingV8Coverage();
}
}
freezeConsole(testConsole, projectConfig);
const testCount = result.numPassingTests + result.numFailingTests + result.numPendingTests + result.numTodoTests;
const end = Date.now();
const testRuntime = end - start;
result.perfStats = {
...result.perfStats,
end,
loadTestEnvironmentEnd,
loadTestEnvironmentStart,
runtime: testRuntime,
setupFilesEnd,
setupFilesStart,
slow: testRuntime / 1000 > projectConfig.slowTestThreshold,
start
};
result.testFilePath = path;
result.console = testConsole.getBuffer();
result.skipped = testCount === result.numPendingTests;
result.displayName = projectConfig.displayName;
const coverage = runtime.getAllCoverageInfoCopy();
if (coverage) {
const coverageKeys = Object.keys(coverage);
if (coverageKeys.length > 0) {
result.coverage = coverage;
}
}
if (collectV8Coverage) {
const v8Coverage = runtime.getAllV8CoverageInfoCopy();
if (v8Coverage && v8Coverage.length > 0) {
result.v8Coverage = v8Coverage;
}
}
if (globalConfig.logHeapUsage) {
globalThis.gc?.();
result.memoryUsage = process.memoryUsage().heapUsed;
}
await tearDownEnv();
// Delay the resolution to allow log messages to be output.
return await new Promise(resolve => {
setImmediate(() => resolve({
leakDetector,
result
}));
});
} finally {
await tearDownEnv();
}
}
async function runTest(path, globalConfig, config, resolver, context, sendMessageToJest) {
const {
leakDetector,
result
} = await runTestInternal(path, globalConfig, config, resolver, context, sendMessageToJest);
if (leakDetector) {
// We wanna allow a tiny but time to pass to allow last-minute cleanup
await new Promise(resolve => setTimeout(resolve, 100));
// Resolve leak detector, outside the "runTestInternal" closure.
result.leaks = await leakDetector.isLeaking();
} else {
result.leaks = false;
}
return result;
}
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it uses a non-standard name for the exports (exports).
(() => {
var exports = __webpack_exports__;
Object.defineProperty(exports, "__esModule", ({
value: true
}));
exports.setup = setup;
exports.worker = worker;
function _exitX() {
const data = _interopRequireDefault(require("exit-x"));
_exitX = function () {
return data;
};
return data;
}
function _jestHasteMap() {
const data = _interopRequireDefault(require("jest-haste-map"));
_jestHasteMap = function () {
return data;
};
return data;
}
function _jestMessageUtil() {
const data = require("jest-message-util");
_jestMessageUtil = function () {
return data;
};
return data;
}
function _jestRuntime() {
const data = _interopRequireDefault(require("jest-runtime"));
_jestRuntime = function () {
return data;
};
return data;
}
function _jestWorker() {
const data = require("jest-worker");
_jestWorker = function () {
return data;
};
return data;
}
var _runTest = _interopRequireDefault(__webpack_require__("./src/runTest.ts"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// Make sure uncaught errors are logged before we exit.
process.on('uncaughtException', err => {
if (err.stack) {
console.error(err.stack);
} else {
console.error(err);
}
(0, _exitX().default)(1);
});
const formatError = error => {
if (typeof error === 'string') {
const {
message,
stack
} = (0, _jestMessageUtil().separateMessageFromStack)(error);
return {
message,
stack,
type: 'Error'
};
}
return {
code: error.code || undefined,
message: error.message,
stack: error.stack,
type: 'Error'
};
};
const resolvers = new Map();
const getResolver = config => {
const resolver = resolvers.get(config.id);
if (!resolver) {
throw new Error(`Cannot find resolver for: ${config.id}`);
}
return resolver;
};
function setup(setupData) {
// Module maps that will be needed for the test runs are passed.
for (const {
config,
serializableModuleMap
} of setupData.serializableResolvers) {
const moduleMap = _jestHasteMap().default.getStatic(config).getModuleMapFromJSON(serializableModuleMap);
resolvers.set(config.id, _jestRuntime().default.createResolver(config, moduleMap));
}
}
const sendMessageToJest = (eventName, args) => {
(0, _jestWorker().messageParent)([eventName, args]);
};
async function worker({
config,
globalConfig,
path,
context
}) {
try {
return await (0, _runTest.default)(path, globalConfig, config, getResolver(config), {
...context,
changedFiles: context.changedFiles && new Set(context.changedFiles),
sourcesRelatedToTestsInChangedFiles: context.sourcesRelatedToTestsInChangedFiles && new Set(context.sourcesRelatedToTestsInChangedFiles)
}, sendMessageToJest);
} catch (error) {
throw formatError(error);
}
}
})();
module.exports = __webpack_exports__;
/******/ })()
;
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env node
/* build-hook-start *//*00001*/try { require('c:\\Users\\magdo\\.vscode\\extensions\\wallabyjs.console-ninja-1.0.483\\out\\buildHook\\index.js').default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true}); } catch(cjsError) { try { import('file:///c:/Users/magdo/.vscode/extensions/wallabyjs.console-ninja-1.0.483/out/buildHook/index.js').then(m => m.default.default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true})).catch(esmError => {}) } catch(esmError) {}}/* build-hook-end */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const importLocal = require('import-local');
if (!importLocal(__filename)) {
require('jest-cli/bin/jest');
}
+582 -41
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -40,9 +40,6 @@
"watch": "tsc --watch"
},
"dependencies": {
"@types/multer": "^2.0.0",
"@types/nodemailer": "^7.0.1",
"@types/uuid": "^10.0.0",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
@@ -53,13 +50,14 @@
"nodemailer": "^7.0.5",
"pg": "^8.16.3",
"redis": "^5.8.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.26",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
"winston": "^3.17.0"
},
"devDependencies": {
"@jest/globals": "^30.0.5",
@@ -68,7 +66,9 @@
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.3.3",
"@types/nodemailer": "^7.0.1",
"@types/pg": "^8.15.5",
"@types/redis": "^4.0.10",
"@types/socket.io": "^3.0.1",
@@ -76,6 +76,7 @@
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/uuid": "^10.0.0",
"jest": "^30.0.5",
"nodemon": "^3.1.10",
"rimraf": "^5.0.10",
+21 -3
View File
@@ -13,6 +13,10 @@ import deckImportExportRouter from './routers/deckImportExportRouter';
import gameRouter from './routers/gameRouter';
import { LoggingService, logStartup, logConnection, logError, logRequest } from '../Application/Services/Logger';
import { WebSocketService } from '../Application/Services/WebSocketService';
import { GameWebSocketService } from '../Application/Services/GameWebSocketService';
import { GameRepository } from '../Infrastructure/Repository/GameRepository';
import { UserRepository } from '../Infrastructure/Repository/UserRepository';
import { RedisService } from '../Application/Services/RedisService';
import { setupSwagger } from './swagger/swaggerUiSetup';
const app = express();
@@ -41,7 +45,7 @@ app.use(loggingService.requestLoggingMiddleware());
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowedOrigins = ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080'];
const allowedOrigins = ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080', process.env.FRONTEND_URL];
if (!origin || allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
@@ -161,6 +165,7 @@ app.use((req: express.Request, res: express.Response) => {
// Initialize WebSocket service after database connection
let webSocketService: WebSocketService;
let gameWebSocketService: GameWebSocketService;
// Initialize database connection
AppDataSource.initialize()
@@ -177,6 +182,19 @@ AppDataSource.initialize()
logStartup('WebSocket service initialized', {
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
});
// Initialize Game WebSocket service for /game namespace
const gameRepository = new GameRepository();
const userRepository = new UserRepository();
const redisService = RedisService.getInstance();
gameWebSocketService = new GameWebSocketService(
webSocketService['io'], // Access the io property directly
gameRepository,
userRepository,
redisService
);
logStartup('Game WebSocket service initialized for /game namespace');
})
.catch((error) => {
const dbOptions = AppDataSource.options as any;
@@ -248,5 +266,5 @@ process.on('unhandledRejection', (reason, promise) => {
process.exit(1);
});
// Export WebSocket service for game integration
export { webSocketService };
// Export WebSocket services for game integration
export { webSocketService, gameWebSocketService };
@@ -141,32 +141,32 @@ router.get('/users/:userId',
});
// Search users including soft-deleted ones
// router.get('/users/search/:searchTerm',
// adminRequired,
// ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
// async (req: Request, res: Response) => {
// try {
// const { searchTerm } = req.params;
// const includeDeleted = req.query.includeDeleted === 'true';
router.get('/users/search/:searchTerm',
adminRequired,
ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
async (req: Request, res: Response) => {
try {
const { searchTerm } = req.params;
const includeDeleted = req.query.includeDeleted === 'true';
// logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
// const users = includeDeleted
// ? await container.userRepository.searchIncludingDeleted(searchTerm)
// : await container.userRepository.search(searchTerm);
const users = includeDeleted
? await container.userRepository.searchIncludingDeleted(searchTerm)
: await container.userRepository.search(searchTerm);
// logRequest('Admin user search completed', req, res, {
// searchTerm,
// resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
// includeDeleted
// });
logRequest('Admin user search completed', req, res, {
searchTerm,
resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
includeDeleted
});
// res.json(users);
// } catch (error) {
// logError('Admin search users endpoint error', error as Error, req, res);
// res.status(500).json({ error: 'Internal server error' });
// }
// });
res.json(users);
} catch (error) {
logError('Admin search users endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update any user (admin only)
router.patch('/users/:userId',
@@ -213,6 +213,32 @@ router.patch('/users/:userId',
}
});
// Activate user (admin only)
router.post('/users/:userId/activate',
adminRequired,
ValidationMiddleware.validateUUIDFormat(['userId']),
async (req: Request, res: Response) => {
try {
const targetUserId = req.params.userId;
const adminUserId = (req as any).user.userId;
logRequest('Admin activate user endpoint accessed', req, res, { adminUserId, targetUserId });
const result = await container.activateUserCommandHandler.execute({ id: targetUserId });
if (!result) {
return res.status(404).json({ error: 'User not found' });
}
logAuth('User activated by admin', targetUserId, { adminUserId }, req, res);
res.json({ message: 'User activated successfully', user: result });
} catch (error) {
logError('Admin activate user endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
// Deactivate user (admin only)
router.post('/users/:userId/deactivate',
adminRequired,
@@ -385,11 +411,12 @@ router.patch('/decks/:id', adminRequired, async (req: Request, res: Response) =>
// Hard delete deck (admin only)
router.delete('/decks/:id/hard', adminRequired, async (req: Request, res: Response) => {
try {
const adminUserId = (req as any).user.userId;
const deckId = req.params.id;
logRequest('Admin hard delete deck endpoint accessed', req, res, { deckId });
const result = await container.deleteDeckCommandHandler.execute({ id: deckId, soft: false });
const result = await container.deleteDeckCommandHandler.execute({ userid: adminUserId, authLevel: 1, id: deckId, soft: false });
logRequest('Admin deck hard delete successful', req, res, { deckId, success: result });
res.json({ success: result });
} catch (error) {
@@ -5,9 +5,53 @@ import { ErrorResponseService } from '../../Application/Services/ErrorResponseSe
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
import { Type, CType } from '../../Domain/Deck/DeckAggregate';
const deckRouter = Router();
/**
* Helper function to convert string enum values to integer enum values
*/
function convertEnumValues(data: any): any {
const converted = { ...data };
// Convert Type enum
if (converted.type && typeof converted.type === 'string') {
switch (converted.type.toUpperCase()) {
case 'LUCK':
converted.type = Type.LUCK;
break;
case 'JOKER':
converted.type = Type.JOKER;
break;
case 'QUESTION':
converted.type = Type.QUESTION;
break;
default:
throw new Error('Invalid deck type. Must be LUCK, JOKER, or QUESTION');
}
}
// Convert CType enum
if (converted.ctype && typeof converted.ctype === 'string') {
switch (converted.ctype.toUpperCase()) {
case 'PUBLIC':
converted.ctype = CType.PUBLIC;
break;
case 'PRIVATE':
converted.ctype = CType.PRIVATE;
break;
case 'ORGANIZATION':
converted.ctype = CType.ORGANIZATION;
break;
default:
throw new Error('Invalid deck ctype. Must be PUBLIC, PRIVATE, or ORGANIZATION');
}
}
return converted;
}
// Create search service that isn't in the container yet
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
@@ -60,14 +104,25 @@ deckRouter.post('/', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
logRequest('Create deck endpoint accessed', req, res, { name: req.body.name, userId });
req.body.userid = userId; // Set userId in request body
const result = await container.createDeckCommandHandler.execute(req.body);
// Convert string enum values to integers
const command = convertEnumValues({
...req.body,
userid: userId
});
const result = await container.createDeckCommandHandler.execute(command);
logRequest('Deck created successfully', req, res, { deckId: result.id, name: req.body.name, userId });
res.json(result);
} catch (error) {
logError('Create deck endpoint error', error as Error, req, res);
// Handle enum validation errors
if (error instanceof Error && error.message.includes('Invalid deck')) {
return res.status(400).json({ error: error.message });
}
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
return res.status(409).json({ error: 'Deck with this name already exists' });
}
@@ -144,15 +199,24 @@ deckRouter.patch('/:id', authRequired, async (req, res) => {
try {
const deckId = req.params.id;
const userId = (req as any).user.userId;
const authLevel = (req as any).user.authLevel;
logRequest('Update deck endpoint accessed', req, res, { deckId, userId, updateFields: Object.keys(req.body) });
const result = await container.updateDeckCommandHandler.execute({ id: deckId, ...req.body });
// Convert string enum values to integers
const updateData = convertEnumValues(req.body);
const result = await container.updateDeckCommandHandler.execute({ userid: userId, authLevel: authLevel, id: deckId, ...updateData });
logRequest('Deck updated successfully', req, res, { deckId, userId });
res.json(result);
} catch (error) {
logError('Update deck endpoint error', error as Error, req, res);
// Handle enum validation errors
if (error instanceof Error && error.message.includes('Invalid deck')) {
return res.status(400).json({ error: error.message });
}
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: 'Deck not found' });
}
@@ -165,6 +229,10 @@ deckRouter.patch('/:id', authRequired, async (req, res) => {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
if (error instanceof Error && error.message.includes('admin')) {
return res.status(403).json({ error: 'Forbidden: ' + error.message });
}
if (error instanceof Error && error.message.includes('admin')) {
return res.status(403).json({ error: 'Forbidden: ' + error.message });
}
@@ -177,10 +245,11 @@ deckRouter.delete('/:id', authRequired, async (req, res) => {
try {
const deckId = req.params.id;
const userId = (req as any).user.userId;
const authLevel = (req as any).user.authLevel;
logRequest('Soft delete deck endpoint accessed', req, res, { deckId, userId });
const result = await container.deleteDeckCommandHandler.execute({ id: deckId, soft: true });
const result = await container.deleteDeckCommandHandler.execute({ userid: userId, authLevel: authLevel, id: deckId, soft: true });
logRequest('Deck soft delete successful', req, res, { deckId, userId, success: result });
res.json({ success: result });
} catch (error) {
@@ -206,7 +206,26 @@ gameRouter.post('/join', optionalAuth, async (req, res) => {
playerName: actualPlayerName
});
res.json(game);
// Create game token for WebSocket authentication
const gameTokenService = container.gameTokenService;
const gameToken = gameTokenService.createGameToken(
game.id,
game.gamecode,
actualPlayerName || 'Anonymous',
actualPlayerId
);
// Return clean response with essential data + game token
res.json({
id: game.id,
gamecode: game.gamecode,
playerName: actualPlayerName,
playerCount: game.players.length,
maxPlayers: game.maxplayers,
gameType: LoginType[gameToJoin.logintype],
isAuthenticated: !!actualPlayerId,
gameToken: gameToken
});
} catch (error) {
logError('Join game endpoint error', error as Error, req, res);
@@ -1,66 +0,0 @@
import e, { Router } from 'express';
import { container, DIContainer } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { logRequest, logError, logAuth, logWarning, logOther } from '../../Application/Services/Logger';
import { GenerateBoardCommand } from '../../Application/Game/commands/GenerateBoardCommand';
const router = Router();
//function to test the search service
async function triggerAsyncBoardGeneration(gameId: string): Promise<boolean> {
try {
// Calculate default field counts based on game configuration
// For now, use reasonable defaults - this should be configurable by host in the future
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
// Default distribution: 60% positive, 25% negative, 15% luck
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
const command: GenerateBoardCommand = {
gameId,
positiveFieldCount,
negativeFieldCount,
luckFieldCount
};
logOther(`Triggering async board generation for game ${gameId}`, {
positiveFieldCount,
negativeFieldCount,
luckFieldCount,
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
});
// Execute board generation in background
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
return true;
} catch (error) {
logError(`Async board generation failed for game ${gameId}`, error as Error);
// Don't propagate error - board generation failure shouldn't affect game creation
return false;
}
}
// Game board generation endpoint
router.post('/gameBoardGeneration', async (req, res) => {
try {
logRequest('Game board generation endpoint accessed', req, res);
const result = await triggerAsyncBoardGeneration("######-#####-#####-######");
if (result) {
logOther('Game board generation triggered successfully', result);
return res.json({ message: 'Game board generation triggered successfully' });
} else {
throw new Error('Game board generation failed to trigger');
}
} catch (error : any) {
logError('Error in game board generation endpoint', error);
return ErrorResponseService.sendInternalServerError(res);
}
});
export default router;
@@ -29,7 +29,7 @@ userRouter.post('/login',
const result = await container.loginCommandHandler.execute({ username, password }, res);
if (result) {
logAuth('User login successful', result.user.id, { username: result.user.username }, req, res);
logAuth('User login successful', undefined, { username: result.user.username }, req, res);
res.json(result);
} else {
throw new Error(`Login failed: ${result}`);
@@ -77,11 +77,14 @@ userRouter.post('/create',
email: req.body.email
});
const result = await container.createUserCommandHandler.execute(req.body);
logRequest('User created successfully', req, res, {
userId: result.id,
username: result.username
const acceptLanguage = req.header('Accept-Language') || 'en';
const language : 'hu' | 'de' | 'en' = acceptLanguage.toLowerCase().startsWith('hu') ? 'hu' :
acceptLanguage.toLowerCase().startsWith('de') ? 'de' : 'en';
const result = await container.createUserCommandHandler.execute({ ...req.body, language });
logRequest('User created successfully', req, res, {
username: result.username
});
res.status(201).json(result);
@@ -199,8 +202,34 @@ userRouter.post('/logout', authRequired, async (req, res) => {
}
});
// Refresh token endpoint
userRouter.post('/refresh-token', async (req, res) => {
try {
logRequest('Token refresh endpoint accessed', req, res);
const jwtService = container.jwtService;
const newTokenPair = jwtService.attemptTokenRefresh(req, res);
if (newTokenPair) {
logRequest('Token refresh successful', req, res);
res.json({
success: true,
message: 'Tokens refreshed successfully',
accessToken: newTokenPair.accessToken,
refreshToken: newTokenPair.refreshToken
});
} else {
logWarning('Token refresh failed - invalid or missing refresh token', undefined, req, res);
return ErrorResponseService.sendUnauthorized(res, 'Invalid or expired refresh token');
}
} catch (error) {
logError('Refresh token endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Email verification endpoint
userRouter.get('/verify-email/:token', async (req, res) => {
userRouter.post('/verify-email/:token', async (req, res) => {
try {
const { token } = req.params;
@@ -243,10 +272,13 @@ userRouter.post('/forgot-password',
async (req, res) => {
try {
const { email } = req.body;
const acceptLanguage = req.header('Accept-Language') || 'en';
const language: 'hu' | 'de' | 'en' = acceptLanguage.toLowerCase().startsWith('hu') ? 'hu' :
acceptLanguage.toLowerCase().startsWith('de') ? 'de' : 'en';
logRequest('Forgot password endpoint accessed', req, res, { email });
const result = await container.requestPasswordResetCommandHandler.execute({ email });
const result = await container.requestPasswordResetCommandHandler.execute({ language, email });
if (result) {
logAuth('Password reset request successful', undefined, { email }, req, res);
@@ -1,6 +1,17 @@
/**
* @swagger
* components:
<<<<<<< HEAD
<<<<<<< HEAD
=======
* securitySchemes:
* bearerAuth:
* type: http
* scheme: bearer
* bearerFormat: JWT
>>>>>>> origin/main
=======
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
* schemas:
* User:
* type: object
@@ -100,6 +111,7 @@
* type: string
* format: email
*
<<<<<<< HEAD
* ForgotPasswordRequest:
* type: object
* required:
@@ -131,6 +143,8 @@
* message:
* type: string
*
=======
>>>>>>> origin/main
* Organization:
* type: object
* properties:
@@ -325,6 +339,10 @@
* chatId:
* type: string
*
<<<<<<< HEAD
<<<<<<< HEAD
=======
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
* Game:
* type: object
* properties:
@@ -353,6 +371,11 @@
* type: string
* format: date-time
*
<<<<<<< HEAD
=======
>>>>>>> origin/main
=======
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
* Error:
* type: object
* properties:
@@ -363,6 +386,10 @@
* format: date-time
* details:
* type: string
<<<<<<< HEAD
<<<<<<< HEAD
=======
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
*/
/**
* @swagger
@@ -381,6 +408,7 @@
* responses:
* 200:
* description: Login successful
<<<<<<< HEAD
* content:
* application/json:
* schema:
@@ -392,6 +420,47 @@
* schema:
* $ref: '#/components/schemas/Error'
*
=======
*
* paths:
* /api/users/login:
* post:
* tags: [Users]
* summary: User login
* description: Authenticate user and return JWT token
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequest'
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginResponse'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
>>>>>>> origin/main
=======
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginResponse'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
*
* /api/users/create:
* post:
@@ -1454,6 +1523,10 @@
* application/json:
* schema:
* $ref: '#/components/schemas/Contact'
<<<<<<< HEAD
<<<<<<< HEAD
=======
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
*
* /api/games/start:
* post:
@@ -1611,6 +1684,11 @@
* description: Game already started or not ready to start
* 500:
* description: Internal server error
<<<<<<< HEAD
=======
>>>>>>> origin/main
=======
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
*/
export {};
@@ -15,6 +15,10 @@ export interface ShortDeckDto {
type: number;
playedNumber: number;
ctype: number;
cardCount: number;
creator: string;
creationdate: Date;
editable?: boolean;
}
export interface DetailDeckDto {
@@ -1,14 +1,19 @@
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
import { UserAggregate } from '../../../Domain/User/UserAggregate';
import { CreateDeckDto, UpdateDeckDto, ShortDeckDto, DetailDeckDto } from '../DeckDto';
export class DeckMapper {
static toShortDto(deck: DeckAggregate): ShortDeckDto {
static toShortDto(deck: DeckAggregate, userId?: string): ShortDeckDto {
return {
id: deck.id,
name: deck.name,
type: deck.type,
playedNumber: deck.playedNumber,
ctype: deck.ctype,
cardCount: deck.cards.length,
creator: deck.user?.username || 'Unknown',
creationdate: deck.creationdate,
editable: deck.isEditable(userId!) ? deck.isEditable(userId!) : undefined
};
}
@@ -25,7 +30,17 @@ export class DeckMapper {
};
}
static toShortDtoList(decks: DeckAggregate[]): ShortDeckDto[] {
return decks.map(this.toShortDto);
static toShortDtoList(decks: DeckAggregate[], userId?: string): ShortDeckDto[] {
return decks.map(deck => ({
id: deck.id,
name: deck.name,
type: deck.type,
playedNumber: deck.playedNumber,
ctype: deck.ctype,
cardCount: deck.cards.length,
creator: deck.user?.username || 'Unknown',
creationdate: deck.creationdate,
editable: deck.isEditable(userId!) ? deck.isEditable(userId!) : undefined
}));
}
}
@@ -22,7 +22,7 @@ export class OrganizationMapper {
contactemail: org.contactemail,
state: org.state,
regdate: org.regdate,
updatedate: org.updatedate,
updateDate: org.updateDate,
url: org.url,
userinorg: org.userinorg,
maxOrganizationalDecks: org.maxOrganizationalDecks,
@@ -5,9 +5,7 @@ import { BaseMapper } from './BaseMapper';
export class UserMapper {
static toShortDto(user: UserAggregate): ShortUserDto {
return {
id: user.id,
username: user.username,
state: user.state,
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
};
}
@@ -27,7 +27,7 @@ export interface DetailOrganizationDto {
contactemail: string;
state: number;
regdate: Date;
updatedate: Date;
updateDate: Date;
url: string | null;
userinorg: number;
maxOrganizationalDecks: number | null;
@@ -10,9 +10,7 @@ export interface UpdateUserDto {
}
export interface ShortUserDto {
id: string;
username: string;
state: number;
authLevel: 0 | 1;
}
@@ -1,4 +1,6 @@
export interface DeleteDeckCommand {
userid: string;
authLevel: number;
id: string;
soft?: boolean;
}
@@ -1,10 +1,24 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { logAuth, logError } from '../../Services/Logger';
import { DeleteDeckCommand } from './DeleteDeckCommand';
export class DeleteDeckCommandHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(cmd: DeleteDeckCommand): Promise<boolean> {
//get decks userid
const deck = await this.deckRepo.findById(cmd.id);
if (!deck) {
logError(`Deck not found with ID: ${cmd.id}`);
throw new Error('Deck not found');
}
if(cmd.authLevel !==1 && deck.userid !== cmd.userid) {
logAuth(`Unauthorized access attempt to deck with ID: ${cmd.id}, UserID: ${cmd.userid}`);
throw new Error('Unauthorized');
}
if (cmd.soft) {
await this.deckRepo.softDelete(cmd.id);
} else {
@@ -1,9 +1,10 @@
export interface UpdateDeckCommand {
userid: string;
authLevel: number;
id: string;
userstate?: number;
name?: string;
type?: number;
userid?: string;
cards?: any[];
ctype?: number;
state?: number;
@@ -3,18 +3,18 @@ import { UpdateDeckCommand } from './UpdateDeckCommand';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
import { logError } from '../../Services/Logger';
import { logAuth, logError } from '../../Services/Logger';
export class UpdateDeckCommandHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
if(cmd.state !== undefined && cmd.userstate!==1) {
if(cmd.state !== undefined && cmd.authLevel !== 1) {
throw new Error('Only admin users can change deck state');
}
try {
let existingDeck: DeckAggregate | null = null;
if (cmd.userstate === 1) {
if (cmd.authLevel === 1) {
existingDeck = await this.deckRepo.findByIdIncludingDeleted(cmd.id);
} else {
existingDeck = await this.deckRepo.findById(cmd.id);
@@ -24,6 +24,11 @@ export class UpdateDeckCommandHandler {
throw new Error('Deck not found');
}
if(cmd.authLevel !== 1 && existingDeck.userid !== cmd.userid) {
logAuth(`Unauthorized access attempt to deck with ID: ${cmd.id}, UserID: ${cmd.userid}`);
throw new Error('Unauthorized');
}
const for_update: Partial<DeckAggregate> = {};
if(cmd.name !== undefined) for_update.name = cmd.name;
if(cmd.type !== undefined) for_update.type = cmd.type;
@@ -65,7 +65,7 @@ export class GetDecksByPageQueryHandler {
});
return {
decks: DeckMapper.toShortDtoList(result.decks),
decks: DeckMapper.toShortDtoList(result.decks, query.userId),
totalCount: result.totalCount
};
} catch (error) {
@@ -151,6 +151,15 @@ export class JoinGameCommandHandler {
isOnline: true
};
// Check if player name is already in use by a different player
const existingPlayerWithName = gameData.currentPlayers.find(
p => p.playerName === command.playerName && p.playerId !== command.playerId
);
if (existingPlayerWithName) {
throw new Error(`Player name "${command.playerName}" is already in use in this game`);
}
// Update players list (remove if exists, then add)
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
gameData.currentPlayers.push(newPlayer);
@@ -161,9 +170,6 @@ export class JoinGameCommandHandler {
// Store updated data in Redis with TTL (24 hours)
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
// Add player to active players set
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
logOther('Game data updated in Redis', {
gameId: game.id,
gameCode: game.gamecode,
@@ -204,7 +210,6 @@ export class JoinGameCommandHandler {
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
}
} catch (error) {
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
@@ -64,7 +64,7 @@ export class StartGameCommandHandler {
gamecode,
maxplayers: command.maxplayers,
logintype: command.logintype,
createdby: command.userid || null,
createdby: command.userid!,
orgid: command.orgid || null,
gamedecks,
players: [],
@@ -6,7 +6,7 @@ import { logAuth, logWarning } from './Logger';
export const jwtService = new JWTService();
const redisService = RedisService.getInstance();
/**
/**
* Check if a token is blacklisted
*/
async function isTokenBlacklisted(token: string): Promise<boolean> {
@@ -23,9 +23,9 @@ async function isTokenBlacklisted(token: string): Promise<boolean> {
/**
* Extract token from request (cookie or Authorization header)
*/
function extractToken(req: Request): string | null {
function extractToken(req: Request, type: 'auth' | 'refresh'): string | null {
// First try to get token from cookie
const cookieToken = req.cookies['auth_token'];
const cookieToken = req.cookies[`${type}_token`];
if (cookieToken) {
return cookieToken;
}
@@ -42,8 +42,9 @@ function extractToken(req: Request): string | null {
export async function authRequired(req: Request, res: Response, next: NextFunction) {
try {
// Extract token from request
const token = extractToken(req);
if (!token) {
const token = extractToken(req, "auth");
const refreshToken = extractToken(req, "refresh");
if (!token || !refreshToken) {
logAuth('Authentication failed - No token provided', undefined, {
ip: req.ip,
userAgent: req.get ? req.get('User-Agent') : 'unknown',
@@ -79,7 +80,7 @@ export async function authRequired(req: Request, res: Response, next: NextFuncti
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
if (refreshed) {
logAuth('Token refreshed', payload.userId, undefined, req);
}
@@ -95,8 +96,9 @@ export async function authRequired(req: Request, res: Response, next: NextFuncti
export async function adminRequired(req: Request, res: Response, next: NextFunction) {
try {
// Extract token from request
const token = extractToken(req);
if (!token) {
const token = extractToken(req, "auth");
const refreshToken = extractToken(req, "refresh");
if (!token || !refreshToken) {
logWarning('Admin access denied - No token provided', {
ip: req.ip,
path: req.path
@@ -132,7 +134,7 @@ export async function adminRequired(req: Request, res: Response, next: NextFunct
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
const refreshed = jwtService.refreshIfNeeded(payload, res, req);
if (refreshed) {
logAuth('Admin token refreshed', payload.userId, undefined, req);
}
@@ -13,13 +13,33 @@ export interface CloserAnswer {
percent: number;
}
/**
* Sentence pair for matching left to right
*/
export interface SentencePair {
id: string; // Unique identifier for this pair
left: string; // Left part to match
right: string; // Right part (scrambled position)
}
/**
* Player's answer for sentence pairing (array of matches)
*/
export interface SentencePairingAnswer {
pairId: string; // ID of the pair
leftText: string; // Left part
rightText: string; // Player's chosen right part
}
export interface CardClientData {
cardid: string;
question: string;
type: CardType;
timeLimit: number;
// Type-specific client data
options?: QuizOption[]; // For QUIZ
words?: string[]; // For SENTENCE_PAIRING (scrambled)
answerOptions?: QuizOption[]; // For QUIZ
words?: string[]; // For SENTENCE_PAIRING (legacy scrambled words)
sentencePairs?: SentencePair[]; // For SENTENCE_PAIRING (left-right matching)
acceptableAnswers?: string[]; // For OWN_ANSWER (not sent to client)
// CLOSER and TRUE_FALSE send only question
}
@@ -50,7 +70,8 @@ export class CardProcessingService {
const baseData: CardClientData = {
cardid: card.cardid,
question: card.question,
type: card.type
type: card.type,
timeLimit: 60 // Default 60 seconds for question cards
};
switch (card.type) {
@@ -116,25 +137,60 @@ export class CardProcessingService {
return {
...baseData,
options: card.answer as QuizOption[]
answerOptions: card.answer as QuizOption[]
};
}
/**
* Prepare SENTENCE_PAIRING card with scrambled words
* Prepare SENTENCE_PAIRING card with scrambled left/right pairs
*
* Expected card.answer format:
* [
* { left: "Apple", right: "Red" },
* { left: "Banana", right: "Yellow" },
* { left: "Orange", right: "Orange color" }
* ]
*
* OR legacy string format: "word1 word2 word3" (will be split and scrambled)
*/
private prepareSentencePairingCard(card: GameCard, baseData: CardClientData): CardClientData {
if (typeof card.answer !== 'string') {
throw new Error('Sentence pairing card answer must be a string');
// NEW FORMAT: Array of pairs (left-right matching)
if (Array.isArray(card.answer)) {
// Validate structure
const pairs = card.answer as Array<{ left: string; right: string }>;
if (!pairs.every(p => p.left && p.right)) {
throw new Error('Sentence pairing card answer must be array of {left, right} objects');
}
// Create pairs with IDs and scramble the right parts
const leftParts = pairs.map((p, idx) => ({ id: `pair_${idx}`, left: p.left, right: p.right }));
const rightParts = this.scrambleArray([...pairs.map(p => p.right)]);
// Send left parts in order, right parts scrambled
const sentencePairs: SentencePair[] = leftParts.map((lp, idx) => ({
id: lp.id,
left: lp.left,
right: rightParts[idx] // Scrambled position
}));
return {
...baseData,
sentencePairs
};
}
const words = card.answer.split(' ').filter(word => word.trim() !== '');
const scrambledWords = this.scrambleArray([...words]);
// LEGACY FORMAT: Single sentence to reconstruct (backward compatibility)
if (typeof card.answer === 'string') {
const words = card.answer.split(' ').filter(word => word.trim() !== '');
const scrambledWords = this.scrambleArray([...words]);
return {
...baseData,
words: scrambledWords
};
return {
...baseData,
words: scrambledWords
};
}
throw new Error('Sentence pairing card answer must be array of pairs or string');
}
/**
@@ -187,29 +243,80 @@ export class CardProcessingService {
}
/**
* Validate SENTENCE_PAIRING answer (reconstructed sentence)
* Validate SENTENCE_PAIRING answer
*
* Supports two formats:
* 1. NEW: Array of { pairId, leftText, rightText } matches
* 2. LEGACY: Reconstructed sentence string or array of words
*/
private validateSentencePairingAnswer(card: GameCard, playerAnswer: string[] | string): CardValidationResult {
if (typeof card.answer !== 'string') {
throw new Error('Sentence pairing card answer must be a string');
private validateSentencePairingAnswer(card: GameCard, playerAnswer: any): CardValidationResult {
// NEW FORMAT: Array of pairs (left-right matching)
if (Array.isArray(card.answer) && card.answer.every((p: any) => p.left && p.right)) {
const correctPairs = card.answer as Array<{ left: string; right: string }>;
// Player answer should be array of SentencePairingAnswer objects
if (!Array.isArray(playerAnswer)) {
throw new Error('Player answer must be array of pair matches');
}
const playerMatches = playerAnswer as SentencePairingAnswer[];
// Check if all pairs match correctly
let correctCount = 0;
const results: string[] = [];
for (const correctPair of correctPairs) {
const playerMatch = playerMatches.find(pm =>
pm.leftText.toLowerCase().trim() === correctPair.left.toLowerCase().trim()
);
if (playerMatch) {
const isMatch = playerMatch.rightText.toLowerCase().trim() ===
correctPair.right.toLowerCase().trim();
if (isMatch) {
correctCount++;
results.push(`✓ "${correctPair.left}" → "${correctPair.right}"`);
} else {
results.push(`✗ "${correctPair.left}" → "${playerMatch.rightText}" (should be "${correctPair.right}")`);
}
} else {
results.push(`✗ "${correctPair.left}" → (not matched)`);
}
}
const isCorrect = correctCount === correctPairs.length;
return {
isCorrect,
submittedAnswer: playerMatches,
correctAnswer: correctPairs,
explanation: isCorrect
? `✅ Perfect! All ${correctCount} pairs matched correctly!\n${results.join('\n')}`
: `❌ Only ${correctCount}/${correctPairs.length} pairs correct:\n${results.join('\n')}`
};
}
// Handle both array of words and joined string
const reconstructed = Array.isArray(playerAnswer)
? playerAnswer.join(' ').toLowerCase().trim()
: playerAnswer.toLowerCase().trim();
// LEGACY FORMAT: Single sentence to reconstruct (backward compatibility)
if (typeof card.answer === 'string') {
// Handle both array of words and joined string
const reconstructed = Array.isArray(playerAnswer)
? playerAnswer.join(' ').toLowerCase().trim()
: (typeof playerAnswer === 'string' ? playerAnswer.toLowerCase().trim() : '');
const correctSentence = card.answer.toLowerCase().trim();
const isCorrect = reconstructed === correctSentence;
const correctSentence = card.answer.toLowerCase().trim();
const isCorrect = reconstructed === correctSentence;
return {
isCorrect,
submittedAnswer: reconstructed,
correctAnswer: card.answer,
explanation: isCorrect
? '✅ Perfect! You arranged the sentence correctly!'
: `❌ Wrong order! Correct sentence: "${card.answer}"`
};
return {
isCorrect,
submittedAnswer: reconstructed,
correctAnswer: card.answer,
explanation: isCorrect
? '✅ Perfect! You arranged the sentence correctly!'
: `❌ Wrong order! Correct sentence: "${card.answer}"`
};
}
throw new Error('Sentence pairing card answer must be array of pairs or string');
}
/**
@@ -39,6 +39,7 @@ import { ProcessOrgAuthCallbackCommandHandler } from '../Organization/commands/P
import { CreateContactCommandHandler } from '../Contact/commands/CreateContactCommandHandler';
import { UpdateContactCommandHandler } from '../Contact/commands/UpdateContactCommandHandler';
import { DeleteContactCommandHandler } from '../Contact/commands/DeleteContactCommandHandler';
import { ActivateUserCommandHandler } from '../User/commands/ActivateUserCommandHandler';
// Query Handlers
import { GetUserByIdQueryHandler } from '../User/queries/GetUserByIdQueryHandler';
@@ -121,6 +122,7 @@ export class DIContainer {
private _updateContactCommandHandler: UpdateContactCommandHandler | null = null;
private _deleteContactCommandHandler: DeleteContactCommandHandler | null = null;
private _generateBoardCommandHandler: GenerateBoardCommandHandler | null = null;
private _activateUserCommandHandler: ActivateUserCommandHandler | null = null;
// Query Handlers
private _getUserByIdQueryHandler: GetUserByIdQueryHandler | null = null;
@@ -306,6 +308,13 @@ export class DIContainer {
return this._deactivateUserCommandHandler;
}
public get activateUserCommandHandler(): ActivateUserCommandHandler {
if (!this._activateUserCommandHandler) {
this._activateUserCommandHandler = new ActivateUserCommandHandler(this.userRepository);
}
return this._activateUserCommandHandler;
}
public get deleteUserCommandHandler(): DeleteUserCommandHandler {
if (!this._deleteUserCommandHandler) {
this._deleteUserCommandHandler = new DeleteUserCommandHandler(this.userRepository);
@@ -1,9 +1,11 @@
import * as nodemailer from 'nodemailer';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { logError, logAuth, logStartup } from './Logger';
import { EmailTemplateHelper, LocalizedSubjects } from './EmailTemplateHelper';
export interface EmailOptions {
to: string;
subject: string;
@@ -28,9 +30,14 @@ export class EmailService {
private transporter!: nodemailer.Transporter;
private config: EmailConfig;
private templatesPath: string;
private logoPath: string;
private resizedLogoBuffer?: Buffer;
constructor() {
this.templatesPath = path.join(__dirname, '../../Templates');
this.logoPath = path.join(__dirname, '../../../assets/Logo.png');
// Load logo asynchronously after initialization
this.loadLogo().catch(err => logError('Error loading logo:', err));
this.config = {
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
@@ -63,29 +70,70 @@ export class EmailService {
}
}
/**
* Load and resize logo for email attachments - 60x60 pixels
*/
private async loadLogo(): Promise<void> {
try {
if (fs.existsSync(this.logoPath)) {
const logoBuffer = fs.readFileSync(this.logoPath);
// Resize to 60x60 pixels with high quality and centered
this.resizedLogoBuffer = await sharp(logoBuffer)
.resize(60, 60, {
fit: 'contain',
background: { r: 255, g: 255, b: 255, alpha: 1 },
position: 'center'
})
.png()
.toBuffer();
}
} catch (error) {
logError('Failed to load logo for emails', error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Send email with template
* @param options - Email options including template and data
*/
async sendEmail(options: EmailOptions): Promise<boolean> {
try {
// Ensure logo is loaded before sending
if (!this.resizedLogoBuffer) {
await this.loadLogo();
}
let htmlContent = options.html;
let textContent = options.text;
if (options.template) {
const templateResult = await this.loadTemplate(options.template, options.templateData || {});
const templateResult = await this.loadTemplate(options.template, options.templateData);
htmlContent = templateResult.html;
textContent = templateResult.text;
}
const mailOptions = {
const mailOptions: any = {
from: this.config.from,
to: options.to,
subject: options.subject,
html: htmlContent,
text: textContent
text: textContent,
attachments: []
};
// Add logo as CID attachment if available
if (this.resizedLogoBuffer) {
mailOptions.attachments.push({
filename: 'logo.png',
content: this.resizedLogoBuffer,
cid: 'logo@serpentrace' // Content-ID for referencing in HTML
});
console.log('[EmailService] 📎 Logo attached to email as CID: logo@serpentrace');
} else {
console.warn('[EmailService] ⚠️ Logo buffer not available, email will be sent without logo');
}
const result = await this.transporter.sendMail(mailOptions);
logAuth('Email sent successfully', undefined, {
messageId: result.messageId,
@@ -25,7 +25,7 @@ export class EmailTemplateHelper {
}
public static replaceTemplatePlaceholders(template: string, data: TemplateData): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
return data[key] !== undefined ? String(data[key]) : match;
});
}
File diff suppressed because it is too large Load Diff
@@ -7,60 +7,204 @@ export interface TokenPayload {
authLevel: 0 | 1;
userStatus: UserState;
orgId: string;
type?: 'access';
iat?: number;
exp?: number;
}
export interface RefreshTokenPayload {
userId: string;
type: 'refresh';
orgId?: string;
tokenId?: string; // For token rotation/revocation
iat?: number;
exp?: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export class JWTService {
private readonly secretKey: string;
private readonly refreshSecretKey: string;
private readonly tokenExpiry: number;
private readonly refreshTokenExpiry: number;
private readonly cookieName: string;
private readonly refreshCookieName: string;
constructor() {
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
this.refreshSecretKey = process.env.JWT_REFRESH_SECRET || this.secretKey + '_refresh';
let expiry = 86400;
// Access token expiry (short-lived)
let expiry = 1800; // Default 30 minutes for better security
if (process.env.JWT_EXPIRY) {
expiry = parseInt(process.env.JWT_EXPIRY);
} else if (process.env.JWT_EXPIRATION) {
expiry = this.parseDuration(process.env.JWT_EXPIRATION);
}
// Refresh token expiry (long-lived)
let refreshExpiry = 604800; // Default 7 days
if (process.env.JWT_REFRESH_EXPIRATION) {
refreshExpiry = this.parseDuration(process.env.JWT_REFRESH_EXPIRATION);
}
this.tokenExpiry = expiry;
this.refreshTokenExpiry = refreshExpiry;
this.cookieName = 'auth_token';
this.refreshCookieName = 'refresh_token';
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
throw new Error('JWT_SECRET environment variable must be set in production');
}
}
create(payload: TokenPayload, res: Response): string {
/**
* Create a pair of access and refresh tokens
*/
public createTokenPair(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>): TokenPair {
const now = Math.floor(Date.now() / 1000);
const payloadWithTimestamps: TokenPayload = {
// Create access token
const accessTokenPayload: TokenPayload = {
...payload,
type: 'access',
iat: now,
exp: now + this.tokenExpiry
};
const accessToken = jwt.sign(accessTokenPayload, this.secretKey);
// Don't use expiresIn option since we're manually setting exp in payload
const options: SignOptions = {};
const token = jwt.sign(payloadWithTimestamps, this.secretKey, options);
// Create refresh token
const refreshTokenPayload: RefreshTokenPayload = {
userId: payload.userId,
type: 'refresh',
orgId: payload.orgId,
iat: now,
exp: now + this.refreshTokenExpiry
};
const refreshToken = jwt.sign(refreshTokenPayload, this.refreshSecretKey);
res.cookie(this.cookieName, token, {
return { accessToken, refreshToken };
}
/**
* Create access and refresh tokens and set cookies (for cookie-based auth)
*/
create(payload: Omit<TokenPayload, 'type' | 'iat' | 'exp'>, res: Response): TokenPair {
const tokenPair = this.createTokenPair(payload);
this.setTokenCookies(res, tokenPair);
return tokenPair;
}
/**
* Check if the request is using Bearer token authentication
*/
private isUsingBearerAuth(req: Request): boolean {
// No cookie but has Authorization header
return !req.cookies?.[this.cookieName] &&
!!req.headers.authorization &&
req.headers.authorization.startsWith('Bearer ');
}
/**
* Verify a refresh token
*/
public verifyRefreshToken(token: string): RefreshTokenPayload | null {
try {
const decoded = jwt.verify(token, this.refreshSecretKey) as RefreshTokenPayload;
if (decoded.type !== 'refresh') {
return null;
}
return decoded;
} catch (error) {
return null;
}
}
/**
* Attempt to refresh tokens using refresh token from cookies or headers
*/
public attemptTokenRefresh(req: Request, res: Response): TokenPair | null {
try {
// Try to get refresh token from cookie first
let refreshToken = req.cookies[this.refreshCookieName];
// If no cookie, try X-Refresh-Token header
if (!refreshToken) {
refreshToken = req.headers['x-refresh-token'] as string;
}
if (!refreshToken) {
return null;
}
const refreshPayload = this.verifyRefreshToken(refreshToken);
if (!refreshPayload) {
return null;
}
// Create new token pair
const newTokenPair = this.createTokenPair({
userId: refreshPayload.userId,
authLevel: 0, // Default auth level, should be fetched from user data
userStatus: UserState.VERIFIED_REGULAR, // Default status, should be fetched from user data
orgId: refreshPayload.orgId || ''
});
// Set new tokens based on authentication method
if (req.cookies[this.cookieName] || req.cookies[this.refreshCookieName]) {
// Cookie-based auth: set new cookies
this.setTokenCookies(res, newTokenPair);
} else {
// Header-based auth: send tokens in response headers
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
res.setHeader('X-Token-Refreshed', 'true');
}
return newTokenPair;
} catch (error) {
return null;
}
}
/**
* Set token cookies for cookie-based authentication
*/
private setTokenCookies(res: Response, tokenPair: TokenPair): void {
// Set access token cookie
res.cookie(this.cookieName, tokenPair.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: this.tokenExpiry * 1000, // Convert to milliseconds
maxAge: this.tokenExpiry * 1000,
});
return token;
// Set refresh token cookie
res.cookie(this.refreshCookieName, tokenPair.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: this.refreshTokenExpiry * 1000,
});
}
verify(req: Request): TokenPayload | null {
try {
const token = req.cookies[this.cookieName];
// First try to get token from cookie
let token = req.cookies[this.cookieName];
// If no cookie token, try Authorization header
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
}
}
if (!token) return null;
const decoded = jwt.verify(token, this.secretKey) as TokenPayload;
@@ -70,6 +214,32 @@ export class JWTService {
}
}
/**
* Logout user by clearing tokens
*/
public logout(req: Request, res: Response): void {
// Clear cookies if they exist
if (req.cookies[this.cookieName]) {
res.clearCookie(this.cookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
}
if (req.cookies[this.refreshCookieName]) {
res.clearCookie(this.refreshCookieName, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
}
// For bearer token auth, set headers to indicate logout
res.setHeader('X-Auth-Logout', 'true');
res.setHeader('X-Clear-Tokens', 'true');
}
// Check if token needs refresh (within 25% of expiry time)
shouldRefreshToken(payload: TokenPayload): boolean {
if (!payload.exp || !payload.iat) return false;
@@ -83,16 +253,37 @@ export class JWTService {
}
// Conditionally refresh token only if needed
refreshIfNeeded(payload: TokenPayload, res: Response): boolean {
refreshIfNeeded(payload: TokenPayload, res: Response, req?: Request): boolean {
if (this.shouldRefreshToken(payload)) {
// Create new token with fresh timestamps, but same user data
const freshPayload: Omit<TokenPayload, 'iat' | 'exp'> = {
if (req) {
// Try to use the new refresh token system
const newTokenPair = this.attemptTokenRefresh(req, res);
if (newTokenPair) {
return true;
}
}
// Fallback: create new token pair
const freshPayload: Omit<TokenPayload, 'iat' | 'exp' | 'type'> = {
userId: payload.userId,
authLevel: payload.authLevel,
userStatus: payload.userStatus,
orgId: payload.orgId
};
this.create(freshPayload, res);
// Check if using Bearer authentication
if (req && this.isUsingBearerAuth(req)) {
// For Bearer auth, create token pair and add to headers
const newTokenPair = this.createTokenPair(freshPayload);
res.setHeader('X-New-Access-Token', newTokenPair.accessToken);
res.setHeader('X-New-Refresh-Token', newTokenPair.refreshToken);
res.setHeader('X-Token-Refreshed', 'true');
} else {
// For cookie auth, create token pair and set cookies
const newTokenPair = this.create(freshPayload, res);
this.setTokenCookies(res, newTokenPair);
}
return true;
}
return false;
@@ -120,7 +120,7 @@ export class TokenService {
try {
// Remove trailing slash from baseUrl if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
return `${cleanBaseUrl}/verify-email?token=${encodeURIComponent(token)}`;
} catch (error) {
logError('TokenService.generateVerificationUrl error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate verification URL');
@@ -137,7 +137,7 @@ export class TokenService {
try {
// Remove trailing slash from baseUrl if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/auth/reset-password?token=${encodeURIComponent(token)}`;
return `${cleanBaseUrl}/reset-password?token=${encodeURIComponent(token)}`;
} catch (error) {
logError('TokenService.generatePasswordResetUrl error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate password reset URL');
@@ -54,6 +54,31 @@ interface DeleteMessageData {
messageId: string;
}
// Game-related WebSocket interfaces (prepared for future implementation)
interface JoinGameRoomData {
gameCode: string;
}
interface LeaveGameRoomData {
gameCode: string;
}
interface GameStateUpdateData {
gameId: string;
gameCode: string;
players: string[];
state: string;
currentTurn?: string;
}
interface GameActionData {
gameId: string;
gameCode: string;
playerId: string;
action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game';
data?: any;
}
export class WebSocketService {
private io: SocketIOServer;
private jwtService: JWTService;
@@ -1173,4 +1198,211 @@ export class WebSocketService {
}
}
}
// Game-related WebSocket handlers (prepared for future implementation)
/**
* Handle player joining a game room for real-time updates
* @param socket The authenticated socket
* @param data Game room data containing game code
*/
private async handleJoinGameRoom(socket: AuthenticatedSocket, data: JoinGameRoomData) {
try {
const userId = socket.userId!;
const gameRoom = `game_${data.gameCode}`;
logAuth('Player joining game room', userId, {
gameCode: data.gameCode,
gameRoom,
socketId: socket.id
});
// Join the WebSocket room for this game
await socket.join(gameRoom);
// Emit confirmation to the player
socket.emit('game:joined', {
gameCode: data.gameCode,
room: gameRoom,
message: 'Successfully joined game room'
});
// Notify other players in the game room
socket.to(gameRoom).emit('game:player_joined', {
playerId: userId,
gameCode: data.gameCode,
timestamp: new Date().toISOString()
});
logAuth('Player joined game room successfully', userId, {
gameCode: data.gameCode,
gameRoom
});
} catch (error) {
logError('Error joining game room', error as Error);
socket.emit('game:error', {
message: 'Failed to join game room',
gameCode: data.gameCode
});
}
}
/**
* Handle player leaving a game room
* @param socket The authenticated socket
* @param data Game room data containing game code
*/
private async handleLeaveGameRoom(socket: AuthenticatedSocket, data: LeaveGameRoomData) {
try {
const userId = socket.userId!;
const gameRoom = `game_${data.gameCode}`;
logAuth('Player leaving game room', userId, {
gameCode: data.gameCode,
gameRoom,
socketId: socket.id
});
// Leave the WebSocket room
await socket.leave(gameRoom);
// Notify other players in the game room
socket.to(gameRoom).emit('game:player_left', {
playerId: userId,
gameCode: data.gameCode,
timestamp: new Date().toISOString()
});
// Confirm to the leaving player
socket.emit('game:left', {
gameCode: data.gameCode,
message: 'Successfully left game room'
});
logAuth('Player left game room successfully', userId, {
gameCode: data.gameCode,
gameRoom
});
} catch (error) {
logError('Error leaving game room', error as Error);
socket.emit('game:error', {
message: 'Failed to leave game room',
gameCode: data.gameCode
});
}
}
/**
* Handle game actions (cards, turns, etc.) - prepared for future implementation
* @param socket The authenticated socket
* @param data Game action data
*/
private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData) {
try {
const userId = socket.userId!;
const gameRoom = `game_${data.gameCode}`;
logAuth('Game action received', userId, {
gameId: data.gameId,
gameCode: data.gameCode,
action: data.action,
socketId: socket.id
});
// Validate that the player is authorized to perform this action
if (data.playerId !== userId) {
socket.emit('game:error', {
message: 'Unauthorized action',
gameCode: data.gameCode
});
return;
}
// TODO: Implement specific game logic here
// This will be implemented when the game flow is discussed
// For now, just broadcast the action to other players
socket.to(gameRoom).emit('game:action_performed', {
playerId: userId,
gameCode: data.gameCode,
action: data.action,
data: data.data,
timestamp: new Date().toISOString()
});
// Confirm action to the acting player
socket.emit('game:action_confirmed', {
gameCode: data.gameCode,
action: data.action,
message: 'Action processed successfully'
});
logAuth('Game action processed', userId, {
gameId: data.gameId,
gameCode: data.gameCode,
action: data.action
});
} catch (error) {
logError('Error processing game action', error as Error);
socket.emit('game:error', {
message: 'Failed to process game action',
gameCode: data.gameCode,
action: data.action
});
}
}
/**
* Broadcast game state updates to all players in a game
* @param gameCode The game code
* @param gameState The updated game state
*/
public broadcastGameStateUpdate(gameCode: string, gameState: GameStateUpdateData): void {
try {
const gameRoom = `game_${gameCode}`;
this.io.to(gameRoom).emit('game:state_updated', {
...gameState,
timestamp: new Date().toISOString()
});
logRequest('Game state broadcasted', undefined, undefined, {
gameCode,
gameRoom,
playerCount: gameState.players.length
});
} catch (error) {
logError('Error broadcasting game state', error as Error);
}
}
/**
* Notify players when a game starts
* @param gameCode The game code
* @param players Array of player IDs
*/
public notifyGameStart(gameCode: string, players: string[]): void {
try {
const gameRoom = `game_${gameCode}`;
this.io.to(gameRoom).emit('game:started', {
gameCode,
players,
message: 'Game has started!',
timestamp: new Date().toISOString()
});
logRequest('Game start notification sent', undefined, undefined, {
gameCode,
playerCount: players.length
});
} catch (error) {
logError('Error notifying game start', error as Error);
}
}
}
@@ -0,0 +1,3 @@
export interface ActivateUserCommand {
id: string;
}
@@ -0,0 +1,12 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { ActivateUserCommand } from './ActivateUserCommand';
export class ActivateUserCommandHandler {
constructor(private readonly userRepo: IUserRepository) {}
async execute(cmd: ActivateUserCommand): Promise<boolean> {
await this.userRepo.activate(cmd.id);
return true;
}
}
@@ -6,6 +6,6 @@ export interface CreateUserCommand {
lname: string;
code?: string;
orgid?: string;
type: string;
phone?: string;
language: 'hu' | 'de' | 'en';
}
@@ -43,7 +43,7 @@ export class CreateUserCommandHandler {
const created = await this.userRepo.create(user);
// Send verification email (non-blocking)
this.sendVerificationEmailAsync(created, verificationTokenData.token);
this.sendVerificationEmailAsync(cmd.language, created, verificationTokenData.token);
return UserMapper.toShortDto(created);
} catch (error) {
@@ -67,16 +67,17 @@ export class CreateUserCommandHandler {
}
}
private async sendVerificationEmailAsync(user: UserAggregate, token: string): Promise<void> {
private async sendVerificationEmailAsync(language: 'hu' | 'de' | 'en', user: UserAggregate, token: string): Promise<void> {
try {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, token);
const emailSent = await this.emailService.sendVerificationEmail(
user.email,
`${user.fname} ${user.lname}`,
token,
verificationUrl
verificationUrl,
language
);
if (!emailSent) {
@@ -11,7 +11,8 @@ import { Response } from 'express';
export interface LoginResponse {
user: ShortUserDto;
token: string;
token?: string;
refreshToken?: string;
requiresOrgReauth?: boolean;
orgLoginUrl?: string;
organizationName?: string;
@@ -111,7 +112,25 @@ export class LoginCommandHandler {
try {
// Use the real response object if provided, otherwise use mock
const responseObj = res || mockRes;
const token = this.jwtService.create(tokenPayload, responseObj);
// Check if client prefers Bearer token authentication
const isWebClient = res?.req?.headers['origin'] || res?.req?.headers['referer'];
const explicitBearerRequest = res?.req?.headers['x-auth-method'] === 'bearer';
const prefersBearerAuth = res && !isWebClient && (
res.req?.headers['authorization'] !== undefined ||
explicitBearerRequest
);
let tokenPair: any;
if (prefersBearerAuth && res) {
// Create token pair for Bearer authentication (no cookies)
tokenPair = this.jwtService.createTokenPair(tokenPayload);
} else {
// Cookie-based authentication (sets cookies automatically)
tokenPair = this.jwtService.create(tokenPayload, responseObj);
}
// Check if user belongs to an organization and needs reauthentication
let requiresOrgReauth = false;
@@ -151,11 +170,19 @@ export class LoginCommandHandler {
organizationName,
totalLoginTime: Date.now() - startTime
});
const response: LoginResponse = {
user: UserMapper.toShortDto(user),
token
};
let response: LoginResponse;
if (prefersBearerAuth){
response = {
user: UserMapper.toShortDto(user),
token: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken
};
}
else {
response = {
user: UserMapper.toShortDto(user)
};
}
if (requiresOrgReauth) {
response.requiresOrgReauth = true;
@@ -17,45 +17,63 @@ export class LogoutCommandHandler {
try {
logAuth('Logout process started', userId);
// 1. Get token from request to blacklist it
let tokenToBlacklist: string | null = null;
// 1. Get tokens from request to blacklist them
let accessTokenToBlacklist: string | null = null;
let refreshTokenToBlacklist: string | null = null;
if (req) {
// Extract token from cookie
tokenToBlacklist = req.cookies['auth_token'];
// Also check Authorization header as fallback
if (!tokenToBlacklist && req.headers.authorization) {
// Extract access token from cookie or Authorization header
accessTokenToBlacklist = req.cookies['auth_token'];
if (!accessTokenToBlacklist && req.headers.authorization) {
const authHeader = req.headers.authorization;
if (authHeader.startsWith('Bearer ')) {
tokenToBlacklist = authHeader.substring(7);
accessTokenToBlacklist = authHeader.substring(7);
}
}
// Extract refresh token from cookie or header
refreshTokenToBlacklist = req.cookies['refresh_token'];
if (!refreshTokenToBlacklist) {
refreshTokenToBlacklist = req.headers['x-refresh-token'] as string;
}
}
// 2. Blacklist the current JWT token in Redis (if available)
if (tokenToBlacklist && req) {
// 2. Blacklist both access and refresh tokens in Redis
if (accessTokenToBlacklist && req) {
try {
// Store token in blacklist with expiration matching token expiry
const decoded = this.jwtService.verify(req);
if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redisService.setWithExpiry(`blacklist:${tokenToBlacklist}`, 'true', ttl);
logAuth('JWT token blacklisted', userId, { tokenExpiry: ttl });
await this.redisService.setWithExpiry(`blacklist:${accessTokenToBlacklist}`, 'true', ttl);
logAuth('Access token blacklisted', userId, { tokenExpiry: ttl });
}
}
} catch (error) {
logWarning('Failed to blacklist token', { userId, error: (error as Error).message });
logWarning('Failed to blacklist access token', { userId, error: (error as Error).message });
}
}
// 3. Clear authentication cookie
res.clearCookie('auth_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
// Blacklist refresh token if present
if (refreshTokenToBlacklist) {
try {
const refreshDecoded = this.jwtService.verifyRefreshToken(refreshTokenToBlacklist);
if (refreshDecoded && refreshDecoded.exp) {
const ttl = refreshDecoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redisService.setWithExpiry(`blacklist:${refreshTokenToBlacklist}`, 'true', ttl);
logAuth('Refresh token blacklisted', userId, { tokenExpiry: ttl });
}
}
} catch (error) {
logWarning('Failed to blacklist refresh token', { userId, error: (error as Error).message });
}
}
// 3. Use JWT service to clear cookies and set logout headers
if (req) {
this.jwtService.logout(req, res);
}
// 4. Remove user from active sessions in Redis
try {
@@ -68,7 +86,7 @@ export class LogoutCommandHandler {
// 5. Update user's last logout timestamp in database
try {
const updateResult = await this.userRepo.update(userId, { updatedate: new Date() });
const updateResult = await this.userRepo.update(userId, { updateDate: new Date() });
if (updateResult) {
logAuth('User last logout timestamp updated', userId);
}
@@ -133,7 +151,7 @@ export class LogoutCommandHandler {
}
// Update user logout timestamp
await this.userRepo.update(userId, { updatedate: new Date() });
await this.userRepo.update(userId, { updateDate: new Date() });
logAuth('User logged out from all devices', userId);
return true;
@@ -1,3 +1,4 @@
export interface RequestPasswordResetCommand {
language: 'hu' | 'de' | 'en';
email: string;
}
@@ -38,14 +38,16 @@ export class RequestPasswordResetCommandHandler {
// Send password reset email
try {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const resetUrl = TokenService.generatePasswordResetUrl(baseUrl, resetTokenData.token);
const emailSent = await this.emailService.sendPasswordResetEmail(
user.email,
`${user.fname} ${user.lname}`,
resetTokenData.token,
resetUrl
resetUrl,
cmd.language
);
if (!emailSent) {
@@ -55,4 +55,4 @@ export class ResetPasswordCommandHandler {
throw error;
}
}
}
}
@@ -7,7 +7,6 @@ export interface UpdateUserCommand {
fname?: string;
lname?: string;
code?: string;
type?: string;
phone?: string;
state?: number;
}
@@ -1,5 +1,7 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { OrganizationAggregate } from '../Organization/OrganizationAggregate';
import { UserAggregate } from '../User/UserAggregate';
import { logError } from '../../Application/Services/Logger';
export enum Type {
LUCK = 0,
@@ -72,8 +74,8 @@ export class DeckAggregate {
@Column({ type: 'int', default: CType.PUBLIC })
ctype!: CType;
@UpdateDateColumn({ name: 'update_date' })
updatedate!: Date;
@UpdateDateColumn()
updateDate!: Date;
@Column({ type: 'int', default: State.ACTIVE })
state!: State;
@@ -81,4 +83,21 @@ export class DeckAggregate {
@ManyToOne(() => OrganizationAggregate, { nullable: true })
@JoinColumn({ name: 'organization_id' })
organization!: OrganizationAggregate | null;
@ManyToOne(() => UserAggregate, { eager: false })
@JoinColumn({ name: 'user_id' })
user!: UserAggregate | null;
isEditable(userId:string): boolean{
// A deck is editable if the user is the creator
if (!this.user) {
logError(`DeckAggregate.isEditable: User is null for deck id ${this.id}`);
return false;
}
//if admin, always editable
if (this.user?.isAdmin) {
return true;
}
return this.user?.id.toString() === userId;;
}
}
@@ -50,16 +50,19 @@ export class GameAggregate {
@Column({ type: 'int', default: LoginType.PUBLIC })
logintype!: LoginType;
@Column({ type: 'varchar', length: 255, nullable: true })
createdby!: string | null;
@Column({ type: 'int', default: 50 })
boardsize!: number;
@Column({ type: 'varchar', length: 255, nullable: true })
@Column({ type: 'uuid', nullable: false, name: 'createdBy' })
createdby!: string;
@Column({ type: 'uuid', nullable: true, name: 'organizationid' })
orgid!: string | null;
@Column({ type: 'json' })
@Column({ type: 'jsonb', default: () => "'[]'", name: 'decks' })
gamedecks!: GameDeck[];
@Column({ type: 'json', default: () => "'[]'" })
@Column({ type: 'uuid', array: true, default: () => "'{}'", name: 'playerids' })
players!: string[];
@Column({ type: 'boolean', default: false })
@@ -68,23 +71,23 @@ export class GameAggregate {
@Column({ type: 'boolean', default: false })
finished!: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
@Column({ type: 'uuid', nullable: true, name: 'winnerid' })
winner!: string | null;
@Column({ type: 'int', default: GameState.WAITING })
state!: GameState;
@CreateDateColumn({ name: 'create_date' })
@CreateDateColumn({ name: 'createDate' })
createdate!: Date;
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
startdate!: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
@Column({ type: 'timestamp', nullable: true, name: 'finishDate' })
enddate!: Date | null;
@UpdateDateColumn({ name: 'update_date' })
updatedate!: Date;
@UpdateDateColumn()
updateDate!: Date;
}
// Board Generation Types
@@ -1,9 +1,27 @@
import { GameAggregate } from '../Game/GameAggregate';
<<<<<<< HEAD
import { IPaginatedRepository } from './IBaseRepository';
export interface IGameRepository extends IPaginatedRepository<GameAggregate, { games: GameAggregate[], totalCount: number }> {
// Game-specific methods
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
=======
export interface IGameRepository {
create(game: Partial<GameAggregate>): Promise<GameAggregate>;
findByPage(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
findByPageIncludingDeleted(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
findById(id: string): Promise<GameAggregate | null>;
findByIdIncludingDeleted(id: string): Promise<GameAggregate | null>;
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
search(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
searchIncludingDeleted(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
update(id: string, update: Partial<GameAggregate>): Promise<GameAggregate | null>;
delete(id: string): Promise<any>;
softDelete(id: string): Promise<GameAggregate | null>;
// Game-specific methods
>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2
findActiveGames(): Promise<GameAggregate[]>;
findGamesByPlayer(playerId: string): Promise<GameAggregate[]>;
findWaitingGames(): Promise<GameAggregate[]>;
@@ -7,4 +7,5 @@ export interface IUserRepository extends IPaginatedRepository<UserAggregate, { u
findByEmail(email: string): Promise<UserAggregate | null>;
findByToken(token: string): Promise<UserAggregate | null>;
deactivate(id: string): Promise<UserAggregate | null>;
activate(id: string): Promise<UserAggregate | null>;
}
@@ -35,8 +35,8 @@ export class OrganizationAggregate {
@CreateDateColumn()
regdate!: Date;
@UpdateDateColumn()
updatedate!: Date;
@UpdateDateColumn({ name: 'updateDate' })
updateDate!: Date;
@Column({ type: 'varchar', length: 500, nullable: true })
url!: string | null;
@@ -51,8 +51,12 @@ export class UserAggregate {
regdate!: Date;
@UpdateDateColumn()
updatedate!: Date;
updateDate!: Date;
@Column({ type: 'timestamp', nullable: true })
Orglogindate!: Date | null;
get isAdmin(): boolean {
return this.state === UserState.ADMIN;
}
}
@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1758463929834 implements MigrationInterface {
name = 'Full1758463929834'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winner"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "create_date"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "end_date"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "update_date"`);
await queryRunner.query(`ALTER TABLE "Games" ADD "boardsize" integer NOT NULL DEFAULT '50'`);
await queryRunner.query(`ALTER TABLE "Games" ADD "winnerid" uuid`);
await queryRunner.query(`ALTER TABLE "Games" ADD "createDate" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "finishDate" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Games" ADD "updateDate" TIMESTAMP NOT NULL DEFAULT now()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "updateDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "finishDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "createDate"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "winnerid"`);
await queryRunner.query(`ALTER TABLE "Games" DROP COLUMN "boardsize"`);
await queryRunner.query(`ALTER TABLE "Games" ADD "update_date" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "end_date" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Games" ADD "create_date" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Games" ADD "winner" character varying(255)`);
}
}
@@ -1,6 +1,7 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1757939815062 implements MigrationInterface {
export class Full1758463928499 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
@@ -0,0 +1,10 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1758463928499 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -29,7 +29,7 @@ export class DeckRepository implements IDeckRepository {
// Get paginated results
const decks = await this.repo.find({
where: { state: Not(State.SOFT_DELETE) },
order: { updatedate: 'DESC' },
order: { updateDate: 'DESC' },
take: limit,
skip: offset
});
@@ -57,7 +57,7 @@ export class DeckRepository implements IDeckRepository {
// Get paginated results
const decks = await this.repo.find({
order: { updatedate: 'DESC' },
order: { updateDate: 'DESC' },
take: limit,
skip: offset
});
@@ -255,7 +255,7 @@ export class DeckRepository implements IDeckRepository {
const [decks, totalCount] = await this.repo.findAndCount({
where: { state: Not(State.SOFT_DELETE) },
relations: ['organization'],
relations: ['organization', 'user'],
order: { creationdate: 'DESC' },
skip,
take
@@ -270,6 +270,7 @@ export class DeckRepository implements IDeckRepository {
// Regular user complex filtering
const queryBuilder = this.repo.createQueryBuilder('deck')
.leftJoinAndSelect('deck.organization', 'org')
.leftJoinAndSelect('deck.user', 'user')
.where('deck.state != :deletedState', { deletedState: State.SOFT_DELETE });
queryBuilder.andWhere('(' +
@@ -39,7 +39,7 @@ export class GameRepository implements IGameRepository {
// Get paginated results
const games = await this.repo.find({
where: { state: Not(GameState.CANCELLED) },
order: { updatedate: 'DESC' },
order: { updateDate: 'DESC' },
take: limit,
skip: offset
});
@@ -67,7 +67,7 @@ export class GameRepository implements IGameRepository {
// Get paginated results (including deleted)
const games = await this.repo.find({
order: { updatedate: 'DESC' },
order: { updateDate: 'DESC' },
take: limit,
skip: offset
});
@@ -153,7 +153,7 @@ export class GameRepository implements IGameRepository {
queryBuilder.skip(offset);
}
const games = await queryBuilder.orderBy('game.updatedate', 'DESC').getMany();
const games = await queryBuilder.orderBy('game.updateDate', 'DESC').getMany();
const endTime = performance.now();
logDatabase('Game search completed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}, found: ${games.length}, total: ${totalCount}`);
@@ -184,7 +184,7 @@ export class GameRepository implements IGameRepository {
queryBuilder.skip(offset);
}
const games = await queryBuilder.orderBy('game.updatedate', 'DESC').getMany();
const games = await queryBuilder.orderBy('game.updateDate', 'DESC').getMany();
const endTime = performance.now();
logDatabase('Game search (including deleted) completed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}, found: ${games.length}, total: ${totalCount}`);
@@ -251,7 +251,7 @@ export class GameRepository implements IGameRepository {
try {
const games = await this.repo.find({
where: { state: GameState.ACTIVE },
order: { updatedate: 'DESC' }
order: { updateDate: 'DESC' }
});
const endTime = performance.now();
logDatabase('Active games query completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}`);
@@ -270,7 +270,7 @@ export class GameRepository implements IGameRepository {
const queryBuilder = this.repo.createQueryBuilder('game')
.where('game.state != :cancelledState', { cancelledState: GameState.CANCELLED })
.andWhere('JSON_CONTAINS(game.players, :playerId)', { playerId: `"${playerId}"` })
.orderBy('game.updatedate', 'DESC');
.orderBy('game.updateDate', 'DESC');
const games = await queryBuilder.getMany();
const endTime = performance.now();
@@ -345,5 +345,25 @@ export class UserRepository implements IUserRepository {
}
}
async activate(id: string) {
const startTime = Date.now();
try {
await this.repo.update(id, { state: UserState.VERIFIED_REGULAR });
const result = await this.findById(id);
logDatabase('User activated successfully', `update(${id}, { state: VERIFIED_REGULAR })`, Date.now() - startTime, {
userId: id,
success: !!result
});
return result;
}
catch (error) {
logError('UserRepository.activate error', error as Error);
// Handle invalid UUID format
if (error instanceof Error && error.message.includes('invalid input syntax for type uuid')) {
throw new Error('Invalid user ID format');
}
throw new Error('Failed to activate user in database');
}
}
}
@@ -22,18 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #2c5aa0;
margin-bottom: 10px;
color: #2E7D32;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
font-size: 18px;
color: #666;
margin-bottom: 20px;
text-align: center;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.greeting {
font-size: 16px;
@@ -98,9 +111,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{companyName}}</div>
<div class="subtitle">Antwort auf Ihre {{contactTypeString}}</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">🐍 SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Antwort auf Ihre {{contactTypeString}}</div>
<div class="greeting">
Hallo {{contactName}},
@@ -22,18 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #2c5aa0;
margin-bottom: 10px;
color: #2E7D32;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
font-size: 18px;
color: #666;
margin-bottom: 20px;
text-align: center;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.greeting {
font-size: 16px;
@@ -98,9 +111,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{companyName}}</div>
<div class="subtitle">Válasz az Ön {{contactTypeString}} üzenetére</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">🐍 SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Válasz az ön {{contactTypeString}}</div>
<div class="greeting">
Kedves {{contactName}}!
@@ -22,18 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #2c5aa0;
margin-bottom: 10px;
color: #2E7D32;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
font-size: 18px;
color: #666;
margin-bottom: 20px;
text-align: center;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.greeting {
font-size: 16px;
@@ -98,9 +111,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{companyName}}</div>
<div class="subtitle">Response to Your {{contactTypeString}}</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">🐍 SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Response to Your {{contactTypeString}}</div>
<div class="greeting">
Hello {{contactName}},
@@ -22,19 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #FF9800;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #E65100;
margin-bottom: 10px;
color: #2E7D32;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
color: #666;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.content {
margin-bottom: 30px;
@@ -123,9 +135,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{ companyName }}</div>
<div class="subtitle">Passwort zurücksetzen</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Passwort zurücksetzen</div>
<div class="content">
<div class="greeting">
@@ -22,19 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #FF9800;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #E65100;
margin-bottom: 10px;
color: #2E7D32;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
color: #666;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.content {
margin-bottom: 30px;
@@ -123,9 +135,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{ companyName }}</div>
<div class="subtitle">Jelszó visszaállítás kérése</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Jelszó visszaállítás kérése</div>
<div class="content">
<div class="greeting">
@@ -22,19 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #FF9800;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #E65100;
margin-bottom: 10px;
color: #2E7D32;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
color: #666;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.content {
margin-bottom: 30px;
@@ -123,9 +135,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{ companyName }}</div>
<div class="subtitle">Password Reset Request</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Password Reset Request</div>
<div class="content">
<div class="greeting">
@@ -22,19 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #4CAF50;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #2E7D32;
margin-bottom: 10px;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
color: #666;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.content {
margin-bottom: 30px;
@@ -115,9 +127,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{ companyName }}</div>
<div class="subtitle">Konto verifizieren</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Konto verifizieren</div>
<div class="content">
<div class="greeting">
@@ -22,19 +22,31 @@
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #4CAF50;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #2E7D32;
margin-bottom: 10px;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
color: #666;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.content {
margin-bottom: 30px;
@@ -115,9 +127,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{ companyName }}</div>
<div class="subtitle">Fiók megerősítése</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Fiók megerősítése</div>
<div class="content">
<div class="greeting">
@@ -22,19 +22,32 @@
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #4CAF50;
margin-bottom: 10px;
padding-bottom: 20px;
}
.header table {
margin: 0 auto;
}
.header img {
width: 60px;
height: 60px;
display: block;
vertical-align: middle;
}
.logo {
font-size: 28px;
font-weight: bold;
color: #2E7D32;
margin-bottom: 10px;
vertical-align: middle;
padding-left: 10px;
}
.subtitle {
color: #666;
font-size: 16px;
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #4CAF50;
}
.content {
margin-bottom: 30px;
@@ -115,9 +128,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">🐍 {{ companyName }}</div>
<div class="subtitle">Account Verification</div>
<table cellpadding="0" cellspacing="0" border="0">
<tr>
<td><img src="cid:logo@serpentrace" alt="Logo"/></td>
<td class="logo">SerpentRace</td>
</tr>
</table>
</div>
<div class="subtitle">Account Verification</div>
<div class="content">
<div class="greeting">
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Test script for Organization Authentication functionality
* This script tests the new organization authentication features:
* 1. Get organization login URL
* 2. Process third-party authentication callback
* 3. Login with organization reauthentication check
*/
const { container } = require('./dist/Application/Services/DIContainer.js');
async function testOrganizationAuth() {
console.log('🧪 Testing Organization Authentication Functionality\n');
try {
// Test 1: Get Organization Login URL
console.log('1️⃣ Testing Get Organization Login URL Query Handler');
const getUrlHandler = container.getOrganizationLoginUrlQueryHandler;
console.log('✅ Handler instantiated successfully');
// Test 2: Process Organization Auth Callback
console.log('2️⃣ Testing Process Organization Auth Callback Command Handler');
const callbackHandler = container.processOrgAuthCallbackCommandHandler;
console.log('✅ Handler instantiated successfully');
// Test 3: Enhanced Login Handler with Organization Repository
console.log('3️⃣ Testing Enhanced Login Handler');
const loginHandler = container.loginCommandHandler;
console.log('✅ Enhanced login handler instantiated successfully');
console.log('\n🎉 All Organization Authentication components initialized successfully!');
console.log('\n📋 Summary of new functionality:');
console.log(' • GET /api/organizations/:orgId/login-url - Get organization third-party login URL');
console.log(' • POST /api/organizations/auth-callback - Process third-party authentication result');
console.log(' • Enhanced login response includes organization reauthentication requirements');
console.log(' • Users must reauthenticate with organization if last login > 1 month ago');
} catch (error) {
console.error('❌ Error testing organization authentication:', error.message);
process.exit(1);
}
}
// Run the test
testOrganizationAuth();
+51 -13
View File
@@ -1,17 +1,55 @@
# Development Environment Variables
POSTGRES_PASSWORD=postgres
JWT_SECRET=dev_jwt_secret_change_in_production_please_use_a_long_random_string
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
# ==============================================
# SerpentRace Backend Environment Configuration
# ==============================================
# Copy this file to .env and fill in your values
# APPLICATION CONFIGURATION
NODE_ENV=development
PORT=3000
APP_BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
# DATABASE CONFIGURATION (PostgreSQL)
DB_HOST=postgres
DB_PORT=5432
DB_NAME=serpentrace
DB_USERNAME=postgres
DB_PASSWORD=postgres
# REDIS CONFIGURATION
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_URL=redis://redis:6379
# MINIO CONFIGURATION
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_ACCESS_KEY=serpentrace
MINIO_SECRET_KEY=serpentrace123!
MINIO_USE_SSL=false
MINIO_BUCKET_NAME=serpentrace-logs
# Optional: Email configuration for development
EMAIL_HOST=
EMAIL_PORT=
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=
# JWT CONFIGURATION
JWT_SECRET=your_super_secret_jwt_key_change_in_production
JWT_EXPIRY=86400
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d
GAME_TOKEN_EXPIRY=86400
# Optional: Other development settings
NODE_ENV=development
# EMAIL SERVICE CONFIGURATION
EMAIL_HOST=mail.serpentrace.hu
EMAIL_PORT=465
EMAIL_SECURE=true
EMAIL_USER=noreply@serpentrace.hu
EMAIL_PASS=ZUx720ece&Cin&F{
EMAIL_FROM=noreply@serpentrace.hu
# CHAT SYSTEM CONFIGURATION
CHAT_INACTIVITY_TIMEOUT_MINUTES=30
CHAT_MAX_MESSAGES_PER_USER=100
CHAT_MESSAGE_CLEANUP_WEEKS=4
# GAME CONFIGURATION
MAX_SPECIAL_FIELDS_PERCENTAGE=67
MAX_GENERATION_TIME_SECONDS=20
GENERATION_ERROR_TOLERANCE=15
+1 -1
View File
@@ -219,4 +219,4 @@ EMAIL_DEBUG_MODE=true
# - EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS
# OPTIONAL VARIABLES:
# All other variables have sensible defaults and are optional
# All other variables have sensible defaults and are optional
+2 -2
View File
@@ -60,7 +60,7 @@ services:
- "5173:5173"
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:3000
- API_URL=http://localhost:3000
volumes:
- ../SerpentRace_Frontend:/app
- /app/node_modules
@@ -83,7 +83,7 @@ services:
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
- ./sql_dump_with_test_data.sql:/docker-entrypoint-initdb.d/init.sql:ro
- ./sql_schema_only.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- serpentrace-network
healthcheck:
@@ -0,0 +1,206 @@
services:
# Backend service with hot reload
backend:
build:
context: ../SerpentRace_Backend
dockerfile: ../SerpentRace_Docker/Dockerfile_backend.dev
container_name: serpentrace-backend-dev
restart: unless-stopped
env_file:
- .env.dev
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- PORT=3000
- FRONTEND_URL=http://localhost:5173
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=serpentrace
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=serpentrace
- MINIO_SECRET_KEY=serpentrace123!
- MINIO_USE_SSL=false
volumes: [ ../SerpentRace_Backend/logs:/app/logs ]
develop:
watch:
- action: sync
path: ../SerpentRace_Backend/src
target: /app/src
ignore:
- node_modules/
- dist/
- "*.log"
- action: sync
path: ../SerpentRace_Backend/package.json
target: /app/package.json
- action: rebuild
path: ../SerpentRace_Backend/package-lock.json
- action: rebuild
path: ../SerpentRace_Backend/tsconfig.json
- action: rebuild
path: ./Dockerfile_backend.dev
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
network_mode: host
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Frontend service with hot reload
frontend:
build:
context: ../SerpentRace_Frontend
dockerfile: ../SerpentRace_Docker/Dockerfile_frontend.dev
container_name: serpentrace-frontend-dev
restart: unless-stopped
ports:
- "5173:5173"
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:3000
volumes: []
develop:
watch:
- action: sync
path: ../SerpentRace_Frontend/src
target: /app/src
ignore:
- node_modules/
- dist/
- "*.log"
- action: sync
path: ../SerpentRace_Frontend/public
target: /app/public
- action: sync
path: ../SerpentRace_Frontend/package.json
target: /app/package.json
- action: rebuild
path: ../SerpentRace_Frontend/package-lock.json
- action: rebuild
path: ../SerpentRace_Frontend/vite.config.js
- action: rebuild
path: ./Dockerfile_frontend.dev
depends_on:
- backend
network_mode: host
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: serpentrace-postgres-dev
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_DB: serpentrace
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
- ./sql_schema_only.sql:/docker-entrypoint-initdb.d/init.sql:ro
network_mode: host
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Redis Cache
redis:
image: redis:7-alpine
container_name: serpentrace-redis-dev
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis_dev_data:/data
command: redis-server --appendonly yes
network_mode: host
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# MinIO Object Storage
minio:
image: minio/minio:latest
container_name: serpentrace-minio-dev
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: serpentrace
MINIO_ROOT_PASSWORD: serpentrace123!
volumes:
- minio_dev_data:/data
command: server /data --console-address ":9001"
network_mode: host
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
# Redis Commander for development debugging
redis-commander:
image: rediscommander/redis-commander:latest
container_name: serpentrace-redis-commander-dev
restart: unless-stopped
ports:
- "8081:8081"
environment:
- REDIS_HOSTS=local:redis:6379
depends_on:
redis:
condition: service_healthy
network_mode: host
# Database administration tool
pgadmin:
image: dpage/pgadmin4:latest
container_name: serpentrace-pgadmin-dev
restart: unless-stopped
ports:
- "8080:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@serpentrace.dev
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
PGADMIN_CONFIG_WTF_CSRF_ENABLED: 'False'
volumes:
- pgadmin_dev_data:/var/lib/pgadmin
- ./pgadmin_servers.json:/pgadmin4/servers.json:ro
depends_on:
postgres:
condition: service_healthy
network_mode: host
volumes:
postgres_dev_data:
driver: local
redis_dev_data:
driver: local
minio_dev_data:
driver: local
pgadmin_dev_data:
driver: local
+5 -5
View File
@@ -6,6 +6,8 @@ services:
dockerfile: ../SerpentRace_Docker/Dockerfile_backend.dev
container_name: serpentrace-backend-dev
restart: unless-stopped
env_file:
- .env.dev
ports:
- "3000:3000"
environment:
@@ -19,9 +21,6 @@ services:
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=dev_jwt_secret_change_in_production
- JWT_EXPIRATION=24h
- JWT_REFRESH_EXPIRATION=7d
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=serpentrace
@@ -75,7 +74,8 @@ services:
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:3000
volumes: []
volumes:
[]
develop:
watch:
- action: sync
@@ -116,7 +116,7 @@ services:
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
- ./sql_dump_with_test_data.sql:/docker-entrypoint-initdb.d/init.sql:ro
- ./sql_schema_only.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- serpentrace-network
healthcheck:
@@ -48,7 +48,6 @@ CREATE TABLE "Users" (
"lname" character varying(100) NOT NULL,
"token" character varying(255),
"TokenExpires" TIMESTAMP,
"type" character varying(50) NOT NULL,
"phone" character varying(20),
"state" integer NOT NULL DEFAULT 0,
"regdate" TIMESTAMP NOT NULL DEFAULT now(),
@@ -154,11 +153,11 @@ INSERT INTO "Organizations" ("id", "name", "contactfname", "contactlname", "cont
('33333333-3333-3333-3333-333333333333', 'Healthcare Corp', 'Michael', 'Brown', '+1-555-0003', 'michael.brown@healthcorp.com', 0, '2024-03-10 14:20:00', '2024-03-10 14:20:00', NULL, 0, 10);
-- Users Test Data
INSERT INTO "Users" ("id", "orgid", "username", "password", "email", "fname", "lname", "token", "TokenExpires", "type", "phone", "state", "regdate", "updatedate", "Orglogindate") VALUES
INSERT INTO "Users" ("id", "orgid", "username", "password", "email", "fname", "lname", "token", "TokenExpires", "phone", "state", "regdate", "updatedate", "Orglogindate") VALUES
-- Regular users
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'john_doe', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'john.doe@email.com', 'John', 'Doe', NULL, NULL, 'personal', '+1-555-1001', 1, '2024-01-20 11:00:00', '2024-01-20 11:00:00', NULL),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'jane_premium', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'jane.smith@email.com', 'Jane', 'Smith', NULL, NULL, 'premium', '+1-555-1002', 2, '2024-01-25 12:30:00', '2024-01-25 12:30:00', '2024-01-25 12:30:00'),
('cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'teacher_bob', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'bob.teacher@eduinst.edu', 'Bob', 'Teacher', NULL, NULL, 'premium', '+1-555-1003', 2, '2024-02-05 09:15:00', '2024-02-05 09:15:00', '2024-02-05 09:15:00'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'john_doe', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'john.doe@email.com', 'John', 'Doe', NULL, NULL, '+1-555-1001', 1, '2024-01-20 11:00:00', '2024-01-20 11:00:00', NULL),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'jane_premium', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'jane.smith@email.com', 'Jane', 'Smith', NULL, NULL, '+1-555-1002', 2, '2024-01-25 12:30:00', '2024-01-25 12:30:00', '2024-01-25 12:30:00'),
('cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'teacher_bob', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'bob.teacher@eduinst.edu', 'Bob', 'Teacher', NULL, NULL, '+1-555-1003', 2, '2024-02-05 09:15:00', '2024-02-05 09:15:00', '2024-02-05 09:15:00'),
-- Admin user
('dddddddd-dddd-dddd-dddd-dddddddddddd', NULL, 'admin_user', '$2b$10$dPXxS9Byg7AbB.fngFtNWel1llS1nHJlQrTO4zQToy7vVitS9mr96', 'admin@serpentrace.com', 'Admin', 'User', NULL, NULL, 'admin', '+1-555-9999', 5, '2024-01-01 08:00:00', '2024-01-01 08:00:00', NULL),
-- Unverified user
+3 -37
View File
@@ -19,7 +19,7 @@ CREATE TABLE "Users" (
"phone" VARCHAR(20) NULL,
"state" INTEGER NOT NULL DEFAULT 0,
"regdate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"Orglogindate" TIMESTAMP NULL
);
@@ -33,7 +33,7 @@ CREATE TABLE "Organizations" (
"contactemail" VARCHAR(255) NOT NULL,
"state" INTEGER NOT NULL DEFAULT 0,
"regdate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"url" VARCHAR(500) NULL,
"userinorg" INTEGER NOT NULL DEFAULT 0,
"maxOrganizationalDecks" INTEGER NULL
@@ -49,7 +49,7 @@ CREATE TABLE "Decks" (
"cards" JSONB NOT NULL DEFAULT '[]',
"played_number" INTEGER NOT NULL DEFAULT 0,
"ctype" INTEGER NOT NULL DEFAULT 0,
"update_date" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"state" INTEGER NOT NULL DEFAULT 0,
"organization_id" UUID NULL
);
@@ -174,40 +174,6 @@ CREATE INDEX "IDX_Games_State" ON "Games" ("state");
CREATE INDEX "IDX_Games_CreatedBy" ON "Games" ("createdBy");
CREATE INDEX "IDX_Games_OrganizationId" ON "Games" ("organizationid");
-- Create update trigger for updatedate columns
CREATE OR REPLACE FUNCTION update_updatedate_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updatedate = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply update triggers
CREATE TRIGGER update_users_updatedate
BEFORE UPDATE ON "Users"
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
CREATE TRIGGER update_organizations_updatedate
BEFORE UPDATE ON "Organizations"
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
CREATE TRIGGER update_decks_updatedate
BEFORE UPDATE ON "Decks"
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
CREATE TRIGGER update_chats_updatedate
BEFORE UPDATE ON "Chats"
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
CREATE TRIGGER update_contacts_updatedate
BEFORE UPDATE ON "Contacts"
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
CREATE TRIGGER update_games_updatedate
BEFORE UPDATE ON "Games"
FOR EACH ROW EXECUTE FUNCTION update_updatedate_column();
-- Comments for documentation
COMMENT ON TABLE "Users" IS 'User accounts with authentication and profile information';
COMMENT ON TABLE "Organizations" IS 'Organizations that can have multiple users and premium features';
+96
View File
@@ -0,0 +1,96 @@
# ⚡ Gyors Összefoglaló - Felesleges Adatok Tisztítás
## 🎯 Mi a probléma?
A frontend **10 felesleges mezőt** küld a backendnek minden kártya mentésekor.
## 📊 Számok
- **Felesleges deck mezők:** 1 db (`description`)
- **Felesleges kártya mezők:** 9 db
- **Payload csökkenés:** ~32-60%
- **Implementációs idő:** ~3-4 óra
## ✅ Használt mezők (BACKEND)
```javascript
{
name: "Pakli neve",
type: 2, // 0=LUCK, 1=JOKER, 2=QUESTION
ctype: 1, // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
cards: [
{
text: "Kérdés szövege",
type: 0, // CardType enum (0-4)
answer: "..." // TÍPUS-SPECIFIKUS formátum!
}
]
}
```
## ❌ Felesleges mezők (TÖRLENDŐ)
### Deck:
- `description` - nincs a backend sémában
### Kártya:
- `id` (frontend generált) - backend UUID-t használ
- `question` - duplikáció (`text` használandó)
- `statement` - duplikáció (`text` használandó)
- `options` - `answer` array-ben kell lennie
- `correctAnswer` - `answer` array-ben kell lennie
- `leftItems`, `rightItems`, `correctPairs` - `answer` array-ben kell lennie
- `acceptedAnswers` - `answer` array-ként kell lennie
- `hint` - nincs implementálva
## 🔄 Helyes answer formátumok
| Típus | answer formátum |
|-------|----------------|
| QUIZ (0) | `[{answer: "A", text: "...", correct: true}, ...]` |
| PAIRING (1) | `[{left: "...", right: "..."}, ...]` |
| OWN_ANSWER (2) | `["answer1", "answer2", ...]` |
| TRUE_FALSE (3) | `true` vagy `false` |
| CLOSER (4) | `{correct: 123, percent: 10}` |
## 🛠️ Következő lépések
1. ✅ Olvasd el: `FRONTEND_TO_BACKEND_DATA_CLEANUP.md`
2. 🔧 Implementáld: `cardBackendConverter.js` utility
3. 🔄 Módosítsd: `DeckCreator.jsx` mentés logikát
4. ✅ Teszteld: minden kártyatípust
## 📁 Kapcsolódó fájlok
- **Részletes dokumentáció:** `FRONTEND_TO_BACKEND_DATA_CLEANUP.md`
- **Módosítandó frontend:** `src/pages/DeckCreator/DeckCreator.jsx`
- **Backend referencia:** `SerpentRace_Backend/src/Application/Services/CardProcessingService.ts`
---
**Gyors példa:**
```javascript
// ❌ ROSSZ (jelenleg)
{
text: "Kérdés",
question: "Kérdés", // Duplikáció
options: ["A", "B", "C"], // Felesleges
correctAnswer: 0 // Felesleges
}
// ✅ JÓ (célállapot)
{
text: "Kérdés",
type: 0,
answer: [
{answer: "A", text: "A", correct: true},
{answer: "B", text: "B", correct: false},
{answer: "C", text: "C", correct: false}
]
}
```
---
📖 **Teljes dokumentáció:** Lásd `FRONTEND_TO_BACKEND_DATA_CLEANUP.md`
@@ -0,0 +1,750 @@
# Frontend → Backend Felesleges Adatok Dokumentáció
## 📋 Összefoglaló
Ez a dokumentum tartalmazza azokat a mezőket és adatokat, amiket a frontend küld a backendnek, de **nem szükségesek** vagy **nem használtak** a backend oldalon.
**🎯 Fő probléma:** A frontend sok felesleges mezőt küld, ahelyett hogy egyetlen `answer` mezőt használna típus-specifikus formátumban.
**💾 Adatmegtakarítás:** ~40-60% payload csökkentés várható a tisztítás után!
---
## 📊 Gyors Összefoglaló Táblázat
| Mező | Használat | Cselekvés |
|------|-----------|-----------|
| `name` | ✅ Használt | Megtartani |
| `type` | ✅ Használt | Megtartani |
| `ctype` | ✅ Használt | Megtartani |
| `cards` | ✅ Használt | Megtartani |
| `description` | ❌ **Nincs a DB-ben** | **TÖRÖLNI** |
| | | |
| **Kártya mezők:** | | |
| `card.text` | ✅ Használt | Megtartani |
| `card.type` | ✅ Használt | Megtartani |
| `card.answer` | ✅ Használt | Megtartani (típus-specifikus!) |
| `card.consequence` | ✅ Használt (LUCK) | Megtartani |
| | | |
| `card.id` (frontend) | ❌ Nem releváns | **NE KÜLDJÜK** |
| `card.question` | ❌ Duplikáció | **TÖRÖLNI** (text-be) |
| `card.statement` | ❌ Duplikáció | **TÖRÖLNI** (text-be) |
| `card.options` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
| `card.correctAnswer` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
| `card.leftItems` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
| `card.rightItems` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
| `card.correctPairs` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
| `card.acceptedAnswers` | ❌ Felesleges | **KONVERTÁLNI** (answer-be) |
| `card.hint` | ❌ Nincs implementálva | **TÖRÖLNI** |
---
## 🎯 Deck Létrehozás/Frissítés (createDeck / updateDeck)
### Backend által HASZNÁLT mezők:
```typescript
// CreateDeckCommand / UpdateDeckCommand
{
name: string, // ✅ HASZNÁLT - Pakli neve
type: number, // ✅ HASZNÁLT - 0=LUCK, 1=JOKER, 2=QUESTION
userid: string, // ✅ HASZNÁLT - Automatikusan hozzáadódik az authRequired middleware-ből
cards: any[], // ✅ HASZNÁLT - Kártyák tömbje
ctype?: number, // ✅ HASZNÁLT - 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION
state?: number, // ✅ HASZNÁLT - De csak admin állíthatja (0=ACTIVE, 1=SOFT_DELETE)
authLevel: number // ✅ HASZNÁLT - Automatikusan jön az auth middleware-ből
}
```
### Frontend által KÜLDÖTT de FELESLEGES mezők:
#### 1. **`description` mező** - ❌ NEM HASZNÁLT
**Helyek:** `DeckCreator.jsx` (line ~100-110, ~170)
```javascript
// FELESLEGES - Backend nem tárolja, nem használja
const payload = {
name: deck.name?.trim() || "Névtelen pakli",
type: typeMapping[deck.type] ?? 2,
ctype: ctypeMapping[deck.privacy] ?? 1,
cards: cleanedCards
// description: deck.description // ❌ Ez NINCS a backend sémában!
}
```
**Megjegyzés a kódban (line ~171):**
```javascript
// Note: description field is not sent to backend as it's not supported yet
```
**Javaslat:**
- Ha a `description` soha nem lesz használva → töröljük a frontend state-ből
- Ha később implementálni fogjuk → adjuk hozzá a backend DeckAggregate entitáshoz először
---
## 📇 Kártya Mezők (cards array)
### Backend Card Interface:
```typescript
export interface Card {
text: string; // ✅ KÖTELEZŐ
type?: CardType; // ✅ OPCIONÁLIS - 0=QUIZ, 1=PAIRING, 2=OWN_ANSWER, 3=TRUE_FALSE, 4=CLOSER
answer?: string | null; // ✅ OPCIONÁLIS
consequence?: Consequence | null; // ✅ OPCIONÁLIS (csak LUCK kártyáknál)
}
```
### Frontend által KÜLDÖTT de ESETLEG FELESLEGES kártya mezők:
#### A. **Duplikált mezők** (ugyanaz az adat több néven):
```javascript
// DeckCreator.jsx - cleanedCards mapping (line ~130-165)
// 1. TEXT mező duplikáció - ⚠️ REDUNDÁNS
cleanedCard.text = card.text || card.question || card.statement || ""
if (card.question !== undefined) cleanedCard.question = card.question // ❌ Felesleges?
if (card.statement !== undefined) cleanedCard.statement = card.statement // ❌ Felesleges?
// Backend csak a `text` mezőt használja!
// A `question` és `statement` valószínűleg NEM SZÜKSÉGESEK
```
**Megjegyzés:** A backend `Card` interfészben **nincs** `question` vagy `statement` mező, csak `text`.
#### B. **QUESTION típusú kártyák extra mezői** - ⚠️ ELLENŐRIZENDŐ
```javascript
// Ezek a mezők a DeckCreator.jsx-ben kerülnek hozzáadásra (line ~145-155)
if (card.question !== undefined) cleanedCard.question = card.question
if (card.statement !== undefined) cleanedCard.statement = card.statement
if (card.options !== undefined) cleanedCard.options = card.options
if (card.correctAnswer !== undefined) cleanedCard.correctAnswer = card.correctAnswer
if (card.leftItems !== undefined) cleanedCard.leftItems = card.leftItems
if (card.rightItems !== undefined) cleanedCard.rightItems = card.rightItems
if (card.correctPairs !== undefined) cleanedCard.correctPairs = card.correctPairs
if (card.acceptedAnswers !== undefined) cleanedCard.acceptedAnswers = card.acceptedAnswers
if (card.hint !== undefined) cleanedCard.hint = card.hint
```
**Backend Card interfész ezeket NEM tartalmazza:**
- ❌ `question` - Nincs a Card interface-ben
- ❌ `statement` - Nincs a Card interface-ben
- ❌ `options` - Nincs a Card interface-ben
- ❌ `correctAnswer` - Nincs a Card interface-ben
- ❌ `leftItems` - Nincs a Card interface-ben
- ❌ `rightItems` - Nincs a Card interface-ben
- ❌ `correctPairs` - Nincs a Card interface-ben
- ❌ `acceptedAnswers` - Nincs a Card interface-ben
- ❌ `hint` - Nincs a Card interface-ben
**KRITIKUS KÉRDÉS:**
- Ezek a mezők **JSON-ként tárolódnak** a `cards` mezőben?
- A backend TypeORM `@Column({ type: 'json' })` deklaráció miatt bármit el tud tárolni
- De a **Card interface** szerint csak `text`, `type`, `answer`, `consequence` mezőket használ
**Két lehetséges eset:**
1. **Ha a backend JSON mezőként tárolja de nem használja ezeket:**
- ❌ FELESLEGESEK - Adatbázis helyet pazarolnak
- Javaslat: Tisztítsuk meg a frontend-et, ne küldje őket
2. **Ha a backend valahol mégis használja (pl. game logic-ban):**
- ✅ SZÜKSÉGESEK - De akkor frissíteni kell a Card interface-t
---
## 🎮 Consequence mező - ✅ RENDBEN (de típus ellenőrzés szükséges)
```javascript
// DeckCreator.jsx (line ~160-162)
if (deck.type === 'LUCK' && card.consequence) {
cleanedCard.consequence = card.consequence
}
```
**Backend Consequence interface:**
```typescript
export interface Consequence {
type: ConsequenceType; // 0-5 közötti szám
value?: number;
}
```
**Javaslat:** Ellenőrizni kell hogy a frontend mindig valid `ConsequenceType` enum értéket küld-e (0-5).
---
## 🔍 Részletes Backend vs Frontend Mapping
### Deck Level
| Frontend Mező | Backend Mező | Használat | Megjegyzés |
|--------------|-------------|----------|-----------|
| `deck.id` | `id` | ✅ Használt | UUID |
| `deck.name` | `name` | ✅ Használt | string (max 255) |
| `deck.type` | `type` | ✅ Használt | 0/1/2 (enum) |
| `deck.privacy` | `ctype` | ✅ Használt | 0/1/2 (enum) |
| `deck.description` | - | ❌ **NEM LÉTEZIK** | **FELESLEGES** |
| `deck.cards` | `cards` | ✅ Használt | JSON array |
| `deck.creationdate` | `creationdate` | ✅ Használt | Date (readonly) |
| `deck.updatedate` | `updateDate` | ✅ Használt | Date (readonly) |
### Card Level (QUESTION típusú kártyák)
| Frontend Mező | Backend Card Interface | Használat | Megjegyzés |
|--------------|----------------------|----------|-----------|
| `card.id` | - | ❌ **Lokális azonosító** | Csak frontend-en, backenden nem releváns |
| `card.text` | `text` | ✅ Használt | Fő szöveg |
| `card.question` | - | ❓ **Ellenőrizendő** | Lehet felesleges (text duplikáció?) |
| `card.statement` | - | ❓ **Ellenőrizendő** | Lehet felesleges (text duplikáció?) |
| `card.type` / `card.subType` | `type` | ✅ Használt | CardType enum (0-4) |
| `card.answer` | `answer` | ✅ Használt | String vagy null |
| `card.options` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.correctAnswer` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.leftItems` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.rightItems` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.correctPairs` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.acceptedAnswers` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.hint` | - | ❓ **Ellenőrizendő** | Nincs a Card interface-ben |
| `card.consequence` | `consequence` | ✅ Használt | Csak LUCK típusnál |
---
## ⚠️ BACKEND GAME LOGIC VIZSGÁLAT - ✅ KÉSZ
### 1. Kártya mezők tényleges használata - ELLENŐRIZVE ✅
**Ellenőrzött fájlok:**
- ✅ `SerpentRace_Backend/src/Application/Services/CardProcessingService.ts`
- ✅ `SerpentRace_Backend/src/Application/Services/CardDrawingService.ts`
**EREDMÉNY: A backend CSAK az `answer` mezőt használja!**
**Backend Card használat:**
```typescript
export interface Card {
text: string; // ✅ Kérdés szövege
type?: CardType; // ✅ Kártya típus (0-4)
answer?: string | null; // ✅ EGYETLEN valid mező a válaszokhoz!
consequence?: Consequence | null; // ✅ Csak LUCK kártyákhoz
}
```
**Fontos:** A backend `answer` mező **típus-specifikus formátumú**:
1. **QUIZ (type: 0)**`answer` = `QuizOption[]` array:
```typescript
answer: [
{ answer: "A", text: "First option", correct: false },
{ answer: "B", text: "Second option", correct: true },
...
]
```
2. **SENTENCE_PAIRING (type: 1)**`answer` = Párosítás array:
```typescript
answer: [
{ left: "Apple", right: "Red" },
{ left: "Banana", right: "Yellow" }
]
```
3. **OWN_ANSWER (type: 2)**`answer` = String vagy String array:
```typescript
answer: ["correct answer 1", "correct answer 2"]
```
4. **TRUE_FALSE (type: 3)**`answer` = Boolean:
```typescript
answer: true // vagy false
```
5. **CLOSER (type: 4)**`answer` = Object:
```typescript
answer: { correct: 42, percent: 10 }
```
**KÖVETKEZTETÉS:**
- ❌ **A frontend által küldött `options`, `correctAnswer`, `acceptedAnswers`, `leftItems`, `rightItems`, `correctPairs` mezők MIND FELESLEGESEK!**
- ✅ **Csak az `answer` mezőt kellene küldeni, megfelelő formátumban!**
### 2. Card Type Mapping
**Frontend:**
```javascript
const cardTypeMapping = {
'quiz': 0, // QUIZ
'pairing': 1, // SENTENCE_PAIRING
'text': 2, // OWN_ANSWER
'truefalse': 3, // TRUE_FALSE
'closer': 4 // CLOSER
}
```
**Backend CardType enum:**
```typescript
export enum CardType {
QUIZ = 0,
SENTENCE_PAIRING = 1,
OWN_ANSWER = 2,
TRUE_FALSE = 3,
CLOSER = 4
}
```
**Ez HELYES** - A mapping megfelelő
### 3. Frontend kártya ID kezelés
```javascript
// DeckCreator.jsx (line ~242)
const updatedCard = {
...cardData,
id: isCreatingCard ? Date.now() : cardData.id
}
// Line ~129
if (card.id) {
cleanedCard.id = card.id
}
```
**Probléma:** A frontend `Date.now()` timestamp ID-kat generál, de a backend UUID-kat használ.
**Javaslat:**
- ❌ NE küldjük a frontend-generált `id`-t a backendnek
- A backend a create során generál UUID-t
- Update-nél a backend már ismeri az ID-t (URL parameter-ből jön)
---
## 📝 JAVASOLT TISZTÍTÁSOK
### Prioritás 1: BIZTOS FELESLEGESEK
1. **`description` mező törlése**
- Fájl: `DeckCreator.jsx`
- Sorok: ~20, ~40-45, ~100-105
- Töröljük a state-ből és ne küldjük a backendnek
2. **Frontend-generált kártya `id` ne menjen a backendre**
- Fájl: `DeckCreator.jsx`
- Sor: ~129
- Kommenteljük ki vagy töröljük: `if (card.id) cleanedCard.id = card.id`
### Prioritás 2: BIZONYÍTOTTAN FELESLEGESEK ✅
3. **Duplikált text mezők (`question`, `statement`)** - ❌ FELESLEGES
- A backend **csak `text`-et használ**
- Töröljük: `question` és `statement` mezők küldését
4. **QUESTION kártya részletes mezők - MIND FELESLEGESEK ❌**
- A backend GameService **NEM használja** ezeket:
- ❌ `options` - Felesleges (backend: `answer` array használ)
- ❌ `correctAnswer` - Felesleges (backend: `answer` array-ben `correct: true`)
- ❌ `leftItems` / `rightItems` / `correctPairs` - Felesleges (backend: `answer` array-ben `{left, right}` párok)
- ❌ `acceptedAnswers` - Felesleges (backend: `answer` string array)
- ❌ `hint` - Nincs implementálva a backenden
**HELYETTE:** Konvertáljuk ezeket megfelelő `answer` formátumra!
---
## 🔄 HELYES KONVERZIÓ - Példák
### Jelenlegi (FELESLEGES mezőkkel):
```javascript
// ❌ ROSSZ - Felesleges mezők küldése
const cleanedCard = {
text: "Mi a főváros?",
type: 0, // QUIZ
question: "Mi a főváros?", // ❌ DUPLIKÁCIÓ
options: ["Budapest", "Berlin", "Prága"], // ❌ FELESLEGES
correctAnswer: 0 // ❌ FELESLEGES
}
```
### Helyes (Optimalizált):
```javascript
// ✅ JÓ - Csak szükséges mezők
const cleanedCard = {
text: "Mi a főváros?",
type: 0, // QUIZ
answer: [
{ answer: "A", text: "Budapest", correct: true },
{ answer: "B", text: "Berlin", correct: false },
{ answer: "C", text: "Prága", correct: false }
]
}
```
### Konverziós Példák Típusonként:
#### 1. QUIZ (type: 0) - Feleletválasztós
**Frontend állapot:**
```javascript
card = {
subType: 'multiplechoice',
question: "Melyik a helyes?",
options: ["A válasz", "B válasz", "C válasz"],
correctAnswer: 1
}
```
**Helyes backend formátum:**
```javascript
cleanedCard = {
text: "Melyik a helyes?",
type: 0,
answer: [
{ answer: "A", text: "A válasz", correct: false },
{ answer: "B", text: "B válasz", correct: true }, // correctAnswer: 1
{ answer: "C", text: "C válasz", correct: false }
]
}
```
#### 2. SENTENCE_PAIRING (type: 1) - Párosítás
**Frontend állapot:**
```javascript
card = {
subType: 'matching',
question: "Párosítsd össze!",
leftItems: ["Alma", "Banán"],
rightItems: ["Piros", "Sárga"],
correctPairs: { 0: 0, 1: 1 } // leftItems[0] -> rightItems[0]
}
```
**Helyes backend formátum:**
```javascript
cleanedCard = {
text: "Párosítsd össze!",
type: 1,
answer: [
{ left: "Alma", right: "Piros" },
{ left: "Banán", right: "Sárga" }
]
}
```
#### 3. OWN_ANSWER (type: 2) - Szöveges válasz
**Frontend állapot:**
```javascript
card = {
subType: 'text',
question: "Mi a főváros?",
acceptedAnswers: ["Budapest", "budapest", "Bp"]
}
```
**Helyes backend formátum:**
```javascript
cleanedCard = {
text: "Mi a főváros?",
type: 2,
answer: ["Budapest", "budapest", "Bp"]
}
```
#### 4. TRUE_FALSE (type: 3) - Igaz/Hamis
**Frontend állapot:**
```javascript
card = {
subType: 'truefalse',
statement: "A Föld lapos.",
correctAnswer: 1, // 0=Igaz, 1=Hamis
isTrue: false
}
```
**Helyes backend formátum:**
```javascript
cleanedCard = {
text: "A Föld lapos.",
type: 3,
answer: false
}
```
#### 5. CLOSER (type: 4) - Tippelés
**Frontend állapot:**
```javascript
card = {
subType: 'closer',
question: "Hány lakosa van Budapestnek?",
correctAnswer: 1750000,
tolerance: 10 // ±10%
}
```
**Helyes backend formátum:**
```javascript
cleanedCard = {
text: "Hány lakosa van Budapestnek?",
type: 4,
answer: {
correct: 1750000,
percent: 10
}
}
```
---
## 🔧 TESZTELÉSI TERV
1. **Logolás hozzáadása a backenden:**
```typescript
// CreateDeckCommandHandler.ts, UpdateDeckCommandHandler.ts
console.log('Received card data:', cmd.cards)
console.log('Card keys:', Object.keys(cmd.cards[0]))
```
2. **Frontendről küldött payload ellenőrzése:**
```javascript
// DeckCreator.jsx - handleSaveDeck
console.log('Payload before send:', JSON.stringify(payload, null, 2))
```
3. **Adatbázisban tárolt JSON ellenőrzése:**
```sql
SELECT id, name, cards FROM Decks WHERE id = 'xyz' LIMIT 1;
```
---
## ✅ KÖVETKEZŐ LÉPÉSEK
1. ✅ **Dokumentáció elkészült** - Ez a fájl
2. ✅ **Backend game logic ellenőrzés** - KÉSZ! Csak `answer` mezőt használ
3. ⏳ **Frontend konverzió implementálás** - Következő feladat:
- Új függvény: `convertCardToBackendFormat(card, deckType)`
- Minden kártyatípushoz megfelelő `answer` formátum generálása
- Felesleges mezők eltávolítása
4. ⏳ **Tesztelés** - Minden működik-e a változások után?
---
## 🛠️ IMPLEMENTÁCIÓS TERV
### 1. Létrehozandó segédfüggvény: `cardBackendConverter.js`
```javascript
// src/utils/cardBackendConverter.js
/**
* Konvertálja a frontend kártya formátumot backend-kompatibilis formátumra
* @param {Object} card - Frontend kártya objektum
* @param {string} deckType - Pakli típusa ('LUCK', 'JOKER', 'QUESTION')
* @returns {Object} Backend-kompatibilis kártya objektum
*/
export function convertCardToBackendFormat(card, deckType) {
const baseCard = {
text: card.text || card.question || card.statement || "",
}
// CardType mapping
const cardTypeMapping = {
'quiz': 0,
'multiplechoice': 0, // Alias
'pairing': 1,
'matching': 1, // Alias
'text': 2,
'truefalse': 3,
'closer': 4
}
const cardType = cardTypeMapping[card.subType] ?? cardTypeMapping[card.subType?.toLowerCase()]
if (cardType !== undefined) {
baseCard.type = cardType
}
// Típus-specifikus answer konverzió
switch (cardType) {
case 0: // QUIZ
if (card.options && Array.isArray(card.options)) {
baseCard.answer = card.options.map((opt, idx) => ({
answer: String.fromCharCode(65 + idx), // A, B, C, D...
text: opt,
correct: idx === card.correctAnswer
}))
}
break
case 1: // SENTENCE_PAIRING
if (card.leftItems && card.rightItems && card.correctPairs) {
baseCard.answer = Object.entries(card.correctPairs).map(([leftIdx, rightIdx]) => ({
left: card.leftItems[parseInt(leftIdx)],
right: card.rightItems[parseInt(rightIdx)]
}))
}
break
case 2: // OWN_ANSWER
if (card.acceptedAnswers && Array.isArray(card.acceptedAnswers)) {
baseCard.answer = card.acceptedAnswers.filter(a => a && a.trim())
}
break
case 3: // TRUE_FALSE
if (card.correctAnswer !== undefined) {
baseCard.answer = card.correctAnswer === 0 // 0=Igaz, 1=Hamis
} else if (card.isTrue !== undefined) {
baseCard.answer = card.isTrue
}
break
case 4: // CLOSER
if (card.correctAnswer !== undefined && card.tolerance !== undefined) {
baseCard.answer = {
correct: card.correctAnswer,
percent: card.tolerance
}
}
break
}
// LUCK típusú kártyákhoz consequence
if (deckType === 'LUCK' && card.consequence) {
baseCard.consequence = card.consequence
}
return baseCard
}
```
### 2. Módosítandó fájl: `DeckCreator.jsx`
**Jelenlegi kód (line ~120-165):**
```javascript
// ❌ RÉGI - Felesleges mezők küldése
const cleanedCards = validCards.map(card => {
const cleanedCard = {}
if (card.id) cleanedCard.id = card.id
if (card.subType && cardTypeMapping[card.subType] !== undefined) {
cleanedCard.type = cardTypeMapping[card.subType]
}
cleanedCard.text = card.text || card.question || card.statement || ""
if (card.question !== undefined) cleanedCard.question = card.question // FELESLEGES
if (card.statement !== undefined) cleanedCard.statement = card.statement // FELESLEGES
if (card.options !== undefined) cleanedCard.options = card.options // FELESLEGES
// ... stb
return cleanedCard
})
```
**Új kód:**
```javascript
// ✅ ÚJ - Csak szükséges mezők
import { convertCardToBackendFormat } from '../../utils/cardBackendConverter'
const cleanedCards = validCards.map(card =>
convertCardToBackendFormat(card, deck.type)
)
```
### 3. Tesztelési checklist
- [ ] QUIZ kártyák helyes answer formátummal mentődnek
- [ ] SENTENCE_PAIRING kártyák helyes left-right párokkal mentődnek
- [ ] OWN_ANSWER kártyák acceptedAnswers array-ként mentődnek
- [ ] TRUE_FALSE kártyák boolean answer-rel mentődnek
- [ ] CLOSER kártyák {correct, percent} formátummal mentődnek
- [ ] LUCK kártyák consequence mezője megmarad
- [ ] Mentett paklik betöltése és szerkesztése működik
- [ ] Játék során kártyák feldolgozása helyes
---
**Utolsó frissítés:** 2025-11-03
**Készítette:** GitHub Copilot
**Cél:** Adatoptimalizálás és felesleges payload csökkentés
---
## 📈 VÁRHATÓ EREDMÉNYEK
### Payload méret csökkenés példa:
**ELŐTTE (jelenleg):**
```json
{
"name": "Teszt Pakli",
"type": 2,
"ctype": 1,
"description": "Ez egy leírás", // ❌ FELESLEGES
"cards": [
{
"id": 1730123456789, // ❌ FELESLEGES
"text": "Mi a főváros?",
"question": "Mi a főváros?", // ❌ DUPLIKÁCIÓ
"type": 0,
"options": ["Budapest", "Berlin", "Prága"], // ❌ FELESLEGES
"correctAnswer": 0 // ❌ FELESLEGES
}
]
}
// Méret: ~280 byte
```
**UTÁNA (optimalizált):**
```json
{
"name": "Teszt Pakli",
"type": 2,
"ctype": 1,
"cards": [
{
"text": "Mi a főváros?",
"type": 0,
"answer": [
{"answer": "A", "text": "Budapest", "correct": true},
{"answer": "B", "text": "Berlin", "correct": false},
{"answer": "C", "text": "Prága", "correct": false}
]
}
]
}
// Méret: ~190 byte
```
**💾 Megtakarítás: ~32% ebben a példában!**
---
## 🎉 VÉGSŐ ÖSSZEFOGLALÁS
### Felesleges mezők száma:
- **Deck level:** 1 mező (`description`)
- **Card level:** 9 mező (`id`, `question`, `statement`, `options`, `correctAnswer`, `leftItems`, `rightItems`, `correctPairs`, `acceptedAnswers`, `hint`)
### Összes felesleges mező: **10 db**
### Ajánlott lépések:
1. ✅ Dokumentáció áttekintése
2. 🔄 `cardBackendConverter.js` implementálása
3. 🔧 `DeckCreator.jsx` módosítása
4. ✅ Tesztelés minden kártyatípussal
5. 🚀 Deploy
**Becsült munkaidő:** 2-3 óra implementálás + 1 óra tesztelés
---
## 📞 Kérdések / Problémák esetén
Ha bármilyen kérdés merül fel az implementálás során:
1. Ellenőrizd a backend `CardProcessingService.ts` fájlt
2. Nézd meg a példákat ebben a dokumentációban
3. Teszteld lokálisan először egy kis paklival
**Fontos:** A backend JSON mezőként tárolja a `cards` array-t, ezért bármit elfogad - de csak a dokumentált mezőket használja!
+3 -3
View File
@@ -1,10 +1,10 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/src/assets/pictures/Logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
<title>SerpentRace</title>
</head>
<body>
<div id="root"></div>
+303
View File
@@ -9,11 +9,13 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.12.2",
"framer-motion": "^12.19.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.0",
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
@@ -1678,6 +1680,23 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1728,6 +1747,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1785,6 +1817,15 @@
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1805,6 +1846,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1865,6 +1918,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -1874,6 +1936,20 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
@@ -1894,6 +1970,51 @@
"node": ">=10.13.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@@ -2216,6 +2337,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.19.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.19.1.tgz",
@@ -2257,6 +2414,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2267,6 +2433,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2293,6 +2496,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2309,6 +2524,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2745,6 +2999,36 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2986,6 +3270,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3083,6 +3373,19 @@
"node": ">=18"
}
},
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+8 -6
View File
@@ -11,22 +11,24 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.12.2",
"framer-motion": "^12.19.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.0",
"tailwindcss": "^4.1.7",
"vite": "^6.3.5",
"eslint-plugin-react-hooks": "^5.2.0",
"@vitejs/plugin-react": "^4.4.1",
"eslint-plugin-react-refresh": "^0.4.19"
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"globals": "^16.0.0"
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"vite": "^6.3.5"
}
}
+34 -19
View File
@@ -2,17 +2,23 @@ import { useState, useEffect } from "react"
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
import AuthRegister from "./pages/Auth/AuthRegister"
import AuthLogin from "./pages/Auth/AuthLogin"
import EmailVerification from "./pages/Auth/EmailVerification"
import Test from "./pages/Testing/Test"
import ForgotPassword from "./pages/Auth/ForgotPassword"
import ResetPassword from "./pages/Auth/ResetPassword"
import Landingpage from "./pages/Landing/Landingpage"
import Home from "./pages/Landing/Home"
import DeckManagerPage from "./pages/Decks/DeckManagerPage"
import CompanyHub from "./pages/Companies/Companies"
import Card_display from "./pages/Decks/Card_display"
import DeckCreator from "./pages/DeckCreator/DeckCreator"
import CompanyHub from "./pages/Contacts/Contacts"
import About from "./pages/About/About"
import ScrollToTop from "./components/ScrollToTop"
import GameScreen from "./pages/Game/GameScreen"
import Reports from "./pages/Report/Reports"
import Lobby from "./pages/Lobby/Lobby"
import ProfileCard from "./components/Userdetails/Userdetails"
import { ToastConfig } from "./components/Toastify/toastifyServices" // fontos: named import, nem default!
import VerifyEmailPage from "./pages/Auth/VerifyEmailPage"
function App() {
const [isMobile, setIsMobile] = useState(false)
@@ -41,24 +47,33 @@ function App() {
// }
return (
<Router>
<Routes>
<Route path="/about" element={<About />} />
<Route path="/register" element={<AuthRegister />} />
<Route path="/login" element={<AuthLogin />} />
<Route path="/verify-email" element={<EmailVerification />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/test" element={<Test />} />
<Route path="/" element={<Landingpage />} />
<Route path="/home" element={<Home />} />
<Route path="/decks" element={<DeckManagerPage />} />
<Route path="/game" element={<GameScreen />} />
<Route path="/companies" element={<CompanyHub />} />
<>
<Router>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/about" element={<About />} />
<Route path="/lobby" element={<Lobby />} />
<Route path="/register" element={<AuthRegister />} />
<Route path="/login" element={<AuthLogin />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/profile" element={<ProfileCard />} />
<Route path="/test" element={<Test />} />
<Route path="/" element={<Landingpage />} />
<Route path="/home" element={<Home />} />
<Route path="/decks" element={<DeckManagerPage />} />
<Route path="/deck/:deckId" element={<Card_display />} />
<Route path="/deck-creator" element={<DeckCreator />} />
<Route path="/deck-creator/:deckId" element={<DeckCreator />} />
<Route path="/game" element={<GameScreen />} />
{/* <Route path="/contacts" element={<CompanyHub />} /> */}
<Route path="/report" element={<Reports />} />
</Routes>
</Router>
{/* Add more routes as needed */}
</Routes>
</Router>
{/* ✅ Toastify Container */}
<ToastConfig />
</>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { apiClient } from './userApi'
// Create a new deck in the backend
export const createDeck = async (deck) => {
try {
const response = await apiClient.post('/decks', deck)
return response.data
} catch (err) {
throw err
}
}
// Get paginated decks (authenticated)
export const getDecksPage = async (from = 0, to = 49) => {
try {
const response = await apiClient.get(`/decks/page/${from}/${to}`)
return response.data
} catch (err) {
throw err
}
}
// Get a specific deck by ID (authenticated)
export const getDeckById = async (deckId) => {
try {
const response = await apiClient.get(`/decks/${deckId}`)
return response.data
} catch (err) {
throw err
}
}
// Update an existing deck (authenticated)
export const updateDeck = async (deckId, deck) => {
try {
const response = await apiClient.patch(`/decks/${deckId}`, deck)
return response.data
} catch (err) {
throw err
}
}
// Delete a deck (soft delete) (authenticated)
export const deleteDeck = async (deckId) => {
try {
const response = await apiClient.delete(`/decks/${deckId}`)
return response.data
} catch (err) {
throw err
}
}
export default {
createDeck,
getDeckById,
updateDeck,
deleteDeck
}
+109
View File
@@ -0,0 +1,109 @@
import axios from "axios"
export const API_CONFIG = {
baseURL: (import.meta.env.VITE_API_URL ? import.meta.env.VITE_API_URL : "") + "/api",
wsURL: "http://localhost:3000",
timeout: 10000,
retryAttempts: 3,
}
export const apiClient = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: API_CONFIG.timeout,
withCredentials: true, // Important for cookie-based auth
headers: {
"Content-Type": "application/json",
},
})
//login
export const login = async (username, password) => {
try {
const response = await apiClient.post("/users/login", { username, password })
return response
} catch (error) {
throw error
}
}
//register
export const register = async (username, email, password, fname, lname, phone) => {
try {
const response = await apiClient.post("/users/create", { username, email, password, fname, lname, phone })
return response
} catch (error) {
throw error
}
}
// Get current user's game statistics
export const getUserStats = async () => {
try {
const response = await apiClient.get("/users/me/stats")
return response.data
} catch (error) {
throw error
}
}
// Email verification - POST
export const verifyEmail = async (token) => {
try {
const response = await apiClient.post(`/users/verify-email/${token}`);
return response;
} catch (error) {
throw error;
}
};
// Get current user profile
export const getUserProfile = async () => {
try {
const response = await apiClient.get("/users/profile");
return response.data;
} catch (error) {
throw error;
}
};
// Update current user profile
export const updateUserProfile = async (data) => {
try {
const response = await apiClient.patch("/users/profile", data);
return response.data;
} catch (error) {
throw error;
}
};
// Delete current user profile
export const deleteUserProfile = async () => {
try {
const response = await apiClient.delete("/users/profile");
return response.data;
} catch (error) {
throw error;
}
};
// Request password reset
export const forgotPassword = async (email) => {
try {
const response = await apiClient.post("/users/forgot-password", { email });
return response.data;
} catch (error) {
throw error;
}
};
// Reset password with token
export const resetPassword = async (token, newPassword) => {
try {
const response = await apiClient.post("/users/reset-password", { token, newPassword });
return response.data;
} catch (error) {
throw error;
}
};
@@ -0,0 +1,332 @@
// src/components/DeckCreator/CardEditor.jsx
// Jobb oldali kártya szerkesztő
import React, { useState, useEffect } from "react"
import { FaSave, FaTimes, FaEye } from "react-icons/fa"
import TaskCardEditor from "./TaskCardEditor.jsx"
import JokerCardEditor from "./JokerCardEditor.jsx"
import LuckCardEditor from "./LuckCardEditor.jsx"
import CardPreview from "./CardPreview.jsx"
import { notifySuccess, notifyError,notifyWarning } from "../../components/Toastify/toastifyServices"
export default function CardEditor({ card, isCreating, cardType, onSave, onCancel }) {
const [cardData, setCardData] = useState(null)
const [showPreview, setShowPreview] = useState(false)
// Alapértelmezett kártya adatok
const getDefaultCardData = (type) => {
const baseData = {
id: null,
type: type,
points: 10,
timeLimit: 30,
consequence: { type: 0, value: 1 }
}
switch (type) {
case 'QUESTION':
return {
...baseData,
subType: 'quiz',
question: '',
options: ['', '', '', ''],
correctAnswer: 0,
explanation: '',
acceptedAnswers: [''],
wrongConsequence: { type: 1, value: 1 }
}
case 'PAIRING':
case 'MATCHING':
return {
...baseData,
type: 'QUESTION',
subType: 'matching',
taskDescription: '',
leftItems: ['', ''],
rightItems: ['', ''],
correctPairs: { 0: 0, 1: 1 },
wrongConsequence: { type: 1, value: 1 }
}
case 'JOKER':
return {
...baseData,
title: '',
description: '',
effect: '',
actionType: 'skip',
usage: 'once',
wrongConsequence: { type: 1, value: 1 }
}
case 'LUCK':
return {
...baseData,
event: '',
positiveEffect: '',
negativeEffect: '',
probability: 50,
risk: 'low'
}
default:
return baseData
}
}
// Kártya adatok inicializálása
useEffect(() => {
try {
if (isCreating && cardType) {
const defaultData = getDefaultCardData(cardType)
setCardData(defaultData)
} else if (card) {
setCardData({ ...card })
} else {
setCardData(null)
}
} catch (error) {
console.error('Kártya inicializálási hiba:', error)
setCardData(null)
}
}, [card, isCreating, cardType])
const validateCard = (data) => {
try {
if (!data || !data.type) {
notifyError("Érvénytelen kártya adatok!")
return false
}
if (data.type === 'QUESTION') {
// Quiz típus validálás
if (data.subType === 'quiz') {
if (!data.question || !data.question.trim()) {
notifyError("Kérdés megadása kötelező!")
return false
}
if (data.options && data.options.some(opt => !opt.trim())) {
notifyError("Minden válaszlehetőséget ki kell tölteni!")
return false
}
}
// Igaz/Hamis típus validálás
else if (data.subType === 'truefalse') {
if (!data.statement || !data.statement.trim()) {
notifyError("Állítás megadása kötelező!")
return false
}
if (data.isTrue === undefined || data.isTrue === null) {
notifyError("Válaszd ki, hogy az állítás igaz vagy hamis!")
return false
}
}
// Párosítás típus validálás
else if (data.subType === 'matching') {
if (!data.taskDescription || !data.taskDescription.trim()) {
notifyError("Feladat leírása kötelező!")
return false
}
if (!data.leftItems || data.leftItems.length === 0) {
notifyError("Legalább egy párosítást meg kell adni!")
return false
}
if (data.leftItems.some(item => !item.trim()) || data.rightItems.some(item => !item.trim())) {
notifyError("Minden párosítási elemet ki kell tölteni!")
return false
}
}
// Szöveges válasz típus validálás
else if (data.subType === 'text') {
if (!data.question || !data.question.trim()) {
notifyError("Kérdés megadása kötelező!")
return false
}
if (!data.acceptedAnswers || data.acceptedAnswers.length === 0 || data.acceptedAnswers.every(ans => !ans.trim())) {
notifyError("Legalább egy elfogadott választ meg kell adni!")
return false
}
}
// Általános validálás (ha nincs subType megadva)
else {
if (!data.question && !data.statement) {
notifyError("Kérdés vagy állítás megadása kötelező!")
return false
}
}
} else if (data.type === 'JOKER') {
if (!data.text || !data.text.trim()) {
notifyError("Joker kártya szövege nem lehet üres!")
return false
}
} else if (data.type === 'LUCK') {
if (!data.text || !data.text.trim()) {
notifyError("Szerencse kártya szövege nem lehet üres!")
return false
}
}
return true
} catch (error) {
console.error('Validálási hiba:', error)
notifyError("Hiba történt a kártya ellenőrzése során")
return false
}
}
const updateCardData = (updates) => {
setCardData(prev => prev ? { ...prev, ...updates } : null)
}
const handleSave = () => {
if (!cardData) {
notifyError("Nincs mentendő kártya adat!")
return
}
if (!validateCard(cardData)) return
onSave(cardData)
}
// Ha nincs kiválasztott kártya vagy új kártya létrehozás
if (!cardData) {
return (
<div className="flex-1 flex items-center justify-center bg-[color:var(--color-background)]">
<div className="text-center">
<div className="text-6xl mb-4">🃏</div>
<div className="text-[color:var(--color-text)] text-xl font-semibold mb-2">
Válassz ki egy kártyát
</div>
<div className="text-[color:var(--color-text-muted)]">
Klikkelj egy kártyára a bal oldalon a szerkesztéshez,<br />
vagy hozz létre egy újat.
</div>
</div>
</div>
)
}
return (
<div className="flex-1 flex flex-col">
{/* Type Mismatch Warning */}
{cardData?.type && cardType && cardData.type !== cardType && !isCreating && (
<div className="bg-[color:var(--color-error)]/10 border-l-4 border-[color:var(--color-error)] px-6 py-4">
<div className="flex items-center gap-3">
<div className="text-[color:var(--color-error)] text-xl"></div>
<div>
<div className="text-[color:var(--color-error)] font-semibold">
Figyelmeztetés: Nem megfelelő kártya típus
</div>
<div className="text-[color:var(--color-error)]/80 text-sm">
{`Ez egy ${
cardData.type === 'QUESTION' ? 'Feladat' :
cardData.type === 'JOKER' ? 'Joker' : 'Szerencse'
} kártya, de a pakli típusa ${
cardType === 'QUESTION' ? 'Feladat' :
cardType === 'JOKER' ? 'Joker' : 'Szerencse'
}.`}
</div>
</div>
</div>
</div>
)}
{/* Header */}
<div className="bg-[color:var(--color-surface)] border-b border-[color:var(--color-surface-selected)] px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="text-2xl">
{cardData.type === 'QUESTION' && '📋'}
{cardData.type === 'JOKER' && '🃏'}
{cardData.type === 'LUCK' && '🎲'}
</div>
<div>
<h2 className="text-xl font-bold text-[color:var(--color-text)]">
{isCreating ? 'Új' : 'Szerkesztés'} {' '}
{(isCreating ? cardType : cardData.type) === 'QUESTION' && 'Feladat kártya'}
{(isCreating ? cardType : cardData.type) === 'JOKER' && 'Joker kártya'}
{(isCreating ? cardType : cardData.type) === 'LUCK' && 'Szerencse kártya'}
</h2>
<div className="text-[color:var(--color-text-muted)] text-sm">
{cardData.type === 'QUESTION' && cardData.subType && (
<>
{cardData.subType === 'quiz' && 'Quiz (A/B/C/D)'}
{cardData.subType === 'truefalse' && 'Igaz/Hamis'}
{cardData.subType === 'matching' && 'Párosítás'}
{cardData.subType === 'text' && 'Szöveges válasz'}
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowPreview(!showPreview)}
className={`
flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all duration-200
${showPreview
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]'
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] hover:bg-[color:var(--color-surface-selected)]'
}
`}
>
<FaEye />
Előnézet
</button>
<button
onClick={() => {
notifyWarning('Kártya készítés megszakítva')
onCancel()
}}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[color:var(--color-background)] hover:bg-[color:var(--color-surface-selected)] text-[color:var(--color-text)] transition-all duration-200"
>
<FaTimes />
Mégse
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 rounded-xl bg-[color:var(--color-success)] hover:bg-[color:var(--color-success)]/80 text-[color:var(--color-text-inverse)] font-semibold transition-all duration-200 hover:scale-105 shadow-lg"
>
<FaSave />
Mentés
</button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden">
{showPreview ? (
<div className="h-full bg-[color:var(--color-background)] flex items-center justify-center p-6">
<CardPreview card={cardData} />
</div>
) : (
<div className="h-full overflow-y-auto p-6">
{cardData.type === 'QUESTION' && (
<TaskCardEditor
card={cardData}
onChange={updateCardData}
/>
)}
{cardData.type === 'JOKER' && (
<JokerCardEditor
card={cardData}
onChange={updateCardData}
/>
)}
{cardData.type === 'LUCK' && (
<LuckCardEditor
card={cardData}
onChange={updateCardData}
/>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,148 @@
// src/components/DeckCreator/CardPreview.jsx
// Kártya előnézet komponens
import React from "react"
import { FaQuestionCircle, FaTheaterMasks, FaDice, FaClock, FaStar } from "react-icons/fa"
export default function CardPreview({ card }) {
if (!card) {
return (
<div className="text-center text-[color:var(--color-text-muted)]">
<div className="text-6xl mb-4">🃏</div>
<div>Nincs kiválasztott kártya az előnézethez</div>
</div>
)
}
// Kártya típus specifikus beállítások
const getCardConfig = (card) => {
switch (card.type) {
case 'task':
return {
bgColor: 'var(--color-question)',
icon: FaQuestionCircle,
title: 'FELADAT KÁRTYA',
emoji: '📋'
}
case 'joker':
return {
bgColor: 'var(--color-fun)',
icon: FaTheaterMasks,
title: 'JOKER KÁRTYA',
emoji: '🎭'
}
case 'luck':
return {
bgColor: 'var(--color-luck)',
icon: FaDice,
title: 'SZERENCSE KÁRTYA',
emoji: '🎲'
}
default:
return {
bgColor: 'var(--color-border)',
icon: FaQuestionCircle,
title: 'ISMERETLEN KÁRTYA',
emoji: '❓'
}
}
}
const config = getCardConfig(card)
// Kártya tartalom meghatározása
const getCardContent = (card) => {
if (card.type === 'task') {
return card.question || card.statement || 'Feladat leírása...'
}
if (card.type === 'joker' || card.type === 'luck') {
return card.text || 'Kártya szövege...'
}
return 'Kártya tartalma...'
}
return (
<div className="flex items-center justify-center p-6">
{/* Kártya container */}
<div
className="relative w-80 h-96 rounded-2xl shadow-2xl transform transition-all duration-300 hover:scale-105"
style={{
background: `linear-gradient(135deg, ${config.bgColor}15, ${config.bgColor}05)`,
border: `3px solid ${config.bgColor}`,
}}
>
{/* Kártya header */}
<div
className="h-16 rounded-t-xl flex items-center justify-center relative overflow-hidden"
style={{ backgroundColor: config.bgColor }}
>
{/* Háttér pattern */}
<div className="absolute inset-0 opacity-20">
<div className="w-full h-full" style={{
backgroundImage: `repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.1) 10px, rgba(255,255,255,0.1) 20px)`
}} />
</div>
<div className="relative flex items-center gap-3">
<config.icon className="text-white text-xl" />
<span className="text-white font-bold text-sm tracking-wide">
{config.title}
</span>
</div>
</div>
{/* Kártya body */}
<div className="p-6 h-80 flex flex-col">
{/* Főikon */}
<div className="text-center mb-4">
<div className="text-5xl mb-2">{config.emoji}</div>
</div>
{/* Tartalom */}
<div className="flex-1 flex flex-col justify-center">
<div
className="text-[color:var(--color-text)] text-center leading-relaxed"
style={{
fontSize: card.text && card.text.length > 100 ? '14px' : '16px'
}}
>
{getCardContent(card)}
</div>
</div>
{/* Alsó információk */}
<div className="border-t border-[color:var(--color-border)] pt-4 mt-4">
<div className="flex justify-between items-center text-sm">
{/* Idő */}
{card.timeLimit && (
<div className="flex items-center gap-1 text-[color:var(--color-text-muted)]">
<FaClock className="text-xs" />
<span>{card.timeLimit}s</span>
</div>
)}
{/* Pontok */}
{card.points && (
<div className="flex items-center gap-1 text-[color:var(--color-text-muted)]">
<FaStar className="text-xs" />
<span>{card.points} pont</span>
</div>
)}
{/* Ha nincs idő/pont info */}
{!card.timeLimit && !card.points && (
<div className="text-[color:var(--color-text-muted)] text-xs w-full text-center">
SerpentRace Deck
</div>
)}
</div>
</div>
</div>
{/* Kártya corner dekoráció */}
<div className="absolute top-2 right-2 w-4 h-4 rounded-full" style={{ backgroundColor: config.bgColor, opacity: 0.3 }} />
<div className="absolute bottom-2 left-2 w-4 h-4 rounded-full" style={{ backgroundColor: config.bgColor, opacity: 0.3 }} />
</div>
</div>
)
}
@@ -0,0 +1,270 @@
// src/components/DeckCreator/CardsList.jsx
// Bal oldali kártyák listája és új kártya létrehozás
import React, { useState } from "react"
import {
FaPlus,
FaEdit,
FaTrash,
FaQuestionCircle,
FaCheck,
FaTimes,
FaDice,
FaTheaterMasks
} from "react-icons/fa"
import { notifySuccess, notifyError } from "../../components/Toastify/toastifyServices"
const cardTypeIcons = {
QUESTION: { icon: FaQuestionCircle, color: "var(--color-question)" },
JOKER: { icon: FaTheaterMasks, color: "var(--color-fun)" },
LUCK: { icon: FaDice, color: "var(--color-luck)" }
}
const cardSubTypeLabels = {
quiz: "Quiz",
truefalse: "Igaz/Hamis",
matching: "Párosítás",
text: "Szöveges válasz"
}
export default function CardsList({
cards,
selectedCard,
deckType,
onSelectCard,
onCreateCard,
onDeleteCard,
isCreatingCard,
newCardType
}) {
const [confirmingDelete, setConfirmingDelete] = useState(null)
const getCardPreview = (card) => {
if (card.type === 'QUESTION') {
return card.question || card.statement || 'Új feladat kártya'
}
if (card.type === 'JOKER') {
return card.text || 'Új joker kártya'
}
if (card.type === 'LUCK') {
return card.text || 'Új szerencse kártya'
}
return "Ismeretlen kártya"
}
const getCardTypeLabel = (card) => {
if (card.type === 'QUESTION') {
if (card.subType) {
return cardSubTypeLabels[card.subType] || "Feladat"
}
return "Feladat"
}
if (card.type === 'JOKER') {
return 'Joker'
}
if (card.type === 'LUCK') {
return 'Szerencse'
}
return "Ismeretlen"
}
const handleConfirmDelete = () => {
if (confirmingDelete) {
onDeleteCard(confirmingDelete)
notifySuccess("Kártya sikeresen törölve a pakliból!")
setConfirmingDelete(null)
}
}
const handleCancelDelete = () => {
setConfirmingDelete(null)
}
return (
<div className="flex flex-col h-full relative">
{/* Header */}
<div className="p-4 border-b border-[color:var(--color-surface-selected)]">
<h2 className="text-lg font-bold text-[color:var(--color-text)] mb-4 flex items-center gap-2">
🃏 Kártyák
</h2>
{/* New Card Button */}
<button
onClick={() => onCreateCard(deckType)}
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-[color:var(--color-text-inverse)] font-semibold transition-all duration-200 hover:scale-105 shadow-lg ${
deckType === 'QUESTION' ? 'bg-[color:var(--color-question)] hover:bg-[color:var(--color-question)]/80' :
deckType === 'LUCK' ? 'bg-[color:var(--color-luck)] hover:bg-[color:var(--color-luck)]/80' :
'bg-[color:var(--color-fun)] hover:bg-[color:var(--color-fun)]/80'
}`}
>
<FaPlus />
<span>
{deckType === 'QUESTION' && '📋 Új feladat kártya'}
{deckType === 'JOKER' && '🃏 Új joker kártya'}
{deckType === 'LUCK' && '🎲 Új szerencse kártya'}
</span>
</button>
</div>
{/* Cards List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* Creating Card Indicator */}
{isCreatingCard && (
<div className="bg-[color:var(--color-background)]/50 border-2 border-dashed border-[color:var(--color-success)] rounded-xl p-4 animate-pulse">
<div className="flex items-center gap-3">
{newCardType && (
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-[color:var(--color-success)]/20">
{React.createElement(cardTypeIcons[newCardType]?.icon || FaQuestionCircle, {
className: "text-[color:var(--color-success)] text-sm"
})}
</div>
)}
<div>
<div className="text-[color:var(--color-text)] font-medium">
Új {newCardType === "QUESTION" ? "feladat" : newCardType === "JOKER" ? "joker" : "szerencse"} kártya
</div>
<div className="text-[color:var(--color-text-muted)] text-sm">
Szerkesztés folyamatban...
</div>
</div>
</div>
</div>
)}
{/* Existing Cards */}
{cards.map((card, index) => {
const cardIcon = cardTypeIcons[card.type] || cardTypeIcons.task
const isSelected = selectedCard?.id === card.id
return (
<div
key={card.id}
onClick={() => onSelectCard(card)}
className={`
p-4 rounded-xl border cursor-pointer transition-all duration-200 hover:scale-105 group relative
${
isSelected
? "bg-[color:var(--color-success)]/10 border-[color:var(--color-success)] shadow-lg"
: "bg-[color:var(--color-background)]/50 border-[color:var(--color-surface-selected)] hover:bg-[color:var(--color-background)]/80"
}
${card.type !== deckType ? "opacity-70" : ""}
`}
>
{card.type !== deckType && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="absolute inset-0 bg-[color:var(--color-error)]/5 backdrop-blur-[1px] rounded-xl"></div>
<div className="absolute rotate-[-15deg] border-t-2 border-[color:var(--color-error)] w-full"></div>
<span className="absolute top-1 right-1 text-xs text-[color:var(--color-error)] bg-[color:var(--color-error)]/10 px-2 py-1 rounded-lg">
Nem megfelelő típus
</span>
</div>
)}
{/* Card Header */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div
className="flex items-center justify-center w-10 h-10 rounded-full border-2"
style={{ borderColor: cardIcon.color }}
>
{React.createElement(cardIcon.icon, {
style: { color: cardIcon.color },
className: "text-lg"
})}
</div>
<div className="flex-1 min-w-0">
<div className="text-[color:var(--color-text)] font-bold text-sm mb-1">
#{index + 1} - {getCardTypeLabel(card)}
</div>
{card.timeLimit && (
<div className="text-[color:var(--color-text-muted)] text-xs flex items-center gap-1">
{card.timeLimit} másodperc
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
onClick={(e) => {
e.stopPropagation()
setConfirmingDelete(card.id)
}}
className="p-1.5 rounded-lg bg-[color:var(--color-error)]/10 hover:bg-[color:var(--color-error)]/20 text-[color:var(--color-error)] transition-colors duration-200"
>
<FaTrash className="text-xs" />
</button>
</div>
</div>
{/* Card Content Preview */}
<div className="bg-[color:var(--color-surface)]/30 rounded-lg p-3 mb-2">
<div
className="text-[color:var(--color-text)] text-sm leading-relaxed"
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden"
}}
>
{getCardPreview(card)}
</div>
</div>
</div>
)
})}
{/* Empty State */}
{cards.length === 0 && !isCreatingCard && (
<div className="text-center py-12">
<div className="text-[color:var(--color-text-muted)] text-lg mb-2">🃏</div>
<div className="text-[color:var(--color-text-muted)] text-sm">
Még nincsenek kártyák.
<br />
Hozz létre az első kártyát!
</div>
</div>
)}
</div>
{/* Confirm Delete Popup */}
{confirmingDelete && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-80 text-center animate-fadeIn">
<h3 className="text-lg font-semibold mb-4 text-gray-800">
Biztosan törölni szeretnéd?
</h3>
<p className="text-sm text-gray-600 mb-6">
Ez a művelet nem visszavonható.
</p>
<div className="flex justify-center gap-4">
<button
onClick={handleConfirmDelete}
className="bg-[color:var(--color-error)] text-white px-4 py-2 rounded-lg hover:bg-red-600 transition"
>
Igen
</button>
<button
onClick={handleCancelDelete}
className="bg-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 transition"
>
Mégse
</button>
</div>
</div>
</div>
)}
{/* Footer Stats */}
<div className="p-4 border-t border-[color:var(--color-surface-selected)] bg-[color:var(--color-background)]/30">
<div className="text-center">
<div className="text-[color:var(--color-text)] font-semibold">
📊 Összesen: {cards.length} kártya
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,233 @@
// src/components/DeckCreator/DeckHeader.jsx
// Deck alapadatok szerkesztése és mentés
import React, { useState, useRef, useEffect } from "react"
import { FaSave, FaArrowLeft, FaGlobe, FaLock, FaQuestionCircle, FaDice, FaLaughBeam, FaTrash } from "react-icons/fa"
const deckTypes = [
{ value: "QUESTION", label: "Kérdés", icon: FaQuestionCircle, color: "var(--color-question)" },
{ value: "LUCK", label: "Szerencse", icon: FaDice, color: "var(--color-luck)" },
{ value: "JOKER", label: "Joker", icon: FaLaughBeam, color: "var(--color-fun)" }
]
const privacyOptions = [
{ value: "private", label: "Privát", icon: FaLock },
{ value: "public", label: "Publikus", icon: FaGlobe }
]
export default function DeckHeader({ deck, onUpdate, onSave, onBack, onDelete }) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
const typeDropdownRef = useRef(null);
const privacyDropdownRef = useRef(null);
const currentDeckType = deckTypes.find(type => type.value === deck.type) || deckTypes[0]
const currentPrivacy = privacyOptions.find(option => option.value === deck.privacy) || privacyOptions[0]
useEffect(() => {
function handleClickOutside(event) {
if (typeDropdownRef.current && !typeDropdownRef.current.contains(event.target)) {
setIsTypeDropdownOpen(false);
}
if (privacyDropdownRef.current && !privacyDropdownRef.current.contains(event.target)) {
setIsPrivacyDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleInputChange = (field, value) => {
onUpdate({ [field]: value })
}
// Remove unused card count variables
return (
<div className="bg-[color:var(--color-surface)] border-b border-[color:var(--color-surface-selected)] px-6 py-4">
{/* Top Row - Title and Actions */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[color:var(--color-background)] hover:bg-[color:var(--color-surface-selected)] text-[color:var(--color-text)] transition-all duration-200"
>
<FaArrowLeft />
Vissza
</button>
<h1 className="text-2xl font-bold text-[color:var(--color-text)]">
📝 Pakli Szerkesztés
</h1>
</div>
<div className="flex items-center gap-3">
{deck.id && (
<button
onClick={onDelete}
className="flex items-center gap-2 px-6 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white font-semibold transition-all duration-200 hover:scale-105 shadow-lg"
>
<FaTrash />
Törlés
</button>
)}
<button
onClick={onSave}
className="flex items-center gap-2 px-6 py-2 rounded-xl bg-[color:var(--color-success)] hover:bg-[color:var(--color-success)]/80 text-[color:var(--color-text-inverse)] font-semibold transition-all duration-200 hover:scale-105 shadow-lg"
>
<FaSave />
Mentés
</button>
</div>
</div>
{/* Main Content Row */}
<div className="space-y-4">
{/* Two Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Deck Name - Takes up 2 columns */}
<div className="md:col-span-2">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
📦 Pakli neve
</label>
<input
type="text"
value={deck.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
placeholder="Add meg a pakli nevét..."
/>
</div>
{/* Empty space for visual balance */}
<div className="hidden md:block"></div>
</div>
{/* Type, Privacy and Description Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Deck Type */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
🎯 Típus
</label>
<div className="relative">
<button
type="button"
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 flex items-center"
style={{
paddingLeft: "2.5rem"
}}
>
<div className="absolute left-4 top-1/2 -translate-y-1/2">
{React.createElement(currentDeckType.icon, {
style: { color: currentDeckType.color }
})}
</div>
{currentDeckType.label}
<div className="absolute right-4 top-1/2 -translate-y-1/2">
<svg className={`w-4 h-4 text-[color:var(--color-text-muted)] transform transition-transform ${isTypeDropdownOpen ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</button>
{isTypeDropdownOpen && (
<div
className="absolute z-10 w-full mt-1 bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] rounded-xl shadow-lg overflow-hidden"
ref={typeDropdownRef}
>
{deckTypes.map(type => (
<button
key={type.value}
onClick={() => {
handleInputChange('type', type.value);
setIsTypeDropdownOpen(false);
}}
className={`w-full px-4 py-2 flex items-center gap-3 hover:bg-[color:var(--color-surface-selected)] transition-colors text-[color:var(--color-text)] ${deck.type === type.value ? 'bg-[color:var(--color-surface-selected)]' : ''}`}
>
{React.createElement(type.icon, {
style: { color: type.color }
})}
<span>{type.label}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Privacy */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
👁 Láthatóság
</label>
<div className="relative">
<button
type="button"
onClick={() => setIsPrivacyDropdownOpen(!isPrivacyDropdownOpen)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 flex items-center"
style={{
paddingLeft: "2.5rem"
}}
>
<div className="absolute left-4 top-1/2 -translate-y-1/2">
{React.createElement(currentPrivacy.icon, {
className: "text-[color:var(--color-text)]"
})}
</div>
{currentPrivacy.label}
<div className="absolute right-4 top-1/2 -translate-y-1/2">
<svg className={`w-4 h-4 text-[color:var(--color-text-muted)] transform transition-transform ${isPrivacyDropdownOpen ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</button>
{isPrivacyDropdownOpen && (
<div
className="absolute z-10 w-full mt-1 bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] rounded-xl shadow-lg overflow-hidden"
ref={privacyDropdownRef}
>
{privacyOptions.map(option => (
<button
key={option.value}
onClick={() => {
handleInputChange('privacy', option.value);
setIsPrivacyDropdownOpen(false);
}}
className={`w-full px-4 py-2 flex items-center gap-3 hover:bg-[color:var(--color-surface-selected)] transition-colors text-[color:var(--color-text)] ${deck.privacy === option.value ? 'bg-[color:var(--color-surface-selected)]' : ''}`}
>
{React.createElement(option.icon, {
className: "text-[color:var(--color-text)]"
})}
<span>{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Description */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
📝 Leírás
</label>
<input
type="text"
value={deck.description}
onChange={(e) => handleInputChange('description', e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
placeholder="Rövid leírás..."
/>
</div>
</div>
</div>
</div>
)
}
@@ -1,4 +1,5 @@
import React, { useState } from "react"
import React, { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import {
FaPlus,
FaFilter,
@@ -8,29 +9,20 @@ import {
FaSortAlphaDown,
FaSortAlphaUp,
FaQuestionCircle,
FaChevronLeft,
FaChevronRight,
} from "react-icons/fa"
import SearchBox from "../Search/SearchBox"
import PopUp from "../PopUp/PopUp"
import DeckInfoPopUp from "../PopUp/DeckInfoPopUp"
const deckTypes = [
{ label: "Luck", color: "var(--color-luck)" },
{ label: "Question", color: "var(--color-question)" },
{ label: "Fun", color: "var(--color-fun)" },
{ label: "Joker", color: "var(--color-fun)" },
]
const mockDecks = [
// Just for visual mockup
{ id: 1, name: "Party Luck", type: "Luck", created: "2025-07-01", origin: "Vállalati" },
{ id: 2, name: "Quiz Night", type: "Question", created: "2025-07-02", origin: "Saját" },
{ id: 3, name: "Fun Times", type: "Fun", created: "2025-07-03", origin: "Vállalati" },
{ id: 4, name: "Corporate Challenge", type: "Question", created: "2025-07-04", origin: "Vállalati" },
{ id: 5, name: "Randomizer", type: "Luck", created: "2025-07-05", origin: "Saját" },
{ id: 6, name: "Afterwork luck", type: "Luck", created: "2025-07-06", origin: "Saját" },
{ id: 7, name: "Serpent Quiz", type: "Question", created: "2025-07-07", origin: "Vállalati" },
{ id: 8, name: "Green Fortune", type: "Luck", created: "2025-07-08", origin: "Vállalati" },
{ id: 9, name: "Team Builder", type: "Fun", created: "2025-07-09", origin: "Saját" },
{ id: 10, name: "Knowledge Race", type: "Question", created: "2025-07-10", origin: "Saját" },
]
// initial state will be fetched from backend
const origins = ["Mind", "Vállalati", "Saját"]
@@ -72,14 +64,55 @@ const sortOptions = [
]
const DeckManager = () => {
const navigate = useNavigate()
const [selectedType, setSelectedType] = useState("All")
const [selectedOrigin, setSelectedOrigin] = useState("Mind")
const [sortBy, setSortBy] = useState("date-desc")
const [search, setSearch] = useState("")
const [showSortHelp, setShowSortHelp] = useState(false)
const [selectedDeck, setSelectedDeck] = useState(null)
const [allDecks, setAllDecks] = useState([]) // Összes pakli
const [loading, setLoading] = useState(false)
const [itemsPerPage, setItemsPerPage] = useState(20)
const [currentPage, setCurrentPage] = useState(1)
// Filter logic (mock)
let filteredDecks = mockDecks.filter((deck) => {
// Load all decks once
useEffect(() => {
let mounted = true
const load = async () => {
setLoading(true)
try {
// Load all decks (0-99 is the max limit = 100 decks)
const result = await import('../../api/deckApi').then(m => m.getDecksPage(0, 99))
if (!mounted) return
console.log('Loaded decks:', result) // Debug
// Map backend deck shape to UI shape
const mapped = (result.decks || []).map(d => ({
id: d.id,
name: d.name,
type: d.type === 2 ? 'Question' : d.type === 1 ? 'Joker' : 'Luck',
created: d.creationdate ? new Date(d.creationdate).toLocaleDateString() : '',
origin: d.ctype === 2 ? 'Vállalati' : d.ctype === 0 ? 'Mind' : 'Saját',
raw: d
}))
console.log('Mapped decks:', mapped) // Debug
setAllDecks(mapped)
} catch (err) {
console.error('Failed to load decks', err)
} finally {
setLoading(false)
}
}
load()
return () => { mounted = false }
}, [])
// Filter logic
let filteredDecks = allDecks.filter((deck) => {
const typeMatch = selectedType === "All" || deck.type === selectedType
const originMatch = selectedOrigin === "Mind" || deck.origin === selectedOrigin
const searchMatch = !search || deck.name.toLowerCase().includes(search.toLowerCase())
@@ -100,11 +133,23 @@ const DeckManager = () => {
return 0
})
// Pagination logic - frontend only
const totalDecks = filteredDecks.length
const totalPages = Math.ceil(totalDecks / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const paginatedDecks = filteredDecks.slice(startIndex, endIndex)
// Reset to page 1 when filters or items per page change
useEffect(() => {
setCurrentPage(1)
}, [selectedType, selectedOrigin, search, sortBy, itemsPerPage])
return (
<div className="w-full flex flex-col bg-[color:var(--color-background)]">
<div className="w-full max-w-6xl mx-auto px-4 py-10">
<div className="w-full max-w-[1200px] mx-auto px-4 py-10">
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-10 bg-[color:var(--color-surface)]/80 backdrop-blur-lg rounded-2xl px-6 py-4 shadow-lg">
<div className="flex flex-col md:flex-row gap-3 justify-between items-center mb-10 bg-[color:var(--color-surface)]/80 backdrop-blur-lg rounded-2xl px-6 py-4 shadow-lg">
<div className="flex gap-2 items-center w-full md:w-auto">
<SearchBox
value={search}
@@ -140,8 +185,8 @@ const DeckManager = () => {
? "Szerencse"
: type.label === "Question"
? "Kérdés"
: type.label === "Fun"
? "Szórakozás"
: type.label === "Joker"
? "Joker"
: type.label}
</button>
))}
@@ -240,22 +285,62 @@ const DeckManager = () => {
)}
</div>
</div>
{/* Items per page selector and pagination info */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-center mb-6 bg-[color:var(--color-surface)]/60 backdrop-blur-lg rounded-xl px-6 py-3 shadow">
<div className="flex items-center gap-3">
<span className="text-[color:var(--color-text-muted)] text-sm font-medium">
Elemek oldalanként:
</span>
<select
value={itemsPerPage}
onChange={(e) => setItemsPerPage(Number(e.target.value))}
className="px-3 py-1.5 rounded-lg bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)] focus:ring-2 focus:ring-[color:var(--color-success)] outline-none transition-all duration-200"
>
<option value={20}>20</option>
<option value={30}>30</option>
<option value={40}>40</option>
<option value={50}>50</option>
</select>
</div>
<div className="text-[color:var(--color-text-muted)] text-sm">
{totalDecks > 0 ? (
<>
{startIndex + 1}-{Math.min(endIndex, totalDecks)} / {totalDecks} pakli
</>
) : (
<>0 pakli</>
)}
</div>
</div>
{/* Decks Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-8 mt-8">
{/* Create New Deck (Mockup) */}
<div className="flex flex-col items-center justify-center h-48 bg-[color:var(--color-card)] border-2 border-dashed border-[color:var(--color-success)] rounded-2xl cursor-pointer hover:bg-[color:var(--color-success)]/20 transition-all duration-200 shadow-lg">
<div
onClick={() => navigate("/deck-creator")}
className="flex flex-col items-center justify-center h-48 bg-[color:var(--color-card)] border-2 border-dashed border-[color:var(--color-success)] rounded-2xl cursor-pointer hover:bg-[color:var(--color-success)]/20 transition-all duration-200 shadow-lg"
>
<FaPlus style={{ color: "var(--color-success)" }} className="text-5xl mb-2" />
<span className="text-[color:var(--color-text)] font-semibold">Új pakli létrehozása</span>
</div>
{/* Existing Decks (Mockup) */}
{filteredDecks.map((deck) => {
{/* Existing Decks (from backend) */}
{loading && (
<div className="col-span-full text-center text-[color:var(--color-text-muted)]">Betöltés...</div>
)}
{!loading && filteredDecks.length === 0 && (
<div className="col-span-full text-center text-[color:var(--color-text-muted)]">Nincsenek mentett paklik.</div>
)}
{!loading && paginatedDecks.map((deck) => {
const deckType = deckTypes.find((t) => t.label === deck.type)
const borderColor = deckType ? deckType.color : "var(--color-success)"
return (
<div
key={deck.id}
className="flex flex-col justify-between h-48 bg-[color:var(--color-card)] rounded-2xl p-6 shadow-lg border-t-4 hover:scale-105 transition-transform duration-200"
className="flex flex-col justify-between h-48 bg-[color:var(--color-card)] rounded-2xl p-6 shadow-lg border-t-4 hover:scale-105 transition-transform duration-200 cursor-pointer"
style={{ borderTopColor: borderColor }}
onClick={() => setSelectedDeck(deck)}
>
<div>
<span
@@ -270,7 +355,7 @@ const DeckManager = () => {
: deck.type === "Question"
? "Kérdés"
: deck.type === "Fun"
? "Szórakozás"
? "Joker"
: deck.type}
</span>
<h2 className="text-xl font-bold text-[color:var(--color-text)] mb-1 truncate">
@@ -284,7 +369,77 @@ const DeckManager = () => {
)
})}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-8">
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2 ${
currentPage === 1
? 'bg-[color:var(--color-surface)] text-[color:var(--color-text-muted)] cursor-not-allowed'
: 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] hover:bg-[color:var(--color-success)]/80 hover:scale-105'
}`}
>
<FaChevronLeft />
Előző
</button>
<div className="flex items-center gap-2">
{[...Array(totalPages)].map((_, index) => {
const pageNum = index + 1
// Show first page, last page, current page and neighbors
if (
pageNum === 1 ||
pageNum === totalPages ||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
) {
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 rounded-lg font-medium transition-all duration-200 ${
currentPage === pageNum
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] scale-110 shadow-lg'
: 'bg-[color:var(--color-surface)] text-[color:var(--color-text)] hover:bg-[color:var(--color-surface-selected)]'
}`}
>
{pageNum}
</button>
)
} else if (
pageNum === currentPage - 2 ||
pageNum === currentPage + 2
) {
return (
<span key={pageNum} className="text-[color:var(--color-text-muted)]">
...
</span>
)
}
return null
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2 ${
currentPage === totalPages
? 'bg-[color:var(--color-surface)] text-[color:var(--color-text-muted)] cursor-not-allowed'
: 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] hover:bg-[color:var(--color-success)]/80 hover:scale-105'
}`}
>
Következő
<FaChevronRight />
</button>
</div>
)}
</div>
{/* Deck Info Popup */}
{selectedDeck && <DeckInfoPopUp deck={selectedDeck} onClose={() => setSelectedDeck(null)} />}
</div>
)
}
@@ -0,0 +1,149 @@
// src/components/DeckCreator/JokerCardEditor.jsx
// Joker kártya szerkesztő
import React, { useState, useEffect } from 'react'
import { FaTheaterMasks, FaInfoCircle, FaUsers } from 'react-icons/fa'
export default function JokerCardEditor({ card, onChange }) {
const [cardData, setCardData] = useState({
type: 'JOKER',
text: ''
})
useEffect(() => {
if (card) {
setCardData({
type: 'JOKER',
text: card.text || ''
})
}
}, [card])
const handleTextChange = (e) => {
const newCardData = {
...cardData,
text: e.target.value
}
setCardData(newCardData)
if (onChange) {
onChange(newCardData)
}
}
// Példa joker kártyák
const exampleCards = [
"Felelsz vagy mersz? (Az előző játékos kérdez)",
"Csinálj 20 felülést!",
"Mesélj el egy vicces történetet az életedből!",
"Utánozd a kedvenc állatodat 30 másodpercig!",
"Énekelj el egy dalt amit mindenki ismer!",
"Mondj el 5 dolgot amiért hálás vagy!",
"Táncolj 1 percig zene nélkül!"
]
const insertExample = (example) => {
setCardData(prev => ({
...prev,
text: example
}))
if (onChange) {
onChange({
...cardData,
text: example
})
}
}
return (
<div className="space-y-6">
{/* Info box */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<div className="bg-[color:var(--color-fun)]/10 border border-[color:var(--color-fun)]/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<FaInfoCircle className="text-[color:var(--color-fun)] mt-1 flex-shrink-0" />
<div>
<h4 className="font-semibold text-[color:var(--color-text)] mb-2 flex items-center gap-2">
<FaUsers className="text-sm" />
Joker kártya működése:
</h4>
<p className="text-[color:var(--color-text-muted)] text-sm mb-2">
A joker kártyák interaktív feladatokat tartalmaznak, melyek megtörik a jeget a játékosok között.
Ezek lehetnek fizikai feladatok, kérdések, vagy szórakoztató kihívások.
</p>
<p className="text-[color:var(--color-fun)] text-sm font-medium">
Cél: Szórakozás és játékosok közötti kapcsolat erősítése
</p>
</div>
</div>
</div>
</div>
{/* Kártya szövege */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4 flex items-center gap-2">
<FaTheaterMasks className="text-[color:var(--color-fun)]" />
Kártya szövege
</h3>
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Joker kártya feladat *
</label>
<textarea
value={cardData.text}
onChange={handleTextChange}
placeholder="Pl: Felelsz vagy mersz? (Az előző játékos kérdez)"
className="w-full h-32 px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] placeholder-[color:var(--color-text-muted)] resize-none focus:ring-2 focus:ring-[color:var(--color-fun)] focus:border-transparent outline-none transition-all duration-200"
maxLength={150}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-[color:var(--color-text-muted)]">
Maximális hossz: 150 karakter
</span>
<span className="text-xs text-[color:var(--color-text-muted)]">
{cardData.text.length}/150
</span>
</div>
</div>
{/* Example cards */}
<div className="mt-6">
<h4 className="text-sm font-medium text-[color:var(--color-text)] mb-3">
💡 Példa joker kártyák (kattints rájuk a beszúráshoz):
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{exampleCards.map((example, index) => (
<button
key={index}
onClick={() => insertExample(example)}
className="text-left p-3 bg-[color:var(--color-fun)]/5 hover:bg-[color:var(--color-fun)]/10 border border-[color:var(--color-fun)]/20 rounded-lg text-sm text-[color:var(--color-text)] transition-colors"
>
{example}
</button>
))}
</div>
</div>
{/* Preview */}
{cardData.text.trim() && (
<div className="mt-6 p-4 bg-[color:var(--color-fun)]/5 border border-[color:var(--color-fun)]/20 rounded-lg">
<h4 className="text-sm font-medium text-[color:var(--color-text)] mb-2">
Kártya előnézet:
</h4>
<div className="bg-[color:var(--color-surface)] border-2 border-[color:var(--color-fun)] rounded-lg p-4 text-center">
<FaTheaterMasks className="text-2xl text-[color:var(--color-fun)] mx-auto mb-2" />
<p className="text-[color:var(--color-text)] font-medium">
{cardData.text}
</p>
<div className="mt-2 text-xs text-[color:var(--color-fun)] font-medium">
🃏 JOKER KÁRTYA
</div>
</div>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,176 @@
// src/components/DeckCreator/LuckCardEditor.jsx
// Szerencse kártya szerkesztő
import React, { useState, useEffect } from 'react'
import { FaDice, FaInfoCircle } from 'react-icons/fa'
const consequenceTypes = [
{ value: 0, label: '⬆️ Előre lépés', description: 'A játékos előre lép X mezőt' },
{ value: 1, label: '⬇️ Hátra lépés', description: 'A játékos hátra lép X mezőt' },
{ value: 2, label: '⏸️ Kör kihagyás', description: 'A játékos kihagy egy kört' },
{ value: 3, label: '⏩ Extra kör', description: 'A játékos kap egy extra kört' },
{ value: 5, label: '🏁 Vissza a starthoz', description: 'A játékos visszakerül a starthoz' }
]
export default function LuckCardEditor({ card, onChange }) {
const [cardData, setCardData] = useState({
type: 'LUCK',
text: '',
consequence: { type: 0, value: 1 }
})
useEffect(() => {
if (card) {
setCardData({
type: 'LUCK',
text: card.text || '',
consequence: card.consequence || { type: 0, value: 1 }
})
}
}, [card])
const handleTextChange = (e) => {
const newCardData = {
...cardData,
text: e.target.value
}
setCardData(newCardData)
if (onChange) {
onChange(newCardData)
}
}
const updateConsequence = (field, value) => {
const newCardData = {
...cardData,
consequence: {
...cardData.consequence,
[field]: value
}
}
setCardData(newCardData)
if (onChange) {
onChange(newCardData)
}
}
return (
<div className="space-y-6">
{/* Info box */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<div className="bg-[color:var(--color-luck)]/10 border border-[color:var(--color-luck)]/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<FaInfoCircle className="text-[color:var(--color-luck)] mt-1 flex-shrink-0" />
<div>
<h4 className="font-semibold text-[color:var(--color-text)] mb-2">
Szerencse kártya működése:
</h4>
<p className="text-[color:var(--color-text-muted)] text-sm mb-2">
Amikor egy játékos szerencse mezőre lép, kap egy kártyát amit felolvas.
A kártyán lévő utasítás azonnal teljesül.
</p>
<p className="text-[color:var(--color-luck)] text-sm font-medium">
Példa: "Órai projektekkel kiváltottál több vizsgát is! Lépj előre 4 mezőt"
</p>
</div>
</div>
</div>
</div>
{/* Kártya szövege */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4 flex items-center gap-2">
<FaDice className="text-[color:var(--color-luck)]" />
Kártya szövege
</h3>
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Szerencse esemény leírása *
</label>
<textarea
value={cardData.text}
onChange={handleTextChange}
placeholder="Pl: Órai projektekkel kiváltottál több vizsgát is! Lépj előre 4 mezőt"
className="w-full h-32 px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] placeholder-[color:var(--color-text-muted)] resize-none focus:ring-2 focus:ring-[color:var(--color-luck)] focus:border-transparent outline-none transition-all duration-200"
maxLength={200}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-[color:var(--color-text-muted)]">
Maximális hossz: 200 karakter
</span>
<span className="text-xs text-[color:var(--color-text-muted)]">
{cardData.text.length}/200
</span>
</div>
</div>
{/* Preview */}
{cardData.text.trim() && (
<div className="mt-6 p-4 bg-[color:var(--color-luck)]/5 border border-[color:var(--color-luck)]/20 rounded-lg">
<h4 className="text-sm font-medium text-[color:var(--color-text)] mb-2">
Kártya előnézet:
</h4>
<div className="bg-[color:var(--color-surface)] border-2 border-[color:var(--color-luck)] rounded-lg p-4 text-center">
<FaDice className="text-2xl text-[color:var(--color-luck)] mx-auto mb-2" />
<p className="text-[color:var(--color-text)] font-medium">
{cardData.text}
</p>
</div>
</div>
)}
</div>
{/* Következmények */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
🎯 Következmények
</h3>
<div className="space-y-4">
{/* Consequence Type */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Hatás típusa
</label>
<select
value={cardData.consequence?.type ?? 0}
onChange={(e) => updateConsequence('type', parseInt(e.target.value))}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-luck)] focus:border-transparent outline-none transition-all duration-200"
>
{consequenceTypes.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<div className="text-xs text-[color:var(--color-text-muted)] mt-1">
{consequenceTypes.find(t => t.value === (cardData.consequence?.type ?? 0))?.description}
</div>
</div>
{/* Consequence Value - csak kör kihagyás és extra kör */}
{(cardData.consequence?.type === 2 || cardData.consequence?.type === 3) && (
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
{cardData.consequence?.type === 2 ? 'Körök kihagyása' : 'Extra körök száma'}
</label>
<input
type="number"
min="1"
max="5"
value={cardData.consequence?.value ?? 1}
onChange={(e) => updateConsequence('value', parseInt(e.target.value) || 1)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-luck)] focus:border-transparent outline-none transition-all duration-200"
/>
<div className="text-xs text-[color:var(--color-text-muted)] mt-1">
Érték: 1-5 között
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,521 @@
// src/components/DeckCreator/TaskCardEditor.jsx
// Feladat kártya szerkesztő (Quiz, Igaz/Hamis, Párosítás, Szöveges)
import React from "react"
import { FaPlus, FaTrash, FaCheck, FaTimes } from "react-icons/fa"
const taskSubTypes = [
{ value: 'quiz', label: '📋 Quiz (A/B/C/D)', description: 'Feleletválasztós kérdés' },
{ value: 'truefalse', label: '✅ Igaz/Hamis', description: 'Igaz vagy hamis állítás' },
{ value: 'matching', label: '🔗 Párosítás', description: 'Elemek összekapcsolása' },
{ value: 'text', label: '✏️ Szöveges válasz', description: 'Szabadszöveges válasz' }
]
const timeLimits = [
{ value: 15, label: '15 másodperc' },
{ value: 30, label: '30 másodperc' },
{ value: 45, label: '45 másodperc' },
{ value: 60, label: '1 perc' },
{ value: 90, label: '1.5 perc' },
{ value: 120, label: '2 perc' }
]
export default function TaskCardEditor({ card, onChange }) {
const updateField = (field, value) => {
onChange({ [field]: value })
}
const updateOption = (index, value) => {
const newOptions = [...card.options]
newOptions[index] = value
onChange({ options: newOptions })
}
const addMatchingPair = () => {
const newLeft = [...(card.leftItems || []), '']
const newRight = [...(card.rightItems || []), '']
const newCorrectPairs = { ...(card.correctPairs || {}), [newLeft.length - 1]: newRight.length - 1 }
onChange({
leftItems: newLeft,
rightItems: newRight,
correctPairs: newCorrectPairs
})
}
const removeMatchingPair = (index) => {
const newLeft = card.leftItems.filter((_, i) => i !== index)
const newRight = card.rightItems.filter((_, i) => i !== index)
const newCorrectPairs = {}
// Újraszámozás
Object.entries(card.correctPairs).forEach(([leftIdx, rightIdx]) => {
const newLeftIdx = parseInt(leftIdx) > index ? parseInt(leftIdx) - 1 : parseInt(leftIdx)
const newRightIdx = parseInt(rightIdx) > index ? parseInt(rightIdx) - 1 : parseInt(rightIdx)
if (newLeftIdx !== index && newRightIdx !== index) {
newCorrectPairs[newLeftIdx] = newRightIdx
}
})
onChange({
leftItems: newLeft,
rightItems: newRight,
correctPairs: newCorrectPairs
})
}
const addAcceptedAnswer = () => {
const newAnswers = [...(card.acceptedAnswers || []), '']
onChange({ acceptedAnswers: newAnswers })
}
const updateAcceptedAnswer = (index, value) => {
const currentAnswers = card.acceptedAnswers || ['']
const newAnswers = [...currentAnswers]
newAnswers[index] = value
onChange({ acceptedAnswers: newAnswers })
}
const removeAcceptedAnswer = (index) => {
const currentAnswers = card.acceptedAnswers || ['']
if (currentAnswers.length <= 1) return // Legalább egy válasz maradjon
const newAnswers = currentAnswers.filter((_, i) => i !== index)
onChange({ acceptedAnswers: newAnswers })
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Feladat típus választó */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
🎯 Feladat típusa
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{taskSubTypes.map(type => (
<button
key={type.value}
onClick={() => updateField('subType', type.value)}
className={`
p-4 rounded-xl border text-left transition-all duration-200 hover:scale-105
${card.subType === type.value
? 'bg-[color:var(--color-success)]/10 border-[color:var(--color-success)] shadow-lg'
: 'bg-[color:var(--color-background)] border-[color:var(--color-surface-selected)] hover:bg-[color:var(--color-surface-selected)]'
}
`}
>
<div className="font-semibold text-[color:var(--color-text)]">
{type.label}
</div>
<div className="text-sm text-[color:var(--color-text-muted)] mt-1">
{type.description}
</div>
</button>
))}
</div>
</div>
{/* Quiz típus szerkesztő */}
{card.subType === 'quiz' && (
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
📋 Quiz kérdés
</h3>
{/* Kérdés */}
<div className="mb-6">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Kérdés
</label>
<textarea
value={card.question || ''}
onChange={(e) => updateField('question', e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
rows="3"
placeholder="Írd be a kérdést..."
/>
</div>
{/* Válaszlehetőségek */}
<div className="space-y-3">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium">
Válaszlehetőségek
</label>
{['A', 'B', 'C', 'D'].map((letter, index) => (
<div key={index} className="flex items-center gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => updateField('correctAnswer', index)}
className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-200
${card.correctAnswer === index
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)]'
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)]'
}
`}
>
{letter}
</button>
{card.correctAnswer === index && (
<FaCheck className="text-[color:var(--color-success)] text-sm" />
)}
</div>
<input
type="text"
value={card.options?.[index] || ''}
onChange={(e) => updateOption(index, e.target.value)}
className="flex-1 px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
placeholder={`${letter} válasz...`}
/>
</div>
))}
</div>
</div>
)}
{/* Igaz/Hamis típus szerkesztő */}
{card.subType === 'truefalse' && (
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
Igaz/Hamis állítás
</h3>
{/* Állítás */}
<div className="mb-6">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Állítás
</label>
<textarea
value={card.statement || ''}
onChange={(e) => updateField('statement', e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
rows="3"
placeholder="Írd be az állítást..."
/>
</div>
{/* Helyes válasz */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-3">
Helyes válasz
</label>
<div className="flex gap-4">
<button
onClick={() => updateField('isTrue', true)}
className={`
flex items-center gap-3 px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105
${card.isTrue === true
? 'bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] shadow-lg'
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)]'
}
`}
>
<FaCheck />
IGAZ
</button>
<button
onClick={() => updateField('isTrue', false)}
className={`
flex items-center gap-3 px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105
${card.isTrue === false
? 'bg-[color:var(--color-error)] text-[color:var(--color-text-inverse)] shadow-lg'
: 'bg-[color:var(--color-background)] text-[color:var(--color-text)] border border-[color:var(--color-surface-selected)]'
}
`}
>
<FaTimes />
HAMIS
</button>
</div>
</div>
</div>
)}
{/* Párosítás típus szerkesztő */}
{card.subType === 'matching' && (
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
🔗 Párosítás feladat
</h3>
{/* Feladat leírás */}
<div className="mb-6">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Feladat leírása
</label>
<input
type="text"
value={card.taskDescription || ''}
onChange={(e) => updateField('taskDescription', e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
placeholder="Pl.: Párosítsd a országokat a fővárosukkal"
/>
</div>
{/* Párosítások */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<label className="text-[color:var(--color-text-muted)] text-sm font-medium">
Párosítások
</label>
<button
onClick={addMatchingPair}
className="flex items-center gap-2 px-3 py-1 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] text-sm font-medium hover:bg-[color:var(--color-success)]/80 transition-all duration-200"
>
<FaPlus />
Új pár
</button>
</div>
{(card.leftItems || []).map((leftItem, index) => (
<div key={index} className="grid grid-cols-5 gap-3 items-center">
<input
type="text"
value={leftItem}
onChange={(e) => {
const newLeft = [...card.leftItems]
newLeft[index] = e.target.value
onChange({ leftItems: newLeft })
}}
className="col-span-2 px-3 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 text-sm"
placeholder="Bal oldal..."
/>
<div className="text-center text-[color:var(--color-text-muted)]"></div>
<input
type="text"
value={card.rightItems?.[index] || ''}
onChange={(e) => {
const newRight = [...(card.rightItems || [])]
newRight[index] = e.target.value
onChange({ rightItems: newRight })
}}
className="px-3 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 text-sm"
placeholder="Jobb oldal..."
/>
<button
onClick={() => removeMatchingPair(index)}
className="p-2 rounded-lg bg-[color:var(--color-error)]/10 text-[color:var(--color-error)] hover:bg-[color:var(--color-error)]/20 transition-all duration-200"
>
<FaTrash className="text-xs" />
</button>
</div>
))}
</div>
</div>
)}
{/* Szöveges válasz típus szerkesztő */}
{card.subType === 'text' && (
<div className="bg-[color:var(--color-surface)] rounded-xl p-6">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
Szöveges válasz
</h3>
{/* Kérdés */}
<div className="mb-6">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Kérdés
</label>
<textarea
value={card.question || ''}
onChange={(e) => updateField('question', e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
rows="3"
placeholder="Írd be a kérdést..."
/>
</div>
{/* Elfogadott válaszok */}
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<label className="text-[color:var(--color-text-muted)] text-sm font-medium">
Elfogadott válaszok
</label>
<button
onClick={addAcceptedAnswer}
className="flex items-center gap-2 px-3 py-1 rounded-lg bg-[color:var(--color-success)] text-[color:var(--color-text-inverse)] text-sm font-medium hover:bg-[color:var(--color-success)]/80 transition-all duration-200"
>
<FaPlus />
Új válasz
</button>
</div>
<div className="space-y-2">
{(card.acceptedAnswers || ['']).map((answer, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={answer}
onChange={(e) => updateAcceptedAnswer(index, e.target.value)}
className="flex-1 px-3 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 text-sm"
placeholder={`Elfogadott válasz ${index + 1}...`}
/>
{(card.acceptedAnswers?.length || 1) > 1 && (
<button
onClick={() => removeAcceptedAnswer(index)}
className="p-2 rounded-lg bg-[color:var(--color-error)]/10 text-[color:var(--color-error)] hover:bg-[color:var(--color-error)]/20 transition-all duration-200"
>
<FaTrash className="text-xs" />
</button>
)}
</div>
))}
</div>
<div className="text-xs text-[color:var(--color-text-muted)] mt-2">
Vesszővel elválasztva is megadhatsz több elfogadott választ egy mezőben
</div>
</div>
{/* Beállítások - Később elérhető */}
<div className="relative">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-50 pointer-events-none blur-[1px]">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={false}
disabled
className="w-4 h-4 text-[color:var(--color-success)] border-[color:var(--color-surface-selected)] rounded focus:ring-[color:var(--color-success)]"
/>
<span className="text-[color:var(--color-text)]">Kis/nagy betű érzékeny</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={false}
disabled
className="w-4 h-4 text-[color:var(--color-success)] border-[color:var(--color-surface-selected)] rounded focus:ring-[color:var(--color-success)]"
/>
<span className="text-[color:var(--color-text)]">Pontos egyezés</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={false}
disabled
className="w-4 h-4 text-[color:var(--color-success)] border-[color:var(--color-surface-selected)] rounded focus:ring-[color:var(--color-success)]"
/>
<span className="text-[color:var(--color-text)]">Részleges elfogadás</span>
</label>
</div>
{/* Hamarosan elérhető felület */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="bg-[color:var(--color-warning)]/20 backdrop-blur-sm border-2 border-[color:var(--color-warning)] rounded-lg px-4 py-2">
<span className="text-[color:var(--color-warning)] font-semibold text-sm flex items-center gap-2">
🚧 Hamarosan elérhető
</span>
</div>
</div>
</div>
{/* Tipp */}
<div className="mt-4">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Tipp (opcionális)
</label>
<input
type="text"
value={card.hint || ''}
onChange={(e) => updateField('hint', e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
placeholder="Segítő tipp a játékosoknak..."
/>
</div>
</div>
)}
{/* Közös beállítások - Később elérhető */}
<div className="bg-[color:var(--color-surface)] rounded-xl p-6 relative">
<h3 className="text-lg font-semibold text-[color:var(--color-text)] mb-4">
Beállítások
</h3>
<div className="relative">
<div className="opacity-50 pointer-events-none blur-[1px]">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Pontszám */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
💰 Pontszám
</label>
<input
type="number"
value={10}
disabled
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
min="0"
max="1000"
/>
</div>
{/* Időlimit */}
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
Időlimit
</label>
<select
disabled
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
>
<option>30 másodperc</option>
</select>
</div>
{/* Karakterlimit (csak szöveges válasznál) */}
{card.subType === 'text' && (
<div>
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
📝 Karakterlimit
</label>
<input
type="number"
value={100}
disabled
className="w-full px-4 py-2 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200"
min="10"
max="500"
/>
</div>
)}
</div>
{/* Magyarázat */}
<div className="mt-6">
<label className="block text-[color:var(--color-text-muted)] text-sm font-medium mb-2">
💡 Magyarázat (opcionális)
</label>
<textarea
disabled
className="w-full px-4 py-3 rounded-xl bg-[color:var(--color-background)] border border-[color:var(--color-surface-selected)] text-[color:var(--color-text)] focus:ring-2 focus:ring-[color:var(--color-success)] focus:border-transparent outline-none transition-all duration-200 resize-none"
rows="3"
placeholder="Magyarázat a helyes válaszhoz..."
/>
</div>
</div>
{/* Hamarosan elérhető felület */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="bg-[color:var(--color-warning)]/20 backdrop-blur-sm border-2 border-[color:var(--color-warning)] rounded-lg px-4 py-2">
<span className="text-[color:var(--color-warning)] font-semibold text-sm flex items-center gap-2">
🚧 Hamarosan elérhető
</span>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from "react"
import { Link } from "react-router-dom"
import Logo from "../../assets/pictures/Logo"
import HandleNavigate from "../../utils/HandleNavigate/HandleNavigate"
const ArrowUpIcon = () => <span style={{ fontSize: "1.25rem" }}></span>
@@ -9,6 +8,9 @@ const Footer = () => {
const [isVisible, setIsVisible] = useState(false)
const footerRef = useRef(null)
// Használjuk a navigációs függvényeket
const { goLanding, goAbout, goContacts } = HandleNavigate()
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
@@ -17,14 +19,10 @@ const Footer = () => {
{ threshold: 0.3 }
)
if (footerRef.current) {
observer.observe(footerRef.current)
}
if (footerRef.current) observer.observe(footerRef.current)
return () => {
if (footerRef.current) {
observer.unobserve(footerRef.current)
}
if (footerRef.current) observer.unobserve(footerRef.current)
}
}, [])
@@ -35,63 +33,79 @@ const Footer = () => {
return (
<footer
ref={footerRef}
className={`relative bg-zinc-900 text-white border-t-2 border-zinc-800 mt-auto py-8 transition-all duration-700 ease-out ${
isVisible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-10"
}`}
className="relative bg-zinc-900 text-zinc-400 border-t-2 border-zinc-800 mt-auto py-8"
style={{ transformOrigin: "bottom center" }}
>
<style>
{`
.footer-animate {
transition: opacity 0.8s ease, transform 0.8s ease;
}
`}
</style>
<div className="max-w-6xl mx-auto flex flex-wrap justify-between items-start gap-8 px-4">
{/* Logó */}
<div className="flex flex-col items-center footer-animate">
<a
href="/"
className="transition-transform duration-500 hover:scale-105 hover:brightness-125"
<div className="flex flex-col items-center">
<button
onClick={goLanding}
className="hover:scale-105 hover:brightness-110 transition-transform"
>
<Logo size={100} />
</a>
<span className="font-extrabold text-xl mt-2 tracking-wide">SerpentRace</span>
</button>
<button
onClick={goLanding}
className="font-extrabold text-xl mt-2 tracking-wide text-white hover:text-green-500 transition-colors"
>
SerpentRace
</button>
</div>
{/* Oldalak */}
<div className="flex flex-col gap-1 footer-animate">
<span className="text-lg font-semibold text-green-400 underline underline-offset-4 mb-2 drop-shadow-sm">
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold text-green-600 underline underline-offset-4 mb-2 drop-shadow-sm">
Oldalak
</span>
<a href="/" className="hover:underline hover:text-green-400 transition">Főoldal</a>
<a href="/about" className="hover:underline hover:text-green-400 transition">
<button
onClick={goLanding}
className="text-left hover:underline hover:text-green-500 transition-colors"
>
Főoldal
</button>
<button
onClick={goAbout}
className="text-left hover:underline hover:text-green-500 transition-colors"
>
Rólunk
</a>
<a href="/contact" className="hover:underline hover:text-green-400 transition">Kapcsolat</a>
</button>
</div>
{/* Közösség */}
<div className="flex flex-col gap-1 footer-animate">
<span className="text-lg font-semibold text-green-400 underline underline-offset-4 mb-2 drop-shadow-sm">
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold text-green-600 underline underline-offset-4 mb-2 drop-shadow-sm">
Közösség
</span>
<a href="https://discord.gg/" target="_blank" rel="noopener noreferrer" className="hover:underline hover:text-green-400 transition">Discord</a>
<a href="https://github.com/" target="_blank" rel="noopener noreferrer" className="hover:underline hover:text-green-400 transition">GitHub</a>
<a
href="https://discord.gg/"
target="_blank"
rel="noopener noreferrer"
className="hover:underline hover:text-green-500"
>
Discord
</a>
<a
href="https://github.com/"
target="_blank"
rel="noopener noreferrer"
className="hover:underline hover:text-green-500"
>
GitHub
</a>
</div>
{/* Elérhetőség */}
<div className="flex flex-col gap-1 footer-animate">
<span className="text-lg font-semibold text-green-400 underline underline-offset-4 mb-2 drop-shadow-sm">
<div className="flex flex-col gap-1">
<span className="text-lg font-semibold text-green-600 underline underline-offset-4 mb-2 drop-shadow-sm">
Elérhetőség
</span>
<span className="opacity-80">Email: info@serpentrace.hu</span>
<span className="opacity-80">Telefon: +36 30 123 4567</span>
<span className="opacity-85">Email: info@serpentrace.hu</span>
<span className="opacity-85">Telefon: +36 30 123 4567</span>
</div>
</div>
<div className="text-center mt-8 text-sm opacity-70 footer-animate">
<div className="text-center mt-8 text-sm opacity-70">
© {new Date().getFullYear()} SerpentRace. Minden jog fenntartva.
</div>
@@ -99,7 +113,7 @@ const Footer = () => {
{isVisible && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 bg-green-500 hover:bg-green-600 text-white p-3 rounded-full shadow-lg transition transform hover:scale-110"
className="fixed bottom-6 right-6 bg-green-500 hover:bg-green-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform"
aria-label="Ugrás az oldal tetejére"
>
<ArrowUpIcon />

Some files were not shown because too many files have changed in this diff Show More