From 65c3b0e72a87cdbf3d15449c8d1c4df7300261ef Mon Sep 17 00:00:00 2001 From: Gyula Kiri Date: Sun, 23 Aug 2026 14:24:59 +0200 Subject: [PATCH 01/25] feat: add specifications --- README.md | 7 +- docs/implementation-plan.md | 978 ++++++++++++++++++++++++++++++++++ docs/product-specification.md | 764 ++++++++++++++++++++++++++ 3 files changed, 1748 insertions(+), 1 deletion(-) create mode 100644 docs/implementation-plan.md create mode 100644 docs/product-specification.md diff --git a/README.md b/README.md index 7c9bdd9..af3dbac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,9 @@ -Welcome to your new TanStack Start app! +# BSS stack + +## Product documentation + +- [V0 követelményspecifikáció](./docs/product-specification.md) +- [Kártyákra bontott implementációs terv](./docs/implementation-plan.md) # Getting Started diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..87d1996 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,978 @@ +# BSS weboldal V0 kártyákra bontott implementációs terve + +## 1. A terv használata + +Ez a terv a [követelményspecifikációra](./product-specification.md) épül. A kártyák függőségi sorrendben követik egymást. Egy hullámon belül a független kártyák párhuzamosan végezhetők. + +Relatív méretek: + +- `S`: kis, egy jól körülhatárolt változtatás; +- `M`: közepes, több réteget vagy több tesztesetet érint; +- `L`: nagy, több PR-re bontható, de egy közös elfogadási eredménye van. + +Minden kártya V0-prioritású. A sorrendet a függőségek határozzák meg, nem a sorszám önmagában. + +## 2. Közös elkészültségi feltétel + +Egy kártya akkor kész, ha: + +- a kód és az adatbázisváltozás verziókezelt; +- a jogosultságot a szerver ellenőrzi; +- a sikeres, üres, betöltési és hibás állapot kezelve van; +- a releváns unit vagy integrációs teszt elkészült; +- a typecheck, lint és érintett tesztek zöldek; +- az új config és lokális lépés dokumentált; +- a mobil- és asztali felület használható; +- nincs néma adatvesztés vagy utolsó mentés nyer viselkedés. + +## 3. Végrehajtási hullámok + +| Hullám | Cél | Kártyák | +| ------ | ----------------------------------------- | ----------------- | +| 0 | Stabil alap és tiszta séma | BSS-001 - BSS-005 | +| 1 | Auth, jogosultság és közös infrastruktúra | BSS-006 - BSS-011 | +| 2 | Tartalmi domainek | BSS-012 - BSS-018 | +| 3 | Publikus felület | BSS-019 - BSS-026 | +| 4 | Adminfelület | BSS-027 - BSS-033 | +| 5 | Seed, SEO, minőség és átadás | BSS-034 - BSS-038 | + +A fő kritikus út: + +`BSS-001 -> BSS-004 -> BSS-005 -> BSS-006 -> BSS-007 -> BSS-008 -> BSS-010 -> BSS-014 -> BSS-028 -> BSS-037 -> BSS-038` + +### Lefedettségi térkép + +| Specifikációs terület | Elsődleges kártyák | +| ------------------------------------- | -------------------------------------------- | +| Lokális alap, config és séma | BSS-001 - BSS-005 | +| Authentik, session és jogosultság | BSS-006 - BSS-008 | +| Slug, audit, háttérfeladat és média | BSS-009 - BSS-011 | +| Címkék és stábszerepek | BSS-012, BSS-030 | +| Események | BSS-013, BSS-022, BSS-029 | +| Videók és megtekintés | BSS-014 - BSS-016, BSS-020, BSS-021, BSS-028 | +| Homepage, live és Rólunk | BSS-017, BSS-024, BSS-026, BSS-031 | +| Keresés | BSS-018, BSS-020, BSS-025 | +| Tagok | BSS-008, BSS-023, BSS-032 | +| Lomtár és audit UI | BSS-033 | +| Seed, SEO, felületi minőség és átadás | BSS-034 - BSS-038 | + +## 4. Kártyák + +### BSS-001 - Toolchain stabilizálása + +- Méret: `M` +- Függőség: nincs + +Cél: reprodukálható, zöld fejlesztői alap létrehozása a jelenlegi nightly és `latest` függőségek helyett. + +Tartalom: + +- kompatibilis verziók rögzítése; +- a Nitro és Vite tesztindítási hiba javítása; +- a jelenlegi TypeScript- és lint-hibák javítása; +- a generált route-kezelés tisztázása; +- egységes `typecheck`, `lint`, `test`, `build` parancsok. + +Elfogadási feltételek: + +- tiszta telepítés után ugyanazok a verziók kerülnek fel; +- `pnpm lint`, typecheck, `pnpm test` és build sikeres; +- nincs nightly vagy indokolatlan `latest` production-függőség; +- a változtatás nem módosít termékfunkciót. + +### BSS-002 - Tesztalap és vezérelhető idő + +- Méret: `M` +- Függőség: BSS-001 + +Cél: olyan tesztkörnyezet kialakítása, amelyben a jogosultság, külső szolgáltatások és időzített törlés megbízhatóan vizsgálható. + +Tartalom: + +- unit és integrációs teszt setup; +- külön tesztadatbázis-kezelés; +- vezérelhető óra a 30 napos törléshez és live-váltáshoz; +- HTTP mock a `v.bsstudio.hu`, YouTube oEmbed és Authentik hívásokhoz; +- közös fixture factoryk. + +Elfogadási feltételek: + +- teszt nem használ élő külső szolgáltatást; +- a tesztóra tetszőleges időpontra állítható; +- egy integrációs smoke test valódi PostgreSQL ellen lefut; +- a tesztek egymástól függetlenül ismételhetők. + +### BSS-003 - OOB konfigurációs szerződés + +- Méret: `M` +- Függőség: BSS-001 + +Cél: a hiányzó Authentik mapping, titkok és seed fájlok formájának rögzítése. + +Tartalom: + +- típusos config séma; +- Authentik claim-, attribútum- és csoportmapping; +- csatlakozási félév és tagsági státusz transzformációs szabályai; +- médiahost- és YouTube-konfiguráció; +- OOB seed fájl helye; +- induláskori validáció és konkrét hibaüzenetek. + +Elfogadási feltételek: + +- hiányzó kötelező config mellett az alkalmazás nem indul félkonfigurált auth módban; +- ismeretlen Authentik státusz vagy félév nem kap kitalált értéket; +- titok nem kerül kliensbundle-be vagy gitbe; +- lokális tesztconfig külön használható. + +### BSS-004 - Új adatbázisséma és migrációs alap + +- Méret: `L` +- Függőség: BSS-001 + +Cél: a prototípus követelményekkel ütköző sémájának lecserélése. + +Tartalom: + +- videók, események, címkék, stábszerepek és stábkapcsolatok; +- videónként legfeljebb egy opcionális esemény; +- tagcache és Authentik `sub` kulcs; +- csatlakozási félév nyers és normalizált értékei; +- slug és slugtörténet; +- tartalomállapotok és videólomtár; +- manuális kapcsolódóvideó-sorrend; +- live, kiemelés és Rólunk-videólista; +- auditnapló; +- megtekintésszám és anonim session tárolás; +- szükséges indexek, egyediségek és idegen kulcsok. + +Elfogadási feltételek: + +- tiszta PostgreSQL adatbázison a migráció lefut; +- ugyanaz a videó nem kapcsolható két eseményhez; +- jogosultsági és stábszerep nem ugyanaz a tábla; +- profilkép bináris tárolása megszűnik; +- timestamp és naptári dátum mezők nem keverednek; +- a séma adatbázis-korlátokkal is védi a legfontosabb invariánsokat. + +### BSS-005 - Lokális infrastruktúra és Authentik bootstrap + +- Méret: `L` +- Függőség: BSS-003, BSS-004 + +Cél: dokumentáltan felépíthető PostgreSQL és Authentik tesztkörnyezet. + +Tartalom: + +- compose konfiguráció rendezése; +- Authentik blueprint vagy bootstrap script; +- schönherzes, tag és vezetőségi tesztesetek; +- szintetikus tagprofilok és csoportok; +- helyi titkok biztonságos előállítása; +- adatbázis-migrációs parancs. + +Elfogadási feltételek: + +- új fejlesztő dokumentált lépésekkel felépíti a környezetet; +- mindhárom nézői szint és mindkét adminjog tesztelhető; +- tesztjelszó nem kerül gitbe; +- újraindítás nem duplikálja a bootstrap adatokat. + +### BSS-006 - OIDC belépés és session + +- Méret: `L` +- Függőség: BSS-003, BSS-005 + +Cél: Authentik OIDC Authorization Code flow PKCE-vel és szerveroldali sessionnel. + +Tartalom: + +- login, callback és logout útvonal; +- HTTP-only, `SameSite=Lax` cookie; +- access token szerveroldali kezelése; +- visszatérési URL megőrzése; +- legfeljebb órás szerepfrissítés; +- Authentik-kiesési viselkedés. + +Elfogadási feltételek: + +- token nem kerül `localStorage`-ba; +- névtelen felhasználó belépés után visszajut az eredeti oldalra; +- Authentik-kieséskor új login nem sikerül, a publikus oldal működik; +- lejárt sessionnel mutáció nem hajtható végre. + +### BSS-007 - Jogosultsági policy és szerveroldali guardok + +- Méret: `M` +- Függőség: BSS-006 + +Cél: egyetlen, tesztelt helyen érvényesíteni a nézői és adminjogokat. + +Tartalom: + +- névtelen, schönherzes, tag és vezetőségi policy; +- videóláthatóság SQL-feltételei; +- adminműveletek jogosultsági mátrixa; +- `403`, `404` és loginra irányítás szabályai; +- kliensoldali navigáció szűrése a szerveroldali guard mellett. + +Elfogadási feltételek: + +- tiltott videó metaadata nem kerül a válaszba; +- tag nem kezelhet címkekatalógust, live-ot vagy auditot; +- vezetőségi jog magában foglalja a tagjogot; +- minden policy-ág integrációs tesztet kap. + +### BSS-008 - Authentik tagcache és szinkron + +- Méret: `L` +- Függőség: BSS-002, BSS-003, BSS-004, BSS-006, BSS-007 + +Cél: publikus tagadatok kiszolgálása helyi, csak olvasható cache-ből. + +Tartalom: + +- induláskori, óránkénti és kézi szinkron; +- mapping és normalizálás; +- nyers csatlakozási félév megőrzése; +- ismeretlen státusz és formátum hibakezelése; +- eltűnt tag utolsó ismert rekordjának megtartása; +- szinkronállapot és hibák; +- szerepváltozás frissítése. + +Elfogadási feltételek: + +- publikus kérés nem hívja az Authentiket; +- eltűnt tag stáblistája nem szakad el; +- hibás profil nem kerül publikus csoportba; +- változatlan szinkron nem ír auditbejegyzést; +- kézi szinkront csak vezetőség indíthat. + +### BSS-009 - Közös slug, audit és optimista zárolás + +- Méret: `L` +- Függőség: BSS-004, BSS-007 + +Cél: közös infrastruktúra minden szerkeszthető entitáshoz. + +Tartalom: + +- slugképzés, ütközéskezelés és átirányítási előzmény; +- `createdBy`, `updatedBy`, verzió és timestamp kezelés; +- elavult mentés blokkolása; +- tranzakciós audit előtte-utána értékekkel; +- `system` szereplő; +- plain text és hosszvalidációk. + +Elfogadási feltételek: + +- régi slug az újra irányít; +- két párhuzamos mentés közül az elavult kérés konfliktust kap; +- sikertelen tranzakció nem hagy auditot vagy félkész adatot; +- audit nem módosítható az alkalmazásból. + +### BSS-010 - Háttérfeladat-futtató, health és adminriasztás + +- Méret: `M` +- Függőség: BSS-002, BSS-004, BSS-008, BSS-009 + +Cél: biztonságos időzített feladatok külső worker nélkül. + +Tartalom: + +- PostgreSQL advisory lock; +- induláskori és óránkénti sync ütemezés; +- regisztrálható napi lomtártörlési és live időzítő feladatok; +- `/health/live` és `/health/ready`; +- tartós vezetőségi hibasáv és részletes hibanapló. + +Elfogadási feltételek: + +- két alkalmazáspéldány nem futtatja ugyanazt a feladatot kétszer; +- health válasz nem szivárogtat titkot; +- a tesztóra vezérli a regisztrált feladatokat; +- háttérhiba nem állítja le a publikus cache-t. + +### BSS-011 - Média- és YouTube-validátor + +- Méret: `M` +- Függőség: BSS-002, BSS-003 + +Cél: csak valóban használható média kerüljön publikált tartalomba. + +Tartalom: + +- `v.bsstudio.hu` hostellenőrzés; +- `HEAD`, timeout, redirect és content type ellenőrzés; +- Range GET tartalék `405` és `501` esetére; +- YouTube URL-normalizálás; +- oEmbed ellenőrzés; +- piszkozat és publikálás eltérő hibakezelése. + +Elfogadási feltételek: + +- a bsstudio.hu főoldalára irányító hiányzó média nem fogadható el; +- MP4 helyén kép és thumbnail helyén HTML nem publikálható; +- hibás URL piszkozatban menthető; +- teszt nem tölt le teljes médiafájlt. + +### BSS-012 - Címke- és stábszerep-domain + +- Méret: `M` +- Függőség: BSS-004, BSS-008, BSS-009 + +Cél: a két katalógus szabályainak szerveroldali megvalósítása. + +Tartalom: + +- címke létrehozás, átnevezés, összevonás és törlés; +- használt címke kapcsolatainak tranzakciós bontása; +- kisbetű- és whitespace-normalizálás; +- ékezeti hasonlóságra figyelmeztetés; +- stábszerep létrehozás, átnevezés, sorrendezés és összevonás; +- használatban lévő stábszerep törlésének tiltása. + +Elfogadási feltételek: + +- tag csak hozzárendelni tud meglévő címkét; +- használt címke törléséhez vezetőségi jog és címbeírás kell; +- szerepösszevonás nem veszít stábkapcsolatot; +- egy videón egy tag több szerepet is kaphat. + +### BSS-013 - Esemény-domain és végleges törlés + +- Méret: `L` +- Függőség: BSS-004, BSS-007, BSS-009, BSS-011 + +Cél: esemény CRUD, állapotkezelés és biztonságos végleges törlés. + +Tartalom: + +- piszkozat, publikált és archivált állapot; +- dátum- és thumbnail-validáció; +- jövőbeli esemény támogatása; +- publikálási feltételek; +- vezetőségi végleges törlés címbeírással; +- videókapcsolatok tranzakciós bontása; +- lista- és részletlekérdezések alapja. + +Elfogadási feltételek: + +- befejezés nem előzheti meg a kezdést; +- tag archiválhat, de nem törölhet végleg; +- eseménytörlés megtartja a videók `recordedAt` értékét; +- esemény és kapcsolatok félállapot nélkül törlődnek. + +### BSS-014 - Videó-domain és életciklus + +- Méret: `L` +- Függőség: BSS-004, BSS-007, BSS-009, BSS-010, BSS-011, BSS-012, BSS-013 + +Cél: a videó minden mezőjének, állapotának és publikálási szabályának szerveroldali megvalósítása. + +Tartalom: + +- piszkozat mentése és azonnali publikálás; +- kötelező thumbnail és MP4; +- alapértelmezett publikus láthatóság; +- múltbeli, de nem jövőbeli `publishedAt`; +- esemény- és `recordedAt` szabályok; +- címke- és stáblista-hozzárendelés; +- archiválás és publikálás; +- lomtár és vezetőségi visszaállítás archivált állapotba; +- napi 30 napos végleges törlési feladat regisztrálása; +- eseménydátum-eltérés figyelmeztetése; +- kiemelés és Rólunk-lista érvénytelenítése állapotváltozáskor. + +Elfogadási feltételek: + +- piszkozat csak címmel menthető; +- publikálás minden kötelező mezőt és médiát ellenőriz; +- egy videónak legfeljebb egy eseménye van; +- eseménymódosítás nem írja felül csendben a videódátumot; +- visszaállítás megőrzi a kapcsolatait. + +### BSS-015 - Kapcsolódó videók szolgáltatása + +- Méret: `M` +- Függőség: BSS-012, BSS-013, BSS-014 + +Cél: manuális, eseményes és közöscímkés kapcsolódó videók egységes kiszolgálása. + +Tartalom: + +- sorrendezett manuális lista; +- azonos esemény öt legutóbbi videója; +- esemény nélkül közös címkéken alapuló rangsor; +- önhivatkozás és duplikáció tiltása; +- állapot- és jogosultsági szűrés. + +Elfogadási feltételek: + +- manuális lista teljesen felülírja az automatikusat; +- az aktuális videó nem szerepel; +- korlátozott videó nem szivárog ki jogosulatlan nézőnek; +- egyező pontszámnál stabil a sorrend. + +### BSS-016 - Megtekintésszámláló + +- Méret: `M` +- Függőség: BSS-002, BSS-004, BSS-014 + +Cél: egyszerű, sessionönként egyszer számoló playerindítás rögzítése. + +Tartalom: + +- anonim, böngésző bezárásáig élő session cookie; +- első sikeres `play` esemény feldolgozása; +- idempotens videó-session kapcsolat; +- párhuzamos kérések kezelése; +- adminlekérdezés. + +Elfogadási feltételek: + +- pause és újraindítás nem növel újra; +- másik böngésző-session növelhet; +- IP és felhasználói előzmény nem tárolódik; +- publikus válasz nem tartalmaz megtekintésszámot. + +### BSS-017 - Homepage, kiemelés, live és Rólunk-domain + +- Méret: `L` +- Függőség: BSS-007, BSS-009, BSS-010, BSS-011, BSS-013, BSS-014 + +Cél: a homepage prioritás és vezetőségi beállítások szerveroldali megvalósítása. + +Tartalom: + +- live, kiemelt és normál prioritás; +- publikus kiemelt videó kiválasztása; +- live időablak, átfedés-tiltás és előzmény; +- `Adás hamarosan` 24 órás szabály; +- `Indítás most` és `Lezárás most`; +- aktiváláskori oEmbed ellenőrzés és fallback; +- Rólunk oldal legfeljebb hat rendezett videója; +- öt vagy hat friss videó és hat esemény lekérdezése. + +Elfogadási feltételek: + +- aktív live mindig felülírja a kiemelést; +- átfedő időablak nem menthető; +- nem publikus vagy archivált videó nem marad kiemelve; +- befejezett live csak adminelőzményben marad; +- Rólunk-listából kiesik az érvénytelen videó. + +### BSS-018 - Keresési szolgáltatás + +- Méret: `L` +- Függőség: BSS-004, BSS-007, BSS-008, BSS-012, BSS-013, BSS-014 + +Cél: súlyozott globális keresés és összetett videószűrés PostgreSQL-ben. + +Tartalom: + +- szükséges `pg_trgm` és ékezetfüggetlen keresési támogatás; +- videó, esemény, tag és címke találatok; +- elfogadott súlyozás; +- videó részletes szűrői; +- címkék `ÉS` logikája; +- relevancia és választható rendezések; +- szerveroldali lapozás; +- jogosultsági feltételek a lekérdezésben. + +Elfogadási feltételek: + +- cím- és névegyezés megelőzi a leírástalálatot; +- zeneszöveg nem kereshető; +- ékezet nélküli és kisebb elgépeléses keresés működik; +- jogosulatlan találat és találatszám sem szivárog; +- azonos lekérdezés stabil sorrendet ad. + +### BSS-019 - Alkalmazásváz, publikus route-ok és hibaoldalak + +- Méret: `M` +- Függőség: BSS-006, BSS-007, BSS-009 + +Cél: a végleges route-struktúra és közös oldalállapotok kialakítása. + +Tartalom: + +- elfogadott angol útvonalak; +- slugfeloldás és régi slug redirect; +- közös navbar, footer és login állapot; +- magyar `403`, `404`, betöltési és hibaoldalak; +- mentetlen űrlap kliensoldali megőrzésének alapja. + +Elfogadási feltételek: + +- piszkozat, archív és lomtár publikus route-ja `404`; +- jogosult és jogosulatlan állapot nem keveredik; +- régi slug az új canonical route-ra irányít; +- mobil navigáció használható. + +### BSS-020 - Publikus videólista + +- Méret: `L` +- Függőség: BSS-014, BSS-018, BSS-019 + +Cél: valós adatokkal működő videókatalógus. + +Tartalom: + +- thumbnail és cím kártya; +- három rendezés; +- elfogadott részletes szűrők; +- címkék `ÉS` kapcsolata; +- 10, 25, 50, 100 oldalméret, alapból 50; +- URL-ben megmaradó állapot; +- jogosultság szerinti eredmények. + +Elfogadási feltételek: + +- lapozáskor nincs duplikáció vagy kihagyás; +- hiányzó `recordedAt` időrendi listában hátul van; +- frissítés és megosztott URL megtartja a szűrőket; +- üres találat külön magyar állapotot mutat. + +### BSS-021 - Videórészlet, player és kapcsolódó tartalom + +- Méret: `L` +- Függőség: BSS-014, BSS-015, BSS-016, BSS-019 + +Cél: a teljes videóoldal valós adatokkal. + +Tartalom: + +- elfogadott blokk-sorrend; +- natív MP4 player, poster és `preload="metadata"`; +- `play` esemény számlálása; +- esemény-, címke- és tagprofil-linkek; +- plain text blokkok; +- kapcsolódó videók; +- médiahiba és újrapróbálás. + +Elfogadási feltételek: + +- autoplay és külön download gomb nincs; +- üres opcionális blokkok nem jelennek meg; +- tiltott kapcsolódó videó nem látszik; +- ugyanaz a session csak egyszer növel számlálót. + +### BSS-022 - Publikus eseménylista és részletoldal + +- Méret: `M` +- Függőség: BSS-013, BSS-014, BSS-019 + +Cél: események listázása és részlete jogosultsággal szűrt származtatott adatokkal. + +Tartalom: + +- thumbnail, cím és videószám overlay; +- kezdődátum szerinti rendezés; +- 50-es alaplapozás; +- részletoldal és videólista; +- thumbnail fallback; +- videókból származtatott, titulus nélküli stáblista. + +Elfogadási feltételek: + +- videószám csak a látható videókat számolja; +- a stáblista nem szivárogtat korlátozott videót; +- esemény videó nélkül is publikus marad; +- `recordedAt` szerinti sorrend működik. + +### BSS-023 - Publikus taglista és tagprofil + +- Méret: `L` +- Függőség: BSS-008, BSS-013, BSS-014, BSS-019 + +Cél: Authentik-cache-ből kiszolgált csoportos tagoldalak és tevékenység. + +Tartalom: + +- vezetőség és tagsági csoportok; +- külön archivált és közreműködő aloldal; +- tagkártya; +- profiladatok; +- év és szerep nézet; +- több szerepnél tudatos ismétlés; +- 50-es `Továbbiak betöltése`; +- jogosultsági videószűrés. + +Elfogadási feltételek: + +- vezetőségi tag nem ismétlődik másik csoportban; +- ismeretlen státuszú profil nem publikus; +- email és mobil nem kerül válaszba; +- a nézet URL-ből visszaállítható. + +### BSS-024 - Homepage és YouTube live felület + +- Méret: `L` +- Függőség: BSS-013, BSS-014, BSS-017, BSS-019 + +Cél: a három homepage-prioritás megjelenítése frissítés nélküli váltással. + +Tartalom: + +- live és kiemelt hero öt friss videóval; +- normál állapot hat videóval; +- minden állapot alatt hat esemény; +- `Adás hamarosan` sáv; +- YouTube nocookie embed autoplay nélkül; +- következő váltás kliensoldali időzítése és percenkénti ellenőrzés. + +Elfogadási feltételek: + +- hero videó nem ismétlődik az oldalsó listában; +- live kezdése és vége kézi frissítés nélkül megjelenik; +- hibás aktiválás fallbacket mutat; +- mobilon minden blokk használható. + +### BSS-025 - Globális kereső és találati oldal + +- Méret: `L` +- Függőség: BSS-018, BSS-019, BSS-020, BSS-022, BSS-023 + +Cél: navbar popover és teljes keresőoldal. + +Tartalom: + +- két karakteres minimum és 250 ms késleltetés; +- öt találat típusonként; +- billentyűzetes kezelés; +- Összes, Videók, Események és Tagok fülek; +- típusonként tíz találat az Összes fülön; +- címke átirányítás aktív videószűrőre; +- üres keresési útmutató. + +Elfogadási feltételek: + +- tag és címke megnevezése nem keveredik; +- popover billentyűzettel nyitható és bezárható; +- tiltott videó metaadata nem jelenik meg; +- részletes videókeresés megtartja a queryt. + +### BSS-026 - Rólunk oldal és tanfolyam-átirányítás + +- Méret: `S` +- Függőség: BSS-017, BSS-019, BSS-021 + +Cél: a két egyszerű publikus útvonal véglegesítése. + +Tartalom: + +- verziókezelt Rólunk-szöveg; +- legfeljebb hat rendezett publikus videó; +- `/courses` átirányítás ugyanabban a fülben. + +Elfogadási feltételek: + +- érvénytelen videó nem marad a Rólunk-listában; +- helyi tanfolyaműrlap és ál-sikerüzenet megszűnik; +- az átirányítás szerveroldalon is működik. + +### BSS-027 - Adminváz és sidebar + +- Méret: `M` +- Függőség: BSS-007, BSS-019 + +Cél: YouTube Studio jellegű információs szerkezet BSS-arculattal. + +Tartalom: + +- elfogadott sidebar-elemek; +- tag- és vezetőségi elemek eltérő megjelenése; +- belépés utáni Videók kezdőoldal; +- közös táblázat és mobil kártyanézet; +- sessionlejárati és jogosultsági hibaállapot. + +Elfogadási feltételek: + +- tag nem lát vezetőségi menüpontot; +- közvetlen URL-en a szerver is tilt; +- mobilon nem kell vízszintesen kezelhetetlen táblát görgetni; +- nincs üres dashboard. + +### BSS-028 - Videó-adminlista és szerkesztő + +- Méret: `L` +- Függőség: BSS-014, BSS-015, BSS-027 + +Cél: a teljes videóéletciklus kezelése adatbázis-kézi beavatkozás nélkül. + +Tartalom: + +- elfogadott listaoszlopok és szűrők; +- létrehozás és szerkesztés; +- piszkozat vagy azonnali publikálás; +- médiaellenőrzés visszajelzése; +- címke-, esemény-, stáb- és manuális kapcsolódóvideó-kezelés; +- dátumwarningok; +- archiválás és lomtár; +- elavult mentési konfliktus; +- mentetlen adat megőrzése új belépésnél. + +Elfogadási feltételek: + +- tag nem tud új címkét létrehozni az űrlapon; +- kötelező mező nélkül publikálás nem sikerül; +- médiahiba mellett piszkozat menthető; +- az elavult mentés nem írja felül a frissebb adatot; +- tömeges művelet nincs. + +### BSS-029 - Esemény-adminlista és szerkesztő + +- Méret: `M` +- Függőség: BSS-013, BSS-027 + +Cél: esemény CRUD és vezetőségi végleges törlés. + +Tartalom: + +- elfogadott listaoszlopok és szűrők; +- piszkozat, publikálás és archiválás; +- dátumintervallum és thumbnail; +- videószám; +- címbeírásos végleges törlés vezetőségnek; +- érintett videók leválasztásának összefoglalója. + +Elfogadási feltételek: + +- tag nem kap végleges törlés gombot; +- vezetőség látja, hány videó válik le; +- törlés tranzakciós; +- tömeges törlés nincs. + +### BSS-030 - Címke- és stábszerep-admin + +- Méret: `M` +- Függőség: BSS-012, BSS-027 + +Cél: a két vezetőségi katalógus kezelőfelülete. + +Tartalom: + +- címke létrehozás, átnevezés, összevonás és törlés; +- használati szám és warning; +- címbeírásos használtcímke-törlés; +- stábszerep létrehozás, átnevezés, összevonás és drag vagy gombos sorrendezés; +- használt szerep törlésének blokkolása. + +Elfogadási feltételek: + +- hasonló, ékezetelt címke figyelmeztetést ad; +- kapcsolatbontás és összevonás eredménye előre látható; +- tag közvetlen API-hívással sem módosíthat katalógust; +- `displayOrder` minden publikus stábnézetben érvényes. + +### BSS-031 - Live, kiemelés és Rólunk admin + +- Méret: `L` +- Függőség: BSS-017, BSS-027 + +Cél: a vezetőség minden homepage- és Rólunk-beállítást elvégezhet egy helyen. + +Tartalom: + +- publikus kiemelt videó választása és törlése; +- YouTube URL normalizálás és előnézet; +- live kezdés, befejezés, átfedésjelzés; +- `Indítás most`, `Lezárás most`, másolatként új ütemezés; +- befejezett live előzmény; +- legfeljebb hat Rólunk-videó rendezése; +- aktiválási hibák. + +Elfogadási feltételek: + +- nem publikus videó nem választható kiemelésnek; +- hibás YouTube azonosító nem publikálható; +- átfedés kliensen és szerveren is blokkolt; +- tag nem látja vagy hívhatja a műveleteket. + +### BSS-032 - Rejtett tagdiagnosztika + +- Méret: `M` +- Függőség: BSS-008, BSS-027 + +Cél: a vezetőség lássa az Authentik-cache állapotát helyi profilszerkesztés nélkül. + +Tartalom: + +- profilok és utolsó szinkron; +- hiányzó vagy hibás mappingek; +- Authentikből eltűnt tagok; +- kézi szinkron; +- Authentik adminra mutató link; +- tartós szinkronhiba-sáv. + +Elfogadási feltételek: + +- helyi profil- és jogosultságmódosítás nincs; +- kézi szinkron auditált; +- tag nem látja a menüpontot vagy az adatot; +- nyers hiba nem szivárogtat titkot. + +### BSS-033 - Lomtár és audit admin + +- Méret: `L` +- Függőség: BSS-009, BSS-010, BSS-014, BSS-027 + +Cél: törölt videók és minden adminmódosítás ellenőrizhető kezelése. + +Tartalom: + +- minden tag számára látható videólomtár; +- törlő, időpont és hátralévő idő; +- vezetőségi visszaállítás archivált állapotba; +- napi végleges törlés állapota; +- vezetőségi auditlista; +- szereplő-, művelet-, entitás- és dátumszűrő; +- előtte-utána részletnézet. + +Elfogadási feltételek: + +- tag nem állíthat vissza; +- visszaállítás megtartja a kapcsolatokat; +- audit nem szerkeszthető, törölhető vagy exportálható; +- rendszerfeladat csak változásnál vagy hibánál jelenik meg. + +### BSS-034 - Scraper és seed importer + +- Méret: `L` +- Függőség: BSS-004, BSS-005, BSS-012, BSS-013, BSS-014 + +Cél: ismételhető, előre kinyert lokális tesztadat létrehozása. + +Tartalom: + +- 50 videó és kapcsolódó események begyűjtése; +- cím, leírás, zene, dátumok, MP4 és thumbnail URL; +- címkék, stábszerepek és kapcsolatok; +- stabil álnevesítés; +- legfeljebb öt párhuzamos kérés; +- retry, backoff és folytatható checkpoint; +- gitignore-olt JSON; +- idempotens seed importer. + +Elfogadási feltételek: + +- médiafájl nem töltődik le; +- email és profilbemutatkozás nem kerül a seedbe; +- újrafuttatás nem duplikál adatot; +- megszakítás után nem indul elölről; +- a seedelt stáblista Authentik tesztprofilokra mutat. + +### BSS-035 - SEO, sitemap és biztonsági headerek + +- Méret: `M` +- Függőség: BSS-019, BSS-021, BSS-022, BSS-023, BSS-024, BSS-026 + +Cél: helyes publikus metaadatok és szűk külső tartalmi engedélyek. + +Tartalom: + +- egyedi title, description, canonical és Open Graph; +- csak publikus tartalmú sitemap; +- robots.txt; +- CSP a `v.bsstudio.hu` és YouTube nocookie forrásokra; +- alap biztonsági headerek; +- admin és keresési technikai oldalak indexelésének tiltása. + +Elfogadási feltételek: + +- korlátozott videó nem kerül sitemapbe vagy metaadatba; +- thumbnail helyesen jelenik meg Open Graph képként; +- CSP mellett a player és YouTube live működik; +- régi slug canonicalként az új útvonalra mutat. + +### BSS-036 - Reszponzív, billentyűzetes és állapotpolírozás + +- Méret: `L` +- Függőség: BSS-020 - BSS-033 + +Cél: egységes, magyar és mobilon is használható teljes rendszer. + +Tartalom: + +- mobil publikus kártyák és admin kártyanézet; +- billentyűzetes kereső és űrlapok; +- fókuszkezelés modáloknál; +- karakterhátralék-jelzés; +- külön üres, betöltési és hibaállapotok; +- média-, Authentik-, jogosultsági és konfliktusüzenetek; +- vizuális egyeztetés a meglévő BSS prototípussal. + +Elfogadási feltételek: + +- kulcsfolyamatok egér nélkül is végigvihetők; +- admin mobilon nem kényszerít használhatatlan táblára; +- hibaüzenet megmondja, mi történt és mi a következő lépés; +- formális WCAG audit nélkül is javul a szemantika és a fókuszkezelés. + +### BSS-037 - Integrációs és végponttól végpontig tesztcsomag + +- Méret: `L` +- Függőség: BSS-020 - BSS-036 + +Cél: a specifikáció 20. fejezetének automatizált lefedése. + +Tartalom: + +- négy hozzáférési szereplő; +- videóállapotok, dátumok, média és lomtár; +- eseménytörlés és kapcsolatbontás; +- címke- és stábszerep-szabályok; +- tagcache és Authentik-kiesés; +- keresési adatszivárgás; +- homepage-prioritás és live; +- elavult mentés; +- slug redirect; +- 30 napos törlés vezérelt órával. + +Elfogadási feltételek: + +- minden elfogadási forgatókönyvhöz van automata teszt vagy indokolt, dokumentált kézi ellenőrzés; +- külső hívások determinisztikus mockot használnak; +- a tesztcsomag tiszta adatbázison indul; +- hiba esetén a teszt megnevezi a sérült üzleti szabályt. + +### BSS-038 - Dokumentáció és lokális átadási próba + +- Méret: `M` +- Függőség: BSS-005, BSS-034, BSS-035, BSS-037 + +Cél: bizonyítani, hogy egy új fejlesztő OOB csomaggal fel tudja építeni és végig tudja mutatni a V0-t. + +Tartalom: + +- README telepítési és indítási lépések; +- OOB fájllista és validáció; +- Authentik bootstrap; +- migráció és seed; +- tesztparancsok; +- szereplőnkénti demo forgatókönyv; +- ismert V0-korlátok és production előtti backlog; +- tiszta klónból végzett átadási próba. + +Elfogadási feltételek: + +- a dokumentált parancsok másolhatóak és működnek; +- hiányzó OOB elem konkrét hibát ad; +- a teljes elfogadási bemutató adatbázis-kézi módosítás nélkül végigvihető; +- a rating, komment, médiafeltöltés, tanfolyamkezelés, production és más kizárt elemek nem csúsztak vissza a V0-ba. + +## 5. Production előtti, külön backlog + +Ezek nem blokkolják a fenti kártyákat: + +- teljes régioldal-migráció; +- Drupal linkek átirányítása; +- production telepítési pipeline; +- UptimeRobot beállítása; +- backup és visszaállítás; +- ténylegesen védett média; +- IP-alapú egyedi nézettség; +- tanfolyam- és felkéréskezelő integráció; +- tömeges adminműveletek; +- formális akadálymentességi audit. diff --git a/docs/product-specification.md b/docs/product-specification.md new file mode 100644 index 0000000..f050d00 --- /dev/null +++ b/docs/product-specification.md @@ -0,0 +1,764 @@ +# BSS weboldal V0 követelményspecifikáció + +## Dokumentumállapot + +| Mező | Érték | +| -------------- | ----------------------------------------------------------- | +| Állapot | Elfogadott termékdöntések alapján készített V0 specifikáció | +| Nyelv | Magyar | +| Időzóna | Europe/Budapest | +| Célkörnyezet | Dokumentáltan indítható lokális környezet | +| Éles telepítés | Nem része a V0-nak | +| Vizuális alap | A repóban lévő publikus prototípus | + +Ez a dokumentum az egyeztetés során elfogadott döntések egyetlen forrása. Ha a jelenlegi prototípus, a Figma vagy a régi bsstudio.hu működése eltér tőle, ez a specifikáció az irányadó. + +## 1. Cél és átadási határ + +A V0 egy lokálisan működő videóarchívum és szerkesztői rendszer. A publikus oldalak mellett az adminfolyamatoknak is használhatóknak kell lenniük. Adatbázis-kézi módosítás nem válthat ki hiányzó adminfunkciót. + +A V0 akkor tekinthető késznek, ha lokálisan bemutatható: + +- a névtelen, schönherzes és BSS-tag nézői hozzáférés; +- a tag és vezetőségi tag adminjogosultsága; +- a videó teljes életciklusa; +- az események kezelése; +- a tagok Authentikből történő szinkronja; +- a globális és részletes keresés; +- a live, kiemelt és normál homepage-prioritás; +- az auditnapló és a 30 napos videótörlés szimulációja. + +## 2. Külső bemenetek + +A következő fájlok OOB érkeznek, és nem kerülnek gitbe: + +- Authentik attribútum- és csoportmappinget tartalmazó config; +- helyi titkok; +- előre kinyert seed JSON. + +A README-nek fel kell sorolnia a fájlok pontos helyét, formáját és ellenőrzési módját. Hiányzó fájlnál az alkalmazás konkrét hibaüzenettel álljon meg. Ne használjon kitalált alapértékeket. + +## 3. Szereplők és jogosultságok + +### 3.1 Nézői szintek + +| Szereplő | Látható videók | Admin | +| -------------------------- | ---------------------------- | ------------------------- | +| Névtelen látogató | `public` | Nem | +| Bejelentkezett schönherzes | `public`, `schonherz` | Nem | +| BSS-tag | `public`, `schonherz`, `bss` | Igen | +| Vezetőségi tag | `public`, `schonherz`, `bss` | Igen, kibővített jogokkal | + +Az Authentik `vezetoseg` csoportja kiegészíti a tagságot. Nem helyettesíti azt. + +### 3.2 Adminjogok + +| Művelet | Tag | Vezetőségi tag | +| ------------------------------------------------------- | ---- | -------------------------------- | +| Videó és esemény létrehozása, szerkesztése, publikálása | Igen | Igen | +| Videó és esemény archiválása | Igen | Igen | +| Videó lomtárba helyezése | Igen | Igen | +| Videólomtár megtekintése | Igen | Igen | +| Videó visszaállítása | Nem | Igen | +| Esemény végleges törlése | Nem | Igen | +| Meglévő címkék videóhoz rendelése | Igen | Igen | +| Címkekatalógus kezelése | Nem | Igen | +| Stábszerepek kezelése | Nem | Igen | +| Stáblista kezelése egy videón | Igen | Igen | +| Live, kiemelés és Rólunk-videók kezelése | Nem | Igen | +| Taglista és Authentik-szinkron diagnosztika | Nem | Igen, csak olvasható profilokkal | +| Auditnapló megtekintése | Nem | Igen | + +Minden tag látja és szerkesztheti más tagok piszkozatait. Nincs tartalmi tulajdonjog szerző szerint. A szerver minden műveletnél újra ellenőrzi a jogosultságot. + +## 4. Közös tartalmi szabályok + +### 4.1 Állapot és láthatóság + +Az állapot és a láthatóság külön adat. + +Videó és esemény állapotai: + +- `draft`; +- `published`; +- `archived`. + +A videó ezen felül `trash` állapotba kerülhet. Csak a live használ időzítést. + +Az archivált tartalom nem látható publikus felületen. Bármely tag visszaállíthatja publikált állapotba. A lomtárból visszaállított videó archivált állapotba kerül, majd külön publikálható. + +Videó láthatóságok: + +- `public`; +- `schonherz`; +- `bss`. + +Az új videó alapértelmezett láthatósága `public`. Az események és tagprofilok publikusak. A hozzájuk tartozó videókat és származtatott adatokat a néző jogosultsága szerint kell szűrni. + +### 4.2 Azonosítók és slugok + +- A belső azonosító UUID. +- A publikus útvonal egyedi slugot használ. +- A slug a címből kisbetűs, ékezet nélküli, kötőjeles formában képződik. +- Ütközéskor számozott utótag készül. +- A slug módosítható. +- A régi slug átirányításként megmarad, és végleges törlés után sem használható fel újra. +- A tagprofil stabil belső kulcsa az Authentik `sub` értéke. A profil slugja az Authentik felhasználónévből készül. + +### 4.3 Szövegek + +Minden leírás plain text, sortörések támogatásával. HTML, Markdown és rich text nem része a V0-nak. + +| Mező | Maximális hossz | +| ----------------------------- | --------------: | +| Cím | 200 karakter | +| Slug | 200 karakter | +| Címke és stábszerep | 64 karakter | +| Leírás és bemutatkozás | 10 000 karakter | +| Vendégek és felhasznált zenék | 5 000 karakter | +| URL | 2 048 karakter | + +A kliens és a szerver ugyanazokat a korlátokat ellenőrizze. + +### 4.4 Dátumok + +- A pontos időpontok UTC timestampként tárolódnak. +- A `recordedAt`, valamint az esemény kezdete és vége időzóna nélküli naptári dátum. +- Megjelenítéskor Europe/Budapest érvényes. +- Publikus dátumformátum: `2026. június 6.` +- Admin és audit formátum: `2026. június 6. 14:32` +- Eseményintervallum: `2026. június 6-8.` +- Csatlakozási félév: `2023 ősz` + +## 5. Videók + +### 5.1 Adatmodell + +Egy videó mezői: + +- UUID és slug; +- cím; +- leírás; +- vendégek, szabad szöveg; +- felhasznált zenék, szabad szöveg; +- MP4 URL; +- thumbnail URL; +- láthatóság és állapot; +- `createdAt`, `updatedAt`, `publishedAt`, `recordedAt`; +- megtekintésszám; +- opcionális esemény; +- címkék; +- stábtagok és stábszerepek; +- sorrendezett manuális kapcsolódó videók; +- létrehozó és utolsó módosító. + +A felhasznált zenék formátuma soronként egy tétel: + +```text +Előadó - Szám címe +Másik előadó - Másik szám +``` + +Egy videó legfeljebb egy eseményhez tartozhat. Az eseménykapcsolat nem kötelező. + +### 5.2 Dátumszabályok + +- A `createdAt` rendszeradat, nem módosítható. +- A `publishedAt` publikáláskor az aktuális időpontot kapja, de tag múltbeli időpontra módosíthatja. +- Jövőbeli `publishedAt` nem engedélyezett. +- Esemény nélküli videónál a `recordedAt` opcionális. +- Egynapos esemény hozzárendelése kitölti az üres `recordedAt` mezőt. +- Többnapos eseménynél publikálás előtt meg kell adni a `recordedAt` értéket. +- Az esemény intervallumán kívüli dátum megengedett, de figyelmeztetést kap. +- Esemény vagy eseménydátum módosítása nem írja felül csendben a videódátumot. +- Esemény leválasztásakor a `recordedAt` megmarad. + +### 5.3 Piszkozat és publikálás + +Piszkozat mentéséhez csak a cím kötelező. + +Publikáláshoz kötelező: + +- cím; +- érvényes MP4 URL; +- érvényes thumbnail URL; +- láthatóság; +- nem jövőbeli `publishedAt`; +- többnapos eseménynél `recordedAt`. + +Az adminűrlap mentéskor választható műveleteket ad: `Piszkozat mentése` és `Publikálás`. Nincs automatikus mentés. Mentetlen változásokkal navigáláskor megerősítés szükséges. + +### 5.4 Média-URL-ek + +Az alkalmazás fájlt nem tölt fel, nem kódol át és nem töröl a médiaszerverről. Csak távoli URL-t tárol. + +Videó és thumbnail esetén: + +- csak `https://v.bsstudio.hu` host engedélyezett; +- a szerver `HEAD` kérést küld 5 másodperces kapcsolódási és 15 másodperces teljes timeouttal; +- csak átirányítás nélküli `200` válasz fogadható el; +- videónál `video/mp4`, thumbnailnél `image/*` content type szükséges; +- `3xx`, `4xx`, timeout és `5xx` nem enged publikálást; +- hibás vagy még nem ellenőrizhető URL piszkozatban menthető; +- `405` vagy `501` esetén egybájtos Range GET használható tartalék ellenőrzésként. + +A láthatóság csak az oldal metaadatait védi. A külső MP4 URL publikus, ezért a link birtokában a fájl az alkalmazás megkerülésével is elérhető. + +### 5.5 Player és megtekintésszám + +- Natív videóvezérlők. +- A thumbnail a poster. +- `preload="metadata"`. +- Nincs autoplay. +- Nincs külön letöltés gomb. +- Nincs lejátszási pozíció mentése. +- Médiahiba esetén magyar hibaüzenet és újrapróbálás jelenik meg. + +A számláló az első sikeres `play` eseménynél nő. Egy böngésző-session ugyanazt a videót egyszer számolja, több fülből is. A session cookie a böngésző bezárásáig él. IP-cím, felhasználói megtekintéstörténet és kézi számlálómódosítás nincs. A megtekintésszám csak adminban látható. + +### 5.6 Kapcsolódó videók + +A kiválasztás sorrendje: + +1. sorrendezett manuális lista, ha van; +2. azonos esemény öt legutóbb publikált videója; +3. esemény nélkül az öt legjobb, legalább egy közös címkével rendelkező videó. + +Közös címkés találatnál a több közös címke erősebb. Egyezésnél a `publishedAt` csökkenő sorrendje dönt. + +Manuálisan bármely publikált videó kiválasztható, láthatóságtól függetlenül. Piszkozat, archivált, lomtárban lévő, önhivatkozás vagy duplikáció nem engedélyezett. Megjelenítéskor a néző jogosultsága minden esetben szűr. + +### 5.7 Videórészlet + +A blokkok sorrendje: + +1. player; +2. cím; +3. készült és feltöltve dátum; +4. esemény linkje; +5. leírás; +6. vendégek; +7. felhasznált zenék; +8. címkék; +9. stáb pozíciónként; +10. kapcsolódó videók. + +Üres opcionális blokk nem jelenik meg. A stábtag neve a tagprofilra visz. A címke a videólistát nyitja aktív címkeszűrővel. + +### 5.8 Videólista + +A videókártya csak thumbnailt és címet mutat. + +Rendezések: + +- alapértelmezett és utoljára feltöltött: `publishedAt` csökkenő; +- időrendi: `recordedAt` csökkenő, hiányzó értékek hátul; +- legnézettebb: megtekintésszám csökkenő. + +Egyezésnél `publishedAt`, majd UUID ad stabil sorrendet. + +Szűrők: + +- szabad szöveg; +- több címke `ÉS` kapcsolattal; +- esemény; +- `recordedAt` dátumtartomány; +- stábtag; +- stábszerep. + +Alapértelmezett oldalméret 50. Választható értékek: 10, 25, 50, 100. A rendezés, lapozás és szűrők az URL-ben maradnak. + +## 6. Események + +### 6.1 Adatmodell és publikálás + +Egy esemény mezői: + +- UUID és slug; +- cím; +- plain text leírás; +- opcionális thumbnail URL; +- kezdődátum; +- opcionális befejezési dátum; +- állapot; +- létrehozó, utolsó módosító és időbélyegek. + +Piszkozathoz csak cím kell. Publikáláshoz cím és kezdődátum szükséges. A befejezés nem lehet korábbi a kezdésnél. Jövőbeli esemény publikálható. + +Az esemény thumbnailje opcionális. Hiányában a legújabb, néző számára látható videó thumbnailje használható, majd placeholder következik. + +### 6.2 Láthatóság és származtatott adatok + +Az esemény mindig publikus. A videólista, a videók száma és a stáblista csak a néző számára látható videókból készül. + +Az eseményhez nincs külön stáblista. Az oldal a videók egyedi stábtagjait mutatja név szerint rendezve, titulus nélkül. + +### 6.3 Lista és részletoldal + +Az eseménykártyán jelenik meg: + +- thumbnail; +- cím; +- a néző számára látható videók száma a thumbnailre helyezett overlayben. + +Az eseménylista kezdődátum szerint csökkenő sorrendű. Alapértelmezett oldalmérete 50, a videólistával azonos választható méretekkel. + +Az eseményrészleten jelenik meg: + +- cím, thumbnail, dátumintervallum és leírás; +- videók `recordedAt` szerint csökkenő sorrendben, 50-es lapozással; +- a származtatott stáblista. + +### 6.4 Végleges törlés + +Eseményt csak vezetőségi tag törölhet. A művelet azonnali és végleges. + +Egy tranzakción belül: + +1. minden videó eseménykapcsolata megszűnik; +2. az esemény törlődik; +3. teljes auditbejegyzés készül. + +A videók `recordedAt` értéke megmarad. A megerősítéshez az esemény címét be kell írni. + +## 7. Címkék és stábszerepek + +### 7.1 Címkék + +Külön kategóriarendszer nincs. Egy videóhoz több címke, egy címkéhez több videó tartozhat. + +- Tag csak meglévő címkét rendelhet videóhoz. +- Vezetőségi tag létrehozhat, átnevezhet, összevonhat és törölhet címkét. +- Használt címke törölhető figyelmeztetés és címbeírás után. +- Törléskor minden videókapcsolat megszűnik. +- Összevonáskor minden kapcsolat a célcímkére kerül. +- Kis- és nagybetű, valamint felesleges szóköz nem hozhat létre duplikációt. +- Az ékezet jelentésmegkülönböztető. A rendszer csak figyelmeztet az ékezet nélkül hasonló névre. + +### 7.2 Stábszerepek + +A jogosultsági szerepek és a stábszerepek külön táblában élnek. + +- Vezetőségi tag hozhat létre, nevezhet át, rendezhet és vonhat össze stábszerepet. +- A szerepnek `displayOrder` értéke van. +- Használatban lévő szerep nem törölhető. +- Egy videó azonos szerepéhez több tag tartozhat. +- Egy tag ugyanazon a videón több szerepet is kaphat. + +## 8. Tagok + +### 8.1 Authentik mint forrás + +Az Authentik a profil- és jogosultsági adatok egyetlen írható forrása. Az alkalmazás profiloldala és adminfelülete csak olvas. + +Az OOB config leképezi: + +- a stabil `sub` azonosítót; +- felhasználónevet; +- teljes nevet; +- becenevet; +- profilkép URL-t; +- tagsági státuszt; +- csatlakozási évet és félévet; +- bemutatkozást; +- `tag` és `vezetoseg` csoportot. + +A csatlakozási félév szabad szövegként is érkezhet. A cache tárolja a nyers értéket, valamint a config alapján feldolgozott évet és `spring | autumn` értéket. Ismeretlen formátum vagy státusz szinkronhibát okoz. A profil ilyenkor nem jelenik meg a publikus listán. + +Emailt és mobilszámot az alkalmazás nem kér, nem cache-el és nem jelenít meg. + +### 8.2 Cache és szinkron + +- Szinkron induláskor, óránként és vezetőségi kézi indításra fut. +- A publikus kérés nem hívja közvetlenül az Authentiket. +- Authentik-kieséskor az utolsó cache marad publikus. +- Új belépés nem lehetséges, a meglévő session legfeljebb egy óráig él. +- Authentikből eltűnt tag utolsó ismert, nem szerkeszthető rekordja megmarad. +- A történelmi stáblista és tevékenység nem törlődik. +- A rejtett vezetőségi felület mutatja a szinkronállapotot, hibákat és az utolsó futást. + +### 8.3 Tagsági státuszok + +Egy személynek pontosan egy tagsági státusza van: + +- stúdiós; +- stúdiósjelölt; +- stúdiósjelölt-jelölt; +- aktív öregtag; +- archivált öregtag; +- dolgozott még velünk. + +A vezetőségi szerep ettől külön áll. A vezetőségi tag csak a Vezetőség blokkban jelenik meg, saját státuszának blokkjában nem ismétlődik. + +### 8.4 Taglista és profil + +Aktív tagoldal blokkjai: + +1. vezetőség; +2. stúdiósok; +3. stúdiósjelöltek; +4. stúdiósjelölt-jelöltek; +5. aktív öregtagok. + +Az archivált öregtagok és a korábbi közreműködők külön publikus aloldalt kapnak, 50-es lapozással. Az aktív tagoldalon nincs lapozás. + +A tagkártya profilképet, teljes nevet és becenevet mutat. + +A profil sorrendje: + +1. profilkép, név és becenév; +2. státusz és vezetőségi szerep; +3. csatlakozási félév; +4. bemutatkozás; +5. tevékenység. + +A tevékenység év és szerep nézet között váltható. Mindkettő `recordedAt` szerint csökkenő. + +- Év nézetben az évek alatt stábszerep szerinti csoportok jelennek meg. +- Szerep nézetben a szerepek alatt időrendben jelennek meg a videók. +- Több szerepnél ugyanaz a videó minden érintett csoportban megjelenik. +- Nézetenként 50 videó töltődik be, majd `Továbbiak betöltése` folytatja. +- A nézet és a kiválasztott csoport az URL-ben marad. +- Csak a néző számára látható videók jelennek meg. + +## 9. Homepage és live + +### 9.1 Prioritás + +A homepage állapota számított prioritás: + +1. aktív live; +2. kiemelt videó; +3. normál állapot. + +Live és kiemelt állapotban a hero mellett öt legutóbbi publikus videó jelenik meg. A hero videó nem ismétlődhet a listában. Normál állapotban hat legutóbbi publikus videó jelenik meg. + +Mindhárom állapot alatt hat esemény látható, kezdődátum szerint csökkenő sorrendben. Jövőbeli publikált esemény is szerepelhet. + +### 9.2 Kiemelés + +- Csak publikált, publikus videó emelhető ki. +- A vezetőség választja ki. +- A kiemelés nem időzíthető. +- Archiválás, lomtár vagy láthatóság-szűkítés ugyanabban a tranzakcióban megszünteti a kiemelést. + +### 9.3 Live + +A live egy YouTube-videóhoz tartozó ütemezés: + +- elfogadott URL-formák: `youtube.com/watch`, `youtube.com/live`, `youtu.be`, YouTube embed; +- a rendszer videóazonosítóra normalizálja az URL-t; +- a megjelenítés `youtube-nocookie.com` embedet használ; +- az oEmbed ellenőrzés mentéskor és aktiváláskor fut; +- kezdési és befejezési idő kötelező; +- egymást átfedő live-ok nem menthetők; +- a vezetőség `Indítás most` és `Lezárás most` műveletet kap; +- nincs autoplay; +- a kezdés előtti 24 órában `Adás hamarosan` sáv jelenik meg; +- a sáv nem váltja le a normál vagy kiemelt hero tartalmát; +- a homepage frissítés nélkül vált, és percenként ellenőrzi az állapotot; +- aktiválási hibánál a homepage kiemelt vagy normál állapotra esik vissza; +- futó live-ot átmeneti YouTube-hiba nem kapcsol le automatikusan. + +Befejezett live nem jelenik meg publikus archívumban. Az admin olvasható előzményt tart meg. Korábbi live csak másolatként ütemezhető újra. + +A live-ból nem készül automatikusan videó. Az editor új normál videót vesz fel, és kézzel rendelheti hozzá az `Adás` címkét. + +## 10. További publikus oldalak + +### 10.1 Rólunk + +- A szöveg verziókezelt plain text tartalom. +- Módosítása kódváltozást igényel. +- Az oldal alján legfeljebb hat, vezetőség által választott és sorrendezett publikus videó jelenik meg. +- Archivált, lomtárban lévő vagy nem publikus videó automatikusan kiesik. + +### 10.2 Tanfolyam + +A `/courses` útvonal ugyanabban a böngészőfülben a `https://tanfolyam.bsstudio.hu/` oldalra irányít. + +Helyi tanfolyami űrlap, adatmodell, admin, export és email nincs. + +## 11. Keresés + +### 11.1 Globális kereső + +A navbar keresője két karaktertől indul, 250 ms késleltetéssel. Csoportonként legfeljebb öt találatot mutat, billentyűzettel is használható. + +Találattípusok: + +- videó, amely a videórészletre visz; +- esemény, amely az eseményrészletre visz; +- tag, amely a tagprofilra visz; +- címke, amely aktív szűrővel a videólistára visz. + +A felületen a személy `Tag`, a videóhoz rendelt adat `Címke` néven jelenik meg. + +### 11.2 Súlyozás + +Fontossági sorrend: + +1. pontos cím, név vagy becenév; +2. cím, név vagy címke elejének egyezése; +3. videócímke és eseménycím; +4. vendégek és stáblista; +5. leírás és bemutatkozás. + +A felhasznált zenékben nincs keresés. A keresés kis- és nagybetűtől, valamint ékezettől független. A kisebb elgépeléseket trigram alapú hasonlóság kezeli. + +Keresőkifejezésnél a relevancia az alapértelmezett rendezés, egyezésnél `publishedAt` csökkenő. A felhasználó ezt felülírhatja a videólista rendezéseivel. + +### 11.3 Teljes keresőoldal + +A `/search` fülei: + +- Összes; +- Videók; +- Események; +- Tagok. + +Az Összes fül típusonként legfeljebb tíz találatot mutat. A részletes videókeresés a `/videos` oldal elfogadott szűrőit használja. A dátum csak külön dátummezőként kereshető, természetes nyelvű dátumfelismerés nincs. + +Üres keresés nem listázza ki az adatbázist. Keresési útmutatót és linket mutat a részletes videószűrőhöz. + +### 11.4 Hozzáférés + +A keresési lekérdezés már az adatbázisban kizárja a nem látható videókat. Cím, thumbnail, találatszám és más metaadat sem szivároghat ki kliensoldali utószűrés előtt. + +## 12. Adminfelület + +### 12.1 Navigáció + +A sidebar elemei: + +- Videók; +- Események; +- Live és kiemelés; +- Címkekatalógus; +- Stábszerepek; +- Tagok, csak vezetőségnek és csak olvashatóan; +- Lomtár; +- Auditnapló, csak vezetőségnek. + +Külön dashboard nincs. Belépés után a Videók lista nyílik meg. Az admin mobilon is használható. Az összetett táblák kártyanézetre válthatnak. + +### 12.2 Videólista + +Oszlopok: + +- thumbnail és cím; +- állapot; +- láthatóság; +- esemény; +- `recordedAt`; +- `publishedAt`; +- megtekintésszám; +- utolsó módosító és módosítás ideje. + +Szűrők: + +- keresés; +- állapot; +- láthatóság; +- esemény; +- címke. + +Tömeges törlés, archiválás és láthatóság-módosítás nincs a V0-ban. + +### 12.3 Eseménylista + +Oszlopok: + +- cím; +- dátum vagy intervallum; +- állapot; +- videók száma; +- utolsó módosító és módosítás ideje. + +Szűrők: keresés, állapot és dátum. Tömeges törlés nincs. + +### 12.4 Párhuzamos szerkesztés + +Az alkalmazás optimista verzióellenőrzést használ. Ha más közben módosította a rekordot, a második mentés blokkolódik. A felület konfliktusüzenetet és frissítési lehetőséget ad. Csendes, utolsó mentés nyer működés nincs. + +Session lejártakor a szerver elutasítja a mentést. A kliens megőrzi a kitöltött adatot, új belépést kér, majd engedi az újraküldést. + +## 13. Törlés és audit + +### 13.1 Videólomtár + +- Bármely tag lomtárba tehet videót normál megerősítés után. +- Minden tag látja a lomtárat és a törlés szereplőjét, illetve idejét. +- Csak vezetőségi tag állíthat vissza. +- Visszaállításkor a videó archivált lesz. +- A címke-, stáb-, esemény- és kapcsolódóvideó-kapcsolatok a lomtárban megmaradnak. +- Napi feladat törli végleg a legalább 30 napja lomtárban lévő rekordot. +- A külső médiafájlok nem törlődnek. + +### 13.2 Auditnapló + +Minden létrehozás, módosítás, publikálás, archiválás, lomtár, visszaállítás, végleges törlés és konfigurált adminművelet naplózódik. + +Az audit tartalma: + +- Authentik-azonosító vagy `system` szereplő; +- pontos idő; +- entitástípus és azonosító; +- művelet; +- változás előtti és utáni érték. + +A naplót csak a vezetőség látja. Szereplőre, műveletre, entitásra és dátumra szűrhető. Nem törölhető, nem exportálható és nem kínál automatikus visszaállítást. Megőrzése korlátlan. + +Rendszerművelet csak tényleges változásnál, hibánál vagy törlésnél ír auditot. Változatlan óránkénti szinkron nem. + +## 14. Authentik és alkalmazásbiztonság + +- OIDC Authorization Code flow PKCE-vel. +- Access token nem kerül `localStorage`-ba. +- Session HTTP-only cookie-ban él. +- Productionben a cookie `Secure`, mindig `SameSite=Lax`. +- Jogosultságot minden szerveroldali lekérdezés és módosítás ellenőriz. +- Szerepváltozás legfeljebb egy órán belül érvényesül. +- Névtelen felhasználó korlátozott linknél belépési lehetőséget kap, megtartott visszatérési URL-lel. +- Bejelentkezett, de jogosulatlan felhasználó `403` oldalt kap. +- A tiltott videó címe és thumbnailje nem kerül a HTML-be. +- Piszkozat, archivált, lomtárban lévő, végleg törölt vagy nem létező publikus útvonal egységes `404` oldalt ad. + +## 15. Háttérfolyamatok és health + +Az alkalmazásszerver indítja: + +- az óránkénti Authentik-szinkront; +- a napi videólomtár-törlést; +- a live kezdési és befejezési állapotváltásait. + +PostgreSQL advisory lock akadályozza meg a párhuzamos, kétszeres futást. Külön worker és Redis nem kell. + +Health végpontok: + +- `/health/live`, amely az alkalmazás futását ellenőrzi; +- `/health/ready`, amely az adatbázist és a migrációk állapotát ellenőrzi. + +Authentik-, live- és médiaellenőrzési hiba tartós vezetőségi figyelmeztető sávban és részletes naplóban jelenik meg. Külső email vagy SMS nincs. + +## 16. Útvonalak és metaadatok + +Publikus útvonalak: + +- `/videos`; +- `/videos/{slug}`; +- `/events`; +- `/events/{slug}`; +- `/members`; +- `/members/{slug}`; +- külön archivált tag- és közreműködő-aloldalak; +- `/about`; +- `/courses`; +- `/search`; +- `/admin`. + +A régi Drupal `/video`, `/event` és `/user` linkek átirányítása nem része a lokális V0-nak. Production átállás előtt külön feladat. + +Minden publikus videó-, esemény- és tagoldal kap: + +- egyedi címet; +- leírást; +- canonical URL-t; +- Open Graph képet. + +Legyen robots.txt és csak publikus tartalmat felsoroló sitemap. Külön megosztás gomb nincs. + +## 17. Seed és lokális környezet + +### 17.1 Scraper + +Egy agent futtatja a scraper folyamatot. Az eredmény előre kinyert, gitignore-olt JSON. + +A minta tartalma: + +- 50 videó; +- a hozzájuk tartozó események és címkék; +- cím, leírás, zene, dátumok, MP4 URL és thumbnail URL; +- stábszerepek és kapcsolatok; +- következetes álnevekre cserélt személyek. + +Profilbemutatkozás, email és médiafájl nem kerül a JSON-ba. A scrapernek kifejezett üzemeltetői engedélye van a robots.txt crawl delay figyelmen kívül hagyására. + +Futtatási szabályok: + +- legfeljebb öt párhuzamos kérés; +- `429` és `5xx` esetén exponenciális visszalépés; +- oldalanként legfeljebb három próbálkozás; +- megszakítás után folytatható működés. + +A seedhez tartozó álneveket a lokális Authentik bootstrap tesztprofiljai képviselik. + +### 17.2 Indítás + +A dokumentált lokális folyamat: + +1. függőségek telepítése; +2. OOB fájlok ellenőrzése; +3. PostgreSQL és Authentik indítása; +4. tiszta migrációk futtatása; +5. Authentik blueprint vagy bootstrap futtatása; +6. seed betöltése; +7. alkalmazás indítása; +8. typecheck, lint és tesztek futtatása. + +A jelenlegi prototípus adatbázissémája eldobható. Új, tiszta migrációs alap készül. + +## 18. Minőségi követelmények + +- Magyar felület. +- Reszponzív publikus és adminoldalak. +- Mobil és asztali használat. +- Billentyűzettel kezelhető kereső és alapvető űrlapok. +- Az akadálymentesség hasznos cél, de formális WCAG megfelelés nem V0 kiadási feltétel. +- Magyar, eltérő üres, betöltési és hibaállapotok. +- Stabil, szerveroldali lapozás és rendezés. +- Typecheck és lint hibamentes. +- Tiszta adatbázison lefutó migrációk. +- Jogosultság-, állapot-, keresés- és törlésfolyamatokat lefedő integrációs tesztek. +- Belépést, videópublikálást, eseménykezelést és live prioritást lefedő végponttól végpontig tesztek. +- Külső média- és YouTube-hívások tesztben mockolva. +- A 30 napos törlés tesztelhető órával, valós várakozás nélkül. + +## 19. V0-n kívüli elemek + +- rating; +- kommentek; +- share és download gomb; +- IP-alapú egyedi megtekintés; +- médiafeltöltés és transzkódolás; +- az MP4-fájlok tényleges hozzáférés-védelme; +- tanfolyami űrlap, admin, export és email; +- felkéréskezelő; +- live publikus archívum és automatikus replay-videó; +- a régi oldal teljes migrációja; +- régi Drupal linkek átirányítása; +- production telepítés; +- külső UptimeRobot-konfiguráció; +- email- és mobilmezők; +- audit export és automatikus visszaállítás; +- tömeges adminműveletek. + +## 20. Elfogadási forgatókönyvek + +1. Névtelen látogató csak publikus videót talál és nyit meg. +2. Schönherzes felhasználó publikus és schönherzes videót lát, adminba nem léphet. +3. Tag minden láthatóságot elér, piszkozatot készít, publikál, archivál és lomtárba helyez. +4. Vezetőségi tag visszaállít videót, végleg töröl eseményt, címkét és stábszerepet kezel. +5. Hibás vagy átirányító média-URL piszkozatban menthető, de nem publikálható. +6. Egy videó egynapos eseménynél automatikus dátumot kap. Többnapos eseménynél a tartományon kívüli dátum figyelmeztetést ad. +7. Kapcsolódó videók manuális, eseményes és közöscímkés ága is működik, jogosultsági szűréssel. +8. Az esemény videószáma, videólistája és stáblistája nem szivárogtat korlátozott videót. +9. A tagprofil tevékenysége év és szerep szerint váltható, és jogosultság szerint szűrt. +10. A globális kereső nem ad ki nem látható videómetaadatot. +11. A homepage live, kiemelt és normál prioritása frissítés nélkül vált. +12. Átfedő live nem menthető, hibás YouTube live nem aktiválódik. +13. Két egyidejű szerkesztés közül az elavult mentést a szerver blokkolja. +14. A lomtár visszaállítása megőrzi a kapcsolatokat, majd archivált állapotot ad. +15. A tesztóra 30 nap után végleg törli a videó rekordját, a külső média érintése nélkül. +16. Authentik-kieséskor a publikus cache működik, új belépés nem. +17. Tiszta klón dokumentált lépésekkel elindítható az OOB csomag birtokában. From f2ce4d0a097e8e07a3302be86b19d5a21912b80e Mon Sep 17 00:00:00 2001 From: Gyula Kiri Date: Sun, 23 Aug 2026 16:46:21 +0200 Subject: [PATCH 02/25] feat: phase 1 --- .cta.json | 8 +- .env.example | 5 +- .gitignore | 2 + .prettierignore | 8 +- README.md | 45 +- docker-compose.dev.yml | 39 +- docs/examples/oob-config.example.json | 46 + docs/implementation-progress.md | 67 + docs/oob-inputs.md | 49 + .../migration.sql | 220 ++ .../snapshot.json | 2316 +++++++++++++++++ eslint.config.js | 10 +- package.json | 40 +- pnpm-lock.yaml | 733 +++--- pnpm-workspace.yaml | 18 + prettier.config.js | 6 +- scripts/bootstrap-local.ts | 31 + scripts/check-oob.ts | 21 + scripts/lib/local-bootstrap.ts | 418 +++ src/components/EventCard.tsx | 6 +- src/components/Footer.tsx | 1 - src/components/MemberCard.tsx | 6 +- src/components/Navbar.tsx | 8 +- src/components/ThemeToggle.tsx | 7 - src/components/Videoplayer.tsx | 2 - src/components/ui/button.tsx | 43 +- src/db/schema.ts | 604 +++-- src/lib/clock.ts | 43 + src/lib/utils.ts | 5 +- src/routeTree.gen.ts | 128 +- src/router.tsx | 6 +- src/routes/__root.tsx | 7 +- src/routes/courses.tsx | 2 +- src/routes/demo/tanstack-query.tsx | 40 - src/routes/index.tsx | 32 +- src/routes/members/$memberId.tsx | 12 +- src/routes/members/index.tsx | 12 +- src/routes/videos/$videoId.tsx | 16 +- src/routes/videos/index.tsx | 5 +- src/server/config/load.ts | 44 + src/server/config/oob-schema.ts | 353 +++ src/styles.css | 114 +- tests/helpers/factories.ts | 148 ++ tests/helpers/http-mock.ts | 107 + tests/helpers/oob-config.ts | 121 + tests/helpers/test-db.ts | 48 + tests/integration/schema.migration.test.ts | 147 ++ tests/integration/smoke.db.test.ts | 79 + tests/unit/clock.test.ts | 46 + tests/unit/http-mock.test.ts | 96 + tests/unit/local-bootstrap.test.ts | 136 + tests/unit/oob-config-load.test.ts | 65 + tests/unit/oob-config.test.ts | 97 + tsconfig.json | 10 +- vite.config.ts | 8 +- vitest.config.ts | 24 + 56 files changed, 5748 insertions(+), 962 deletions(-) create mode 100644 docs/examples/oob-config.example.json create mode 100644 docs/implementation-progress.md create mode 100644 docs/oob-inputs.md create mode 100644 drizzle/20260823133645_silent_titanium_man/migration.sql create mode 100644 drizzle/20260823133645_silent_titanium_man/snapshot.json create mode 100644 scripts/bootstrap-local.ts create mode 100644 scripts/check-oob.ts create mode 100644 scripts/lib/local-bootstrap.ts create mode 100644 src/lib/clock.ts delete mode 100644 src/routes/demo/tanstack-query.tsx create mode 100644 src/server/config/load.ts create mode 100644 src/server/config/oob-schema.ts create mode 100644 tests/helpers/factories.ts create mode 100644 tests/helpers/http-mock.ts create mode 100644 tests/helpers/oob-config.ts create mode 100644 tests/helpers/test-db.ts create mode 100644 tests/integration/schema.migration.test.ts create mode 100644 tests/integration/smoke.db.test.ts create mode 100644 tests/unit/clock.test.ts create mode 100644 tests/unit/http-mock.test.ts create mode 100644 tests/unit/local-bootstrap.test.ts create mode 100644 tests/unit/oob-config-load.test.ts create mode 100644 tests/unit/oob-config.test.ts create mode 100644 vitest.config.ts diff --git a/.cta.json b/.cta.json index 8a495a3..fbb2a92 100644 --- a/.cta.json +++ b/.cta.json @@ -12,9 +12,5 @@ "routerOnly": false, "version": 1, "framework": "react", - "chosenAddOns": [ - "eslint", - "nitro", - "tanstack-query" - ] -} \ No newline at end of file + "chosenAddOns": ["eslint", "nitro", "tanstack-query"] +} diff --git a/.env.example b/.env.example index 4c32ad6..6f3d087 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ -DATABASE_URL=postgres://bss:bss@127.0.0.1:5582/bss \ No newline at end of file +DATABASE_URL=postgres://bss:bss@127.0.0.1:5582/bss + +# Integrációs tesztek adminisztrátori kapcsolata (adatbázis-létrehozáshoz) +TEST_DATABASE_URL=postgres://bss:bss@127.0.0.1:5582/bss diff --git a/.gitignore b/.gitignore index 8b25bb5..a5c6d41 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ dist dist-ssr *.local .env +.env.* +oob .nitro .tanstack .wrangler diff --git a/.prettierignore b/.prettierignore index 5322d7f..e8770fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,9 @@ package-lock.json pnpm-lock.yaml -yarn.lock \ No newline at end of file +yarn.lock + +dist +.output +.nitro +.tanstack +node_modules \ No newline at end of file diff --git a/README.md b/README.md index af3dbac..db24644 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,41 @@ - [V0 követelményspecifikáció](./docs/product-specification.md) - [Kártyákra bontott implementációs terv](./docs/implementation-plan.md) +- [OOB bemenetek](./docs/oob-inputs.md) + +# Lokális környezet (fejlesztői alap) + +Előfeltétel: Node 24+, pnpm (pl. `corepack enable`), Docker. + +```bash +pnpm install +pnpm infra:bootstrap # helyi titkok és Authentik blueprint generálása az oob/ könyvtárba +docker compose -f docker-compose.dev.yml up -d +export DATABASE_URL=postgres://bss:bss@127.0.0.1:5582/bss +pnpm db:migrate # tiszta adatbázison lefutó migráció +pnpm check:oob # OOB config ellenőrzése +pnpm dev # http://localhost:3000 +``` + +A `pnpm infra:bootstrap` idempotens: meglévő titkokat nem ír felül, újraindítás nem duplikál adatot. + +Tesztfelhasználók és jelszavak: `oob/local-secrets.json` (gitignore-olt). Szereplők: + +| Felhasználó | Szerep | +| --------------------- | ------------------------------------ | +| (nincs bejelentkezés) | névtelen látogató | +| schonherz-dev | bejelentkezett schönherzes | +| tag-dev | BSS-tag, minden adminjog | +| vezetoseg-dev | vezetőségi tag, kibővített jogok | +| további `-dev` userek | szintetikus tagprofilok státuszokhoz | + +# OOB bemenetek + +Az alkalmazás három bemenete nem érkezik a gitből: az Authentik mapping config, a helyi titkok és a seed JSON. Helyüket, formájukat és ellenőrzésüket a [docs/oob-inputs.md](./docs/oob-inputs.md) dokumentum írja le. Ellenőrzésük: + +```bash +pnpm check:oob +``` # Getting Started @@ -45,7 +80,6 @@ If you prefer not to use Tailwind CSS: ## Linting & Formatting - This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available: ```bash @@ -54,7 +88,6 @@ pnpm format pnpm check ``` - ## Deploy with Nitro This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host. @@ -68,8 +101,6 @@ The build output is a self-contained Node server. To deploy, push the `dist/` di For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy. - - ## Routing This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`. @@ -87,7 +118,7 @@ Now that you have two routes you can use a `Link` component to navigate between To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. ```tsx -import { Link } from "@tanstack/react-router"; +import { Link } from '@tanstack/react-router' ``` Then anywhere in your JSX you can use it like so: @@ -155,11 +186,11 @@ const getServerTime = createServerFn({ // Use in a component function MyComponent() { const [time, setTime] = useState('') - + useEffect(() => { getServerTime().then(setTime) }, []) - + return
Server time: {time}
} ``` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 883c527..8cba9f7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,14 +4,21 @@ services: image: postgres:18.4 restart: always ports: - - "5582:5432" + - '5582:5432' environment: POSTGRES_USER: bss POSTGRES_PASSWORD: bss POSTGRES_DB: bss volumes: - bss-dev_db:/var/lib/postgresql -# - ./assets/initdb.sql:/docker-entrypoint-initdb.d/initdb.sql + healthcheck: + test: + - CMD-SHELL + - pg_isready -U bss -d bss + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s networks: - bss-dev @@ -21,7 +28,7 @@ services: image: dpage/pgadmin4:9.16 restart: always ports: - - "8081:80" + - '8081:80' environment: PGADMIN_DEFAULT_EMAIL: bss@bss.dev PGADMIN_DEFAULT_USERNAME: bss @@ -31,7 +38,6 @@ services: - bss-dev_pgadmin:/var/lib/pgadmin networks: - bss-dev - - bss-dev-authentik bss-dev-authentik-postgresql: container_name: bss-dev-authentik-postgresql @@ -61,21 +67,21 @@ services: depends_on: bss-dev-authentik-postgresql: condition: service_healthy + env_file: + - path: ./oob/authentik.env + required: false environment: AUTHENTIK_POSTGRESQL__HOST: bss-dev-authentik-postgresql AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: authentik AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_SECRET_KEY: secret image: authentik/server:2026.5.4 ports: - - "9000:9000" - - "9443:9443" + - '9000:9000' restart: unless-stopped shm_size: 512mb volumes: - - ./assets/data:/data - - ./assets/custom-templates:/templates + - ./oob/authentik/blueprints:/blueprints networks: - bss-dev-authentik @@ -85,30 +91,29 @@ services: depends_on: bss-dev-authentik-postgresql: condition: service_healthy + env_file: + - path: ./oob/authentik.env + required: false environment: AUTHENTIK_POSTGRESQL__HOST: bss-dev-authentik-postgresql AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: authentik AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_SECRET_KEY: secret image: authentik/server:2026.5.4 restart: unless-stopped shm_size: 512mb user: root volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./assets/data:/data - - ./assets/certs:/certs - - ./assets/custom-templates:/templates + - ./oob/authentik/blueprints:/blueprints networks: - bss-dev-authentik networks: - bss-dev: - bss-dev-authentik: + bss-dev: + bss-dev-authentik: volumes: bss-dev_db: bss-dev_pgadmin: bss-dev-authentik_database: - driver: local \ No newline at end of file + driver: local diff --git a/docs/examples/oob-config.example.json b/docs/examples/oob-config.example.json new file mode 100644 index 0000000..a4d2085 --- /dev/null +++ b/docs/examples/oob-config.example.json @@ -0,0 +1,46 @@ +{ + "authentik": { + "issuerUrl": "https://TÖLTSD-KI/authentik/application/o/bss-stack/", + "clientId": "TÖLTSD-KI", + "clientSecret": "TÖLTSD-KI-titok-nem-kerülhet-gitbe", + "scopes": ["openid", "profile", "email"], + "claims": { + "sub": "sub", + "username": "preferred_username", + "fullName": "name", + "nickname": "nickname", + "avatarUrl": "picture" + }, + "groups": { + "schonherz": "TÖLTSD-KI-schönherzes-csoport-neve", + "tag": "TÖLTSD-KI-tag-csoport-neve", + "vezetoseg": "TÖLTSD-KI-vezetőség-csoport-neve" + }, + "attributes": { + "membershipStatus": { + "attribute": "TÖLTSD-KI-tagsági-státusz-attribútum-kulcsa", + "values": { + "TÖLTSD-KI-nyers-érték": "studio_member", + "TÖLTSD-KI-nyers-érték-2": "senior_active" + } + }, + "joinedSemester": { + "attribute": "TÖLTSD-KI-csatlakozási-félév-attribútum-kulcsa", + "rules": [ + { "pattern": "^(\\d{4})\\s+(ősz|őszi)$", "semester": "autumn" }, + { "pattern": "^(\\d{4})\\s+(tavasz|tavaszi)$", "semester": "spring" } + ] + }, + "introduction": "TÖLTSD-KI-bemutatkozás-attribútum-kulcsa" + } + }, + "media": { + "allowedHosts": ["v.bsstudio.hu"] + }, + "youtube": { + "oEmbedEndpoint": "https://www.youtube.com/oEmbed" + }, + "seed": { + "path": "oob/seed.json" + } +} diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md new file mode 100644 index 0000000..c1ffd05 --- /dev/null +++ b/docs/implementation-progress.md @@ -0,0 +1,67 @@ +# BSS V0 implementációs állapot + +Utolsó frissítés: 2026-08-23 (0. fázis lezárása után) + +## Aktuális fázis + +**0. fázis – Stabil alap és tiszta séma: KÉSZ, felhasználói jóváhagyásra vár.** + +A következő fázis az **1. fázis: auth és közös infrastruktúra** (BSS-006 – BSS-011). Munka csak a felhasználó külön jóváhagyása után kezdhető. + +## Kártyák állapota + +| Kártya | Név | Állapot | Ellenőrzések | +| ------- | --------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | +| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | +| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | +| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); régi prototípus-táblákon is lefut; DB-szintű invariánstesztek: published↔publishedAt, önhivatkozás-tilalom, live átfedés-tilalom (EXCLUDE), eseménydátum-check, Rólunk pozíció-limit; 3 integrációs sémateszt | +| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva: Authentik 2026.5.4 elindul, blueprint alkalmazódik, discovery végpont válaszol; teljes restart után is 3 csoport / 8 felhasználó / 1 provider (nem duplikál) | + +## Fáziskapu eredménye (0. fázis) + +- [x] rögzített, kompatibilis függőségek (lockfile + nincs `latest`/nightly) +- [x] zöld format check, lint, typecheck, test, build +- [x] tiszta adatbázison lefutó migráció +- [x] dokumentált config séma (`docs/oob-inputs.md`) +- [x] idempotens lokális Authentik bootstrap (élőben ellenőrizve) +- [x] mindhárom nézői szint és mindkét adminszerep tesztadata létrehozható + +## OOB bemenetek állapota + +| Fájl | Hely | Állapot | +| ------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------- | +| Authentik mapping config | `oob/config.json` (vagy `BSS_OOB_CONFIG`) | lokális változat generált és valid; éles értékek továbbra is OOB | +| Helyi titkok | `.env`, `oob/local-secrets.json`, `oob/authentik.env` | generálva, gitignore-olt, nem kerülnek gitbe | +| Seed JSON | `oob/seed.json` | helye a configban rögzített; tartalma az 5. fázisban (BSS-034) | + +Az éles Authentik mapping és seed hiánya miatt semmilyen szerződéses vagy lokális értéket nem kellett kitalálni: a lokális bootstrap saját, véletlenszerű titkokkal dolgozik. + +## Elfogadott felhasználói döntések ebben a fázisban + +Nincs új termékdöntés; minden a specifikációból és a meglévő prototípusból következik. Technikai döntések, amiket dokumentáltan hoztam: + +- OIDC callback útvonal szerződése: `/api/auth/callback` (1. fázis implementálja). +- A migrációs alap a régi lokális prototípus-objektumokat célzottan eldobja (a specifikáció szerint a séma eldobható). Csak fejlesztői adatbázisra fusson. +- `routeTree.gen.ts` committed marad; `tsr generate` a typecheck része. +- drizzle-orm/drizzle-kit pontosan rögzítve `1.0.0-rc.4`-en (a v1 relációs API-hoz); programmatikus migrátor helyett CLI + tesztfuttató. + +## Ismert hibák és technikai tartozás + +- A publikus prototípus oldalai még statikus placeholder tartalmat mutatnak (ez várható; a tartalmi domainek a 2–3. fázisban épülnek). +- `@tanstack/router-cli` (tsr) futáskor ártalmatlan circular-dependency figyelmeztetést ad Node 24 alatt. +- Az Authentik 2026.5.4-ben nincsenek gyári default flow-k/mappingek; a lokális blueprint ezért saját authentication/authorization flow-t és scope mappingeket definiál (dokumentálva a generált YAML-ben). +- docker-compose: a 9443-as külső port ütközött a gépen futó másik szolgáltatással, ezért a lokális stack HTTPS portfeltárása elhagyva (HTTP :9000 használatos). + +## Parancsok + +| Parancs | Funkció | +| -------------------------------------- | ------------------------------------ | +| `pnpm check` | formátumellenőrzés | +| `pnpm lint` | ESLint | +| `pnpm typecheck` | route-generálás + `tsc --noEmit` | +| `TEST_DATABASE_URL=... pnpm test` | unit + integrációs tesztek | +| `pnpm build` | produkciós build | +| `pnpm db:generate` / `pnpm db:migrate` | migráció generálása / futtatása | +| `pnpm check:oob` | OOB config validáció | +| `pnpm infra:bootstrap` | lokális titkok + Authentik blueprint | diff --git a/docs/oob-inputs.md b/docs/oob-inputs.md new file mode 100644 index 0000000..cef673b --- /dev/null +++ b/docs/oob-inputs.md @@ -0,0 +1,49 @@ +# OOB bemenetek + +Az alkalmazás három bemenete out-of-band, azaz nem a git repóban érkezik. Hiányzó vagy érvénytelen fájl esetén az alkalmazás konkrét hibaüzenettel áll meg, kitalált alapértéket soha nem használ. + +## 1. BSS OOB config + +- Helye alapból: `oob/config.json` (gitignore-olt). +- Felülírható a `BSS_OOB_CONFIG` környezeti változóval. +- Formája: JSON, a szerkezetét a [`docs/examples/oob-config.example.json`](./examples/oob-config.example.json) mutatja. +- Tartalma: + - Authentik OIDC kapcsolat: issuer URL, client id, client secret, scope-ok; + - claim-leképezés: `sub`, felhasználónév, teljes név, becenév, profilkép URL; + - csoportleképezés: schönherzes, tag és vezetőség csoport neve; + - attribútumleképezés: tagsági státusz nyers érték → belső kulcs, csatlakozási félév értelmezési szabályai, bemutatkozás; + - médiahost-engedélylista (specifikáció szerint `v.bsstudio.hu`); + - YouTube oEmbed végpont; + - seed JSON helye. + +## 2. Helyi titkok + +A titkok (pl. Authentik client secret, session titok) kizárólag OOB fájlokban vagy környezeti változókban élnek: + +- `.env` (gitignore-olt) — pl. `DATABASE_URL`; +- a config fájl `clientSecret` mezője. + +Titok soha nem kerül kliensbundle-be: a config csak szerveroldalon töltődik be. + +Lokális fejlesztésben a titkokat a bootstrap script generálja (`pnpm infra:bootstrap`), a generált fájlok az `oob/` könyvtárba íródnak. + +## 3. Előre kinyert seed JSON + +- Helye: a config `seed.path` mezője által megadott fájl (alapból `oob/seed.json`, gitignore-olt). +- Formáját a seed importer (BSS-034 kártya) definiálja és dokumentálja. +- A scraper kimenete; személyes adatot és médiát nem tartalmaz, álneveket használ. + +## Ellenőrzés + +A config fájl ellenőrzése a repó gyökeréből: + +```bash +pnpm check:oob +``` + +A parancs kiírja az összes hiányzó vagy érvénytelen elemet magyarul, felsorolásosan. Sikeres ellenőrzésnél összefoglalja a betöltött értékeket (a titok nélkül). + +## Viselkedés hiányzó fájl esetén + +- Az alkalmazás indítása megszakad konkrét hibaüzenettel, amely megnevezi a hiányzó fájl elérési útját és az első hibákat. +- Nincs „félig működő” névtelen mód és nincs kitalált mapping. diff --git a/drizzle/20260823133645_silent_titanium_man/migration.sql b/drizzle/20260823133645_silent_titanium_man/migration.sql new file mode 100644 index 0000000..dd5fbcb --- /dev/null +++ b/drizzle/20260823133645_silent_titanium_man/migration.sql @@ -0,0 +1,220 @@ +-- Tiszta migrációs alap: a specifikáció szerint a prototípus sémája eldobható. +-- Ez a migráció a régi lokális prototípus-objektumokat eldobja. +-- Csak fejlesztői környezetben fusson! +DROP TABLE IF EXISTS "users_roles" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "videos_events" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "events_roles_users" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "videos_roles_users" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "roles" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "events" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "video_tags" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "related_videos" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "videos" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "tags" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "users" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "current_homepage_status" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "homepage_status" CASCADE;--> statement-breakpoint +DROP TYPE IF EXISTS "homepage_status";--> statement-breakpoint +DROP TYPE IF EXISTS "visibility";--> statement-breakpoint +CREATE EXTENSION IF NOT EXISTS "btree_gist";--> statement-breakpoint +CREATE TYPE "content_status" AS ENUM('draft', 'published', 'archived', 'trash');--> statement-breakpoint +CREATE TYPE "event_status" AS ENUM('draft', 'published', 'archived');--> statement-breakpoint +CREATE TYPE "live_status" AS ENUM('scheduled', 'active', 'ended');--> statement-breakpoint +CREATE TYPE "member_sync_status" AS ENUM('ok', 'error');--> statement-breakpoint +CREATE TYPE "membership_status" AS ENUM('studio_member', 'studio_candidate', 'studio_applicant', 'senior_active', 'senior_archived', 'contributor');--> statement-breakpoint +CREATE TYPE "semester" AS ENUM('spring', 'autumn');--> statement-breakpoint +CREATE TYPE "slug_entity_type" AS ENUM('video', 'event');--> statement-breakpoint +CREATE TYPE "visibility" AS ENUM('public', 'schonherz', 'bss');--> statement-breakpoint +CREATE TABLE "about_page_videos" ( + "position" integer, + "video_id" uuid, + CONSTRAINT "about_page_videos_pkey" PRIMARY KEY("position","video_id"), + CONSTRAINT "about_page_videos_position_range_check" CHECK ("position" >= 1 and "position" <= 6) +); +--> statement-breakpoint +CREATE TABLE "audit_log" ( + "id" bigserial PRIMARY KEY, + "actor" varchar(255) NOT NULL, + "entity_type" varchar(50) NOT NULL, + "entity_id" varchar(255) NOT NULL, + "action" varchar(50) NOT NULL, + "before_value" jsonb, + "after_value" jsonb, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "slug" varchar(200) NOT NULL, + "title" varchar(200) NOT NULL, + "description" varchar(10000), + "thumbnail_url" varchar(2048), + "start_date" date NOT NULL, + "end_date" date, + "status" "event_status" DEFAULT 'draft'::"event_status" NOT NULL, + "created_by" varchar(255), + "updated_by" varchar(255), + "version" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "events_end_after_start_check" CHECK ("end_date" is null or "end_date" >= "start_date") +); +--> statement-breakpoint +CREATE TABLE "live_streams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "youtube_video_id" varchar(64) NOT NULL, + "starts_at" timestamp with time zone NOT NULL, + "ends_at" timestamp with time zone NOT NULL, + "status" "live_status" DEFAULT 'scheduled'::"live_status" NOT NULL, + "activation_error" text, + "activated_at" timestamp with time zone, + "ended_at" timestamp with time zone, + "created_by" varchar(255), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "live_streams_end_after_start_check" CHECK ("ends_at" > "starts_at") +); +--> statement-breakpoint +CREATE TABLE "member_cache" ( + "sub" varchar(255) PRIMARY KEY, + "username" varchar(200) NOT NULL, + "full_name" varchar(200) NOT NULL, + "nickname" varchar(200), + "avatar_url" varchar(2048), + "membership_status" "membership_status" NOT NULL, + "is_leadership" boolean DEFAULT false NOT NULL, + "joined_year" integer, + "joined_semester" "semester", + "joined_semester_raw" varchar(100), + "introduction" varchar(10000), + "sync_status" "member_sync_status" DEFAULT 'ok'::"member_sync_status" NOT NULL, + "last_sync_error" text, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "related_videos" ( + "video_id" uuid, + "related_video_id" uuid, + "position" integer NOT NULL, + CONSTRAINT "related_videos_pkey" PRIMARY KEY("video_id","related_video_id"), + CONSTRAINT "related_videos_no_self_reference_check" CHECK ("video_id" <> "related_video_id") +); +--> statement-breakpoint +CREATE TABLE "site_settings" ( + "id" integer PRIMARY KEY DEFAULT 0, + "highlighted_video_id" uuid, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "slug_history" ( + "entity_type" "slug_entity_type", + "slug" varchar(200), + "entity_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "slug_history_pkey" PRIMARY KEY("entity_type","slug") +); +--> statement-breakpoint +CREATE TABLE "staff_roles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" varchar(64) NOT NULL, + "normalized_name" varchar(64) NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tags" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" varchar(64) NOT NULL, + "normalized_name" varchar(64) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "video_staff" ( + "video_id" uuid, + "role_id" uuid, + "member_sub" varchar(255), + CONSTRAINT "video_staff_pkey" PRIMARY KEY("video_id","role_id","member_sub") +); +--> statement-breakpoint +CREATE TABLE "video_tags" ( + "video_id" uuid, + "tag_id" uuid, + CONSTRAINT "video_tags_pkey" PRIMARY KEY("video_id","tag_id") +); +--> statement-breakpoint +CREATE TABLE "videos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "slug" varchar(200) NOT NULL, + "title" varchar(200) NOT NULL, + "description" varchar(10000), + "guests" varchar(5000), + "songs" varchar(5000), + "video_url" varchar(2048), + "thumbnail_url" varchar(2048), + "visibility" "visibility" DEFAULT 'public'::"visibility" NOT NULL, + "status" "content_status" DEFAULT 'draft'::"content_status" NOT NULL, + "event_id" uuid, + "recorded_at" date, + "published_at" timestamp with time zone, + "view_count" integer DEFAULT 0 NOT NULL, + "created_by" varchar(255), + "updated_by" varchar(255), + "version" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "trashed_at" timestamp with time zone, + "trashed_by" varchar(255), + CONSTRAINT "videos_published_requires_timestamp_check" CHECK ("status" <> 'published' or "published_at" is not null), + CONSTRAINT "videos_trash_needs_timestamp_check" CHECK ("status" <> 'trash' or "trashed_at" is not null) +); +--> statement-breakpoint +CREATE TABLE "view_sessions" ( + "video_id" uuid, + "session_id" varchar(128), + "viewed_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "view_sessions_pkey" PRIMARY KEY("video_id","session_id") +); +--> statement-breakpoint +CREATE UNIQUE INDEX "about_page_videos_video_key" ON "about_page_videos" ("video_id");--> statement-breakpoint +CREATE INDEX "audit_log_occurred_idx" ON "audit_log" ("occurred_at");--> statement-breakpoint +CREATE INDEX "audit_log_entity_idx" ON "audit_log" ("entity_type","entity_id");--> statement-breakpoint +CREATE INDEX "audit_log_actor_idx" ON "audit_log" ("actor");--> statement-breakpoint +CREATE INDEX "audit_log_action_idx" ON "audit_log" ("action");--> statement-breakpoint +CREATE UNIQUE INDEX "events_slug_key" ON "events" ("slug");--> statement-breakpoint +CREATE INDEX "events_status_start_idx" ON "events" ("status","start_date");--> statement-breakpoint +CREATE INDEX "live_streams_status_starts_idx" ON "live_streams" ("status","starts_at");--> statement-breakpoint +CREATE UNIQUE INDEX "member_cache_username_key" ON "member_cache" ("username");--> statement-breakpoint +CREATE INDEX "member_cache_status_idx" ON "member_cache" ("membership_status");--> statement-breakpoint +CREATE INDEX "slug_history_entity_idx" ON "slug_history" ("entity_type","entity_id");--> statement-breakpoint +CREATE UNIQUE INDEX "staff_roles_normalized_name_key" ON "staff_roles" ("normalized_name");--> statement-breakpoint +CREATE INDEX "staff_roles_display_order_idx" ON "staff_roles" ("display_order");--> statement-breakpoint +CREATE UNIQUE INDEX "tags_normalized_name_key" ON "tags" ("normalized_name");--> statement-breakpoint +CREATE INDEX "video_staff_member_idx" ON "video_staff" ("member_sub");--> statement-breakpoint +CREATE INDEX "video_staff_role_idx" ON "video_staff" ("role_id");--> statement-breakpoint +CREATE INDEX "video_tags_tag_idx" ON "video_tags" ("tag_id");--> statement-breakpoint +CREATE UNIQUE INDEX "videos_slug_key" ON "videos" ("slug");--> statement-breakpoint +CREATE INDEX "videos_visibility_status_idx" ON "videos" ("visibility","status","published_at");--> statement-breakpoint +CREATE INDEX "videos_event_idx" ON "videos" ("event_id");--> statement-breakpoint +CREATE INDEX "videos_recorded_at_idx" ON "videos" ("recorded_at");--> statement-breakpoint +CREATE INDEX "videos_trash_purge_idx" ON "videos" ("status","trashed_at");--> statement-breakpoint +CREATE INDEX "view_sessions_viewed_idx" ON "view_sessions" ("viewed_at");--> statement-breakpoint +ALTER TABLE "about_page_videos" ADD CONSTRAINT "about_page_videos_video_id_videos_id_fkey" FOREIGN KEY ("video_id") REFERENCES "videos"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_created_by_member_cache_sub_fkey" FOREIGN KEY ("created_by") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_updated_by_member_cache_sub_fkey" FOREIGN KEY ("updated_by") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "live_streams" ADD CONSTRAINT "live_streams_created_by_member_cache_sub_fkey" FOREIGN KEY ("created_by") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "related_videos" ADD CONSTRAINT "related_videos_video_id_videos_id_fkey" FOREIGN KEY ("video_id") REFERENCES "videos"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "related_videos" ADD CONSTRAINT "related_videos_related_video_id_videos_id_fkey" FOREIGN KEY ("related_video_id") REFERENCES "videos"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "site_settings" ADD CONSTRAINT "site_settings_highlighted_video_id_videos_id_fkey" FOREIGN KEY ("highlighted_video_id") REFERENCES "videos"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "video_staff" ADD CONSTRAINT "video_staff_video_id_videos_id_fkey" FOREIGN KEY ("video_id") REFERENCES "videos"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "video_staff" ADD CONSTRAINT "video_staff_role_id_staff_roles_id_fkey" FOREIGN KEY ("role_id") REFERENCES "staff_roles"("id") ON DELETE RESTRICT;--> statement-breakpoint +ALTER TABLE "video_staff" ADD CONSTRAINT "video_staff_member_sub_member_cache_sub_fkey" FOREIGN KEY ("member_sub") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "video_tags" ADD CONSTRAINT "video_tags_video_id_videos_id_fkey" FOREIGN KEY ("video_id") REFERENCES "videos"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "video_tags" ADD CONSTRAINT "video_tags_tag_id_tags_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "videos" ADD CONSTRAINT "videos_event_id_events_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "videos" ADD CONSTRAINT "videos_created_by_member_cache_sub_fkey" FOREIGN KEY ("created_by") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "videos" ADD CONSTRAINT "videos_updated_by_member_cache_sub_fkey" FOREIGN KEY ("updated_by") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "videos" ADD CONSTRAINT "videos_trashed_by_member_cache_sub_fkey" FOREIGN KEY ("trashed_by") REFERENCES "member_cache"("sub");--> statement-breakpoint +ALTER TABLE "view_sessions" ADD CONSTRAINT "view_sessions_video_id_videos_id_fkey" FOREIGN KEY ("video_id") REFERENCES "videos"("id") ON DELETE CASCADE;--> statement-breakpoint +-- Live időablakok nem fedhetik egymást (specifikáció 9.3). +ALTER TABLE "live_streams" ADD CONSTRAINT "live_streams_no_overlap_excl" EXCLUDE USING gist (tstzrange("starts_at", "ends_at") WITH &&); diff --git a/drizzle/20260823133645_silent_titanium_man/snapshot.json b/drizzle/20260823133645_silent_titanium_man/snapshot.json new file mode 100644 index 0000000..43229a5 --- /dev/null +++ b/drizzle/20260823133645_silent_titanium_man/snapshot.json @@ -0,0 +1,2316 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "eb102e26-e589-497d-aab1-16017519f1ce", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/eslint.config.js b/eslint.config.js index 3f272c0..319666d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,14 @@ export default [ }, }, { - ignores: ['eslint.config.js', 'prettier.config.js'], + ignores: [ + 'eslint.config.js', + 'prettier.config.js', + 'dist/**', + '.output/**', + '.nitro/**', + '.tanstack/**', + 'node_modules/**', + ], }, ] diff --git a/package.json b/package.json index d4a6e6a..011934b 100644 --- a/package.json +++ b/package.json @@ -10,32 +10,34 @@ "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", + "typecheck": "tsr generate && tsc --noEmit", "test": "vitest run", "lint": "eslint", "format": "prettier --write . && eslint --fix", "check": "prettier --check .", - "push": "npx drizzle-kit push", - "generate": "npx drizzle-kit generate", - "migrate": "npx drizzle-kit migrate" + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "check:oob": "tsx scripts/check-oob.ts", + "infra:bootstrap": "tsx scripts/bootstrap-local.ts" }, "dependencies": { "@base-ui/react": "^1.6.0", "@fontsource-variable/geist": "^5.2.9", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", - "@tanstack/react-query": "latest", - "@tanstack/react-query-devtools": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", - "@tanstack/react-start": "latest", - "@tanstack/router-plugin": "^1.132.0", + "@tanstack/react-devtools": "^0.10.12", + "@tanstack/react-query": "^5.102.1", + "@tanstack/react-query-devtools": "^5.102.1", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-router-ssr-query": "^1.167.1", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.4.2", "drizzle-orm": "1.0.0-rc.4", "lucide-react": "^0.545.0", - "nitro": "npm:nitro-nightly@latest", + "nitro": "^3.0.0", "pg": "^8.22.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -46,9 +48,9 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.16", - "@tanstack/devtools-vite": "latest", - "@tanstack/eslint-config": "latest", - "@tanstack/router-cli": "^1.132.0", + "@tanstack/devtools-vite": "^0.8.5", + "@tanstack/eslint-config": "^0.4.0", + "@tanstack/router-cli": "^1.167.33", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/node": "^22.10.2", @@ -64,11 +66,5 @@ "typescript": "^6.0.2", "vite": "^8.0.0", "vitest": "^4.1.5" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild", - "lightningcss" - ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27e6eca..7af522b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,29 +18,29 @@ importers: specifier: ^4.1.18 version: 4.3.2(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@tanstack/react-devtools': - specifier: latest - version: 0.10.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) + specifier: ^0.10.12 + version: 0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) '@tanstack/react-query': - specifier: latest - version: 5.101.2(react@19.2.7) + specifier: ^5.102.1 + version: 5.102.1(react@19.2.7) '@tanstack/react-query-devtools': - specifier: latest - version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) + specifier: ^5.102.1 + version: 5.102.1(@tanstack/react-query@5.102.1(react@19.2.7))(react@19.2.7) '@tanstack/react-router': - specifier: latest - version: 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-router-devtools': - specifier: latest - version: 1.167.0(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-router-ssr-query': - specifier: latest - version: 1.167.1(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/query-core@5.102.1)(@tanstack/react-query@5.102.1(react@19.2.7))(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.27)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-start': - specifier: latest - version: 1.168.28(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@tanstack/router-plugin': - specifier: ^1.132.0 - version: 1.168.20(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -57,8 +57,8 @@ importers: specifier: ^0.545.0 version: 0.545.0(react@19.2.7) nitro: - specifier: npm:nitro-nightly@latest - version: nitro-nightly@4.0.0-20251010-091516-7cafddba(drizzle-orm@1.0.0-rc.4(@types/pg@8.20.0)(pg@8.22.0)(zod@3.25.76))(lru-cache@11.5.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + specifier: ^3.0.0 + version: 3.0.0(drizzle-orm@1.0.0-rc.4(@types/pg@8.20.0)(pg@8.22.0)(zod@3.25.76))(lru-cache@11.5.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) pg: specifier: ^8.22.0 version: 8.22.0 @@ -70,7 +70,7 @@ importers: version: 19.2.7(react@19.2.7) shadcn: specifier: ^4.13.0 - version: 4.13.0(typescript@6.0.3) + version: 4.13.0(supports-color@7.2.0)(typescript@6.0.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -85,14 +85,14 @@ importers: specifier: ^0.5.16 version: 0.5.20(tailwindcss@4.3.2) '@tanstack/devtools-vite': - specifier: latest - version: 0.8.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + specifier: ^0.8.5 + version: 0.8.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@tanstack/eslint-config': - specifier: latest - version: 0.4.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + specifier: ^0.4.0 + version: 0.4.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@tanstack/router-cli': - specifier: ^1.132.0 - version: 1.167.19 + specifier: ^1.167.33 + version: 1.167.33(supports-color@7.2.0) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -119,10 +119,10 @@ importers: version: 1.0.0-rc.4 eslint: specifier: ^9.20.0 - version: 9.39.5(jiti@2.7.0) + version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) jsdom: specifier: ^28.1.0 - version: 28.1.0 + version: 28.1.0(supports-color@7.2.0) prettier: specifier: ^3.8.1 version: 3.9.5 @@ -137,7 +137,7 @@ importers: version: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) vitest: specifier: ^4.1.5 - version: 4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@22.20.1)(jsdom@28.1.0(supports-color@7.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages: @@ -838,6 +838,15 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@neodrag/core@3.0.0-next.11': + resolution: {integrity: sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg==} + + '@neodrag/solid@3.0.0-next.11': + resolution: {integrity: sha512-vCBIn/pimjWMQ6vhTS2/O1XNAwzVtc4eUhdbQ91WykbZWWqQ5NocDXt/1OdYrEkeRzJcpCv8wEz5PnMkgKP81Q==} + peerDependencies: + '@neodrag/core': 3.0.0-next.11 + solid-js: ^1.0.0 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1380,12 +1389,16 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/devtools-bundler-core@0.1.3': + resolution: {integrity: sha512-F0tlxIyfFqXkZ1mJP1EjtkiSeJA+ztXY2AYOHf7r4goCIEAOp86N9PFJ/yv8vu1TnmxGS6vKsZCS3Kyls9xQQA==} + engines: {node: '>=18'} + '@tanstack/devtools-client@0.0.8': resolution: {integrity: sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA==} engines: {node: '>=18'} - '@tanstack/devtools-event-bus@0.4.2': - resolution: {integrity: sha512-2LHzhwBFlKHCcklsQrGe8TeyjHd4XAF8nuCO6wHmva5fePUkJUULbu6CsCNAlGlCi0KkEsMXZSvRdR4HgMq4yA==} + '@tanstack/devtools-event-bus@0.4.3': + resolution: {integrity: sha512-NeegBt5/n2E5q4DbrXHqECBq42+kDi6JBOp8/+RNqkIE+P4hJpbM36kGyjnGQFMWOoku31qhMyX9/48VuTTdmg==} engines: {node: '>=18'} '@tanstack/devtools-event-client@0.5.0': @@ -1393,21 +1406,21 @@ packages: engines: {node: '>=18'} hasBin: true - '@tanstack/devtools-ui@0.6.0': - resolution: {integrity: sha512-CVaM6rT6Nl5ijo83vJYFa2SjofvpuOl/uOvbYGhBrRgUhhelNHhx8zZX+hnZCHmIr0/lzM65hsocnZ72592Rvg==} + '@tanstack/devtools-ui@0.7.1': + resolution: {integrity: sha512-3xQ/ezZ2qVNszhjpCN2N3jn7uHc2J1PMgcyjHzH4XZBt9xAQyMMcPNoR2cd7rzReyxWoJpZUWiDBmOJiCtLj9A==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' - '@tanstack/devtools-vite@0.8.1': - resolution: {integrity: sha512-oQxOo0fI0bwhHtw/psFlIR0OS/bsKrirBxwnw2vuhCM4bjt3k4EZZsW/lvZ1+Vpouhts7LSyvngnxvGXbQ1sUQ==} + '@tanstack/devtools-vite@0.8.5': + resolution: {integrity: sha512-xaifCEmiwwzizlbp973oISXNsnmuU/BugLa66gryAZaPJJ/qo1kJd2DxY5X960eHwmyIS3SN0KYrL3/aRhucmA==} engines: {node: '>=18'} hasBin: true peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@tanstack/devtools@0.12.5': - resolution: {integrity: sha512-JdxTSeVdjJheycgz4c7qbldNKDCEDWlWr1l9dZBhd9sOmRBT5Z70ka9Eb8mb+FUnalcOIB62IDSR/iSxAIUD8Q==} + '@tanstack/devtools@0.14.2': + resolution: {integrity: sha512-8FVVmDU+x3iEwrl5rtbIhud8gwp9eSXPlhs36SCV59yAtXmheFFvLqLAUXpfIU79sZVBg3LLTy2VlTIXaWbaBw==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -1419,18 +1432,18 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 - '@tanstack/history@1.162.0': - resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.101.2': - resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + '@tanstack/query-core@5.102.1': + resolution: {integrity: sha512-bJTsrO2ODWSgg+x1leTsK036GBtmNNjnrF1Vk06J5fg/phYZLQIdXrBe0uf5xTItXYvIW1MtbLhyPw7cPMqUUQ==} - '@tanstack/query-devtools@5.101.2': - resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==} + '@tanstack/query-devtools@5.102.1': + resolution: {integrity: sha512-zX3fv4ZpkK+5Wzw0V2Axk1z6d1jMfMGgRdLVkF6BCJwZt8Iqveboy1G8rXV1o4cQ4ptKpMS45iOWPHape4QXoQ==} - '@tanstack/react-devtools@0.10.8': - resolution: {integrity: sha512-YJV6YttQf9lhhPbPBLULgy1eScEvJUMsCS26mjg9hfBKgAJQA5sF9zvzorDzW9Ob6o/asoXikO81JnRUVuFX0Q==} + '@tanstack/react-devtools@0.10.12': + resolution: {integrity: sha512-dgoz7TFm97Izo/D34z91PD0h+ufk+eBmoN9OgRHJlj/c7Ol5xpIP7bqLBNByjgt5paRE2eSu5AQHV92Ul+G6iw==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -1438,23 +1451,23 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query-devtools@5.101.2': - resolution: {integrity: sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==} + '@tanstack/react-query-devtools@5.102.1': + resolution: {integrity: sha512-4ZrumxXJyt3va+L85M5g/ppBU5LfBrl01IbxmVU9M4Ti/b+2O9E76emulZRsOjRS6AGtdDFQnMJnmYA/fGga0A==} peerDependencies: - '@tanstack/react-query': ^5.101.2 + '@tanstack/react-query': ^5.102.1 react: ^18 || ^19 - '@tanstack/react-query@5.101.2': - resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + '@tanstack/react-query@5.102.1': + resolution: {integrity: sha512-DKJeRvxjsGUWhUVnVVXSseTS0SKBMLSkrz8dm7i08r93IQQ2gAfUkX1e801gVZ18KSwHiG5yng4tQm5+qSGS0w==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.167.0': - resolution: {integrity: sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w==} + '@tanstack/react-router-devtools@1.167.1': + resolution: {integrity: sha512-pjfGrmjj4d7naEPM7oshqFfwBoxDPNo/UxltlHH5ePbHsJ+plBhd+JaAewm1ueYOjZ0js9hckjWWDYXpCrSfKw==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.170.0 - '@tanstack/router-core': ^1.170.0 + '@tanstack/react-router': ^1.170.19 + '@tanstack/router-core': ^1.171.16 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: @@ -1471,26 +1484,26 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router@1.170.18': - resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} + '@tanstack/react-router@1.170.32': + resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-client@1.168.16': - resolution: {integrity: sha512-1OfHgy0wpHwe2tlB3FxMeA+IMX6Il/QAMf+8UdXuimReIc2Lz3BkMLBL38k4GIxBguX9sI8EMLO5jlTZ4e1olw==} + '@tanstack/react-start-client@1.168.30': + resolution: {integrity: sha512-qsZuykUl1EF0/rc1bin1RtjFzz07YMOTBzhOstSDzbOVm/WKf1QKFTN+qAZi74xlbXSWNuT2MiFRyg4RKwj8iw==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-rsc@0.1.27': - resolution: {integrity: sha512-4Gt8+XHRhcYJjfw1rTlkW5ZxRWcp6AmvPoBf1p7ysOSVlnfVitQBqgAAFNGy+d745vWfoz1/JWqyFBP8IVVYYg==} + '@tanstack/react-start-rsc@0.1.48': + resolution: {integrity: sha512-UglRdTMuF3c4dvzL/gh4dMVbMWHsPy8ZgTQdT2qpTlyt1b/3m+R40tBFdsfTgw76VTXivW+qV/ih0PN9000XTw==} engines: {node: '>=22.12.0'} peerDependencies: '@rspack/core': '>=2.0.0-0' - '@vitejs/plugin-rsc': '>=0.5.20' + '@vitejs/plugin-rsc': '>=0.5.30' react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' react-server-dom-rspack: '>=0.0.2' @@ -1502,15 +1515,15 @@ packages: react-server-dom-rspack: optional: true - '@tanstack/react-start-server@1.167.22': - resolution: {integrity: sha512-eH2PeHuLfL3R5YzE9+y2FfcE4Ld1LNV2ZfrCNVPJMMJFt+9nXDaRHg9BsEmc+JkTAGzz3FKLyQEoWwpbG6Ehqg==} + '@tanstack/react-start-server@1.167.37': + resolution: {integrity: sha512-cODHpFU8vIm7AdHii9W3NEuwyruNmT5wLDZjRsJLT9Jp+7FACnfJrRbvxnp1ldSv/9mxcHKi/OgwbdHQeMZcAQ==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start@1.168.28': - resolution: {integrity: sha512-UyJ/OYBw7x8MQclyXKlbg72qzSTK6Do4AugHcx0LoagHNg6tQgvF0l2HZqll2CrxKAlv2Ar8+0gEkHnFYxxI5Q==} + '@tanstack/react-start@1.168.49': + resolution: {integrity: sha512-iQb1ZoEHqvZMGLR4G7v3tTtgL06yv/bwxvGP3waVHxVn7bRpyopM44YbOluaGkcJzc13ZTvdLKWYNAN3bxMX/Q==} engines: {node: '>=22.12.0'} peerDependencies: '@rsbuild/core': ^2.0.0 @@ -1532,35 +1545,35 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-cli@1.167.19': - resolution: {integrity: sha512-QdgcIJ/BBaMD94RRnkUFrwOnDe5cO3fXbt3p3DklEqUCI1PpTkt7DssRsL0Hf01y0gJU7Jm1KHE4/Am72BcOZQ==} + '@tanstack/router-cli@1.167.33': + resolution: {integrity: sha512-OZcP4zmzj85rLq2Cr6I3tZDT9Yb8gsquDgka+baKOKXJULBqP5uZ56X8Ggrgq5IBypk47/b1K2tA1qChd1qUig==} engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-core@1.171.15': - resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} + '@tanstack/router-core@1.171.27': + resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==} engines: {node: '>=20.19'} - '@tanstack/router-devtools-core@1.168.0': - resolution: {integrity: sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg==} + '@tanstack/router-devtools-core@1.168.1': + resolution: {integrity: sha512-qr4voa4cpSMwQvS3867xkU3AB3MtJbTuovKIy+btjJ/Faju6er9w0nDylmD+005Mk/3YKw9/iueZJl2JAB7JOA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.170.0 + '@tanstack/router-core': ^1.171.16 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.167.19': - resolution: {integrity: sha512-db3AQGLinf8ZQ8AKMyxLVWwHIFZxtx+zLktvPXv/nFH/B793/Z7lKLl4dUWM3JpAluynDSVVKaTWQVTAgTYmVA==} + '@tanstack/router-generator@1.167.33': + resolution: {integrity: sha512-Z3lCWIPuRUMPmuI8Mm48x/s49TxmHOaFVZ52j1W1QKYrsFHyT6U/h9bqfHJDxfQ8kz7y9q+W1YPKZ15Ee7yuCA==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.168.20': - resolution: {integrity: sha512-G5S63xDr2AxADtWP+0tQtJsduijT/Mk85hDh3Od4ct8UV0PHr2f/H1CGl72lYWLgcWRMn6bKJoQhz27QFIgLRQ==} + '@tanstack/router-plugin@1.168.35': + resolution: {integrity: sha512-foDAZKFqHXae+oFbIgcsSvy2QCVRn7XdS3nhwcRvD+ed6JrKPUP/1lQMsZLJqWycgR1vkZF7gs955KGa0NZQ0w==} engines: {node: '>=20.19'} peerDependencies: '@rsbuild/core': '>=1.0.2 || ^2.0.0' - '@tanstack/react-router': ^1.170.18 + '@tanstack/react-router': ^1.170.32 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' vite-plugin-solid: ^2.11.10 || ^3.0.0-0 webpack: '>=5.92.0' @@ -1587,16 +1600,16 @@ packages: resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} engines: {node: '>=20.19'} - '@tanstack/start-client-core@1.170.14': - resolution: {integrity: sha512-yasBgEIFSWysL4EiFIGwp638nCoXXKiTqkc48EP2oty4OyNsZPTC1yfJ82zjq2KGkTAYtIaeMl7otqqRl1n85Q==} + '@tanstack/start-client-core@1.170.27': + resolution: {integrity: sha512-Ro6ZSM0NgYKDMxM0e8qyU4mBfnld2Zb74BA/9f4i35C0Y3IAA8Zxs/DIPfOo9VRYWp5c5n5Q8dRBn2qn0vtPhQ==} engines: {node: '>=22.12.0'} '@tanstack/start-fn-stubs@1.162.0': resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} engines: {node: '>=22.12.0'} - '@tanstack/start-plugin-core@1.171.20': - resolution: {integrity: sha512-s2n82ykGXGkDSoJwGZP85vC9Dv5vAN6fya5TCSm/cvUn+r/g30iDR1x+Ptizas3fIvcracSOOvRnma4RJ8Orww==} + '@tanstack/start-plugin-core@1.171.39': + resolution: {integrity: sha512-Zyj6G4MDFLXcHYhPavhewCBo8dsxi3qvPk31zl/QtTzYzOY9rJHDDBGarKsJq20ziChmkGn/sttYqb4vGCxJDA==} engines: {node: '>=22.12.0'} peerDependencies: '@rsbuild/core': ^2.0.0 @@ -1607,12 +1620,12 @@ packages: vite: optional: true - '@tanstack/start-server-core@1.169.17': - resolution: {integrity: sha512-u0N+PHJhMHnzfnlXYI9F+A/qweDe3E2X0mfkORPGIEkNQgvS548RA9fjwvixR2en5b848CfpEqUzwFhm/tQ40Q==} + '@tanstack/start-server-core@1.169.31': + resolution: {integrity: sha512-56w8l+Fao01YCrmv0hzNxL3b3FRmqLRGdE11izZNQsW+1CcSYuehAM5khWKABuI1DI/UIBLGV7BwL4Dlg0eHCw==} engines: {node: '>=22.12.0'} - '@tanstack/start-storage-context@1.167.17': - resolution: {integrity: sha512-ntkDyGx0PE0opIlWNAMpkMb8qkjR4uyCUOfC0CiT0STM25+EcwPuwYNfDXXeVObMrTAPgsQ4yOj3xdY0Xr4ptw==} + '@tanstack/start-storage-context@1.167.29': + resolution: {integrity: sha512-8qfprC5774XMRDQlMogkfiGpFLiBf0xDG4bMFUbfkSzpCAQwpLbgGY4Zwft22O9rKYq2vUXusKAdVjvJTUEquQ==} engines: {node: '>=22.12.0'} '@tanstack/store@0.9.3': @@ -2586,6 +2599,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3274,9 +3288,10 @@ packages: nf3@0.1.12: resolution: {integrity: sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ==} - nitro-nightly@4.0.0-20251010-091516-7cafddba: - resolution: {integrity: sha512-biADkmoR/Nb9OmUURTfDI2BpGo2IMEt2RbsiMhjqdwz2w3tEm+tx0Hd28LXjBCwid+l8jpqyfmpwc8jzwdng3w==} + nitro@3.0.0: + resolution: {integrity: sha512-pPrH77/oiYz3q1xGgOYQPEkXERsCi4PkErL2DpHhg4raag3EX7Zh41KFJ0ploSmflmDOPRPeAtC1/19fVrOqoQ==} engines: {node: ^20.19.0 || >=22.12.0} + deprecated: 'IMPORTANT: please use nitro@3.0.1' hasBin: true peerDependencies: rolldown: '*' @@ -3661,10 +3676,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.6.2: + resolution: {integrity: sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.5: resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==} engines: {node: '>=10'} + seroval@1.6.2: + resolution: {integrity: sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4324,20 +4349,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4364,41 +4389,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4408,18 +4433,18 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -4439,43 +4464,43 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4487,7 +4512,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -4495,7 +4520,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4766,17 +4791,17 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4789,10 +4814,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.6': + '@eslint/eslintrc@3.3.6(supports-color@7.2.0)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4803,9 +4828,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@9.39.5(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) '@eslint/js@9.39.5': {} @@ -4880,7 +4905,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.30) ajv: 8.20.0 @@ -4890,8 +4915,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) hono: 4.12.30 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -4916,6 +4941,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@neodrag/core@3.0.0-next.11': {} + + '@neodrag/solid@3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.14)': + dependencies: + '@neodrag/core': 3.0.0-next.11 + solid-js: 1.9.14 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5180,11 +5212,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/types': 8.64.0 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 @@ -5263,11 +5295,26 @@ snapshots: tailwindcss: 4.3.2 vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + '@tanstack/devtools-bundler-core@0.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@tanstack/devtools-client': 0.0.8 + '@tanstack/devtools-event-bus': 0.4.3 + chalk: 5.6.2 + launch-editor: 2.14.1 + magic-string: 0.30.21 + oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + picomatch: 4.0.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - bufferutil + - utf-8-validate + '@tanstack/devtools-client@0.0.8': dependencies: '@tanstack/devtools-event-client': 0.5.0 - '@tanstack/devtools-event-bus@0.4.2': + '@tanstack/devtools-event-bus@0.4.3': dependencies: ws: 8.21.0 transitivePeerDependencies: @@ -5276,7 +5323,7 @@ snapshots: '@tanstack/devtools-event-client@0.5.0': {} - '@tanstack/devtools-ui@0.6.0(csstype@3.2.3)(solid-js@1.9.14)': + '@tanstack/devtools-ui@0.7.1(csstype@3.2.3)(solid-js@1.9.14)': dependencies: clsx: 2.1.1 dayjs: 1.11.21 @@ -5285,15 +5332,12 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.8.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@tanstack/devtools-vite@0.8.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: + '@tanstack/devtools-bundler-core': 0.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@tanstack/devtools-client': 0.0.8 - '@tanstack/devtools-event-bus': 0.4.2 + '@tanstack/devtools-event-bus': 0.4.3 chalk: 5.6.2 - launch-editor: 2.14.1 - magic-string: 0.30.21 - oxc-parser: 0.120.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - picomatch: 4.0.5 vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) transitivePeerDependencies: - '@emnapi/core' @@ -5301,14 +5345,16 @@ snapshots: - bufferutil - utf-8-validate - '@tanstack/devtools@0.12.5(csstype@3.2.3)(solid-js@1.9.14)': + '@tanstack/devtools@0.14.2(csstype@3.2.3)(solid-js@1.9.14)': dependencies: + '@neodrag/core': 3.0.0-next.11 + '@neodrag/solid': 3.0.0-next.11(@neodrag/core@3.0.0-next.11)(solid-js@1.9.14) '@solid-primitives/event-listener': 2.4.6(solid-js@1.9.14) '@solid-primitives/keyboard': 1.3.7(solid-js@1.9.14) '@solid-primitives/resize-observer': 2.2.0(solid-js@1.9.14) '@tanstack/devtools-client': 0.0.8 - '@tanstack/devtools-event-bus': 0.4.2 - '@tanstack/devtools-ui': 0.6.0(csstype@3.2.3)(solid-js@1.9.14) + '@tanstack/devtools-event-bus': 0.4.3 + '@tanstack/devtools-ui': 0.7.1(csstype@3.2.3)(solid-js@1.9.14) clsx: 2.1.1 goober: 2.1.19(csstype@3.2.3) solid-js: 1.9.14 @@ -5317,31 +5363,31 @@ snapshots: - csstype - utf-8-validate - '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint/js': 10.0.1(eslint@9.39.5(jiti@2.7.0)) - '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.5(jiti@2.7.0)) - eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-n: 17.24.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@eslint/js': 10.0.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) + '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-n: 17.24.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) globals: 17.7.0 - typescript-eslint: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)) + typescript-eslint: 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - '@typescript-eslint/utils' - eslint-import-resolver-node - supports-color - typescript - '@tanstack/history@1.162.0': {} + '@tanstack/history@1.162.1': {} - '@tanstack/query-core@5.101.2': {} + '@tanstack/query-core@5.102.1': {} - '@tanstack/query-devtools@5.101.2': {} + '@tanstack/query-devtools@5.102.1': {} - '@tanstack/react-devtools@0.10.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': + '@tanstack/react-devtools@0.10.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': dependencies: - '@tanstack/devtools': 0.12.5(csstype@3.2.3)(solid-js@1.9.14) + '@tanstack/devtools': 0.14.2(csstype@3.2.3)(solid-js@1.9.14) '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) react: 19.2.7 @@ -5352,66 +5398,65 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-query-devtools@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)': + '@tanstack/react-query-devtools@5.102.1(@tanstack/react-query@5.102.1(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/query-devtools': 5.101.2 - '@tanstack/react-query': 5.101.2(react@19.2.7) + '@tanstack/query-devtools': 5.102.1 + '@tanstack/react-query': 5.102.1(react@19.2.7) react: 19.2.7 - '@tanstack/react-query@5.101.2(react@19.2.7)': + '@tanstack/react-query@5.102.1(react@19.2.7)': dependencies: - '@tanstack/query-core': 5.101.2 + '@tanstack/query-core': 5.102.1 react: 19.2.7 - '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-router-devtools@1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.15)(csstype@3.2.3) + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-devtools-core': 1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: - '@tanstack/router-core': 1.171.15 + '@tanstack/router-core': 1.171.27 transitivePeerDependencies: - csstype - '@tanstack/react-router-ssr-query@1.167.1(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-router-ssr-query@1.167.1(@tanstack/query-core@5.102.1)(@tanstack/react-query@5.102.1(react@19.2.7))(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.27)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/query-core': 5.101.2 - '@tanstack/react-query': 5.101.2(react@19.2.7) - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-ssr-query-core': 1.169.1(@tanstack/query-core@5.101.2)(@tanstack/router-core@1.171.15) + '@tanstack/query-core': 5.102.1 + '@tanstack/react-query': 5.102.1(react@19.2.7) + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-ssr-query-core': 1.169.1(@tanstack/query-core@5.102.1)(@tanstack/router-core@1.171.27) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - '@tanstack/router-core' - '@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/history': 1.162.0 + '@tanstack/history': 1.162.1 '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 + '@tanstack/router-core': 1.171.27 isbot: 5.2.1 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/react-start-client@1.168.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-start-client@1.168.30(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 - '@tanstack/start-client-core': 1.170.14 + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.27 + '@tanstack/start-client-core': 1.170.27 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/react-start-rsc@0.1.27(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@tanstack/react-start-rsc@0.1.48(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils': 1.162.2 - '@tanstack/start-client-core': 1.170.14 + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.27 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/start-client-core': 1.170.27 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.20(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) - '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.8.16)) - '@tanstack/start-storage-context': 1.167.17 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@tanstack/start-storage-context': 1.167.29 pathe: 2.0.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -5429,26 +5474,26 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start-server@1.167.22(crossws@0.4.10(srvx@0.8.16))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-start-server@1.167.37(crossws@0.4.10(srvx@0.8.16))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-core': 1.171.15 - '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.8.16)) + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.27 + '@tanstack/start-server-core': 1.169.31(crossws@0.4.10(srvx@0.8.16)) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.28(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@tanstack/react-start@1.168.49(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-start-client': 1.168.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-start-rsc': 0.1.27(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) - '@tanstack/react-start-server': 1.167.22(crossws@0.4.10(srvx@0.8.16))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/router-utils': 1.162.2 - '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.20(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) - '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.8.16)) + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-start-client': 1.168.30(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-start-rsc': 0.1.48(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@tanstack/react-start-server': 1.167.37(crossws@0.4.10(srvx@0.8.16))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@tanstack/start-server-core': 1.169.31(crossws@0.4.10(srvx@0.8.16)) pathe: 2.0.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -5475,34 +5520,34 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) - '@tanstack/router-cli@1.167.19': + '@tanstack/router-cli@1.167.33(supports-color@7.2.0)': dependencies: - '@tanstack/router-generator': 1.167.19 + '@tanstack/router-generator': 1.167.33(supports-color@7.2.0) chokidar: 5.0.0 yargs: 17.7.3 transitivePeerDependencies: - supports-color - '@tanstack/router-core@1.171.15': + '@tanstack/router-core@1.171.27': dependencies: - '@tanstack/history': 1.162.0 + '@tanstack/history': 1.162.1 cookie-es: 3.1.1 - seroval: 1.5.5 - seroval-plugins: 1.5.5(seroval@1.5.5) + seroval: 1.6.2 + seroval-plugins: 1.6.2(seroval@1.6.2) - '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.15)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.171.15 + '@tanstack/router-core': 1.171.27 clsx: 2.1.1 goober: 2.1.19(csstype@3.2.3) optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.167.19': + '@tanstack/router-generator@1.167.33(supports-color@7.2.0)': dependencies: '@babel/types': 7.29.7 - '@tanstack/router-core': 1.171.15 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 @@ -5511,19 +5556,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.20(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@tanstack/router-plugin@1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@tanstack/router-core': 1.171.15 - '@tanstack/router-generator': 1.167.19 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-generator': 1.167.33(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) chokidar: 5.0.0 unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) zod: 4.4.3 optionalDependencies: - '@tanstack/react-router': 1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-router': 1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7) vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) transitivePeerDependencies: - '@farmfe/core' @@ -5535,48 +5580,48 @@ snapshots: - supports-color - unloader - '@tanstack/router-ssr-query-core@1.169.1(@tanstack/query-core@5.101.2)(@tanstack/router-core@1.171.15)': + '@tanstack/router-ssr-query-core@1.169.1(@tanstack/query-core@5.102.1)(@tanstack/router-core@1.171.27)': dependencies: - '@tanstack/query-core': 5.101.2 - '@tanstack/router-core': 1.171.15 + '@tanstack/query-core': 5.102.1 + '@tanstack/router-core': 1.171.27 - '@tanstack/router-utils@1.162.2': + '@tanstack/router-utils@1.162.2(supports-color@7.2.0)': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 ansis: 4.3.1 - babel-dead-code-elimination: 1.0.12 + babel-dead-code-elimination: 1.0.12(supports-color@7.2.0) diff: 8.0.4 pathe: 2.0.3 tinyglobby: 0.2.17 transitivePeerDependencies: - supports-color - '@tanstack/start-client-core@1.170.14': + '@tanstack/start-client-core@1.170.27': dependencies: - '@tanstack/router-core': 1.171.15 + '@tanstack/router-core': 1.171.27 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-storage-context': 1.167.17 - seroval: 1.5.5 + '@tanstack/start-storage-context': 1.167.29 + seroval: 1.6.2 '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.20(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@tanstack/start-plugin-core@1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(crossws@0.4.10(srvx@0.8.16))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 - '@tanstack/router-core': 1.171.15 - '@tanstack/router-generator': 1.167.19 - '@tanstack/router-plugin': 1.168.20(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) - '@tanstack/router-utils': 1.162.2 - '@tanstack/start-server-core': 1.169.17(crossws@0.4.10(srvx@0.8.16)) + '@tanstack/router-core': 1.171.27 + '@tanstack/router-generator': 1.167.33(supports-color@7.2.0) + '@tanstack/router-plugin': 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.62.2)(supports-color@7.2.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) + '@tanstack/start-server-core': 1.169.31(crossws@0.4.10(srvx@0.8.16)) exsolve: 1.1.0 lightningcss: 1.32.0 pathe: 2.0.3 picomatch: 4.0.5 - seroval: 1.5.5 + seroval: 1.6.2 source-map: 0.7.6 srvx: 0.11.22 tinyglobby: 0.2.17 @@ -5600,21 +5645,21 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-server-core@1.169.17(crossws@0.4.10(srvx@0.8.16))': + '@tanstack/start-server-core@1.169.31(crossws@0.4.10(srvx@0.8.16))': dependencies: - '@tanstack/history': 1.162.0 - '@tanstack/router-core': 1.171.15 - '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-storage-context': 1.167.17 + '@tanstack/history': 1.162.1 + '@tanstack/router-core': 1.171.27 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-storage-context': 1.167.29 fetchdts: 0.1.7 h3-v2: h3@2.0.1-rc.20(crossws@0.4.10(srvx@0.8.16)) - seroval: 1.5.5 + seroval: 1.6.2 transitivePeerDependencies: - crossws - '@tanstack/start-storage-context@1.167.17': + '@tanstack/start-storage-context@1.167.29': dependencies: - '@tanstack/router-core': 1.171.15 + '@tanstack/router-core': 1.171.27 '@tanstack/store@0.9.3': {} @@ -5687,15 +5732,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.64.0 - '@typescript-eslint/type-utils': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.64.0 - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -5703,23 +5748,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.64.0 '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.64.0 - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.64.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.64.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3) '@typescript-eslint/types': 8.64.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5733,13 +5778,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -5747,13 +5792,13 @@ snapshots: '@typescript-eslint/types@8.64.0': {} - '@typescript-eslint/typescript-estree@8.64.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.64.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.64.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.64.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@6.0.3) '@typescript-eslint/types': 8.64.0 '@typescript-eslint/visitor-keys': 8.64.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.5 semver: 7.8.5 tinyglobby: 0.2.17 @@ -5762,13 +5807,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.64.0 '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3) - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5957,11 +6002,11 @@ snapshots: atomically@1.7.0: {} - babel-dead-code-elimination@1.0.12: + babel-dead-code-elimination@1.0.12(supports-color@7.2.0): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -5976,11 +6021,11 @@ snapshots: dependencies: require-from-string: 2.0.2 - body-parser@2.3.0: + body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -6166,9 +6211,11 @@ snapshots: dependencies: mimic-fn: 3.1.0 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} @@ -6336,9 +6383,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-compat-utils@0.5.1(eslint@9.39.5(jiti@2.7.0)): + eslint-compat-utils@0.5.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 9.39.5(jiti@2.7.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) semver: 7.8.5 eslint-import-context@0.1.9(unrs-resolver@1.12.2): @@ -6348,19 +6395,19 @@ snapshots: optionalDependencies: unrs-resolver: 1.12.2 - eslint-plugin-es-x@7.8.0(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-es-x@7.8.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - eslint: 9.39.5(jiti@2.7.0) - eslint-compat-utils: 0.5.1(eslint@9.39.5(jiti@2.7.0)) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint-compat-utils: 0.5.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@typescript-eslint/types': 8.64.0 comment-parser: 1.4.7 - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-import-context: 0.1.9(unrs-resolver@1.12.2) is-glob: 4.0.3 minimatch: 10.2.5 @@ -6368,16 +6415,16 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.12.2 optionalDependencies: - '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) transitivePeerDependencies: - supports-color - eslint-plugin-n@17.24.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-n@17.24.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) enhanced-resolve: 5.24.2 - eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-es-x: 7.8.0(eslint@9.39.5(jiti@2.7.0)) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-es-x: 7.8.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) get-tsconfig: 4.14.0 globals: 15.15.0 globrex: 0.1.2 @@ -6405,14 +6452,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(jiti@2.7.0): + eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.6 + '@eslint/eslintrc': 3.3.6(supports-color@7.2.0) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -6422,7 +6469,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -6513,25 +6560,25 @@ snapshots: expect-type@1.4.0: {} - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)): dependencies: - express: 5.2.1 + express: 5.2.1(supports-color@7.2.0) ip-address: 10.2.0 - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -6542,9 +6589,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -6591,9 +6638,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -6736,17 +6783,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6845,7 +6892,7 @@ snapshots: jsbi@4.3.2: {} - jsdom@28.1.0: + jsdom@28.1.0(supports-color@7.2.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 @@ -6855,8 +6902,8 @@ snapshots: data-urls: 7.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 parse5: 8.0.1 saxes: 6.0.0 @@ -7048,7 +7095,7 @@ snapshots: nf3@0.1.12: {} - nitro-nightly@4.0.0-20251010-091516-7cafddba(drizzle-orm@1.0.0-rc.4(@types/pg@8.20.0)(pg@8.22.0)(zod@3.25.76))(lru-cache@11.5.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + nitro@3.0.0(drizzle-orm@1.0.0-rc.4(@types/pg@8.20.0)(pg@8.22.0)(zod@3.25.76))(lru-cache@11.5.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: consola: 3.4.2 cookie-es: 2.0.1 @@ -7473,9 +7520,9 @@ snapshots: rou3@0.8.1: {} - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -7501,9 +7548,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -7521,27 +7568,33 @@ snapshots: dependencies: seroval: 1.5.5 + seroval-plugins@1.6.2(seroval@1.6.2): + dependencies: + seroval: 1.6.2 + seroval@1.5.5: {} - serve-static@2.2.1: + seroval@1.6.2: {} + + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color setprototypeof@1.2.0: {} - shadcn@4.13.0(typescript@6.0.3): + shadcn@4.13.0(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.7 - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@7.2.0)(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.6 commander: 14.0.3 @@ -7767,13 +7820,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.64.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) - eslint: 9.39.5(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -7882,7 +7935,7 @@ snapshots: optionalDependencies: vite: 8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) - vitest@4.1.10(@types/node@22.20.1)(jsdom@28.1.0)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@22.20.1)(jsdom@28.1.0(supports-color@7.2.0))(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) @@ -7906,14 +7959,14 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 - jsdom: 28.1.0 + jsdom: 28.1.0(supports-color@7.2.0) transitivePeerDependencies: - msw - vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)): + vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3984565..40286a8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,21 @@ allowBuilds: esbuild: false unrs-resolver: false +minimumReleaseAgeExclude: + - '@tanstack/query-core@5.102.1' + - '@tanstack/query-devtools@5.102.1' + - '@tanstack/react-query-devtools@5.102.1' + - '@tanstack/react-query@5.102.1' + - '@tanstack/react-router@1.170.32' + - '@tanstack/react-start-client@1.168.30' + - '@tanstack/react-start-rsc@0.1.48' + - '@tanstack/react-start-server@1.167.37' + - '@tanstack/react-start@1.168.49' + - '@tanstack/router-cli@1.167.33' + - '@tanstack/router-core@1.171.27' + - '@tanstack/router-generator@1.167.33' + - '@tanstack/router-plugin@1.168.35' + - '@tanstack/start-client-core@1.170.27' + - '@tanstack/start-plugin-core@1.171.39' + - '@tanstack/start-server-core@1.169.31' + - '@tanstack/start-storage-context@1.167.29' diff --git a/prettier.config.js b/prettier.config.js index aea1c48..f7279f7 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -4,7 +4,7 @@ const config = { semi: false, singleQuote: true, - trailingComma: "all", -}; + trailingComma: 'all', +} -export default config; +export default config diff --git a/scripts/bootstrap-local.ts b/scripts/bootstrap-local.ts new file mode 100644 index 0000000..046e823 --- /dev/null +++ b/scripts/bootstrap-local.ts @@ -0,0 +1,31 @@ +import { writeLocalFiles } from './lib/local-bootstrap.ts' + +const result = writeLocalFiles(process.cwd()) + +console.log( + 'Lokális infrastruktúra fájlok elkészültek (oob/ könyvtár, gitignore-olt).', +) +if (result.created) { + console.log('Új helyi titkok generálva.') +} else { + console.log('Meglévő helyi titkok megőrizve (idempotens futás).') +} + +console.log('') +console.log('Tesztfelhasználók (jelszavak: oob/local-secrets.json):') +for (const [username, password] of Object.entries(result.secrets.passwords)) { + console.log(` ${username} / ${password}`) +} +console.log('') +console.log('Következő lépések:') +console.log(' 1. docker compose -f docker-compose.dev.yml up -d') +console.log( + ' 2. DATABASE_URL=postgres://bss:bss@127.0.0.1:5582/bss pnpm db:migrate', +) +console.log(' 3. pnpm check:oob') +console.log( + ' Ha az Authentik már fut, a blueprint automatikusan érvényesül; ellenőrzés:', +) +console.log( + ' curl http://127.0.0.1:9000/application/o/bss-stack/.well-known/openid-configuration', +) diff --git a/scripts/check-oob.ts b/scripts/check-oob.ts new file mode 100644 index 0000000..b8dc79d --- /dev/null +++ b/scripts/check-oob.ts @@ -0,0 +1,21 @@ +import { loadOobConfig } from '#/server/config/load.ts' + +const path: string | undefined = + process.argv.length > 2 ? process.argv[2] : undefined + +try { + const config = loadOobConfig(path) + console.log('A BSS OOB config érvényes.') + console.log( + ` Betöltött fájl: ${path ?? process.env['BSS_OOB_CONFIG'] ?? 'oob/config.json'}`, + ) + console.log(` Authentik issuer: ${config.authentik.issuerUrl}`) + console.log( + ` Csoportok: schonherz=${config.authentik.groups.schonherz}, tag=${config.authentik.groups.tag}, vezetoseg=${config.authentik.groups.vezetoseg}`, + ) + console.log(` Méria hostok: ${config.media.allowedHosts.join(', ')}`) + console.log(` Seed fájl helye: ${config.seed.path}`) +} catch (error) { + console.error((error as Error).message) + process.exit(1) +} diff --git a/scripts/lib/local-bootstrap.ts b/scripts/lib/local-bootstrap.ts new file mode 100644 index 0000000..9a07823 --- /dev/null +++ b/scripts/lib/local-bootstrap.ts @@ -0,0 +1,418 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' +import { validateOobConfig } from '#/server/config/oob-schema.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' + +export const OOB_DIR = 'oob' +export const SECRETS_FILE = 'local-secrets.json' +export const BLUEPRINT_FILE = join('authentik', 'blueprints', 'bss-local.yaml') +export const AUTHENTIK_ENV_FILE = 'authentik.env' +export const CONFIG_FILE = 'config.json' + +export interface LocalSecrets { + authentikSecretKey: string + oidcClientSecret: string + passwords: Record +} + +const AUTHENTIK_BASE_URL = 'http://127.0.0.1:9000' +const OIDC_CLIENT_ID = 'bss-stack-local' + +interface LocalUserSpec { + username: string + fullName: string + nickname: string + groups: string[] + status: string | null + joinedSemester: string | null + introduction: string | null +} + +export const LOCAL_USERS: LocalUserSpec[] = [ + { + username: 'schonherz-dev', + fullName: 'Schönherzes Teszt Felhasználó', + nickname: 'Schi', + groups: ['bss-schonherz'], + status: null, + joinedSemester: null, + introduction: null, + }, + { + username: 'tag-dev', + fullName: 'Teszt BSS Tag', + nickname: 'Tagocska', + groups: ['bss-tag'], + status: 'stúdiós', + joinedSemester: '2023 ősz', + introduction: 'Lokális teszt profil egy stúdiós szerepére.', + }, + { + username: 'vezetoseg-dev', + fullName: 'Teszt Vezetőségi Tag', + nickname: 'Vezér', + groups: ['bss-tag', 'bss-vezetoseg'], + status: 'stúdiós', + joinedSemester: '2021 tavasz', + introduction: 'Lokális teszt profil vezetőségi joggal.', + }, + { + username: 'jelolt-dev', + fullName: 'Teszt Stúdiósjelölt', + nickname: 'Jelöltke', + groups: [], + status: 'stúdiósjelölt', + joinedSemester: '2025 ősz', + introduction: null, + }, + { + username: 'jeloltjelolt-dev', + fullName: 'Teszt Stúdiósjelölt-jelölt', + nickname: 'Jelcsa', + groups: [], + status: 'stúdiósjelölt-jelölt', + joinedSemester: '2026 ősz', + introduction: null, + }, + { + username: 'oregtag-dev', + fullName: 'Teszt Aktív Öregtag', + nickname: 'Öreg', + groups: [], + status: 'aktív öregtag', + joinedSemester: '2019 ősz', + introduction: null, + }, + { + username: 'archivalt-oregtag-dev', + fullName: 'Teszt Archivált Öregtag', + nickname: 'Archie', + groups: [], + status: 'archivált öregtag', + joinedSemester: '2018 ősz', + introduction: null, + }, + { + username: 'kozremukodo-dev', + fullName: 'Teszt Korábbi Közreműködő', + nickname: 'Közreműködő', + groups: [], + status: 'dolgozott még velünk', + joinedSemester: '2020 tavasz', + introduction: null, + }, +] + +function generateToken(): string { + return randomBytes(24).toString('base64url') +} + +export function generateLocalSecrets(): LocalSecrets { + const passwords: Record = {} + for (const user of LOCAL_USERS) { + passwords[user.username] = generateToken() + } + return { + authentikSecretKey: generateToken(), + oidcClientSecret: generateToken(), + passwords, + } +} + +export function renderBlueprint(secrets: LocalSecrets): string { + const userEntries = LOCAL_USERS.map((user) => { + const lines: string[] = [] + + if (user.groups.length > 0) { + lines.push(' groups:') + for (const group of user.groups) { + lines.push(` - !KeyOf id-${group}`) + } + } + if ( + user.status !== null || + user.joinedSemester !== null || + user.introduction !== null + ) { + lines.push(' attributes:') + if (user.status !== null) { + lines.push(` bss_status: "${user.status}"`) + } + if (user.joinedSemester !== null) { + lines.push(` bss_csatlakozas: "${user.joinedSemester}"`) + } + if (user.introduction !== null) { + lines.push(' bss_bemutatkozas: >-') + lines.push(` ${user.introduction}`) + } + } + + return [ + ' - model: authentik_core.user', + ' identifiers:', + ` username: ${user.username}`, + ' attrs:', + ` name: ${user.fullName}`, + ` username: ${user.username}`, + ' type: internal', + ` password: "${secrets.passwords[user.username]}"`, + ...lines, + ].join('\n') + }).join('\n') + + return [ + '# AUTOMATIKUSAN GENERÁLT FÁJL - NE SZERKESZD KÉZZEL!', + '# A scripts/bootstrap-local.ts hozza létre az oob/ könyvtárba.', + 'version: 1', + 'entries:', + ' - model: authentik_flows.flow', + ' id: id-authn-flow', + ' identifiers:', + ' slug: bss-local-authentication', + ' attrs:', + ' name: bss-local-authentication', + ' title: Bejelentkezés a BSS Stackbe', + ' designation: authentication', + ' authentication: none', + ' denied_action: message_continue', + ' - model: authentik_flows.flow', + ' id: id-authz-flow', + ' identifiers:', + ' slug: bss-local-authorization', + ' attrs:', + ' name: bss-local-authorization', + ' title: Hozzáférés engedélyezése', + ' designation: authorization', + ' authentication: require_authenticated', + ' denied_action: message_continue', + ' - model: authentik_stages_identification.identificationstage', + ' id: id-stage-identification', + ' identifiers:', + ' name: bss-local-identification', + ' attrs:', + ' name: bss-local-identification', + ' user_fields:', + ' - username', + ' case_insensitive_matching: true', + ' - model: authentik_stages_password.passwordstage', + ' id: id-stage-password', + ' identifiers:', + ' name: bss-local-password', + ' attrs:', + ' name: bss-local-password', + ' backends:', + ' - authentik.core.auth.InbuiltBackend', + ' - model: authentik_stages_user_login.userloginstage', + ' id: id-stage-login', + ' identifiers:', + ' name: bss-local-user-login', + ' attrs:', + ' name: bss-local-user-login', + ' session_duration: days=1', + ' - model: authentik_flows.flowstagebinding', + ' identifiers:', + ' order: 0', + ' target: !KeyOf id-authn-flow', + ' attrs:', + ' order: 0', + ' target: !KeyOf id-authn-flow', + ' stage: !KeyOf id-stage-identification', + ' - model: authentik_flows.flowstagebinding', + ' identifiers:', + ' order: 1', + ' target: !KeyOf id-authn-flow', + ' attrs:', + ' order: 1', + ' target: !KeyOf id-authn-flow', + ' stage: !KeyOf id-stage-password', + ' - model: authentik_flows.flowstagebinding', + ' identifiers:', + ' order: 2', + ' target: !KeyOf id-authn-flow', + ' attrs:', + ' order: 2', + ' target: !KeyOf id-authn-flow', + ' stage: !KeyOf id-stage-login', + ' - model: authentik_core.group', + ' id: id-bss-schonherz', + ' identifiers:', + ' name: bss-schonherz', + ' attrs:', + ' name: bss-schonherz', + ' is_superuser: false', + ' - model: authentik_core.group', + ' id: id-bss-tag', + ' identifiers:', + ' name: bss-tag', + ' attrs:', + ' name: bss-tag', + ' is_superuser: false', + ' - model: authentik_core.group', + ' id: id-bss-vezetoseg', + ' identifiers:', + ' name: bss-vezetoseg', + ' attrs:', + ' name: bss-vezetoseg', + ' is_superuser: false', + userEntries, + ' - model: authentik_providers_oauth2.scopemapping', + ' id: id-map-profile', + ' identifiers:', + ' name: bss-local-scope-profile', + ' attrs:', + ' name: bss-local-scope-profile', + ' scope_name: profile', + ' description: Profil adatok', + ' expression: |', + ' return {', + ' "preferred_username": request.user.username,', + ' "name": request.user.name,', + ' "nickname": request.user.attributes.get("nickname", request.user.username),', + ' "picture": request.user.avatar,', + ' }', + ' - model: authentik_providers_oauth2.scopemapping', + ' id: id-map-email', + ' identifiers:', + ' name: bss-local-scope-email', + ' attrs:', + ' name: bss-local-scope-email', + ' scope_name: email', + ' description: Email cím', + ' expression: |', + ' return {"email": request.user.email, "email_verified": True}', + ' - model: authentik_providers_oauth2.scopemapping', + ' id: id-map-bss', + ' identifiers:', + ' name: bss-custom-claims', + ' attrs:', + ' name: bss-custom-claims', + ' scope_name: bss', + ' description: BSS tagsági attribútumok', + ' expression: |', + ' return {', + ' "bss_status": request.user.attributes.get("bss_status", ""),', + ' "bss_csatlakozas": request.user.attributes.get("bss_csatlakozas", ""),', + ' "bss_bemutatkozas": request.user.attributes.get("bss_bemutatkozas", "")', + ' }', + ' - model: authentik_providers_oauth2.oauth2provider', + ' id: bss-stack-provider', + ' identifiers:', + ' name: bss-stack-provider', + ' attrs:', + ' name: bss-stack-provider', + ` client_id: ${OIDC_CLIENT_ID}`, + ` client_secret: "${secrets.oidcClientSecret}"`, + ' redirect_uris:', + ' - matching_mode: strict', + ' url: http://localhost:3000/api/auth/callback', + ' - matching_mode: strict', + ' url: http://127.0.0.1:3000/api/auth/callback', + ' authorization_flow: !KeyOf id-authz-flow', + ' invalidation_flow: !Find [authentik_flows.Flow, [slug, default-provider-invalidation-flow]]', + ' property_mappings:', + ' - !KeyOf id-map-profile', + ' - !KeyOf id-map-email', + ' - !KeyOf id-map-bss', + ' sub_mode: hashed_user_id', + ' access_token_validity: hours=1', + ' signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]', + ' - model: authentik_core.application', + ' identifiers:', + ' slug: bss-stack', + ' attrs:', + ' name: BSS Stack', + ' slug: bss-stack', + ' provider: !KeyOf bss-stack-provider', + '', + ].join('\n') +} + +export function renderOobConfig(secrets: LocalSecrets): unknown { + return { + authentik: { + issuerUrl: `${AUTHENTIK_BASE_URL}/application/o/bss-stack/`, + clientId: OIDC_CLIENT_ID, + clientSecret: secrets.oidcClientSecret, + scopes: ['openid', 'profile', 'email', 'bss'], + claims: { + sub: 'sub', + username: 'preferred_username', + fullName: 'name', + nickname: 'nickname', + avatarUrl: 'picture', + }, + groups: { + schonherz: 'bss-schonherz', + tag: 'bss-tag', + vezetoseg: 'bss-vezetoseg', + }, + attributes: { + membershipStatus: { + attribute: 'bss_status', + values: { + stúdiós: 'studio_member', + stúdiósjelölt: 'studio_candidate', + 'stúdiósjelölt-jelölt': 'studio_applicant', + 'aktív öregtag': 'senior_active', + 'archivált öregtag': 'senior_archived', + 'dolgozott még velünk': 'contributor', + }, + }, + joinedSemester: { + attribute: 'bss_csatlakozas', + rules: [ + { pattern: '^(\\d{4})\\s+(ősz|őszi)$', semester: 'autumn' }, + { pattern: '^(\\d{4})\\s+(tavasz|tavaszi)$', semester: 'spring' }, + ], + }, + introduction: 'bss_bemutatkozas', + }, + }, + media: { + allowedHosts: ['v.bsstudio.hu'], + }, + youtube: { + oEmbedEndpoint: 'https://www.youtube.com/oEmbed', + }, + seed: { + path: 'oob/seed.json', + }, + } +} + +export function writeLocalFiles(baseDir: string): { + secrets: LocalSecrets + created: boolean +} { + const oobDir = join(baseDir, OOB_DIR) + mkdirSync(join(oobDir, 'authentik', 'blueprints'), { recursive: true }) + + const secretsPath = join(oobDir, SECRETS_FILE) + let secrets: LocalSecrets + let created = false + + if (existsSync(secretsPath)) { + secrets = JSON.parse(readFileSync(secretsPath, 'utf-8')) as LocalSecrets + } else { + secrets = generateLocalSecrets() + writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`) + created = true + } + + writeFileSync(join(oobDir, BLUEPRINT_FILE), renderBlueprint(secrets)) + writeFileSync( + join(oobDir, AUTHENTIK_ENV_FILE), + `AUTHENTIK_SECRET_KEY=${secrets.authentikSecretKey}\n`, + ) + + const rawConfig = renderOobConfig(secrets) + const validated: OobConfig = validateOobConfig(rawConfig) + writeFileSync( + join(oobDir, CONFIG_FILE), + `${JSON.stringify(rawConfig, null, 2)}\n`, + ) + void validated + + return { secrets, created } +} diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 9079e31..07c50d8 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -1,8 +1,4 @@ -export default function EventCard({ - eventId = 'unknown', -}: Readonly<{ - eventId?: string -}>) { +export default function EventCard() { return (
Kapcsolat
diff --git a/src/components/MemberCard.tsx b/src/components/MemberCard.tsx index e31b91e..1d4a48b 100644 --- a/src/components/MemberCard.tsx +++ b/src/components/MemberCard.tsx @@ -1,8 +1,4 @@ - - -export default function MemberCard({ - memberId = 'unknown', -}: Readonly<{ memberId?: string }>) { +export default function MemberCard() { return (
- typeof window !== 'undefined' ? window.location.pathname : '/' + typeof window !== 'undefined' ? window.location.pathname : '/', ) useEffect(() => { @@ -103,4 +103,4 @@ export default function Navbar() { ) -} \ No newline at end of file +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 5cdc9b3..de3d6c1 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -114,13 +114,6 @@ export default function ThemeToggle() { window.localStorage.setItem('theme', nextMode) } - let modeLabel = 'Light' - if (mode === 'dark') { - modeLabel = 'Dark' - } else if (mode === 'auto') { - modeLabel = 'Auto' - } - const label = mode === 'auto' ? 'Theme mode: auto (system). Click to switch to light mode.' diff --git a/src/components/Videoplayer.tsx b/src/components/Videoplayer.tsx index bbceabb..aad0b78 100644 --- a/src/components/Videoplayer.tsx +++ b/src/components/Videoplayer.tsx @@ -3,10 +3,8 @@ import { useRef, useState, useEffect } from 'react' export default function Videoplayer({ - videoId = '', videoUrl = '/test_video.mp4', }: Readonly<{ - videoId?: string videoUrl?: string }>) { const videoRef = useRef(null) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b033601..49bd073 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,49 +1,50 @@ -import { Button as ButtonPrimitive } from "@base-ui/react/button" -import { cva, type VariantProps } from "class-variance-authority" +import { Button as ButtonPrimitive } from '@base-ui/react/button' +import { cva } from 'class-variance-authority' +import type { VariantProps } from 'class-variance-authority' -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils' const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + default: 'bg-primary text-primary-foreground hover:bg-primary/80', outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', }, size: { default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + icon: 'size-8', + 'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', }, }, defaultVariants: { - variant: "default", - size: "default", + variant: 'default', + size: 'default', }, - } + }, ) function Button({ className, - variant = "default", - size = "default", + variant = 'default', + size = 'default', ...props }: ButtonPrimitive.Props & VariantProps) { return ( diff --git a/src/db/schema.ts b/src/db/schema.ts index e796ca7..453f7e2 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,67 +1,199 @@ -import { defineRelations } from 'drizzle-orm' +import { sql } from 'drizzle-orm' import { + bigserial, + boolean, + check, + date, + index, integer, + jsonb, + pgEnum, pgTable, - varchar, - uuid, - text, primaryKey, - date, pgEnum, bytea, + text, + timestamp, + uniqueIndex, + uuid, + varchar, } from 'drizzle-orm/pg-core' -export const visibilityEnum = pgEnum('visibility', ['public', 'schonherz', 'bss']) +export const visibilityEnum = pgEnum('visibility', [ + 'public', + 'schonherz', + 'bss', +]) -export const homepageStatusEnum = pgEnum('homepage_status', ['live', 'highlighted_video', 'normal']) +export const contentStatusEnum = pgEnum('content_status', [ + 'draft', + 'published', + 'archived', + 'trash', +]) -export const homepageStatusTable = pgTable('current_homepage_status', { - id: integer().primaryKey().default(0), - status: homepageStatusEnum().notNull().default('normal'), - updated_at: date().defaultNow(), - created_at: date().defaultNow(), -}) +export const eventStatusEnum = pgEnum('event_status', [ + 'draft', + 'published', + 'archived', +]) -export const usersTable = pgTable('users', { - id: uuid().primaryKey().defaultRandom(), - name: varchar({ length: 255 }).notNull(), - nickname: varchar({ length: 255 }), - profile_picture: bytea(), - joined_at: text(), - status: text(), - introduction: text(), -}) +export const membershipStatusEnum = pgEnum('membership_status', [ + 'studio_member', + 'studio_candidate', + 'studio_applicant', + 'senior_active', + 'senior_archived', + 'contributor', +]) -export const videos = pgTable('videos', { - id: uuid().primaryKey().defaultRandom(), - title: text().notNull(), - description: text(), - visibility: visibilityEnum().notNull().default('bss'), - video_url: text().notNull(), - views: integer().default(0), - songs: text(), - changeable_uploaded_at: date().notNull().defaultNow(), - updated_at: date().defaultNow(), - created_at: date().defaultNow(), -}) +export const semesterEnum = pgEnum('semester', ['spring', 'autumn']) -export const relatedVideos = pgTable( - 'related_videos', +export const memberSyncStatusEnum = pgEnum('member_sync_status', [ + 'ok', + 'error', +]) + +export const liveStatusEnum = pgEnum('live_status', [ + 'scheduled', + 'active', + 'ended', +]) + +export const slugEntityTypeEnum = pgEnum('slug_entity_type', ['video', 'event']) + +export const memberCache = pgTable( + 'member_cache', { - videoId: uuid('video_id') + sub: varchar('sub', { length: 255 }).primaryKey(), + username: varchar('username', { length: 200 }).notNull(), + fullName: varchar('full_name', { length: 200 }).notNull(), + nickname: varchar('nickname', { length: 200 }), + avatarUrl: varchar('avatar_url', { length: 2048 }), + membershipStatus: membershipStatusEnum('membership_status').notNull(), + isLeadership: boolean('is_leadership').notNull().default(false), + joinedYear: integer('joined_year'), + joinedSemester: semesterEnum('joined_semester'), + joinedSemesterRaw: varchar('joined_semester_raw', { length: 100 }), + introduction: varchar('introduction', { length: 10_000 }), + syncStatus: memberSyncStatusEnum('sync_status').notNull().default('ok'), + lastSyncError: text('last_sync_error'), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }) .notNull() - .references(() => videos.id, { onDelete: 'cascade' }), - relatedVideoId: uuid('related_video_id') + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() - .references(() => videos.id, { onDelete: 'cascade' }), + .defaultNow(), }, - (table) => ({ - pk: primaryKey({ columns: [table.videoId, table.relatedVideoId] }), - }), + (table) => [ + uniqueIndex('member_cache_username_key').on(table.username), + index('member_cache_status_idx').on(table.membershipStatus), + ], ) -export const tags = pgTable('tags', { - id: uuid().primaryKey().defaultRandom(), - name: varchar({ length: 255 }).notNull().unique(), -}) +export const events = pgTable( + 'events', + { + id: uuid().primaryKey().defaultRandom(), + slug: varchar('slug', { length: 200 }).notNull(), + title: varchar({ length: 200 }).notNull(), + description: varchar({ length: 10_000 }), + thumbnailUrl: varchar('thumbnail_url', { length: 2048 }), + startDate: date('start_date').notNull(), + endDate: date('end_date'), + status: eventStatusEnum('status').notNull().default('draft'), + createdBy: varchar('created_by', { length: 255 }).references( + () => memberCache.sub, + ), + updatedBy: varchar('updated_by', { length: 255 }).references( + () => memberCache.sub, + ), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex('events_slug_key').on(table.slug), + index('events_status_start_idx').on(table.status, table.startDate), + check( + 'events_end_after_start_check', + sql`${table.endDate} is null or ${table.endDate} >= ${table.startDate}`, + ), + ], +) + +export const videos = pgTable( + 'videos', + { + id: uuid().primaryKey().defaultRandom(), + slug: varchar('slug', { length: 200 }).notNull(), + title: varchar({ length: 200 }).notNull(), + description: varchar({ length: 10_000 }), + guests: varchar({ length: 5000 }), + songs: varchar({ length: 5000 }), + videoUrl: varchar('video_url', { length: 2048 }), + thumbnailUrl: varchar('thumbnail_url', { length: 2048 }), + visibility: visibilityEnum('visibility').notNull().default('public'), + status: contentStatusEnum('status').notNull().default('draft'), + eventId: uuid('event_id').references(() => events.id, { + onDelete: 'set null', + }), + recordedAt: date('recorded_at'), + publishedAt: timestamp('published_at', { withTimezone: true }), + viewCount: integer('view_count').notNull().default(0), + createdBy: varchar('created_by', { length: 255 }).references( + () => memberCache.sub, + ), + updatedBy: varchar('updated_by', { length: 255 }).references( + () => memberCache.sub, + ), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), + trashedAt: timestamp('trashed_at', { withTimezone: true }), + trashedBy: varchar('trashed_by', { length: 255 }).references( + () => memberCache.sub, + ), + }, + (table) => [ + uniqueIndex('videos_slug_key').on(table.slug), + index('videos_visibility_status_idx').on( + table.visibility, + table.status, + table.publishedAt, + ), + index('videos_event_idx').on(table.eventId), + index('videos_recorded_at_idx').on(table.recordedAt), + index('videos_trash_purge_idx').on(table.status, table.trashedAt), + check( + 'videos_published_requires_timestamp_check', + sql`${table.status} <> 'published' or ${table.publishedAt} is not null`, + ), + check( + 'videos_trash_needs_timestamp_check', + sql`${table.status} <> 'trash' or ${table.trashedAt} is not null`, + ), + ], +) + +export const tags = pgTable( + 'tags', + { + id: uuid().primaryKey().defaultRandom(), + name: varchar('name', { length: 64 }).notNull(), + normalizedName: varchar('normalized_name', { length: 64 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [uniqueIndex('tags_normalized_name_key').on(table.normalizedName)], +) export const videoTags = pgTable( 'video_tags', @@ -73,266 +205,178 @@ export const videoTags = pgTable( .notNull() .references(() => tags.id, { onDelete: 'cascade' }), }, - (table) => ({ - pk: primaryKey({ columns: [table.videoId, table.tagId] }), - }), + (table) => [ + primaryKey({ columns: [table.videoId, table.tagId] }), + index('video_tags_tag_idx').on(table.tagId), + ], ) -export const events = pgTable('events', { - id: uuid().primaryKey().defaultRandom(), - name: text().notNull(), - description: text(), - event_start_date: date().notNull(), - event_end_date: date().notNull(), - created_at: date().defaultNow(), - updated_at: date().defaultNow(), -}) - -export const roles = pgTable('roles', { - id: uuid().primaryKey().defaultRandom(), - name: text().notNull(), -}) +export const staffRoles = pgTable( + 'staff_roles', + { + id: uuid().primaryKey().defaultRandom(), + name: varchar('name', { length: 64 }).notNull(), + normalizedName: varchar('normalized_name', { length: 64 }).notNull(), + displayOrder: integer('display_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + uniqueIndex('staff_roles_normalized_name_key').on(table.normalizedName), + index('staff_roles_display_order_idx').on(table.displayOrder), + ], +) -export const videosRolesUsers = pgTable( - 'videos_roles_users', +export const videoStaff = pgTable( + 'video_staff', { videoId: uuid('video_id') .notNull() .references(() => videos.id, { onDelete: 'cascade' }), roleId: uuid('role_id') .notNull() - .references(() => roles.id, { onDelete: 'cascade' }), - userId: uuid('user_id') + .references(() => staffRoles.id, { onDelete: 'restrict' }), + memberSub: varchar('member_sub', { length: 255 }) .notNull() - .references(() => usersTable.id, { onDelete: 'cascade' }), + .references(() => memberCache.sub), }, - (table) => ({ - pk: primaryKey({ - columns: [table.videoId, table.roleId, table.userId], - }), - }), + (table) => [ + primaryKey({ columns: [table.videoId, table.roleId, table.memberSub] }), + index('video_staff_member_idx').on(table.memberSub), + index('video_staff_role_idx').on(table.roleId), + ], ) -export const eventsRolesUsers = pgTable( - 'events_roles_users', +export const relatedVideos = pgTable( + 'related_videos', { - eventId: uuid('event_id') - .notNull() - .references(() => events.id, { onDelete: 'cascade' }), - roleId: uuid('role_id') + videoId: uuid('video_id') .notNull() - .references(() => roles.id, { onDelete: 'cascade' }), - userId: uuid('user_id') + .references(() => videos.id, { onDelete: 'cascade' }), + relatedVideoId: uuid('related_video_id') .notNull() - .references(() => usersTable.id, { onDelete: 'cascade' }), + .references(() => videos.id, { onDelete: 'cascade' }), + position: integer('position').notNull(), }, - (table) => ({ - pk: primaryKey({ - columns: [table.eventId, table.roleId, table.userId], - }), - }), + (table) => [ + primaryKey({ columns: [table.videoId, table.relatedVideoId] }), + check( + 'related_videos_no_self_reference_check', + sql`${table.videoId} <> ${table.relatedVideoId}`, + ), + ], ) -export const videosEvents = pgTable('videos_events', { - videoId: uuid('video_id') - .notNull() - .references(() => videos.id, { onDelete: 'cascade' }), - eventId: uuid('event_id') - .notNull() - .references(() => events.id, { onDelete: 'cascade' }), -}, (table) => ({ - pk: primaryKey({ columns: [table.videoId, table.eventId] }), -})) - -export const usersRoles = pgTable('users_roles', { - userId: uuid('user_id') - .notNull() - .references(() => usersTable.id, { onDelete: 'cascade' }), - roleId: uuid('role_id') - .notNull() - .references(() => roles.id, { onDelete: 'cascade' }), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.roleId] }), -})) - -export const usersRolesRelations = defineRelations( - { usersTable, roles, usersRoles }, - (r) => ({ - usersTable: { - usersRoles: r.many.usersRoles({ - from: r.usersTable.id, - to: r.usersRoles.userId, - }), - }, - roles: { - usersRoles: r.many.usersRoles({ - from: r.roles.id, - to: r.usersRoles.roleId, - }), - }, - usersRoles: { - user: r.one.usersTable({ - from: r.usersRoles.userId, - to: r.usersTable.id, - }), - role: r.one.roles({ - from: r.usersRoles.roleId, - to: r.roles.id, - }), - }, - }), +export const slugHistory = pgTable( + 'slug_history', + { + entityType: slugEntityTypeEnum('entity_type').notNull(), + slug: varchar('slug', { length: 200 }).notNull(), + entityId: uuid('entity_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.entityType, table.slug] }), + index('slug_history_entity_idx').on(table.entityType, table.entityId), + ], ) -export const eventsRolesUsersRelations = defineRelations( - { events, roles, usersTable, eventsRolesUsers }, - (r) => ({ - events: { - eventsRolesUsers: r.many.eventsRolesUsers({ - from: r.events.id, - to: r.eventsRolesUsers.eventId, - }), - }, - roles: { - eventsRolesUsers: r.many.eventsRolesUsers({ - from: r.roles.id, - to: r.eventsRolesUsers.roleId, - }), - }, - usersTable: { - eventsRolesUsers: r.many.eventsRolesUsers({ - from: r.usersTable.id, - to: r.eventsRolesUsers.userId, - }), - }, - eventsRolesUsers: { - event: r.one.events({ - from: r.eventsRolesUsers.eventId, - to: r.events.id, - }), - role: r.one.roles({ - from: r.eventsRolesUsers.roleId, - to: r.roles.id, - }), - user: r.one.usersTable({ - from: r.eventsRolesUsers.userId, - to: r.usersTable.id, - }), - }, - }), +export const liveStreams = pgTable( + 'live_streams', + { + id: uuid().primaryKey().defaultRandom(), + youtubeVideoId: varchar('youtube_video_id', { length: 64 }).notNull(), + startsAt: timestamp('starts_at', { withTimezone: true }).notNull(), + endsAt: timestamp('ends_at', { withTimezone: true }).notNull(), + status: liveStatusEnum('status').notNull().default('scheduled'), + activationError: text('activation_error'), + activatedAt: timestamp('activated_at', { withTimezone: true }), + endedAt: timestamp('ended_at', { withTimezone: true }), + createdBy: varchar('created_by', { length: 255 }).references( + () => memberCache.sub, + ), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index('live_streams_status_starts_idx').on(table.status, table.startsAt), + check( + 'live_streams_end_after_start_check', + sql`${table.endsAt} > ${table.startsAt}`, + ), + ], ) -export const videosEventsRelations = defineRelations( - { videos, events, videosEvents }, - (r) => ({ - videos: { - videosEvents: r.many.videosEvents({ - from: r.videos.id, - to: r.videosEvents.videoId, - }), - }, - events: { - videosEvents: r.many.videosEvents({ - from: r.events.id, - to: r.videosEvents.eventId, - }), - }, - videosEvents: { - video: r.one.videos({ - from: r.videosEvents.videoId, - to: r.videos.id, - }), - event: r.one.events({ - from: r.videosEvents.eventId, - to: r.events.id, - }), - }, +export const siteSettings = pgTable('site_settings', { + id: integer().primaryKey().default(0), + highlightedVideoId: uuid('highlighted_video_id').references(() => videos.id, { + onDelete: 'set null', }), -) + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow(), +}) -export const videosRolesUsersRelations = defineRelations( - { videos, roles, usersTable, videosRolesUsers }, - (r) => ({ - videos: { - videosRolesUsers: r.many.videosRolesUsers({ - from: r.videos.id, - to: r.videosRolesUsers.videoId, - }), - }, - roles: { - videosRolesUsers: r.many.videosRolesUsers({ - from: r.roles.id, - to: r.videosRolesUsers.roleId, - }), - }, - usersTable: { - videosRolesUsers: r.many.videosRolesUsers({ - from: r.usersTable.id, - to: r.videosRolesUsers.userId, - }), - }, - videosRolesUsers: { - video: r.one.videos({ - from: r.videosRolesUsers.videoId, - to: r.videos.id, - }), - role: r.one.roles({ - from: r.videosRolesUsers.roleId, - to: r.roles.id, - }), - user: r.one.usersTable({ - from: r.videosRolesUsers.userId, - to: r.usersTable.id, - }), - }, - }), +export const aboutPageVideos = pgTable( + 'about_page_videos', + { + position: integer('position').notNull(), + videoId: uuid('video_id') + .notNull() + .references(() => videos.id, { onDelete: 'cascade' }), + }, + (table) => [ + primaryKey({ columns: [table.position, table.videoId] }), + uniqueIndex('about_page_videos_video_key').on(table.videoId), + check( + 'about_page_videos_position_range_check', + sql`${table.position} >= 1 and ${table.position} <= 6`, + ), + ], ) -export const videoTagRelations = defineRelations( - { videos, tags, videoTags }, - (r) => ({ - videos: { - videoTags: r.many.videoTags({ - from: r.videos.id, - to: r.videoTags.videoId, - }), - }, - tags: { - videoTags: r.many.videoTags({ - from: r.tags.id, - to: r.videoTags.tagId, - }), - }, - videoTags: { - video: r.one.videos({ - from: r.videoTags.videoId, - to: r.videos.id, - }), - tag: r.one.tags({ - from: r.videoTags.tagId, - to: r.tags.id, - }), - }, - }), +export const auditLog = pgTable( + 'audit_log', + { + id: bigserial('id', { mode: 'number' }).primaryKey(), + actor: varchar('actor', { length: 255 }).notNull(), + entityType: varchar('entity_type', { length: 50 }).notNull(), + entityId: varchar('entity_id', { length: 255 }).notNull(), + action: varchar('action', { length: 50 }).notNull(), + beforeValue: jsonb('before_value'), + afterValue: jsonb('after_value'), + occurredAt: timestamp('occurred_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index('audit_log_occurred_idx').on(table.occurredAt), + index('audit_log_entity_idx').on(table.entityType, table.entityId), + index('audit_log_actor_idx').on(table.actor), + index('audit_log_action_idx').on(table.action), + ], ) -export const videosRelatedVideosRelations = defineRelations( - { videos, relatedVideos }, - (r) => ({ - videos: { - relatedVideos: r.many.relatedVideos({ - from: r.videos.id, - to: r.relatedVideos.videoId, - }), - }, - relatedVideos: { - video: r.one.videos({ - from: r.relatedVideos.videoId, - to: r.videos.id, - }), - relatedVideo: r.one.videos({ - from: r.relatedVideos.relatedVideoId, - to: r.videos.id, - }), - }, - }), +export const viewSessions = pgTable( + 'view_sessions', + { + videoId: uuid('video_id') + .notNull() + .references(() => videos.id, { onDelete: 'cascade' }), + sessionId: varchar('session_id', { length: 128 }).notNull(), + viewedAt: timestamp('viewed_at', { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.videoId, table.sessionId] }), + index('view_sessions_viewed_idx').on(table.viewedAt), + ], ) diff --git a/src/lib/clock.ts b/src/lib/clock.ts new file mode 100644 index 0000000..acb6a8d --- /dev/null +++ b/src/lib/clock.ts @@ -0,0 +1,43 @@ +export interface Clock { + now: () => Date +} + +export class SystemClock implements Clock { + now(): Date { + return new Date() + } +} + +export class FakeClock implements Clock { + private current: Date + + constructor(initial?: Date | string | number) { + this.current = new Date(initial ?? Date.now()) + } + + now(): Date { + return new Date(this.current) + } + + set(instant: Date | string | number): void { + this.current = new Date(instant) + } + + advanceMilliseconds(milliseconds: number): void { + this.current = new Date(this.current.getTime() + milliseconds) + } + + advanceMinutes(minutes: number): void { + this.advanceMilliseconds(minutes * 60_000) + } + + advanceHours(hours: number): void { + this.advanceMilliseconds(hours * 3_600_000) + } + + advanceDays(days: number): void { + this.advanceMilliseconds(days * 86_400_000) + } +} + +export const systemClock: Clock = new SystemClock() diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..31e8033 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx } from 'clsx' +import type { ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 9ea2c62..d5e6de2 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,39 +9,28 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as CoursesRouteImport } from './routes/courses' -import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' -import { Route as VideosIndexRouteImport } from './routes/videos/index' -import { Route as MembersIndexRouteImport } from './routes/members/index' +import { Route as AboutRouteImport } from './routes/about' +import { Route as CoursesRouteImport } from './routes/courses' import { Route as EventsIndexRouteImport } from './routes/events/index' -import { Route as VideosVideoIdRouteImport } from './routes/videos/$videoId' +import { Route as MembersIndexRouteImport } from './routes/members/index' import { Route as MembersMemberIdRouteImport } from './routes/members/$memberId' -import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query' +import { Route as VideosIndexRouteImport } from './routes/videos/index' +import { Route as VideosVideoIdRouteImport } from './routes/videos/$videoId' -const CoursesRoute = CoursesRouteImport.update({ - id: '/courses', - path: '/courses', - getParentRoute: () => rootRouteImport, -} as any) -const AboutRoute = AboutRouteImport.update({ - id: '/about', - path: '/about', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const VideosIndexRoute = VideosIndexRouteImport.update({ - id: '/videos/', - path: '/videos/', +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', getParentRoute: () => rootRouteImport, } as any) -const MembersIndexRoute = MembersIndexRouteImport.update({ - id: '/members/', - path: '/members/', +const CoursesRoute = CoursesRouteImport.update({ + id: '/courses', + path: '/courses', getParentRoute: () => rootRouteImport, } as any) const EventsIndexRoute = EventsIndexRouteImport.update({ @@ -49,9 +38,9 @@ const EventsIndexRoute = EventsIndexRouteImport.update({ path: '/events/', getParentRoute: () => rootRouteImport, } as any) -const VideosVideoIdRoute = VideosVideoIdRouteImport.update({ - id: '/videos/$videoId', - path: '/videos/$videoId', +const MembersIndexRoute = MembersIndexRouteImport.update({ + id: '/members/', + path: '/members/', getParentRoute: () => rootRouteImport, } as any) const MembersMemberIdRoute = MembersMemberIdRouteImport.update({ @@ -59,9 +48,14 @@ const MembersMemberIdRoute = MembersMemberIdRouteImport.update({ path: '/members/$memberId', getParentRoute: () => rootRouteImport, } as any) -const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({ - id: '/demo/tanstack-query', - path: '/demo/tanstack-query', +const VideosIndexRoute = VideosIndexRouteImport.update({ + id: '/videos/', + path: '/videos/', + getParentRoute: () => rootRouteImport, +} as any) +const VideosVideoIdRoute = VideosVideoIdRouteImport.update({ + id: '/videos/$videoId', + path: '/videos/$videoId', getParentRoute: () => rootRouteImport, } as any) @@ -69,7 +63,6 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute '/courses': typeof CoursesRoute - '/demo/tanstack-query': typeof DemoTanstackQueryRoute '/members/$memberId': typeof MembersMemberIdRoute '/videos/$videoId': typeof VideosVideoIdRoute '/events/': typeof EventsIndexRoute @@ -80,7 +73,6 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute '/courses': typeof CoursesRoute - '/demo/tanstack-query': typeof DemoTanstackQueryRoute '/members/$memberId': typeof MembersMemberIdRoute '/videos/$videoId': typeof VideosVideoIdRoute '/events': typeof EventsIndexRoute @@ -92,7 +84,6 @@ export interface FileRoutesById { '/': typeof IndexRoute '/about': typeof AboutRoute '/courses': typeof CoursesRoute - '/demo/tanstack-query': typeof DemoTanstackQueryRoute '/members/$memberId': typeof MembersMemberIdRoute '/videos/$videoId': typeof VideosVideoIdRoute '/events/': typeof EventsIndexRoute @@ -105,7 +96,6 @@ export interface FileRouteTypes { | '/' | '/about' | '/courses' - | '/demo/tanstack-query' | '/members/$memberId' | '/videos/$videoId' | '/events/' @@ -116,7 +106,6 @@ export interface FileRouteTypes { | '/' | '/about' | '/courses' - | '/demo/tanstack-query' | '/members/$memberId' | '/videos/$videoId' | '/events' @@ -127,7 +116,6 @@ export interface FileRouteTypes { | '/' | '/about' | '/courses' - | '/demo/tanstack-query' | '/members/$memberId' | '/videos/$videoId' | '/events/' @@ -139,7 +127,6 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute CoursesRoute: typeof CoursesRoute - DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute MembersMemberIdRoute: typeof MembersMemberIdRoute VideosVideoIdRoute: typeof VideosVideoIdRoute EventsIndexRoute: typeof EventsIndexRoute @@ -149,20 +136,6 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/courses': { - id: '/courses' - path: '/courses' - fullPath: '/courses' - preLoaderRoute: typeof CoursesRouteImport - parentRoute: typeof rootRouteImport - } - '/about': { - id: '/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof AboutRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -170,18 +143,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/videos/': { - id: '/videos/' - path: '/videos' - fullPath: '/videos/' - preLoaderRoute: typeof VideosIndexRouteImport + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport parentRoute: typeof rootRouteImport } - '/members/': { - id: '/members/' - path: '/members' - fullPath: '/members/' - preLoaderRoute: typeof MembersIndexRouteImport + '/courses': { + id: '/courses' + path: '/courses' + fullPath: '/courses' + preLoaderRoute: typeof CoursesRouteImport parentRoute: typeof rootRouteImport } '/events/': { @@ -191,11 +164,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof EventsIndexRouteImport parentRoute: typeof rootRouteImport } - '/videos/$videoId': { - id: '/videos/$videoId' - path: '/videos/$videoId' - fullPath: '/videos/$videoId' - preLoaderRoute: typeof VideosVideoIdRouteImport + '/members/': { + id: '/members/' + path: '/members' + fullPath: '/members/' + preLoaderRoute: typeof MembersIndexRouteImport parentRoute: typeof rootRouteImport } '/members/$memberId': { @@ -205,11 +178,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MembersMemberIdRouteImport parentRoute: typeof rootRouteImport } - '/demo/tanstack-query': { - id: '/demo/tanstack-query' - path: '/demo/tanstack-query' - fullPath: '/demo/tanstack-query' - preLoaderRoute: typeof DemoTanstackQueryRouteImport + '/videos/': { + id: '/videos/' + path: '/videos' + fullPath: '/videos/' + preLoaderRoute: typeof VideosIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/videos/$videoId': { + id: '/videos/$videoId' + path: '/videos/$videoId' + fullPath: '/videos/$videoId' + preLoaderRoute: typeof VideosVideoIdRouteImport parentRoute: typeof rootRouteImport } } @@ -219,7 +199,6 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, CoursesRoute: CoursesRoute, - DemoTanstackQueryRoute: DemoTanstackQueryRoute, MembersMemberIdRoute: MembersMemberIdRoute, VideosVideoIdRoute: VideosVideoIdRoute, EventsIndexRoute: EventsIndexRoute, @@ -229,12 +208,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/src/router.tsx b/src/router.tsx index 2161efb..9f9cd78 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,12 +1,8 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' -import type { ReactNode } from 'react' -import { QueryClient } from '@tanstack/react-query' import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' -import TanstackQueryProvider, { - getContext, -} from './integrations/tanstack-query/root-provider' +import { getContext } from './integrations/tanstack-query/root-provider' export function getRouter() { const context = getContext() diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a91dacf..829c7bd 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -44,10 +44,7 @@ export const Route = createRootRouteWithContext()({ shellComponent: RootDocument, }) - function RootDocument({ children }: { children: React.ReactNode }) { - - return ( @@ -56,9 +53,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { -
- {children} -
+
{children}
{ - return db.select().from(usersTable) -}) - -export const Route = createFileRoute('/demo/tanstack-query')({ - component: TanStackQueryDemo, -}) - -function TanStackQueryDemo() { - const { data } = useQuery({ - queryKey: ['users'], - queryFn: async () => - await getUsers(), - initialData: [], - }) - - return ( -
-
-

TanStack Query

-

- TanStack Query Simple Promise Handling -

-
    - {data.map((user) => ( -
  • - {user.name} {user.age} {user.email} -
  • - ))} -
-
-
- ) -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index c937a6e..cc5fb25 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,23 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' import { useRef, useEffect } from 'react' -import { usersTable } from '#/db/schema.ts' -import { db } from '#/db/drizzleConnect.ts' -import { Button } from '#/components/ui/button.tsx' -import { createServerFn } from '@tanstack/react-start' import MiniVideo from '#/components/MiniVideo.tsx' -import Card from "#/components/Card.tsx"; -import Videoplayer from "#/components/Videoplayer.tsx"; - -const createUser = createServerFn({ method: 'POST' }).handler(async () => { - const user: typeof usersTable.$inferInsert = { - name: 'asdf', - age: 11, - email: 'asdffdsa', - } - - await db.insert(usersTable).values(user) -}) +import Card from '#/components/Card.tsx' +import Videoplayer from '#/components/Videoplayer.tsx' export const Route = createFileRoute('/')({ component: App, @@ -92,11 +78,15 @@ function App() {
-
- - - - +
+ + + +
) diff --git a/src/routes/members/$memberId.tsx b/src/routes/members/$memberId.tsx index 89fdb53..fb91ae9 100644 --- a/src/routes/members/$memberId.tsx +++ b/src/routes/members/$memberId.tsx @@ -20,15 +20,9 @@ function RouteComponent() { { title: 'BSTV adas 2022. aprilis 21.', role: 'musorvezeto' }, { title: 'BSTV adas 2022. februar 24.', role: 'rendezö' }, ], - 2021: [ - { title: 'BSTV adas 2021. december 15.', role: 'musorvezeto' }, - ], - 2020: [ - { title: 'BSTV adas 2020. oktober 10.', role: 'producer' }, - ], - 2019: [ - { title: 'BSTV adas 2019. szeptember 5.', role: 'musorvezeto' }, - ], + 2021: [{ title: 'BSTV adas 2021. december 15.', role: 'musorvezeto' }], + 2020: [{ title: 'BSTV adas 2020. oktober 10.', role: 'producer' }], + 2019: [{ title: 'BSTV adas 2019. szeptember 5.', role: 'musorvezeto' }], } function toggleYear(year: number) { diff --git a/src/routes/members/index.tsx b/src/routes/members/index.tsx index d64eea0..20bd146 100644 --- a/src/routes/members/index.tsx +++ b/src/routes/members/index.tsx @@ -23,7 +23,9 @@ function RouteComponent() { Ez az oldal Neked keszult, ha kivsnics vagy a BSS tagjaira, reszletesebb adataikra.

-
VEZETOSEG
+
+ VEZETOSEG +
@@ -36,13 +38,17 @@ function RouteComponent() { ))}
-
STUDIOSOK
+
+ STUDIOSOK +
{Array.from({ length: 14 }).map((_, index) => ( ))}
-
UJONCOK
+
+ UJONCOK +
{Array.from({ length: 14 }).map((_, index) => ( diff --git a/src/routes/videos/$videoId.tsx b/src/routes/videos/$videoId.tsx index c3a1ffd..3f17d72 100644 --- a/src/routes/videos/$videoId.tsx +++ b/src/routes/videos/$videoId.tsx @@ -7,12 +7,11 @@ export const Route = createFileRoute('/videos/$videoId')({ }) function RouteComponent() { - const { videoId } = Route.useParams() return (
- +
@@ -37,16 +36,23 @@ function RouteComponent() {
- Felhasznalt zenek: + + Felhasznalt zenek: +

Alma egyuttes - Valami Dal

Alma egyuttes - Valami Dal

Alma egyuttes - Valami Dal

- Az esemeny datuma:{' '}2022. november 03. + + Az esemeny datuma:{' '} + + 2022. november 03.
- Tovabbi videok + + Tovabbi videok +
diff --git a/src/routes/videos/index.tsx b/src/routes/videos/index.tsx index f5c0eae..bf05935 100644 --- a/src/routes/videos/index.tsx +++ b/src/routes/videos/index.tsx @@ -190,7 +190,10 @@ function RouteComponent() { className="inline-flex w-[288px] max-w-full h-[40px] max-h-full justify-between items-center px-4 py-2 bg-(--videos-search-bg) shadow-sm hover:bg-(--videos-search-bg)" > - {'Rendezés: ' + (sort ? options.find((o) => o.value === sort)?.label : 'Rendezés kiválasztása')} + {'Rendezés: ' + + (sort + ? options.find((o) => o.value === sort)?.label + : 'Rendezés kiválasztása')} + } + joinedSemester: { + attribute: string + rules: Array<{ + pattern: RegExp + semester: SemesterKey + }> + } + introduction: string + } + } + media: { + allowedHosts: string[] + } + youtube: { + oEmbedEndpoint: string + } + seed: { + path: string + } +} + +export class OobConfigError extends Error { + readonly problems: string[] + + constructor(problems: string[]) { + super( + `A BSS OOB config érvénytelen vagy hiányzó elemeket tartalmaz (${problems.length} probléma):\n` + + problems.map((problem) => ` - ${problem}`).join('\n') + + '\nLásd: docs/oob-inputs.md', + ) + this.name = 'OobConfigError' + this.problems = problems + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requireString( + source: Record, + path: string, + problems: string[], +): void { + const value = source[path.split('.').pop()!] + if (typeof value !== 'string' || value.trim() === '') { + problems.push(`${path}: kötelező szövegmező, hiányzik vagy üres.`) + } +} + +export function validateOobConfig(raw: unknown): OobConfig { + const problems: string[] = [] + + if (!isRecord(raw)) { + throw new OobConfigError(['A config gyökere objektum kell legyen (JSON).']) + } + + if (!isRecord(raw.authentik)) { + problems.push('authentik: kötelező szekció hiányzik.') + } + if (!isRecord(raw.media)) { + problems.push('media: kötelező szekció hiányzik.') + } + if (!isRecord(raw.youtube)) { + problems.push('youtube: kötelező szekció hiányzik.') + } + if (!isRecord(raw.seed)) { + problems.push('seed: kötelező szekció hiányzik.') + } + + if (problems.length > 0) { + throw new OobConfigError(problems) + } + + const authentik = raw.authentik as Record + const media = raw.media as Record + const youtube = raw.youtube as Record + const seed = raw.seed as Record + + requireString(authentik, 'authentik.issuerUrl', problems) + requireString(authentik, 'authentik.clientId', problems) + requireString(authentik, 'authentik.clientSecret', problems) + + const issuerUrlValue = authentik['issuerUrl'] + if ( + typeof issuerUrlValue === 'string' && + issuerUrlValue.trim() !== '' && + !issuerUrlValue.startsWith('https://') && + !issuerUrlValue.startsWith('http://localhost') && + !issuerUrlValue.startsWith('http://127.0.0.1') + ) { + problems.push( + 'authentik.issuerUrl: https:// kezdetű URL vagy lokális http cím kell legyen.', + ) + } + + if (!Array.isArray(authentik['scopes']) || authentik['scopes'].length === 0) { + problems.push( + 'authentik.scopes: kötelező nem üres lista (pl. openid, profile).', + ) + } else if (!authentik['scopes'].includes('openid')) { + problems.push('authentik.scopes: az openid scope kötelező.') + } + + if (!isRecord(authentik['claims'])) { + problems.push('authentik.claims: kötelező szekció hiányzik.') + } else { + const claims = authentik['claims'] + for (const key of [ + 'sub', + 'username', + 'fullName', + 'nickname', + 'avatarUrl', + ]) { + requireString(claims, `authentik.claims.${key}`, problems) + } + } + + if (!isRecord(authentik['groups'])) { + problems.push('authentik.groups: kötelező szekció hiányzik.') + } else { + const groups = authentik['groups'] + for (const key of ['schonherz', 'tag', 'vezetoseg']) { + requireString(groups, `authentik.groups.${key}`, problems) + } + const groupValues = Object.values(groups) + if (new Set(groupValues).size !== groupValues.length) { + problems.push( + 'authentik.groups: minden csoportnévnek különböznie kell (a vezetőség tagságot nem helyettesítheti).', + ) + } + } + + if (!isRecord(authentik['attributes'])) { + problems.push('authentik.attributes: kötelező szekció hiányzik.') + } else { + const attributes = authentik['attributes'] + + if (!isRecord(attributes['membershipStatus'])) { + problems.push( + 'authentik.attributes.membershipStatus: kötelező szekció hiányzik.', + ) + } else { + const status = attributes['membershipStatus'] + requireString( + status, + 'authentik.attributes.membershipStatus.attribute', + problems, + ) + + if (!isRecord(status['values'])) { + problems.push( + 'authentik.attributes.membershipStatus.values: kötelező leképezés hiányzik.', + ) + } else { + const values = status['values'] + const entries = Object.entries(values) + if (entries.length === 0) { + problems.push( + 'authentik.attributes.membershipStatus.values: legalább egy nyers státusz leképezése kell.', + ) + } + for (const [rawStatus, mapped] of entries) { + if ( + typeof mapped !== 'string' || + !MEMBERSHIP_STATUS_KEYS.includes(mapped as MembershipStatusKey) + ) { + problems.push( + `authentik.attributes.membershipStatus.values["${rawStatus}"]: ismeretlen célállapot "${String(mapped)}". Engedélyezett: ${MEMBERSHIP_STATUS_KEYS.join(', ')}.`, + ) + } + } + } + } + + if (!isRecord(attributes['joinedSemester'])) { + problems.push( + 'authentik.attributes.joinedSemester: kötelező szekció hiányzik.', + ) + } else { + const semester = attributes['joinedSemester'] + requireString( + semester, + 'authentik.attributes.joinedSemester.attribute', + problems, + ) + + if (!Array.isArray(semester['rules']) || semester['rules'].length === 0) { + problems.push( + 'authentik.attributes.joinedSemester.rules: legalább egy értelmezési szabály kell.', + ) + } else { + ;(semester['rules'] as unknown[]).forEach((rule, index) => { + const path = `authentik.attributes.joinedSemester.rules[${index}]` + if (!isRecord(rule)) { + problems.push(`${path}: objektum kell legyen (pattern, semester).`) + return + } + if ( + typeof rule['pattern'] !== 'string' || + rule['pattern'].trim() === '' + ) { + problems.push( + `${path}.pattern: reguláris kifejezés szövegként kötelező.`, + ) + } else { + try { + const compiled = new RegExp(rule['pattern']) + if (compiled.flags.includes('g')) { + problems.push(`${path}.pattern: ne használj g flaget.`) + } + } catch { + problems.push(`${path}.pattern: érvénytelen reguláris kifejezés.`) + } + } + if (rule['semester'] !== 'spring' && rule['semester'] !== 'autumn') { + problems.push( + `${path}.semester: csak "spring" vagy "autumn" lehet, nem "${String(rule['semester'])}".`, + ) + } + }) + } + } + + requireString(attributes, 'authentik.attributes.introduction', problems) + } + + if ( + !Array.isArray(media['allowedHosts']) || + media['allowedHosts'].length === 0 + ) { + problems.push( + 'media.allowedHosts: kötelező nem üres lista (specifikáció szerint v.bsstudio.hu).', + ) + } else { + for (const host of media['allowedHosts']) { + if (typeof host !== 'string' || !/^[a-z0-9.-]+$/.test(host)) { + problems.push( + `media.allowedHosts: érvénytelen hostnév: "${String(host)}".`, + ) + } + } + } + + requireString(youtube, 'youtube.oEmbedEndpoint', problems) + const oEmbedEndpoint = youtube['oEmbedEndpoint'] + if (typeof oEmbedEndpoint === 'string' && oEmbedEndpoint.trim() !== '') { + try { + const parsed = new URL(oEmbedEndpoint) + if (!parsed.hostname.endsWith('youtube.com')) { + problems.push( + 'youtube.oEmbedEndpoint: youtube.com alatti végpont kell legyen.', + ) + } + } catch { + problems.push('youtube.oEmbedEndpoint: érvénytelen URL.') + } + } + + requireString(seed, 'seed.path', problems) + + if (problems.length > 0) { + throw new OobConfigError(problems) + } + + const claims = authentik['claims'] as Record + const groups = authentik['groups'] as Record + const attributes = authentik['attributes'] as Record + const membershipStatus = attributes['membershipStatus'] as Record< + string, + unknown + > + const joinedSemester = attributes['joinedSemester'] as Record + + return { + authentik: { + issuerUrl: authentik['issuerUrl'] as string, + clientId: authentik['clientId'] as string, + clientSecret: authentik['clientSecret'] as string, + scopes: authentik['scopes'] as string[], + claims: claims as unknown as OobConfig['authentik']['claims'], + groups: groups as unknown as OobConfig['authentik']['groups'], + attributes: { + membershipStatus: { + attribute: membershipStatus['attribute'] as string, + values: membershipStatus['values'] as Record< + string, + MembershipStatusKey + >, + }, + joinedSemester: { + attribute: joinedSemester['attribute'] as string, + rules: ( + joinedSemester['rules'] as Array<{ + pattern: string + semester: SemesterKey + }> + ).map((rule) => ({ + pattern: new RegExp(rule.pattern), + semester: rule.semester, + })), + }, + introduction: attributes['introduction'] as string, + }, + }, + media: { + allowedHosts: media['allowedHosts'] as string[], + }, + youtube: { + oEmbedEndpoint: youtube['oEmbedEndpoint'] as string, + }, + seed: { + path: seed['path'] as string, + }, + } +} diff --git a/src/styles.css b/src/styles.css index 255a29d..bab2a84 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,38 +1,36 @@ - -@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap"); +@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap'); -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; -@import "@fontsource-variable/geist"; +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; +@import '@fontsource-variable/geist'; @font-face { - font-family: "Bank Gothic"; - src: url("/fonts/Bank Gothic Light Regular.otf") format("opentype"); + font-family: 'Bank Gothic'; + src: url('/fonts/Bank Gothic Light Regular.otf') format('opentype'); font-weight: 300; } @font-face { - font-family: "Bank Gothic"; - src: url("/fonts/bank gothic medium bt.ttf") format("truetype"); + font-family: 'Bank Gothic'; + src: url('/fonts/bank gothic medium bt.ttf') format('truetype'); font-weight: 500; } @font-face { - font-family: "Bank Gothic"; - src: url("/fonts/BankGothic Bold.ttf") format("truetype"); + font-family: 'Bank Gothic'; + src: url('/fonts/BankGothic Bold.ttf') format('truetype'); font-weight: 700; } @custom-variant dark (&:is(.dark *)); @plugin "@tailwindcss/typography"; - - @theme { /*--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;*/ - --font-montserrat: "Montserrat", ui-sans-serif, system-ui, sans-serif; - --font-bank-gothic: "Bank Gothic", Montserrat, ui-sans-serif, system-ui, sans-serif; + --font-montserrat: 'Montserrat', ui-sans-serif, system-ui, sans-serif; + --font-bank-gothic: + 'Bank Gothic', Montserrat, ui-sans-serif, system-ui, sans-serif; } :root { @@ -87,42 +85,42 @@ /*--sidebar-accent-foreground: oklch(0.205 0 0);*/ /*--sidebar-border: oklch(0.922 0 0);*/ /*--sidebar-ring: oklch(0.708 0 0);*/ - --bg: #FFFFFF; - --orange: #FF9100; - --nav-bg: #FFFFFF; - --blue: #023E8A; + --bg: #ffffff; + --orange: #ff9100; + --nav-bg: #ffffff; + --blue: #023e8a; --darker-blue: #031731; --bss-text: var(--blue); --bss-text-secondary: var(--darker-blue); - --nav-search-bg: #F4F4F4; + --nav-search-bg: #f4f4f4; --nav-icon: var(--blue); - --nav-border-b: #8D8D8D; + --nav-border-b: #8d8d8d; --mini-video-triangle: var(--blue); - --mini-video-bg: #FFFFFF; + --mini-video-bg: #ffffff; --card-title: var(--blue); - --card-text: #6F6F6F; - --card-bg: #FFFFFF; - --videos-search-bg: #F4F4F4; - --videos-search-placeholder: #A8A8A8; - --vidoes-search-border-b: #8D8D8D; + --card-text: #6f6f6f; + --card-bg: #ffffff; + --videos-search-bg: #f4f4f4; + --videos-search-placeholder: #a8a8a8; + --vidoes-search-border-b: #8d8d8d; --vidoes-search-icon: #161616; - --videos-dropdown-hr: #E0E0E0; - --videos-tag: #E5E0DF; + --videos-dropdown-hr: #e0e0e0; + --videos-tag: #e5e0df; --videos-tag-text: #171414; --videos-video-title: var(--blue); --members-title: var(--blue); - --members-card-bg: #FFFFFF; - --members-data-category: #6F6F6F; + --members-card-bg: #ffffff; + --members-data-category: #6f6f6f; --members-data: var(--blue); - --courses-input-bg: #F4F4F4; - --courses-input-label: #A8A8A8; + --courses-input-bg: #f4f4f4; + --courses-input-label: #a8a8a8; --courses-title: var(--blue); --courses-input-placeholder: var(--darker-blue); - --events-card-bg: #FFFFFF; + --events-card-bg: #ffffff; --about-title: var(--darker-blue); } -:root[data-theme="dark"] { +:root[data-theme='dark'] { /*--sea-ink: #d7ece8;*/ /*--sea-ink-soft: #afcdc8;*/ /*--lagoon: #60d7cf;*/ @@ -144,16 +142,16 @@ /*--hero-b: rgba(110, 200, 154, 0.12);*/ --bg: #262626; --nav-bg: #161616; - --bss-text: #FFFFFF; - --bss-text-secondary: #FFFFFF; + --bss-text: #ffffff; + --bss-text-secondary: #ffffff; --nav-search-bg: #262626; - --nav-search-placeholder: #A8A8A8; + --nav-search-placeholder: #a8a8a8; --nav-icon: var(--orange); --nav-border-b: #262626; - --mini-video-triangle: #FFFFFF; + --mini-video-triangle: #ffffff; --mini-video-bg: #161616; --card-title: var(--orange); - --card-text: #FFFFFF; + --card-text: #ffffff; --card-bg: #161616; --videos-video-title: var(--orange); --members-title: var(--blue); @@ -165,7 +163,7 @@ } @media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { + :root:not([data-theme='light']) { /*--sea-ink: #d7ece8;*/ /*--sea-ink-soft: #afcdc8;*/ /*--lagoon: #60d7cf;*/ @@ -188,16 +186,16 @@ --bg: #262626; --bg-color: #262626; --nav-bg: #161616; - --bss-text: #FFFFFF; - --bss-text-secondary: #FFFFFF; + --bss-text: #ffffff; + --bss-text-secondary: #ffffff; --nav-search-bg: #262626; - --nav-search-placeholder: #A8A8A8; + --nav-search-placeholder: #a8a8a8; --nav-icon: var(--orange); --nav-border-b: #262626; - --mini-video-triangle: #FFFFFF; + --mini-video-triangle: #ffffff; --mini-video-bg: #161616; --card-title: var(--orange); - --card-text: #FFFFFF; + --card-text: #ffffff; --card-bg: #161616; --videos-video-title: var(--orange); --members-title: var(--orange); @@ -220,16 +218,16 @@ /*}*/ body { -/* margin: 0;*/ -/* color: var(--sea-ink);*/ -/* font-family: var(--font-bank-gothic);*/ -/* background-color: var(--bg-base);*/ -/* background:*/ -/* radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),*/ -/* radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),*/ -/* radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),*/ -/* linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);*/ -/* overflow-x: hidden;*/ + /* margin: 0;*/ + /* color: var(--sea-ink);*/ + /* font-family: var(--font-bank-gothic);*/ + /* background-color: var(--bg-base);*/ + /* background:*/ + /* radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),*/ + /* radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),*/ + /* radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),*/ + /* linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);*/ + /* overflow-x: hidden;*/ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -692,4 +690,4 @@ body { html { @apply font-montserrat; } -} \ No newline at end of file +} diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts new file mode 100644 index 0000000..0bce8c0 --- /dev/null +++ b/tests/helpers/factories.ts @@ -0,0 +1,148 @@ +export type Visibility = 'public' | 'schonherz' | 'bss' +export type ContentStatus = 'draft' | 'published' | 'archived' | 'trash' +export type EventStatus = 'draft' | 'published' | 'archived' + +let sequence = 0 + +function nextId(): string { + sequence += 1 + return `00000000-0000-4000-8000-${String(sequence).padStart(12, '0')}` +} + +export interface VideoFixture { + id: string + slug: string + title: string + description: string | null + guests: string | null + songs: string | null + videoUrl: string + thumbnailUrl: string + visibility: Visibility + status: ContentStatus + createdAt: Date + updatedAt: Date + publishedAt: Date | null + recordedAt: string | null + viewCount: number + eventId: string | null + version: number +} + +export function buildVideo( + overrides: Partial = {}, +): VideoFixture { + const id = overrides.id ?? nextId() + const title = overrides.title ?? 'Teszt videó' + return { + id, + slug: overrides.slug ?? `teszt-video-${sequence}`, + title, + description: null, + guests: null, + songs: null, + videoUrl: 'https://v.bsstudio.hu/media/video.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/media/thumb.jpg', + visibility: 'public', + status: 'draft', + createdAt: new Date('2026-01-01T10:00:00.000Z'), + updatedAt: new Date('2026-01-01T10:00:00.000Z'), + publishedAt: null, + recordedAt: null, + viewCount: 0, + eventId: null, + version: 1, + ...overrides, + } +} + +export interface EventFixture { + id: string + slug: string + title: string + description: string | null + thumbnailUrl: string | null + startDate: string + endDate: string | null + status: EventStatus + createdAt: Date + updatedAt: Date + version: number +} + +export function buildEvent( + overrides: Partial = {}, +): EventFixture { + const id = overrides.id ?? nextId() + const title = overrides.title ?? 'Teszt esemény' + return { + id, + slug: overrides.slug ?? `teszt-esemeny-${sequence}`, + title, + description: null, + thumbnailUrl: null, + startDate: '2026-05-01', + endDate: null, + status: 'published', + createdAt: new Date('2026-04-01T10:00:00.000Z'), + updatedAt: new Date('2026-04-01T10:00:00.000Z'), + version: 1, + ...overrides, + } +} + +export interface TagFixture { + id: string + name: string +} + +export function buildTag(overrides: Partial = {}): TagFixture { + return { + id: overrides.id ?? nextId(), + name: overrides.name ?? 'Teszt címke', + } +} + +export type MembershipStatus = + | 'studio_member' + | 'studio_candidate' + | 'studio_applicant' + | 'senior_active' + | 'senior_archived' + | 'contributor' + +export interface MemberFixture { + sub: string + username: string + fullName: string + nickname: string | null + avatarUrl: string | null + membershipStatus: MembershipStatus + isLeadership: boolean + joinedYear: number | null + joinedSemester: 'spring' | 'autumn' | null + joinedSemesterRaw: string | null + introduction: string | null + lastSeenAt: Date +} + +export function buildMember( + overrides: Partial = {}, +): MemberFixture { + const username = overrides.username ?? `teszt-tag-${sequence}` + return { + sub: overrides.sub ?? nextId(), + username, + fullName: overrides.fullName ?? 'Teszt Teljes Név', + nickname: overrides.nickname ?? 'Teszti', + avatarUrl: null, + membershipStatus: overrides.membershipStatus ?? 'studio_member', + isLeadership: overrides.isLeadership ?? false, + joinedYear: overrides.joinedYear ?? 2023, + joinedSemester: overrides.joinedSemester ?? 'autumn', + joinedSemesterRaw: overrides.joinedSemesterRaw ?? '2023 ősz', + introduction: overrides.introduction ?? null, + lastSeenAt: new Date('2026-06-01T10:00:00.000Z'), + ...overrides, + } +} diff --git a/tests/helpers/http-mock.ts b/tests/helpers/http-mock.ts new file mode 100644 index 0000000..3579683 --- /dev/null +++ b/tests/helpers/http-mock.ts @@ -0,0 +1,107 @@ +export interface MockRequest { + url: URL + method: string + headers: Headers + body: unknown +} + +export interface MockResponseSpec { + status: number + headers?: Record + body?: string | Buffer | object +} + +export interface MockRoute { + method?: string + urlPattern: RegExp | string + respond: ( + request: MockRequest, + ) => MockResponseSpec | Promise +} + +export interface FetchMock { + restore: () => void + calls: () => Array<{ method: string; url: string }> + reset: () => void +} + +function matches(route: MockRoute, request: MockRequest): boolean { + if (route.method && route.method.toUpperCase() !== request.method) { + return false + } + if (typeof route.urlPattern === 'string') { + return request.url.toString().includes(route.urlPattern) + } + return route.urlPattern.test(request.url.toString()) +} + +function toResponse(spec: MockResponseSpec): Response { + const headers = new Headers(spec.headers) + let body: BodyInit | undefined + + if (spec.body instanceof Buffer) { + body = new Uint8Array(spec.body) + } else if (typeof spec.body === 'object') { + headers.set( + 'content-type', + spec.headers?.['content-type'] ?? 'application/json', + ) + body = JSON.stringify(spec.body) + } else if (spec.body !== undefined) { + body = spec.body + } + + return new Response(body, { status: spec.status, headers }) +} + +export function installFetchMock(routes: MockRoute[]): FetchMock { + const originalFetch = globalThis.fetch + const recordedCalls: Array<{ method: string; url: string }> = [] + + const mockedFetch = async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = input instanceof Request ? input.url : String(input) + const method = ( + init?.method ?? (input instanceof Request ? input.method : 'GET') + ).toUpperCase() + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ) + + const request: MockRequest = { + url: new URL(url), + method, + headers, + body: init?.body, + } + + recordedCalls.push({ method, url }) + + for (const route of routes) { + if (matches(route, request)) { + const spec = await route.respond(request) + return toResponse(spec) + } + } + + throw new Error( + `Nincs mock a kéréshez: ${method} ${url}. Bővítsd a teszt fetch mock útvonalait.`, + ) + } + + globalThis.fetch = mockedFetch + + return { + restore() { + globalThis.fetch = originalFetch + }, + calls() { + return [...recordedCalls] + }, + reset() { + recordedCalls.length = 0 + }, + } +} diff --git a/tests/helpers/oob-config.ts b/tests/helpers/oob-config.ts new file mode 100644 index 0000000..434c613 --- /dev/null +++ b/tests/helpers/oob-config.ts @@ -0,0 +1,121 @@ +export interface RawSemesterRule { + pattern: string + semester: string +} + +export interface RawOobConfig { + authentik: { + issuerUrl: string + clientId: string + clientSecret: string + scopes: string[] + claims: Record + groups: Record + attributes: { + membershipStatus: { + attribute: string + values: Record + } + joinedSemester: { + attribute: string + rules: RawSemesterRule[] + } + introduction: string + } + } + media: { + allowedHosts: string[] + } + youtube: { + oEmbedEndpoint: string + } + seed: { + path: string + } +} + +export function buildRawOobConfig( + overrides: DeepPartial = {}, +): RawOobConfig { + const base: RawOobConfig = { + authentik: { + issuerUrl: 'https://authentik.local/application/o/bss/', + clientId: 'bss-stack-local', + clientSecret: 'local-test-secret-not-for-production', + scopes: ['openid', 'profile', 'email'], + claims: { + sub: 'sub', + username: 'preferred_username', + fullName: 'name', + nickname: 'nickname', + avatarUrl: 'picture', + }, + groups: { + schonherz: 'schonherz-dev', + tag: 'tag-dev', + vezetoseg: 'vezetoseg-dev', + }, + attributes: { + membershipStatus: { + attribute: 'bss_status', + values: { + stúdiós: 'studio_member', + stúdiósjelölt: 'studio_candidate', + 'stúdiósjelölt-jelölt': 'studio_applicant', + 'aktív öregtag': 'senior_active', + 'archivált öregtag': 'senior_archived', + 'dolgozott még velünk': 'contributor', + }, + }, + joinedSemester: { + attribute: 'bss_csatlakozas', + rules: [ + { pattern: '^(\\d{4})\\s+(ősz|őszi)$', semester: 'autumn' }, + { pattern: '^(\\d{4})\\s+(tavasz|tavaszi)$', semester: 'spring' }, + ], + }, + introduction: 'bss_bemutatkozas', + }, + }, + media: { + allowedHosts: ['v.bsstudio.hu'], + }, + youtube: { + oEmbedEndpoint: 'https://www.youtube.com/oEmbed', + }, + seed: { + path: 'oob/seed.json', + }, + } + + return mergeDeep(base, overrides) +} + +type DeepPartial = { + [K in keyof T]?: T[K] extends object + ? T[K] extends Array + ? Array> + : DeepPartial + : T[K] +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function mergeDeep(target: T, patch: DeepPartial): T { + const result = target + + for (const key of Object.keys(patch)) { + const patchValue = (patch as Record)[key] + const targetValue = (target as Record)[key] + + if (isPlainObject(patchValue) && isPlainObject(targetValue)) { + mergeDeep(targetValue, patchValue as never) + } else { + ;(result as Record)[key] = patchValue + } + } + + return result +} diff --git a/tests/helpers/test-db.ts b/tests/helpers/test-db.ts new file mode 100644 index 0000000..559576e --- /dev/null +++ b/tests/helpers/test-db.ts @@ -0,0 +1,48 @@ +import { Client } from 'pg' + +export interface TestDatabase { + databaseName: string + connectionString: string + drop: () => Promise +} + +function adminUrl(): string { + const url = process.env.TEST_DATABASE_URL + if (!url) { + throw new Error( + 'A TEST_DATABASE_URL környezeti változó nincs beállítva. Indítsd a fejlesztői PostgreSQL-t (docker compose -f docker-compose.dev.yml up -d bss-dev-db), és állítsd be: TEST_DATABASE_URL=postgres://bss:bss@127.0.0.1:5582/bss', + ) + } + return url +} + +export async function createTestDatabase( + prefix = 'bss_test', +): Promise { + const admin = new Client({ connectionString: adminUrl() }) + await admin.connect() + + const databaseName = `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + + try { + await admin.query(`CREATE DATABASE "${databaseName}"`) + + const parsed = new URL(adminUrl()) + parsed.pathname = `/${databaseName}` + const connectionString = parsed.toString() + + return { + databaseName, + connectionString, + async drop() { + await admin.query( + `DROP DATABASE IF EXISTS "${databaseName}" WITH (FORCE)`, + ) + await admin.end() + }, + } + } catch (error) { + await admin.end() + throw error + } +} diff --git a/tests/integration/schema.migration.test.ts b/tests/integration/schema.migration.test.ts new file mode 100644 index 0000000..f685901 --- /dev/null +++ b/tests/integration/schema.migration.test.ts @@ -0,0 +1,147 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { Client } from 'pg' +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { createTestDatabase } from '../helpers/test-db.ts' +import type { TestDatabase } from '../helpers/test-db.ts' + +const databases: TestDatabase[] = [] + +afterAll(async () => { + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +async function applyMigrations(client: Client): Promise { + const drizzleDir = join(process.cwd(), 'drizzle') + const folders = readdirSync(drizzleDir) + .filter((name) => /^\d{14}_/.test(name)) + .sort() + + for (const folder of folders) { + const sql = readFileSync(join(drizzleDir, folder, 'migration.sql'), 'utf-8') + const statements = sql + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) + + for (const statement of statements) { + await client.query(statement) + } + } +} + +async function createMigratedDatabase(): Promise<{ + database: TestDatabase + client: Client +}> { + const database = await createTestDatabase('bss_schema_test') + databases.push(database) + + const client = new Client({ connectionString: database.connectionString }) + await client.connect() + await applyMigrations(client) + + return { database, client } +} + +describe('új adatbázisséma és migrációs alap', () => { + it('tiszta adatbázison a migráció lefut és minden tábla létezik', async () => { + const { client } = await createMigratedDatabase() + try { + const result = await client.query<{ table_name: string }>(` + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name + `) + const tables = result.rows.map((row) => row.table_name) + expect(tables).toEqual( + expect.arrayContaining([ + 'about_page_videos', + 'audit_log', + 'events', + 'live_streams', + 'member_cache', + 'related_videos', + 'site_settings', + 'slug_history', + 'staff_roles', + 'tags', + 'video_staff', + 'video_tags', + 'videos', + 'view_sessions', + ]), + ) + } finally { + await client.end() + } + }, 30_000) + + it('a séma adatbázis-korlátokkal védi az invariánsokat', async () => { + const { client } = await createMigratedDatabase() + try { + await client.query( + "INSERT INTO member_cache (sub, username, full_name, membership_status) VALUES ('sub1','user1','Egy Tag','studio_member')", + ) + + await expect( + client.query( + "INSERT INTO videos (slug, title, status) VALUES ('pub1','Publikálhatatlan','published')", + ), + ).rejects.toThrow(/check/i) + + await client.query( + "INSERT INTO videos (id, slug, title) VALUES ('11111111-1111-1111-1111-111111111111','v1','V1')", + ) + await expect( + client.query( + "INSERT INTO related_videos VALUES ('11111111-1111-1111-1111-111111111111','11111111-1111-1111-1111-111111111111',1)", + ), + ).rejects.toThrow(/check|self_reference/i) + + const inserted = await client.query( + "INSERT INTO videos (id, slug, title) VALUES ('22222222-2222-2222-2222-222222222222','v2','V2') RETURNING id", + ) + + void inserted + + await expect( + client.query( + "INSERT INTO events (slug,title,start_date,end_date) VALUES ('e1','E1','2026-06-10','2026-06-09')", + ), + ).rejects.toThrow() + + await expect( + client.query( + "INSERT INTO about_page_videos VALUES (7,'11111111-1111-1111-1111-111111111111')", + ), + ).rejects.toThrow(/check/i) + } finally { + await client.end() + } + }, 30_000) + + it('átfedő live időablak nem menthető adatbázis-szinten', async () => { + const { client } = await createMigratedDatabase() + try { + await client.query( + "INSERT INTO live_streams (youtube_video_id, starts_at, ends_at) VALUES ('abc1','2026-06-01 10:00+00','2026-06-01 12:00+00')", + ) + + await expect( + client.query( + "INSERT INTO live_streams (youtube_video_id, starts_at, ends_at) VALUES ('abc2','2026-06-01 11:00+00','2026-06-01 13:00+00')", + ), + ).rejects.toThrow() + + const nonOverlapping = await client.query( + "INSERT INTO live_streams (youtube_video_id, starts_at, ends_at) VALUES ('abc3','2026-06-02 10:00+00','2026-06-02 12:00+00') RETURNING id", + ) + expect(nonOverlapping.rowCount).toBe(1) + } finally { + await client.end() + } + }, 30_000) +}) diff --git a/tests/integration/smoke.db.test.ts b/tests/integration/smoke.db.test.ts new file mode 100644 index 0000000..03df73a --- /dev/null +++ b/tests/integration/smoke.db.test.ts @@ -0,0 +1,79 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { Client } from 'pg' +import { createTestDatabase } from '../helpers/test-db.ts' +import type { TestDatabase } from '../helpers/test-db.ts' + +const databases: TestDatabase[] = [] + +afterAll(async () => { + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +function withIsolatedDatabase( + run: (database: TestDatabase) => Promise, +): () => Promise { + return async () => { + const database = await createTestDatabase() + databases.push(database) + const client = new Client({ connectionString: database.connectionString }) + await client.connect() + try { + await run(database) + } finally { + await client.end() + } + } +} + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +describe.skipIf(!hasTestDatabase)( + 'integrációs smoke test valódi PostgreSQL ellen', + () => { + it( + 'izolált tesztadatbázist hoz létre és kérdez le', + withIsolatedDatabase(async (database) => { + expect(database.databaseName).toMatch(/^bss_test_/) + + const client = new Client({ + connectionString: database.connectionString, + }) + await client.connect() + try { + await client.query( + 'CREATE TABLE smoke_check (id integer primary key, label text)', + ) + await client.query( + "INSERT INTO smoke_check (id, label) VALUES (1, 'működik')", + ) + const result = await client.query<{ label: string }>( + 'SELECT label FROM smoke_check WHERE id = 1', + ) + expect(result.rows[0]?.label).toBe('működik') + } finally { + await client.end() + } + }), + ) + + it( + 'minden teszt tiszta, egymástól független adatbázison fut', + withIsolatedDatabase(async (database) => { + const client = new Client({ + connectionString: database.connectionString, + }) + await client.connect() + try { + const exists = await client.query( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'smoke_check'", + ) + expect(exists.rowCount).toBe(0) + } finally { + await client.end() + } + }), + ) + }, +) diff --git a/tests/unit/clock.test.ts b/tests/unit/clock.test.ts new file mode 100644 index 0000000..7f7c61a --- /dev/null +++ b/tests/unit/clock.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { FakeClock, SystemClock } from '#/lib/clock.ts' + +describe('FakeClock', () => { + it('fixált időpontot ad vissza', () => { + const clock = new FakeClock('2026-06-06T12:00:00.000Z') + expect(clock.now().toISOString()).toBe('2026-06-06T12:00:00.000Z') + }) + + it('tetszőleges időpontra állítható', () => { + const clock = new FakeClock() + clock.set('2030-01-01T00:00:00.000Z') + expect(clock.now().toISOString()).toBe('2030-01-01T00:00:00.000Z') + }) + + it('percenkénti előretolás működik', () => { + const clock = new FakeClock('2026-06-06T10:00:00.000Z') + clock.advanceMinutes(90) + expect(clock.now().toISOString()).toBe('2026-06-06T11:30:00.000Z') + }) + + it('30 nap előretolása a lomtár-törlés szimulációjához', () => { + const trashedAt = new Date('2026-06-01T08:00:00.000Z') + const clock = new FakeClock(trashedAt) + clock.advanceDays(30) + expect(clock.now().getTime() - trashedAt.getTime()).toBe(30 * 86_400_000) + expect(clock.now().toISOString()).toBe('2026-07-01T08:00:00.000Z') + }) + + it('órapéldányok egymástól függetlenek', () => { + const first = new FakeClock('2026-01-01T00:00:00.000Z') + const second = new FakeClock('2026-01-01T00:00:00.000Z') + first.advanceDays(5) + expect(second.now().toISOString()).toBe('2026-01-01T00:00:00.000Z') + }) +}) + +describe('SystemClock', () => { + it('a valós időhöz közeli értéket ad', () => { + const before = Date.now() + const value = new SystemClock().now().getTime() + const after = Date.now() + expect(value).toBeGreaterThanOrEqual(before) + expect(value).toBeLessThanOrEqual(after) + }) +}) diff --git a/tests/unit/http-mock.test.ts b/tests/unit/http-mock.test.ts new file mode 100644 index 0000000..c735d2b --- /dev/null +++ b/tests/unit/http-mock.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { installFetchMock } from '../helpers/http-mock.ts' + +const cleanup: Array<() => void> = [] + +afterEach(() => { + while (cleanup.length > 0) { + cleanup.pop()!() + } +}) + +describe('installFetchMock', () => { + it('URL mintára válaszol determinisztikusan', async () => { + const mock = installFetchMock([ + { + method: 'HEAD', + urlPattern: 'https://v.bsstudio.hu/media/', + respond: () => ({ + status: 200, + headers: { 'content-type': 'video/mp4' }, + }), + }, + ]) + cleanup.push(mock.restore) + + const response = await fetch('https://v.bsstudio.hu/media/video1.mp4', { + method: 'HEAD', + }) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('video/mp4') + }) + + it('átirányítást szimuláló válasz is beállítható', async () => { + const mock = installFetchMock([ + { + urlPattern: /example\.com\/redirect/, + respond: () => ({ + status: 302, + headers: { location: 'https://bsstudio.hu/' }, + }), + }, + ]) + cleanup.push(mock.restore) + + const response = await fetch('https://example.com/redirect/video.mp4') + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('https://bsstudio.hu/') + }) + + it('JSON választ ad oEmbed híváshoz', async () => { + const mock = installFetchMock([ + { + urlPattern: 'www.youtube.com/oEmbed', + respond: () => ({ + status: 200, + body: { title: 'Teszt live', author_name: 'BSS' }, + }), + }, + ]) + cleanup.push(mock.restore) + + const response = await fetch( + 'https://www.youtube.com/oEmbed?url=https://www.youtube.com/watch?v=abc123&format=json', + ) + const payload = (await response.json()) as { title: string } + expect(payload.title).toBe('Teszt live') + }) + + it('mock nélküli útvonalnál konkrét hibát dob', async () => { + const mock = installFetchMock([]) + cleanup.push(mock.restore) + + await expect(fetch('https://unknown.example.com/x')).rejects.toThrow( + /Nincs mock a kéréshez/, + ) + }) + + it('a hívások naplózása és visszaállítás után az eredeti fetch él', async () => { + const mock = installFetchMock([ + { + urlPattern: 'authentik.local', + respond: () => ({ status: 200, body: { ok: true } }), + }, + ]) + + await fetch('https://authentik.local/application/o/bss/') + expect(mock.calls()).toHaveLength(1) + expect(mock.calls()[0]?.method).toBe('GET') + + mock.reset() + expect(mock.calls()).toHaveLength(0) + + mock.restore() + }) +}) diff --git a/tests/unit/local-bootstrap.test.ts b/tests/unit/local-bootstrap.test.ts new file mode 100644 index 0000000..7891d40 --- /dev/null +++ b/tests/unit/local-bootstrap.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + LOCAL_USERS, + renderBlueprint, + renderOobConfig, + writeLocalFiles, +} from '../../scripts/lib/local-bootstrap.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' + +const cleanupDirs: string[] = [] + +afterEach(() => { + while (cleanupDirs.length > 0) { + rmSync(cleanupDirs.pop()!, { recursive: true, force: true }) + } +}) + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'bss-bootstrap-')) + cleanupDirs.push(dir) + return dir +} + +describe('lokális Authentik bootstrap', () => { + it('mindhárom nézői szint és mindkét adminszerep tesztadatot kap', () => { + const usernames = LOCAL_USERS.map((user) => user.username) + expect(usernames).toContain('schonherz-dev') + expect(usernames).toContain('tag-dev') + expect(usernames).toContain('vezetoseg-dev') + + const tag = LOCAL_USERS.find((user) => user.username === 'tag-dev')! + expect(tag.groups).toContain('bss-tag') + + const leadership = LOCAL_USERS.find( + (user) => user.username === 'vezetoseg-dev', + )! + expect(leadership.groups).toContain('bss-tag') + expect(leadership.groups).toContain('bss-vezetoseg') + + const schonherz = LOCAL_USERS.find( + (user) => user.username === 'schonherz-dev', + )! + expect(schonherz.groups).not.toContain('bss-tag') + + const statuses = LOCAL_USERS.map((user) => user.status) + expect(statuses).toEqual( + expect.arrayContaining([ + 'stúdiós', + 'stúdiósjelölt', + 'stúdiósjelölt-jelölt', + 'aktív öregtag', + 'archivált öregtag', + 'dolgozott még velünk', + ]), + ) + }) + + it('a generált config átmegy az OOB validáción', () => { + const secrets = { + authentikSecretKey: 'secret-key-value', + oidcClientSecret: 'client-secret-value', + passwords: {}, + } + const validated = validateOobConfig(renderOobConfig(secrets)) + expect(validated.authentik.clientId).toBe('bss-stack-local') + expect(validated.authentik.groups.tag).toBe('bss-tag') + expect( + Object.keys(validated.authentik.attributes.membershipStatus.values), + ).toHaveLength(6) + }) + + it('a blueprint tartalmazza a csoportokat, felhasználókat és a providert', () => { + const secrets = { + authentikSecretKey: 'secret-key-value', + oidcClientSecret: 'client-secret-value', + passwords: { 'tag-dev': 'jelszo1' }, + } + const yaml = renderBlueprint(secrets) + + for (const group of ['bss-schonherz', 'bss-tag', 'bss-vezetoseg']) { + expect(yaml).toContain(`name: ${group}`) + } + for (const user of LOCAL_USERS) { + expect(yaml).toContain(`username: ${user.username}`) + } + expect(yaml).toContain('authentik_providers_oauth2.oauth2provider') + expect(yaml).toContain('client_id: bss-stack-local') + expect(yaml).toContain('http://localhost:3000/api/auth/callback') + expect(yaml).toContain('password: "jelszo1"') + }) + + it('idempotens: újrafuttatás nem generál új titkokat és nem duplikál', () => { + const dir = tempDir() + const first = writeLocalFiles(dir) + const blueprintPath = join( + dir, + 'oob', + 'authentik', + 'blueprints', + 'bss-local.yaml', + ) + const firstBlueprint = readFileSync(blueprintPath, 'utf-8') + + const second = writeLocalFiles(dir) + const secondBlueprint = readFileSync(blueprintPath, 'utf-8') + + expect(first.created).toBe(true) + expect(second.created).toBe(false) + expect(second.secrets.oidcClientSecret).toBe(first.secrets.oidcClientSecret) + expect(secondBlueprint).toBe(firstBlueprint) + expect(existsSync(join(dir, 'oob', 'config.json'))).toBe(true) + expect(existsSync(join(dir, 'oob', 'authentik.env'))).toBe(true) + }) + + it('módosított meglévő titokfájl mellett nem írja felül', () => { + const dir = tempDir() + writeLocalFiles(dir) + const secretsPath = join(dir, 'oob', 'local-secrets.json') + const original = JSON.parse(readFileSync(secretsPath, 'utf-8')) + original.oidcClientSecret = 'megmarado-ertek' + writeFileSync(secretsPath, JSON.stringify(original)) + + writeLocalFiles(dir) + const after = JSON.parse(readFileSync(secretsPath, 'utf-8')) + expect(after.oidcClientSecret).toBe('megmarado-ertek') + }) +}) diff --git a/tests/unit/oob-config-load.test.ts b/tests/unit/oob-config-load.test.ts new file mode 100644 index 0000000..182210a --- /dev/null +++ b/tests/unit/oob-config-load.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadOobConfig, OobConfigFileError } from '#/server/config/load.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' + +describe('OOB config betöltés', () => { + it('hiányzó fájlnál konkrét hibával megáll', () => { + expect(() => loadOobConfig('/nem/letezo/config.json')).toThrow( + OobConfigFileError, + ) + try { + loadOobConfig('/nem/letezo/config.json') + expect.unreachable() + } catch (error) { + expect((error as Error).message).toContain('/nem/letezo/config.json') + expect((error as Error).message).toContain('BSS_OOB_CONFIG') + } + }) + + it('érvénytelen JSON-nál konkrét hibával megáll', () => { + const dir = mkdtempSync(join(tmpdir(), 'bss-oob-')) + const path = join(dir, 'config.json') + writeFileSync(path, '{ ez nem json') + + try { + expect(() => loadOobConfig(path)).toThrow(/nem érvényes JSON/) + expect(() => loadOobConfig(path)).toThrow(path) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('érvényes fájlt betölt és validál', () => { + const dir = mkdtempSync(join(tmpdir(), 'bss-oob-')) + const path = join(dir, 'config.json') + writeFileSync(path, JSON.stringify(buildRawOobConfig())) + + try { + const config = loadOobConfig(path) + expect(config.seed.path).toBe('oob/seed.json') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('validációs hibaüzenetben nem jelenik meg a titok', () => { + const secret = 'nagyon-titkos-ertek-amit-soha-nem-szabad-kiirni' + const raw = buildRawOobConfig() + raw.authentik.clientSecret = secret + raw.authentik.scopes = [] + + try { + validateOobConfig(raw) + expect.unreachable() + } catch (error) { + expect((error as Error).message).not.toContain(secret) + expect((error as Error).message).not.toContain( + 'local-test-secret-not-for-production', + ) + } + }) +}) diff --git a/tests/unit/oob-config.test.ts b/tests/unit/oob-config.test.ts new file mode 100644 index 0000000..360ef7b --- /dev/null +++ b/tests/unit/oob-config.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + OobConfigError, + validateOobConfig, +} from '#/server/config/oob-schema.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' + +describe('OOB config validáció', () => { + it('érvényes konfigurációnál típusos objektumot ad', () => { + const config = validateOobConfig(buildRawOobConfig()) + expect(config.authentik.clientId).toBe('bss-stack-local') + expect(config.media.allowedHosts).toEqual(['v.bsstudio.hu']) + expect(config.authentik.attributes.joinedSemester.rules).toHaveLength(2) + expect( + config.authentik.attributes.joinedSemester.rules[0].pattern, + ).toBeInstanceOf(RegExp) + expect(config.authentik.attributes.membershipStatus.values['stúdiós']).toBe( + 'studio_member', + ) + }) + + it('hiányzó szekciókra konkrét hibaüzenetet ad', () => { + expect(() => validateOobConfig({})).toThrow(OobConfigError) + try { + validateOobConfig({}) + expect.unreachable() + } catch (error) { + const problems = (error as OobConfigError).problems + expect(problems.some((p) => p.startsWith('authentik:'))).toBe(true) + expect(problems.some((p) => p.startsWith('media:'))).toBe(true) + expect(problems.some((p) => p.startsWith('youtube:'))).toBe(true) + expect(problems.some((p) => p.startsWith('seed:'))).toBe(true) + } + }) + + it('üres titok nem elfogadott, félkonfigurált auth mód nincs', () => { + const raw = buildRawOobConfig() + raw.authentik.clientSecret = '' + + expect(() => validateOobConfig(raw)).toThrow(/clientSecret/) + }) + + it('ismeretlen tagsági státusz célra hibát dob, nem talál ki értéket', () => { + const raw = buildRawOobConfig() + raw.authentik.attributes.membershipStatus.values = { + 'kitalalt-status': 'nem_letezo_kulcs', + } + + expect(() => validateOobConfig(raw)).toThrow( + /ismeretlen célállapot "nem_letezo_kulcs"/, + ) + }) + + it('érvénytelen félévszabály regexre hibát dob', () => { + const raw = buildRawOobConfig() + raw.authentik.attributes.joinedSemester.rules[0].pattern = '([évtelen' + + expect(() => validateOobConfig(raw)).toThrow( + /érvénytelen reguláris kifejezés/, + ) + }) + + it('ismeretlen félév értékre hibát dob', () => { + const raw = buildRawOobConfig() + raw.authentik.attributes.joinedSemester.rules[0].semester = 'tel' + + expect(() => validateOobConfig(raw)).toThrow( + /csak "spring" vagy "autumn" lehet/, + ) + }) + + it('azonos csoportnevek elfogadatlanok', () => { + const raw = buildRawOobConfig({ + authentik: { + groups: { + schonherz: 'ugyanaz', + tag: 'ugyanaz', + vezetoseg: 'vezetoseg-dev', + }, + }, + }) + + expect(() => validateOobConfig(raw)).toThrow( + /minden csoportnévnek különböznie kell/, + ) + }) + + it('openid scope nélkül hibát dob', () => { + const raw = buildRawOobConfig({ authentik: { scopes: ['profile'] } }) + expect(() => validateOobConfig(raw)).toThrow(/openid/) + }) + + it('média host lista nem lehet üres', () => { + const raw = buildRawOobConfig({ media: { allowedHosts: [] } }) + expect(() => validateOobConfig(raw)).toThrow(/allowedHosts/) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 9bdc820..f08699f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,12 @@ { - "include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"], - + "include": [ + "**/*.ts", + "**/*.tsx", + "eslint.config.js", + "prettier.config.js", + "vite.config.js" + ], + "compilerOptions": { "target": "ES2022", "jsx": "react-jsx", diff --git a/vite.config.ts b/vite.config.ts index 5a46a66..f5d9604 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,13 +9,7 @@ import { nitro } from 'nitro/vite' const config = defineConfig({ resolve: { tsconfigPaths: true }, - plugins: [ - devtools(), - nitro({ rollupConfig: { external: [/^@sentry\//] } }), - tailwindcss(), - tanstackStart(), - viteReact(), - ], + plugins: [devtools(), nitro(), tailwindcss(), tanstackStart(), viteReact()], }) export default config diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4f9f6c4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + projects: [ + { + test: { + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + }, + }, + { + test: { + name: 'integration', + include: ['tests/integration/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + fileParallelism: false, + }, + }, + ], + }, +}) From 117f39caac3039e8bf71cff0f801498dd13ac7bb Mon Sep 17 00:00:00 2001 From: Gyula Kiri Date: Sun, 23 Aug 2026 20:20:35 +0200 Subject: [PATCH 03/25] feat: complete phase 1 auth, sync, and job infrastructure - Add OIDC login/callback/logout flow with DB-backed sessions (BSS-006) - Add viewer policy and admin/leadership route guards (BSS-007) - Add Authentik member cache sync job and mapping (BSS-008) - Add shared slug history, immutable audit log, and optimistic locking (BSS-009) - Add background job runner with locking, health, and sync alerts (BSS-010) - Add media/YouTube URL validators for publish and draft flows (BSS-011) - Add auth_sessions migration and svc-bss-sync OOB config docs --- docs/examples/oob-config.example.json | 14 +- docs/implementation-progress.md | 96 +- docs/oob-inputs.md | 4 + .../migration.sql | 11 + .../snapshot.json | 2442 ++++++++++++++++ .../migration.sql | 14 + .../snapshot.json | 2600 +++++++++++++++++ .../migration.sql | 12 + .../snapshot.json | 2600 +++++++++++++++++ scripts/lib/local-bootstrap.ts | 52 +- src/db/schema.ts | 41 + src/routeTree.gen.ts | 9 + src/server.ts | 38 + src/server/api/auth-routes.ts | 327 +++ src/server/api/http.ts | 106 + src/server/api/router.ts | 28 + src/server/auth/guards.ts | 52 + src/server/auth/oidc.ts | 398 +++ src/server/auth/policy.ts | 63 + src/server/auth/session-cookies.ts | 125 + src/server/auth/session-store.ts | 145 + src/server/auth/viewer.ts | 73 + src/server/config/load.ts | 13 + src/server/config/oob-schema.ts | 22 + src/server/jobs/health.ts | 79 + src/server/jobs/locks.ts | 85 + src/server/jobs/runner.ts | 262 ++ src/server/media/validator.ts | 213 ++ src/server/media/youtube.ts | 149 + src/server/members/authentik-api.ts | 224 ++ src/server/members/map.ts | 125 + src/server/members/sync.ts | 321 ++ src/server/shared/db-executor.ts | 11 + src/server/shared/slug.ts | 160 + src/server/shared/text.ts | 66 + src/server/shared/write.ts | 149 + src/server/videos/visibility.ts | 34 + tests/helpers/oob-config.ts | 8 + tests/helpers/test-db.ts | 47 +- tests/integration/auth-flow.test.ts | 512 ++++ tests/integration/auth-policy.test.ts | 171 ++ tests/integration/health.test.ts | 69 + tests/integration/member-sync.test.ts | 427 +++ tests/integration/schema.migration.test.ts | 32 +- tests/integration/shared-write.test.ts | 338 +++ tests/unit/auth-oidc.test.ts | 367 +++ tests/unit/auth-policy.test.ts | 147 + tests/unit/auth-session-cookies.test.ts | 114 + tests/unit/job-runner.test.ts | 228 ++ tests/unit/local-bootstrap.test.ts | 2 + tests/unit/media-validator.test.ts | 315 ++ tests/unit/member-map.test.ts | 159 + 52 files changed, 14046 insertions(+), 53 deletions(-) create mode 100644 drizzle/20260823151053_solid_stingray/migration.sql create mode 100644 drizzle/20260823151053_solid_stingray/snapshot.json create mode 100644 drizzle/20260823164832_married_vivisector/migration.sql create mode 100644 drizzle/20260823164832_married_vivisector/snapshot.json create mode 100644 drizzle/20260823170852_audit_immutability/migration.sql create mode 100644 drizzle/20260823170852_audit_immutability/snapshot.json create mode 100644 src/server.ts create mode 100644 src/server/api/auth-routes.ts create mode 100644 src/server/api/http.ts create mode 100644 src/server/api/router.ts create mode 100644 src/server/auth/guards.ts create mode 100644 src/server/auth/oidc.ts create mode 100644 src/server/auth/policy.ts create mode 100644 src/server/auth/session-cookies.ts create mode 100644 src/server/auth/session-store.ts create mode 100644 src/server/auth/viewer.ts create mode 100644 src/server/jobs/health.ts create mode 100644 src/server/jobs/locks.ts create mode 100644 src/server/jobs/runner.ts create mode 100644 src/server/media/validator.ts create mode 100644 src/server/media/youtube.ts create mode 100644 src/server/members/authentik-api.ts create mode 100644 src/server/members/map.ts create mode 100644 src/server/members/sync.ts create mode 100644 src/server/shared/db-executor.ts create mode 100644 src/server/shared/slug.ts create mode 100644 src/server/shared/text.ts create mode 100644 src/server/shared/write.ts create mode 100644 src/server/videos/visibility.ts create mode 100644 tests/integration/auth-flow.test.ts create mode 100644 tests/integration/auth-policy.test.ts create mode 100644 tests/integration/health.test.ts create mode 100644 tests/integration/member-sync.test.ts create mode 100644 tests/integration/shared-write.test.ts create mode 100644 tests/unit/auth-oidc.test.ts create mode 100644 tests/unit/auth-policy.test.ts create mode 100644 tests/unit/auth-session-cookies.test.ts create mode 100644 tests/unit/job-runner.test.ts create mode 100644 tests/unit/media-validator.test.ts create mode 100644 tests/unit/member-map.test.ts diff --git a/docs/examples/oob-config.example.json b/docs/examples/oob-config.example.json index a4d2085..17c3f84 100644 --- a/docs/examples/oob-config.example.json +++ b/docs/examples/oob-config.example.json @@ -27,11 +27,21 @@ "joinedSemester": { "attribute": "TÖLTSD-KI-csatlakozási-félév-attribútum-kulcsa", "rules": [ - { "pattern": "^(\\d{4})\\s+(ősz|őszi)$", "semester": "autumn" }, - { "pattern": "^(\\d{4})\\s+(tavasz|tavaszi)$", "semester": "spring" } + { + "pattern": "^(\\d{4})\\s+(ősz|őszi)$", + "semester": "autumn" + }, + { + "pattern": "^(\\d{4})\\s+(tavasz|tavaszi)$", + "semester": "spring" + } ] }, "introduction": "TÖLTSD-KI-bemutatkozás-attribútum-kulcsa" + }, + "sync": { + "username": "svc-bss-sync", + "token": "" } }, "media": { diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md index c1ffd05..303a905 100644 --- a/docs/implementation-progress.md +++ b/docs/implementation-progress.md @@ -1,24 +1,31 @@ # BSS V0 implementációs állapot -Utolsó frissítés: 2026-08-23 (0. fázis lezárása után) +Utolsó frissítés: 2026-08-23 (1. fázis közbeni állapot, BSS-006 után) ## Aktuális fázis -**0. fázis – Stabil alap és tiszta séma: KÉSZ, felhasználói jóváhagyásra vár.** +**1. fázis – Auth és közös infrastruktúra: KÉSZ, felhasználói jóváhagyásra vár.** -A következő fázis az **1. fázis: auth és közös infrastruktúra** (BSS-006 – BSS-011). Munka csak a felhasználó külön jóváhagyása után kezdhető. +Mind a hat kártya (BSS-006 – BSS-011) elkészült, a fáziskapu gate-je zöld. +Következő: **2. fázis – Tartalmi domainek** (BSS-012 – BSS-018), munka csak külön jóváhagyással. ## Kártyák állapota -| Kártya | Név | Állapot | Ellenőrzések | -| ------- | --------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | -| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | -| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | -| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); régi prototípus-táblákon is lefut; DB-szintű invariánstesztek: published↔publishedAt, önhivatkozás-tilalom, live átfedés-tilalom (EXCLUDE), eseménydátum-check, Rólunk pozíció-limit; 3 integrációs sémateszt | -| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva: Authentik 2026.5.4 elindul, blueprint alkalmazódik, discovery végpont válaszol; teljes restart után is 3 csoport / 8 felhasználó / 1 provider (nem duplikál) | - -## Fáziskapu eredménye (0. fázis) +| Kártya | Név | Állapot | Ellenőrzések | +| ------- | --------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | +| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`, új: `createMigratedTestDatabase`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | +| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | +| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla + `auth_sessions` migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); DB-szintű invariánsok; integrációs sématesztek | +| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva; teljes restart után sem duplikál | +| BSS-006 | OIDC belépés és session | done | `/api/auth/login`, `/api/auth/callback`, `/api/auth/logout` PKCE-s flow-val; DB-ben tárolt session (`auth_sessions`), access token csak szerveroldalon; HTTP-only SameSite=Lax cookie-k; returnTo megőrzés nyitott átirányítás ellenőrzéssel; abszolút 60 perces TTL; Authentik-kiesés magyar 503 oldallal; élő dev szerveren: login 302 az Authentik authorize végpontra, callback hibaág 400, publikus oldal 200 | +| BSS-007 | Jogosultsági policy és guardok | done | viewer szintek (anonymous/schonherz/member/leadership) csoportokból; vezetőség csak tag csoporttal együtt; teljes admin mátrix unit tesztekkel (spec 3.2); `visibleVideoCondition` SQL feltétel valódi adatbázison tesztelve mind a 4 nézői szintre + metaadat-szivárgás ellenőrzés; `requireAdmin`/`requireLeadership` guardok (login redirect returnTo-val / 403); `/api/auth/me` állapotvégpont Authentik-hívás nélkül | +| BSS-008 | Authentik tagcache és szinkron | done | `runMemberSync` client_credentials granthasználatával az Authentik API-ról (lapozott users/groups); mapping konfig szerint; nyers félév megőrzése; ismeretlen státusz → syncStatus=error (publikusból kimarad, utolsó ismert adat megmarad); eltűnt tag rekordja megmarad; változatlan futás NEM ír auditot; szerepváltozás audit előtte-utána párral; `member_sync_runs` állapottábla; kézi indítás csak vezetőségnek; élőben verifikálva a lokális Authentikkel (7 tag cache-elve, idempotens) | +| BSS-009 | Közös slug, audit és optimista zárolás | done | `slugify` magyar ékezet-feloldással; ütközésnél számozott utótag; slug_history lefoglalja a régi slugokat végleges törlés után is; `renameSlugWithHistory` + `resolveSlugRedirect`; `updateWithOptimisticLock`: FOR UPDATE sorzár + verzióellenőrzés (StaleWriteError) + tranzakciós audit előtte-utána értékkel; system aktornál updatedBy NULL; DB-trigger tiltja az audit UPDATE/DELETE-t (custom migráció); plain text és hosszvalidáció (`TEXT_LIMITS`); 14 integrációs teszt | +| BSS-010 | Háttérfeladat-futtató, health, riasztás | done | `JobRegistry` + `dueJobs` FakeClock-kal vezérelt ütemezéssel; `runJobWithLock` PostgreSQL advisory lockkal: két példány közül egy futtat (skipped-locked); induláskori + óránkénti sync feladatok regisztrálva, extra feladatok (lomtár, live) regisztrálhatók; `/health/live` mindig 200, `/health/ready` DB és kulcstábla ellenőrzéssel (503, titok nélkül); hibás háttérfeladat nem dönti le az alkalmazást; vezetőségi szinkronriasztás `getRecentSyncAlerts`; élőben: health 200/200 | +| BSS-011 | Média- és YouTube-validátor | done | host engedélylista (https + v.bsstudio.hu); publikáláshoz HEAD 200 átirányítás nélkül, video/mp4 vs image/* content-type; 405/501-nél egybájtos Range GET tartalék (bytes=0-0, tartalom letöltése nélkül); piszkozathoz hálózat nélküli formaellenőrzés (hibás URL menthető piszkozatban); YouTube normalizálás (watch/live/youtu.be/embed/nocookie) + oEmbed ellenőrzés magyar hibaüzenetekkel; 16 unit teszt | + +## Fáziskapu eredménye (0. fázis, megtörtént) - [x] rögzített, kompatibilis függőségek (lockfile + nincs `latest`/nightly) - [x] zöld format check, lint, typecheck, test, build @@ -35,23 +42,64 @@ A következő fázis az **1. fázis: auth és közös infrastruktúra** (BSS-006 | Helyi titkok | `.env`, `oob/local-secrets.json`, `oob/authentik.env` | generálva, gitignore-olt, nem kerülnek gitbe | | Seed JSON | `oob/seed.json` | helye a configban rögzített; tartalma az 5. fázisban (BSS-034) | -Az éles Authentik mapping és seed hiánya miatt semmilyen szerződéses vagy lokális értéket nem kellett kitalálni: a lokális bootstrap saját, véletlenszerű titkokkal dolgozik. - ## Elfogadott felhasználói döntések ebben a fázisban -Nincs új termékdöntés; minden a specifikációból és a meglévő prototípusból következik. Technikai döntések, amiket dokumentáltan hoztam: - -- OIDC callback útvonal szerződése: `/api/auth/callback` (1. fázis implementálja). -- A migrációs alap a régi lokális prototípus-objektumokat célzottan eldobja (a specifikáció szerint a séma eldobható). Csak fejlesztői adatbázisra fusson. -- `routeTree.gen.ts` committed marad; `tsr generate` a typecheck része. -- drizzle-orm/drizzle-kit pontosan rögzítve `1.0.0-rc.4`-en (a v1 relációs API-hoz); programmatikus migrátor helyett CLI + tesztfuttató. +Új termékdöntés nincs; a specifikációból következő technikai döntések: + +- A telepített TanStack Start verzió nem támogat server-route fájlokat; helyette testre szabott + `src/server.ts` entry szolgálja ki a `/api/*` és `/health/*` útvonalakat, minden mást az SSR handlernek adva. +- Session modell: `auth_sessions` tábla, a cookie-ban csak véletlen token van, a DB-ben annak SHA-256 hash-e. + Az access token csak a DB-ben él. Abszolút 60 perces lejárat adja a „legfeljebb órás szerepfrissítés” + garanciáját (nincs refresh token, mert a lokális provider nem kér `offline_access` scope-ot). +- Az `auth_sessions.member_sub` NEM idegen kulcs a `member_cache.sub`-ra: így a belépés akkor is működik, + ha a tag még nem szerepel a cache-ben (BSS-008 szinkron feltölti). +- A jogosultsághoz szükséges csoportlistát a szabványos `groups` OIDC claim hordozza; a lokális blueprint + profile scope-ját bővítettük (`scripts/lib/local-bootstrap.ts`), a generált YAML regenerálódott. +- Az OIDC tranzakció (state/PKCE/returnTo) rövid életű, HMAC-SHA256 aláírt cookie-ban utazik; az aláíró + kulcs a config clientId+clientSecret titkából származik (új OOB titok bevezetése nélkül). +- Az id_token signature-ét nem ellenőrizzük (backchannel token), de iss/aud/exp/nonce igen. ## Ismert hibák és technikai tartozás -- A publikus prototípus oldalai még statikus placeholder tartalmat mutatnak (ez várható; a tartalmi domainek a 2–3. fázisban épülnek). -- `@tanstack/router-cli` (tsr) futáskor ártalmatlan circular-dependency figyelmeztetést ad Node 24 alatt. -- Az Authentik 2026.5.4-ben nincsenek gyári default flow-k/mappingek; a lokális blueprint ezért saját authentication/authorization flow-t és scope mappingeket definiál (dokumentálva a generált YAML-ben). -- docker-compose: a 9443-as külső port ütközött a gépen futó másik szolgáltatással, ezért a lokális stack HTTPS portfeltárása elhagyva (HTTP :9000 használatos). +- A publikus prototípus oldalai még statikus placeholder tartalmat mutatnak (2–3. fázis). +- `@tanstack/router-cli` (tsr) Node 24-es circular-dependency figyelmeztetése ártalmatlan. +- docker-compose: HTTPS portfeltárás elhagyva (HTTP :9000). +- A belépési felület navbar-integrációja (Belépés/Kilépés gomb) a BSS-019/BSS-027 kártyákhoz tartozik; + addig közvetlen URL-lel (`/api/auth/login`) érhető el a belépés. + +## Fáziskapu eredménye (1. fázis) + +- [x] `pnpm check` zöld +- [x] `pnpm lint` zöld +- [x] `pnpm typecheck` zöld (0 hiba) +- [x] `TEST_DATABASE_URL=... pnpm test` zöld — 18 fájl / 160 teszt +- [x] `pnpm build` zöld (nitro .output) +- [x] migrációk tiszta adatbázison lefutnak (CLI próba: bss_gate_check, 16 tábla) +- [x] git diff átnézve: titkok csak gitignore-olt oob/ fájlokban; idegen módosítás nincs + +## Elfogadott technikai döntések az 1. fázisban + +- TanStack Start server entry (`src/server.ts`) szolgálja ki a `/api/*` és `/health/*` + útvonalakat; a telepített verziónál a createFileRoute-os server routes még nem elérhetők. +- Session: `auth_sessions` tábla; a cookie-ban véletlen token van, a DB-ben annak + SHA-256 hash-e; abszolút 60 perces TTL adja az órás szerepfrissítést. + Az `auth_sessions.member_sub` NEM idegen kulcs (a belépés cache-szinkron előtt is működjön). +- OIDC tranzakció HMAC-SHA256 aláírt cookie-ban; az aláíró kulcs a clientId+clientSecretből + képzett. Az id_token signature-ét nem ellenőrizzük (backchannel token), de + iss/aud/exp/nonce igen. +- Blueprint változások a szinkronhoz: `sub_mode: user_id` (az API pk-ja = OIDC sub, + így a cache összekapcsolható az API adataival), explicit `grant_types` + (authorization_code + refresh_token + client_credentials), `svc-bss-sync` + szolgáltatási fiók app-password tokennel és csak olvasási jogokkal + (`authentik_core.view_user/view_group`). Új OOB mező: `authentik.sync.{username,token}` + — a docs/oob-inputs.md frissült. +- Tagsági státusz nélküli felhasználók (pl. schönherzesek) NEM kerülnek a tagcache-be; + a korábban ok profil hibás státuszra váltásnál megtartja utolsó adatait, + syncStatus=error jelöléssel (publikus nézetek erre szűrnek). +- Audit immunitás DB-triggerrel védve: UPDATE és DELETE exception-t dob. +- A média HEAD „kapcsolódási” fázisa a fetch API-val nem különíthető el: + egyetlen 15 mp-es teljes timeout van (dokumentált eltérés az 5 mp-es connect + timeout helyett). ## Parancsok diff --git a/docs/oob-inputs.md b/docs/oob-inputs.md index cef673b..701d643 100644 --- a/docs/oob-inputs.md +++ b/docs/oob-inputs.md @@ -9,6 +9,10 @@ Az alkalmazás három bemenete out-of-band, azaz nem a git repóban érkezik. Hi - Formája: JSON, a szerkezetét a [`docs/examples/oob-config.example.json`](./examples/oob-config.example.json) mutatja. - Tartalma: - Authentik OIDC kapcsolat: issuer URL, client id, client secret, scope-ok; + - Authentik tagcache-szinkron szolgáltatási fiókja: `authentik.sync.username` és + `authentik.sync.token` (az `svc-bss-sync` felhasználó app-password tokene, amelyet + a `pnpm infra:bootstrap` generál; a szinkron ezzel client_credentials granttal + fér hozzá az Authentik API-hoz a `goauthentik.io/api` scope-pal); - claim-leképezés: `sub`, felhasználónév, teljes név, becenév, profilkép URL; - csoportleképezés: schönherzes, tag és vezetőség csoport neve; - attribútumleképezés: tagsági státusz nyers érték → belső kulcs, csatlakozási félév értelmezési szabályai, bemutatkozás; diff --git a/drizzle/20260823151053_solid_stingray/migration.sql b/drizzle/20260823151053_solid_stingray/migration.sql new file mode 100644 index 0000000..3c9de1a --- /dev/null +++ b/drizzle/20260823151053_solid_stingray/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE "auth_sessions" ( + "id" varchar(64) PRIMARY KEY, + "member_sub" varchar(255) NOT NULL, + "username" varchar(200) NOT NULL, + "groups" jsonb DEFAULT '[]' NOT NULL, + "access_token" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE INDEX "auth_sessions_expires_idx" ON "auth_sessions" ("expires_at"); \ No newline at end of file diff --git a/drizzle/20260823151053_solid_stingray/snapshot.json b/drizzle/20260823151053_solid_stingray/snapshot.json new file mode 100644 index 0000000..f6cbd73 --- /dev/null +++ b/drizzle/20260823151053_solid_stingray/snapshot.json @@ -0,0 +1,2442 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "377136ef-6e59-4cd0-8717-f131f79a5f2b", + "prevIds": ["eb102e26-e589-497d-aab1-16017519f1ce"], + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auth_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auth_sessions_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "auth_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pkey", + "schema": "public", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/drizzle/20260823164832_married_vivisector/migration.sql b/drizzle/20260823164832_married_vivisector/migration.sql new file mode 100644 index 0000000..1cc3a69 --- /dev/null +++ b/drizzle/20260823164832_married_vivisector/migration.sql @@ -0,0 +1,14 @@ +CREATE TYPE "member_sync_trigger" AS ENUM('startup', 'hourly', 'manual', 'test');--> statement-breakpoint +CREATE TABLE "member_sync_runs" ( + "id" bigserial PRIMARY KEY, + "trigger" "member_sync_trigger" NOT NULL, + "status" "member_sync_status" NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone, + "total_count" integer DEFAULT 0 NOT NULL, + "changed_count" integer DEFAULT 0 NOT NULL, + "error_count" integer DEFAULT 0 NOT NULL, + "message" text +); +--> statement-breakpoint +CREATE INDEX "member_sync_runs_started_idx" ON "member_sync_runs" ("started_at"); \ No newline at end of file diff --git a/drizzle/20260823164832_married_vivisector/snapshot.json b/drizzle/20260823164832_married_vivisector/snapshot.json new file mode 100644 index 0000000..4ddef31 --- /dev/null +++ b/drizzle/20260823164832_married_vivisector/snapshot.json @@ -0,0 +1,2600 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "12e7fda7-cad1-4402-b57a-66558828c74d", + "prevIds": ["377136ef-6e59-4cd0-8717-f131f79a5f2b"], + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["startup", "hourly", "manual", "test"], + "name": "member_sync_trigger", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auth_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_sync_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_trigger", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trigger", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "changed_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "error_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auth_sessions_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "auth_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_sync_runs_started_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_sync_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pkey", + "schema": "public", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_sync_runs_pkey", + "schema": "public", + "table": "member_sync_runs", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/drizzle/20260823170852_audit_immutability/migration.sql b/drizzle/20260823170852_audit_immutability/migration.sql new file mode 100644 index 0000000..d0b6f63 --- /dev/null +++ b/drizzle/20260823170852_audit_immutability/migration.sql @@ -0,0 +1,12 @@ +-- Az auditnapló alkalmazásból nem módosítható és nem törölhető (spec 13.2). +CREATE OR REPLACE FUNCTION prevent_audit_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'Az auditnapló nem módosítható és nem törölhető'; +END; +$$ LANGUAGE plpgsql; + +--> statement-breakpoint + +CREATE TRIGGER audit_log_no_update +BEFORE UPDATE OR DELETE ON "audit_log" +FOR EACH ROW EXECUTE FUNCTION prevent_audit_mutation(); diff --git a/drizzle/20260823170852_audit_immutability/snapshot.json b/drizzle/20260823170852_audit_immutability/snapshot.json new file mode 100644 index 0000000..e2047b7 --- /dev/null +++ b/drizzle/20260823170852_audit_immutability/snapshot.json @@ -0,0 +1,2600 @@ +{ + "id": "516f562d-d3f2-44bb-bb25-d7a607a92f20", + "prevIds": ["12e7fda7-cad1-4402-b57a-66558828c74d"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["startup", "hourly", "manual", "test"], + "name": "member_sync_trigger", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auth_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_sync_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_trigger", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trigger", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "changed_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "error_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auth_sessions_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "auth_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_sync_runs_started_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_sync_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pkey", + "schema": "public", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_sync_runs_pkey", + "schema": "public", + "table": "member_sync_runs", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/scripts/lib/local-bootstrap.ts b/scripts/lib/local-bootstrap.ts index 9a07823..dd910ff 100644 --- a/scripts/lib/local-bootstrap.ts +++ b/scripts/lib/local-bootstrap.ts @@ -13,11 +13,14 @@ export const CONFIG_FILE = 'config.json' export interface LocalSecrets { authentikSecretKey: string oidcClientSecret: string + /** Tagcache-szinkron szolgáltatási fiók Authentik API tokene. */ + syncApiToken: string passwords: Record } const AUTHENTIK_BASE_URL = 'http://127.0.0.1:9000' const OIDC_CLIENT_ID = 'bss-stack-local' +export const SYNC_SERVICE_USERNAME = 'svc-bss-sync' interface LocalUserSpec { username: string @@ -116,6 +119,7 @@ export function generateLocalSecrets(): LocalSecrets { return { authentikSecretKey: generateToken(), oidcClientSecret: generateToken(), + syncApiToken: generateToken(), passwords, } } @@ -256,6 +260,36 @@ export function renderBlueprint(secrets: LocalSecrets): string { ' name: bss-vezetoseg', ' is_superuser: false', userEntries, + ' - model: authentik_core.user', + ' id: id-svc-bss-sync', + ' identifiers:', + ` username: ${SYNC_SERVICE_USERNAME}`, + ' attrs:', + ` name: BSS tagcache szinkron szolgáltatás`, + ` username: ${SYNC_SERVICE_USERNAME}`, + ' type: internal', + ' # Csak a szinkronhoz szükséges olvasási jogok (a régi is_superuser RBAC alatt nem él)', + ' permissions:', + ' - authentik_core.view_user', + ' - authentik_core.view_group', + ' - model: authentik_core.token', + ' identifiers:', + ` identifier: bss-local-sync-token`, + ' attrs:', + ` key: "${secrets.syncApiToken}"`, + ' intent: app_password', + ' expiring: false', + ' user: !KeyOf id-svc-bss-sync', + ' - model: authentik_providers_oauth2.scopemapping', + ' id: id-map-api-scope', + ' identifiers:', + ' name: bss-local-scope-api', + ' attrs:', + ' name: bss-local-scope-api', + ' scope_name: goauthentik.io/api', + ' description: Authentik API hozzáférés a szinkronhoz', + ' expression: |', + ' return {}', ' - model: authentik_providers_oauth2.scopemapping', ' id: id-map-profile', ' identifiers:', @@ -270,6 +304,7 @@ export function renderBlueprint(secrets: LocalSecrets): string { ' "name": request.user.name,', ' "nickname": request.user.attributes.get("nickname", request.user.username),', ' "picture": request.user.avatar,', + ' "groups": [group.name for group in request.user.groups.all()],', ' }', ' - model: authentik_providers_oauth2.scopemapping', ' id: id-map-email', @@ -308,13 +343,19 @@ export function renderBlueprint(secrets: LocalSecrets): string { ' url: http://localhost:3000/api/auth/callback', ' - matching_mode: strict', ' url: http://127.0.0.1:3000/api/auth/callback', + ' grant_types:', + ' - authorization_code', + ' - refresh_token', + ' - client_credentials', ' authorization_flow: !KeyOf id-authz-flow', ' invalidation_flow: !Find [authentik_flows.Flow, [slug, default-provider-invalidation-flow]]', ' property_mappings:', ' - !KeyOf id-map-profile', ' - !KeyOf id-map-email', ' - !KeyOf id-map-bss', - ' sub_mode: hashed_user_id', + ' - !KeyOf id-map-api-scope', + ' # user_id sub: a tagcache szinkron az API pk-jával illeszthető', + ' sub_mode: user_id', ' access_token_validity: hours=1', ' signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]', ' - model: authentik_core.application', @@ -335,6 +376,10 @@ export function renderOobConfig(secrets: LocalSecrets): unknown { clientId: OIDC_CLIENT_ID, clientSecret: secrets.oidcClientSecret, scopes: ['openid', 'profile', 'email', 'bss'], + sync: { + username: SYNC_SERVICE_USERNAME, + token: secrets.syncApiToken, + }, claims: { sub: 'sub', username: 'preferred_username', @@ -394,6 +439,11 @@ export function writeLocalFiles(baseDir: string): { if (existsSync(secretsPath)) { secrets = JSON.parse(readFileSync(secretsPath, 'utf-8')) as LocalSecrets + // Újabb mezők utólagos kiegészítése régi titokfájlon (idempotens futás). + if (!secrets.syncApiToken) { + secrets.syncApiToken = generateToken() + writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`) + } } else { secrets = generateLocalSecrets() writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`) diff --git a/src/db/schema.ts b/src/db/schema.ts index 453f7e2..b4732ca 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -52,6 +52,13 @@ export const memberSyncStatusEnum = pgEnum('member_sync_status', [ 'error', ]) +export const memberSyncTriggerEnum = pgEnum('member_sync_trigger', [ + 'startup', + 'hourly', + 'manual', + 'test', +]) + export const liveStatusEnum = pgEnum('live_status', [ 'scheduled', 'active', @@ -60,6 +67,22 @@ export const liveStatusEnum = pgEnum('live_status', [ export const slugEntityTypeEnum = pgEnum('slug_entity_type', ['video', 'event']) +export const authSessions = pgTable( + 'auth_sessions', + { + id: varchar('id', { length: 64 }).primaryKey(), + memberSub: varchar('member_sub', { length: 255 }).notNull(), + username: varchar('username', { length: 200 }).notNull(), + groups: jsonb('groups').$type().notNull().default([]), + accessToken: text('access_token'), + createdAt: timestamp('created_at', { withTimezone: true }) + .notNull() + .defaultNow(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + }, + (table) => [index('auth_sessions_expires_idx').on(table.expiresAt)], +) + export const memberCache = pgTable( 'member_cache', { @@ -89,6 +112,24 @@ export const memberCache = pgTable( ], ) +export const memberSyncRuns = pgTable( + 'member_sync_runs', + { + id: bigserial('id', { mode: 'number' }).primaryKey(), + trigger: memberSyncTriggerEnum('trigger').notNull(), + status: memberSyncStatusEnum('status').notNull(), + startedAt: timestamp('started_at', { withTimezone: true }) + .notNull() + .defaultNow(), + finishedAt: timestamp('finished_at', { withTimezone: true }), + totalCount: integer('total_count').notNull().default(0), + changedCount: integer('changed_count').notNull().default(0), + errorCount: integer('error_count').notNull().default(0), + message: text('message'), + }, + (table) => [index('member_sync_runs_started_idx').on(table.startedAt)], +) + export const events = pgTable( 'events', { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d5e6de2..c72b5b3 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -208,3 +208,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..f252564 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,38 @@ +import { + createStartHandler, + defaultStreamHandler, +} from '@tanstack/react-start/server' +import { handleApiRequest, API_PATH_PREFIXES } from '#/server/api/router.ts' +import { startBackgroundRunner } from '#/server/jobs/runner.ts' +import type { BackgroundRunnerHandle } from '#/server/jobs/runner.ts' + +const ssrHandler = createStartHandler(defaultStreamHandler) + +// Háttérfeladatok (induláskori + óránkénti szinkron) egyszer indulnak. +// Hiba esetén az alkalmazás tovább fut; a hiba a futások táblájába kerül. +let runnerHandle: BackgroundRunnerHandle | null = null + +function ensureBackgroundRunner(): void { + if (runnerHandle === null) { + try { + runnerHandle = startBackgroundRunner() + } catch (error) { + console.error('[jobs] A háttérfutató indítása nem sikerült:', error) + } + } +} + +function isApiPath(pathname: string): boolean { + return API_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)) +} + +export default { + async fetch(request: Request): Promise { + const { pathname } = new URL(request.url) + if (isApiPath(pathname)) { + ensureBackgroundRunner() + return handleApiRequest(request) + } + return ssrHandler(request) + }, +} diff --git a/src/server/api/auth-routes.ts b/src/server/api/auth-routes.ts new file mode 100644 index 0000000..8184587 --- /dev/null +++ b/src/server/api/auth-routes.ts @@ -0,0 +1,327 @@ +import { + authFailurePage, + authUnavailablePage, + badRequestPage, + forbiddenPage, + getRequestOrigin, + internalErrorPage, + isSecureRequest, + redirectResponse, + sanitizeReturnTo, + unauthorizedConfigPage, +} from '#/server/api/http.ts' +import { + buildAuthorizationUrl, + buildLoginTransaction, + decodeJwtPayload, + exchangeCodeForTokens, + extractIdentityFromClaims, + fetchDiscovery, + OidcProtocolError, + OidcUnavailableError, + safeEquals, + validateIdTokenClaims, +} from '#/server/auth/oidc.ts' +import type { LoginTransactionData, OidcDiscovery } from '#/server/auth/oidc.ts' +import { + createAuthSession, + deleteAuthSession, + findActiveAuthSession, +} from '#/server/auth/session-store.ts' +import type { Database } from '#/server/auth/session-store.ts' +import { + OIDC_TXN_COOKIE_NAME, + OIDC_TXN_TTL_SECONDS, + readCookieValue, + SESSION_COOKIE_NAME, + SESSION_TTL_MS, + signOidcTxn, + verifyAndReadOidcTxn, +} from '#/server/auth/session-cookies.ts' +import type { CookieSpec } from '#/server/auth/session-cookies.ts' +import type { Clock } from '#/lib/clock.ts' +import { getCachedOobConfig } from '#/server/config/load.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' +import { viewerFromSession } from '#/server/auth/viewer.ts' + +const TXN_MAX_AGE_MS = OIDC_TXN_TTL_SECONDS * 1000 + +export const CALLBACK_PATH = '/api/auth/callback' + +export interface AuthRouteDeps { + loadConfig?: () => OobConfig + db?: Database + clock?: Clock +} + +export interface AuthRouteHandlers { + login: (request: Request) => Promise + callback: (request: Request) => Promise + logout: (request: Request) => Promise + me: (request: Request) => Promise +} + +function logAuthFailure(context: string, error: unknown): void { + console.error(`[auth] ${context}`, error) +} + +function parseTxn(json: string): LoginTransactionData | null { + try { + const raw: unknown = JSON.parse(json) + if (typeof raw !== 'object' || raw === null) { + return null + } + const record = raw as Record + if ( + typeof record['state'] !== 'string' || + typeof record['codeVerifier'] !== 'string' || + typeof record['nonce'] !== 'string' || + typeof record['returnTo'] !== 'string' || + typeof record['createdAtIso'] !== 'string' + ) { + return null + } + return { + state: record['state'], + codeVerifier: record['codeVerifier'], + nonce: record['nonce'], + returnTo: sanitizeReturnTo(record['returnTo']), + createdAtIso: record['createdAtIso'], + } + } catch { + return null + } +} + +function txnIsFresh(txn: LoginTransactionData, nowMs: number): boolean { + const createdAt = Date.parse(txn.createdAtIso) + if (Number.isNaN(createdAt)) { + return false + } + return nowMs - createdAt <= TXN_MAX_AGE_MS +} + +function loadDepsConfig( + deps: AuthRouteDeps, +): { config: OobConfig } | { errorResponse: Response } { + try { + return { config: (deps.loadConfig ?? getCachedOobConfigDefault)() } + } catch (error) { + logAuthFailure('OOB konfiguráció betöltése sikertelen', error) + return { errorResponse: unauthorizedConfigPage() } + } +} + +function getCachedOobConfigDefault(): OobConfig { + return getCachedOobConfig() +} + +export function createAuthRouteHandlers( + deps: AuthRouteDeps = {}, +): AuthRouteHandlers { + async function login(request: Request): Promise { + const loaded = loadDepsConfig(deps) + if ('errorResponse' in loaded) { + return loaded.errorResponse + } + const config = loaded.config + + let discovery: OidcDiscovery + try { + discovery = await fetchDiscovery(config.authentik) + } catch (error) { + logAuthFailure('Az Authentik discovery nem elérhető', error) + return authUnavailablePage() + } + + const url = new URL(request.url) + const returnTo = sanitizeReturnTo(url.searchParams.get('returnTo')) + const transaction = buildLoginTransaction(returnTo) + const redirectUri = `${getRequestOrigin(request)}${CALLBACK_PATH}` + const authorizeUrl = buildAuthorizationUrl( + discovery, + config.authentik, + redirectUri, + transaction, + ) + + const txnCookie: CookieSpec = { + name: OIDC_TXN_COOKIE_NAME, + value: signOidcTxn(JSON.stringify(transaction), config.authentik), + maxAgeSeconds: OIDC_TXN_TTL_SECONDS, + secure: isSecureRequest(request), + } + + return redirectResponse(authorizeUrl, [txnCookie]) + } + + async function callback(request: Request): Promise { + const loaded = loadDepsConfig(deps) + if ('errorResponse' in loaded) { + return loaded.errorResponse + } + const config = loaded.config + + const url = new URL(request.url) + const cookieValue = readCookieValue(request, OIDC_TXN_COOKIE_NAME) + const txnJson = + cookieValue === null + ? null + : verifyAndReadOidcTxn(cookieValue, config.authentik) + + if (txnJson === null) { + return badRequestPage( + 'A bejelentkezési folyamat lejárt vagy érvénytelen. Indítsd el újra a belépést.', + ) + } + const transaction = parseTxn(txnJson) + if ( + transaction === null || + !txnIsFresh(transaction, Date.now()) || + !safeEquals(url.searchParams.get('state') ?? '', transaction.state) + ) { + return badRequestPage( + 'A bejelentkezési folyamat állapota nem egyezik. Indítsd el újra a belépést.', + ) + } + + if (url.searchParams.get('error') !== null) { + return badRequestPage( + 'A bejelentkezés nem fejeződött be a bejelentkezési szolgáltatásnál. Próbáld újra.', + ) + } + const code = url.searchParams.get('code') + if (code === null || code === '') { + return badRequestPage( + 'Hiányzik a bejelentkezési kód. Próbáld újra a belépést.', + ) + } + + try { + const discovery = await fetchDiscovery(config.authentik) + const redirectUri = `${getRequestOrigin(request)}${CALLBACK_PATH}` + const tokens = await exchangeCodeForTokens( + discovery, + config.authentik, + redirectUri, + code, + transaction.codeVerifier, + ) + const claims = decodeJwtPayload(tokens.idToken) + validateIdTokenClaims(claims, { + issuer: discovery.issuer, + clientId: config.authentik.clientId, + nonce: transaction.nonce, + clock: deps.clock, + }) + const identity = extractIdentityFromClaims(claims, config.authentik) + + const created = await createAuthSession( + { + memberSub: identity.sub, + username: identity.username, + groups: identity.groups, + accessToken: tokens.accessToken, + }, + { db: deps.db, clock: deps.clock }, + ) + + const secure = isSecureRequest(request) + return redirectResponse(transaction.returnTo, [ + { + name: SESSION_COOKIE_NAME, + value: created.token, + maxAgeSeconds: Math.floor(SESSION_TTL_MS / 1000), + secure, + }, + { + name: OIDC_TXN_COOKIE_NAME, + value: '', + maxAgeSeconds: 0, + secure, + }, + ]) + } catch (error) { + if (error instanceof OidcUnavailableError) { + logAuthFailure( + 'Az Authentik nem elérhető a bejelentkezés közben', + error, + ) + return authUnavailablePage() + } + if (error instanceof OidcProtocolError) { + logAuthFailure('Protokolli hiba a bejelentkezés közben', error) + return authFailurePage( + 'A bejelentkezés során hiba történt a bejelentkezési szolgáltatással. Próbáld újra később.', + ) + } + logAuthFailure('Váratlan hiba a callback kezelésekor', error) + return internalErrorPage( + 'Váratlan hiba történt a bejelentkezés során. Próbáld újra.', + ) + } + } + + async function logout(request: Request): Promise { + if (request.method.toUpperCase() !== 'POST') { + return new Response(JSON.stringify({ error: 'method_not_allowed' }), { + status: 405, + headers: { 'content-type': 'application/json', allow: 'POST' }, + }) + } + const origin = request.headers.get('origin') + if ( + origin !== null && + origin !== '' && + origin !== getRequestOrigin(request) + ) { + return forbiddenPage() + } + + const token = readCookieValue(request, SESSION_COOKIE_NAME) + const secure = isSecureRequest(request) + if (token !== null && token !== '') { + try { + await deleteAuthSession(token, { db: deps.db }) + } catch (error) { + logAuthFailure('A session törlése az adatbázisból nem sikerült', error) + } + } + + return redirectResponse('/', [ + { name: SESSION_COOKIE_NAME, value: '', maxAgeSeconds: 0, secure }, + { name: OIDC_TXN_COOKIE_NAME, value: '', maxAgeSeconds: 0, secure }, + ]) + } + + /** + * Bejelentkezési állapot lekérdezése a kliensnek. Csak a lokális DB-t olvassa, + * az Authentiket soha nem hívja (a publikus kérés nem függhet külső szolgáltatástól). + */ + async function me(request: Request): Promise { + const token = readCookieValue(request, SESSION_COOKIE_NAME) + const config = loadDepsConfig(deps) + if ('errorResponse' in config) { + return config.errorResponse + } + let session = null + if (token !== null && token !== '') { + try { + session = await findActiveAuthSession(token, { + db: deps.db, + clock: deps.clock, + }) + } catch (error) { + logAuthFailure('A session lekérdezése nem sikerült', error) + session = null + } + } + const viewer = viewerFromSession(session, config.config.authentik) + return new Response(JSON.stringify(viewer), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + return { login, callback, logout, me } +} diff --git a/src/server/api/http.ts b/src/server/api/http.ts new file mode 100644 index 0000000..4a78d3d --- /dev/null +++ b/src/server/api/http.ts @@ -0,0 +1,106 @@ +import type { CookieSpec } from '#/server/auth/session-cookies.ts' +import { serializeSetCookie } from '#/server/auth/session-cookies.ts' + +export function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function errorPage(status: number, title: string, message: string): Response { + const html = + `\n\n` + + `${escapeHtml(title)}\n` + + `

${escapeHtml(title)}

${escapeHtml(message)}

` + + `

Vissza a főoldalra

\n` + return new Response(html, { + status, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) +} + +export function badRequestPage(message: string): Response { + return errorPage(400, 'Hibás kérés', message) +} + +export function unauthorizedConfigPage(): Response { + return errorPage( + 500, + 'Szerverkonfigurációs hiba', + 'A BSS OOB konfiguráció nem elérhető vagy érvénytelen. Indítsd el a `pnpm infra:bootstrap` lépést, majd ellenőrizd a `pnpm check:oob` paranccsal.', + ) +} + +export function authUnavailablePage(): Response { + return errorPage( + 503, + 'Bejelentkezés nem elérhető', + 'A bejelentkezési szolgáltatás (Authentik) most nem érhető el. A publikus oldalak működnek; próbáld újra később.', + ) +} + +export function authFailurePage(message: string): Response { + return errorPage(502, 'Bejelentkezési hiba', message) +} + +export function internalErrorPage(message: string): Response { + return errorPage(500, 'Szerverhiba', message) +} + +export function forbiddenPage(): Response { + return errorPage( + 403, + 'Hozzáférés megtagadva', + 'Ehhez a művelethez nincs jogosultságod.', + ) +} + +export function apiNotFoundResponse(): Response { + return new Response(JSON.stringify({ error: 'not_found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) +} + +export function redirectResponse( + location: string, + cookies: CookieSpec[] = [], +): Response { + const headers = new Headers({ location: location }) + for (const cookie of cookies) { + headers.append('set-cookie', serializeSetCookie(cookie)) + } + return new Response(null, { status: 302, headers }) +} + +/** Csak relatív, egyszintű "/" kezdetű útvonal engedélyezett (nyitott átirányítás ellen). */ +export function sanitizeReturnTo(raw: string | null): string { + if ( + !raw || + !raw.startsWith('/') || + raw.startsWith('//') || + raw.includes('\\') + ) { + return '/' + } + if (raw.length > 2048) { + return '/' + } + return raw +} + +export function getRequestOrigin(request: Request): string { + const url = new URL(request.url) + const forwardedHost = request.headers.get('x-forwarded-host') + const forwardedProto = request.headers.get('x-forwarded-proto') + const host = forwardedHost ?? request.headers.get('host') ?? url.host + const proto = forwardedProto ?? url.protocol.replace(/:$/, '') + return `${proto}://${host}` +} + +export function isSecureRequest(request: Request): boolean { + return getRequestOrigin(request).startsWith('https://') +} diff --git a/src/server/api/router.ts b/src/server/api/router.ts new file mode 100644 index 0000000..3005c11 --- /dev/null +++ b/src/server/api/router.ts @@ -0,0 +1,28 @@ +import { createAuthRouteHandlers } from '#/server/api/auth-routes.ts' +import { apiNotFoundResponse } from '#/server/api/http.ts' +import { livenessResponse, readinessResponse } from '#/server/jobs/health.ts' + +export const API_PATH_PREFIXES = ['/api/', '/health/'] + +const authHandlers = createAuthRouteHandlers() + +export async function handleApiRequest(request: Request): Promise { + const pathname = new URL(request.url).pathname.replace(/\/+$/, '') || '/' + + switch (pathname) { + case '/api/auth/login': + return authHandlers.login(request) + case '/api/auth/callback': + return authHandlers.callback(request) + case '/api/auth/logout': + return authHandlers.logout(request) + case '/api/auth/me': + return authHandlers.me(request) + case '/health/live': + return livenessResponse() + case '/health/ready': + return readinessResponse() + default: + return apiNotFoundResponse() + } +} diff --git a/src/server/auth/guards.ts b/src/server/auth/guards.ts new file mode 100644 index 0000000..3fa1d03 --- /dev/null +++ b/src/server/auth/guards.ts @@ -0,0 +1,52 @@ +import { anonymousViewer } from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { isAdminAreaAllowed, isLeadership } from '#/server/auth/policy.ts' + +/** Session hiánya vagy lejárata: a kliensnek új bejelentkezést kell kérnie. */ +export class AuthRequiredError extends Error { + constructor(readonly loginUrl: string) { + super('A bejelentkezés lejárt vagy nem történt meg.') + this.name = 'AuthRequiredError' + } +} + +/** Be van jelentkezve, de nincs hozzá jog: magyar 403. */ +export class ForbiddenError extends Error { + constructor(message = 'Ehhez a művelethez nincs jogosultságod.') { + super(message) + this.name = 'ForbiddenError' + } +} + +/** + * Szerveroldali guard adminműveletekhez és -oldalakhoz. + * Névtelen felhasználó belépésre irányítandó a megtartott returnTo-val + * (AuthRequiredError.loginUrl), bejelentkezett, de jogosulatlan 403-at kap. + */ +export function requireAdmin(viewer: Viewer, returnTo: string): void { + if (viewer.level === 'anonymous') { + throw new AuthRequiredError(loginUrlFor(returnTo)) + } + if (!isAdminAreaAllowed(viewer)) { + throw new ForbiddenError() + } +} + +export function requireLeadership(viewer: Viewer): void { + if (viewer.level === 'anonymous') { + // Vezetőségi terület névtelenül: általános belépési oldalra irányítunk. + throw new AuthRequiredError(loginUrlFor('/')) + } + if (!isLeadership(viewer)) { + throw new ForbiddenError() + } +} + +/** Publikus tartalom olvasásához soha nincs szükség guardra. */ +export function viewerOrAnonymous(viewer: Viewer | null): Viewer { + return viewer ?? anonymousViewer() +} + +function loginUrlFor(returnTo: string): string { + return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}` +} diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts new file mode 100644 index 0000000..c811ae1 --- /dev/null +++ b/src/server/auth/oidc.ts @@ -0,0 +1,398 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { systemClock } from '#/lib/clock.ts' +import type { Clock } from '#/lib/clock.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' + +export const DISCOVERY_CACHE_TTL_MS = 60 * 60 * 1000 +export const DISCOVERY_TIMEOUT_MS = 5_000 +export const TOKEN_TIMEOUT_MS = 10_000 +const CLOCK_SKEW_MS = 30_000 + +export class OidcUnavailableError extends Error { + constructor( + message: string, + readonly detail?: unknown, + ) { + super(message) + this.name = 'OidcUnavailableError' + } +} + +export class OidcProtocolError extends Error { + constructor( + message: string, + readonly detail?: unknown, + ) { + super(message) + this.name = 'OidcProtocolError' + } +} + +export interface OidcDiscovery { + issuer: string + authorizationEndpoint: string + tokenEndpoint: string +} + +export interface LoginTransactionData { + state: string + codeVerifier: string + nonce: string + returnTo: string + createdAtIso: string +} + +export interface TokenSet { + accessToken: string + idToken: string +} + +export interface AuthenticatedIdentity { + sub: string + username: string + fullName: string | null + nickname: string | null + avatarUrl: string | null + groups: string[] +} + +interface DiscoveryCacheEntry { + discovery: OidcDiscovery + fetchedAt: number +} + +const discoveryCache = new Map() + +export function clearDiscoveryCache(): void { + discoveryCache.clear() +} + +function base64url(input: Buffer): string { + return input.toString('base64url') +} + +export function randomToken(byteLength = 32): string { + return base64url(randomBytes(byteLength)) +} + +export function pkceChallenge(codeVerifier: string): string { + return base64url(createHash('sha256').update(codeVerifier).digest()) +} + +export function safeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + if (leftBuffer.length !== rightBuffer.length) { + return false + } + return timingSafeEqual(leftBuffer, rightBuffer) +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, + fetchImpl: typeof fetch, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetchImpl(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +export async function fetchDiscovery( + authentik: OobConfig['authentik'], + options: { fetchImpl?: typeof fetch; clock?: Clock } = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const clock = options.clock ?? systemClock + const cacheKey = `${authentik.clientId}@${authentik.issuerUrl}` + const cached = discoveryCache.get(cacheKey) + const now = clock.now().getTime() + + if ( + cached && + now - cached.fetchedAt < DISCOVERY_CACHE_TTL_MS && + cached.discovery.authorizationEndpoint && + cached.discovery.tokenEndpoint + ) { + return cached.discovery + } + + const wellKnownUrl = new URL( + '.well-known/openid-configuration', + authentik.issuerUrl.endsWith('/') + ? authentik.issuerUrl + : `${authentik.issuerUrl}/`, + ) + + let response: Response + try { + response = await fetchWithTimeout( + wellKnownUrl.toString(), + { method: 'GET', headers: { accept: 'application/json' } }, + DISCOVERY_TIMEOUT_MS, + fetchImpl, + ) + } catch (error) { + throw new OidcUnavailableError( + `Az Authentik nem elérhető a discovery lekérdezéshez: ${wellKnownUrl.toString()}`, + error, + ) + } + + if (!response.ok) { + throw new OidcUnavailableError( + `Az Authentik discovery válasza hibás: HTTP ${response.status}`, + ) + } + + let raw: unknown + try { + raw = await response.json() + } catch (error) { + throw new OidcUnavailableError( + 'Az Authentik discovery válasza nem érvényes JSON.', + error, + ) + } + + const discovery = parseDiscovery(raw) + if ( + normalizeIssuer(discovery.issuer) !== normalizeIssuer(authentik.issuerUrl) + ) { + throw new OidcProtocolError( + `Az Authentik discovery issuer-e eltér a beállítótól: ${discovery.issuer}`, + ) + } + + discoveryCache.set(cacheKey, { discovery, fetchedAt: now }) + return discovery +} + +function normalizeIssuer(value: string): string { + return value.trim().replace(/\/+$/, '') +} + +function parseDiscovery(raw: unknown): OidcDiscovery { + if (typeof raw !== 'object' || raw === null) { + throw new OidcProtocolError('A discovery dokumentum nem objektum.') + } + const record = raw as Record + for (const key of ['issuer', 'authorization_endpoint', 'token_endpoint']) { + if (typeof record[key] !== 'string' || record[key].trim() === '') { + throw new OidcProtocolError( + `A discovery dokumentumban hiányzik a(z) "${key}" mező.`, + ) + } + } + return { + issuer: record['issuer'] as string, + authorizationEndpoint: record['authorization_endpoint'] as string, + tokenEndpoint: record['token_endpoint'] as string, + } +} + +export function buildLoginTransaction(returnTo: string): LoginTransactionData { + return { + state: randomToken(), + codeVerifier: randomToken(), + nonce: randomToken(), + returnTo, + createdAtIso: new Date().toISOString(), + } +} + +export function buildAuthorizationUrl( + discovery: OidcDiscovery, + config: OobConfig['authentik'], + redirectUri: string, + transaction: Pick, +): string { + const url = new URL(discovery.authorizationEndpoint) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', config.clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', config.scopes.join(' ')) + url.searchParams.set('state', transaction.state) + url.searchParams.set('nonce', transaction.nonce) + url.searchParams.set( + 'code_challenge', + pkceChallenge(transaction.codeVerifier), + ) + url.searchParams.set('code_challenge_method', 'S256') + return url.toString() +} + +export async function exchangeCodeForTokens( + discovery: OidcDiscovery, + config: OobConfig['authentik'], + redirectUri: string, + code: string, + codeVerifier: string, + options: { fetchImpl?: typeof fetch } = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch + + let response: Response + try { + response = await fetchWithTimeout( + discovery.tokenEndpoint, + { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + code_verifier: codeVerifier, + }), + }, + TOKEN_TIMEOUT_MS, + fetchImpl, + ) + } catch (error) { + throw new OidcUnavailableError( + 'A token csere nem sikerült, az Authentik nem elérhető.', + error, + ) + } + + if (!response.ok) { + throw new OidcUnavailableError( + `A token csere hibás választ adott: HTTP ${response.status}`, + ) + } + + let raw: unknown + try { + raw = await response.json() + } catch (error) { + throw new OidcProtocolError('A token válasz nem érvényes JSON.', error) + } + + if (typeof raw !== 'object' || raw === null) { + throw new OidcProtocolError('A token válasz nem objektum.') + } + const record = raw as Record + const accessToken = record['access_token'] + const idToken = record['id_token'] + if (typeof accessToken !== 'string' || accessToken === '') { + throw new OidcProtocolError('A token válaszban hiányzik az access_token.') + } + if (typeof idToken !== 'string' || idToken === '') { + throw new OidcProtocolError('A token válaszban hiányzik az id_token.') + } + return { accessToken, idToken } +} + +export function decodeJwtPayload(idToken: string): Record { + const parts = idToken.split('.') + if (parts.length !== 3) { + throw new OidcProtocolError('Az id_token nem három részes JWT.') + } + try { + const json = Buffer.from(parts[1], 'base64url').toString('utf-8') + const payload: unknown = JSON.parse(json) + if ( + typeof payload !== 'object' || + payload === null || + Array.isArray(payload) + ) { + throw new Error('a payload nem objektum') + } + return payload as Record + } catch (error) { + throw new OidcProtocolError('Az id_token payload nem értelmezhető.', error) + } +} + +export function validateIdTokenClaims( + claims: Record, + expected: { + issuer: string + clientId: string + nonce: string + clock?: Clock + }, +): void { + const clock = expected.clock ?? systemClock + + if (claims['iss'] !== expected.issuer) { + throw new OidcProtocolError('Az id_token issuer-e nem egyezik.') + } + + const aud = claims['aud'] + const audMatches = + aud === expected.clientId || + (Array.isArray(aud) && (aud as unknown[]).includes(expected.clientId)) + if (!audMatches) { + throw new OidcProtocolError('Az id_token audience-e nem egyezik.') + } + + const exp = claims['exp'] + if (typeof exp !== 'number') { + throw new OidcProtocolError('Az id_tokenben hiányzik az exp.') + } + if ((exp + CLOCK_SKEW_MS / 1000) * 1000 < clock.now().getTime()) { + throw new OidcProtocolError('Az id_token lejárt.') + } + + if (claims['nonce'] !== expected.nonce) { + throw new OidcProtocolError('Az id_token nonce-a nem egyezik.') + } +} + +function claimString( + claims: Record, + claimName: string, +): string | null { + const value = claims[claimName] + if (typeof value === 'string' && value.trim() !== '') { + return value + } + return null +} + +export function extractIdentityFromClaims( + claims: Record, + config: OobConfig['authentik'], +): AuthenticatedIdentity { + const sub = claimString(claims, config.claims.sub) + if (!sub) { + throw new OidcProtocolError( + `Az id_tokenben hiányzik a kötelező "${config.claims.sub}" (sub) claim.`, + ) + } + const username = claimString(claims, config.claims.username) + if (!username) { + throw new OidcProtocolError( + `Az id_tokenben hiányzik a kötelező "${config.claims.username}" (felhasználónév) claim.`, + ) + } + + const rawGroups = claims['groups'] + const groups = Array.isArray(rawGroups) + ? rawGroups.filter( + (group): group is string => + typeof group === 'string' && group.trim() !== '', + ) + : [] + + return { + sub, + username, + fullName: claimString(claims, config.claims.fullName), + nickname: claimString(claims, config.claims.nickname), + avatarUrl: claimString(claims, config.claims.avatarUrl), + groups, + } +} diff --git a/src/server/auth/policy.ts b/src/server/auth/policy.ts new file mode 100644 index 0000000..2794902 --- /dev/null +++ b/src/server/auth/policy.ts @@ -0,0 +1,63 @@ +import { atLeast } from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' + +/** + * Adminjogosultsági mátrix a specifikáció 3.2 fejezete alapján. + * A vezetőségi jog magában foglalja a tagjogot: a leadership szint + * minden member képességre igaz. + */ + +export function isMember(viewer: Viewer): boolean { + return atLeast(viewer, 'member') +} + +export function isLeadership(viewer: Viewer): boolean { + return atLeast(viewer, 'leadership') +} + +export const can = { + createOrEditContent(viewer: Viewer): boolean { + return isMember(viewer) + }, + archiveContent(viewer: Viewer): boolean { + return isMember(viewer) + }, + trashVideo(viewer: Viewer): boolean { + return isMember(viewer) + }, + viewTrash(viewer: Viewer): boolean { + return isMember(viewer) + }, + restoreVideo(viewer: Viewer): boolean { + return isLeadership(viewer) + }, + permanentlyDeleteEvent(viewer: Viewer): boolean { + return isLeadership(viewer) + }, + assignExistingTagToVideo(viewer: Viewer): boolean { + return isMember(viewer) + }, + manageTagCatalog(viewer: Viewer): boolean { + return isLeadership(viewer) + }, + manageStaffRoles(viewer: Viewer): boolean { + return isLeadership(viewer) + }, + manageVideoStaffList(viewer: Viewer): boolean { + return isMember(viewer) + }, + manageHomepageSettings(viewer: Viewer): boolean { + return isLeadership(viewer) + }, + viewMemberDiagnostics(viewer: Viewer): boolean { + return isLeadership(viewer) + }, + viewAuditLog(viewer: Viewer): boolean { + return isLeadership(viewer) + }, +} as const + +/** Az adminfelület bármely eleméhez legalább tagság kell. */ +export function isAdminAreaAllowed(viewer: Viewer): boolean { + return isMember(viewer) +} diff --git a/src/server/auth/session-cookies.ts b/src/server/auth/session-cookies.ts new file mode 100644 index 0000000..140e663 --- /dev/null +++ b/src/server/auth/session-cookies.ts @@ -0,0 +1,125 @@ +import { + createHmac, + createHash, + randomBytes, + timingSafeEqual, +} from 'node:crypto' +import type { OobConfig } from '#/server/config/oob-schema.ts' + +export const SESSION_COOKIE_NAME = 'bss_session' +export const OIDC_TXN_COOKIE_NAME = 'bss_oidc_txn' + +/** A szerepváltozás legfeljebb egy órán belül érvényesül: abszolút session TTL. */ +export const SESSION_TTL_MS = 60 * 60 * 1000 +/** A bejelentkezési tranzakció (state/PKCE/returnTo) rövid életű cookie-ban él. */ +export const OIDC_TXN_TTL_SECONDS = 600 + +export interface CookieSpec { + name: string + value: string + maxAgeSeconds?: number + secure?: boolean +} + +export function serializeSetCookie(spec: CookieSpec): string { + const parts = [ + `${spec.name}=${spec.value}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + ] + if (spec.maxAgeSeconds !== undefined) { + parts.push(`Max-Age=${Math.floor(spec.maxAgeSeconds)}`) + } + if (spec.secure) { + parts.push('Secure') + } + return parts.join('; ') +} + +function parseCookies(header: string): Map { + const cookies = new Map() + for (const part of header.split(';')) { + const separator = part.indexOf('=') + if (separator === -1) { + continue + } + const name = part.slice(0, separator).trim() + const value = part.slice(separator + 1).trim() + if (name !== '') { + cookies.set(name, decodeURIComponent(value)) + } + } + return cookies +} + +export function readCookieValue(request: Request, name: string): string | null { + const header = request.headers.get('cookie') + if (!header) { + return null + } + return parseCookies(header).get(name) ?? null +} + +export function expiredCookieHeader(name: string, secure = false): string { + return serializeSetCookie({ name, value: '', maxAgeSeconds: 0, secure }) +} + +export function newSessionToken(): string { + return randomBytes(32).toString('base64url') +} + +export function hashSessionToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function txnSigningKey(config: OobConfig['authentik']): Buffer { + return createHash('sha256') + .update(`bss-oidc-txn:${config.clientId}:${config.clientSecret}`) + .digest() +} + +export interface SignedTxnPayload { + value: string + signatureHex: string +} + +export function signOidcTxn( + jsonPayload: string, + config: OobConfig['authentik'], +): string { + const signature = createHmac('sha256', txnSigningKey(config)) + .update(jsonPayload) + .digest('hex') + return `${Buffer.from(jsonPayload, 'utf-8').toString('base64url')}.${signature}` +} + +export function verifyAndReadOidcTxn( + cookieValue: string, + config: OobConfig['authentik'], +): string | null { + const separator = cookieValue.lastIndexOf('.') + if (separator === -1) { + return null + } + const encoded = cookieValue.slice(0, separator) + const signatureHex = cookieValue.slice(separator + 1) + + const expected = createHmac('sha256', txnSigningKey(config)) + .update(Buffer.from(encoded, 'base64url')) + .digest() + + const provided = Buffer.from(signatureHex, 'hex') + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + return null + } + + try { + return Buffer.from(encoded, 'base64url').toString('utf-8') + } catch { + return null + } +} diff --git a/src/server/auth/session-store.ts b/src/server/auth/session-store.ts new file mode 100644 index 0000000..1321c98 --- /dev/null +++ b/src/server/auth/session-store.ts @@ -0,0 +1,145 @@ +import { and, eq, gt, lt } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { authSessions } from '#/db/schema.ts' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { + hashSessionToken, + newSessionToken, + SESSION_TTL_MS, +} from './session-cookies.ts' + +export type Database = NodePgDatabase> + +export interface AuthSessionRecord { + id: string + memberSub: string + username: string + groups: string[] + accessToken: string | null + createdAt: Date + expiresAt: Date +} + +let defaultDbPromise: Promise | null = null + +export function getDefaultDb(): Promise { + if (defaultDbPromise === null) { + defaultDbPromise = (async () => { + if (!process.env.DATABASE_URL) { + throw new Error( + 'A DATABASE_URL környezeti változó nincs beállítva. Másold a .env.example alapú .env fájlba, vagy állítsd be explicit módon.', + ) + } + const module_ = await import('#/db/drizzleConnect.ts') + return module_.db + })() + } + return defaultDbPromise +} + +export interface CreatedAuthSession { + token: string + session: AuthSessionRecord +} + +export async function createAuthSession( + identity: { + memberSub: string + username: string + groups: string[] + accessToken: string | null + }, + options: { + db?: Database + clock?: Clock + ttlMs?: number + } = {}, +): Promise { + const database = options.db ?? (await getDefaultDb()) + const clock = options.clock ?? systemClock + const token = newSessionToken() + const now = clock.now() + const expiresAt = new Date(now.getTime() + (options.ttlMs ?? SESSION_TTL_MS)) + const id = hashSessionToken(token) + + await database.insert(authSessions).values({ + id, + memberSub: identity.memberSub, + username: identity.username, + groups: identity.groups, + accessToken: identity.accessToken, + createdAt: now, + expiresAt, + }) + + return { + token, + session: { + id, + memberSub: identity.memberSub, + username: identity.username, + groups: identity.groups, + accessToken: identity.accessToken, + createdAt: now, + expiresAt, + }, + } +} + +export async function findActiveAuthSession( + token: string, + options: { db?: Database; clock?: Clock } = {}, +): Promise { + if (token === '') { + return null + } + const database = options.db ?? (await getDefaultDb()) + const clock = options.clock ?? systemClock + + const rows = await database + .select() + .from(authSessions) + .where( + and( + eq(authSessions.id, hashSessionToken(token)), + gt(authSessions.expiresAt, clock.now()), + ), + ) + .limit(1) + + const row = rows.at(0) + if (row === undefined) { + return null + } + return { + id: row.id, + memberSub: row.memberSub, + username: row.username, + groups: row.groups, + accessToken: row.accessToken, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } +} + +export async function deleteAuthSession( + token: string, + options: { db?: Database } = {}, +): Promise { + const database = options.db ?? (await getDefaultDb()) + await database + .delete(authSessions) + .where(eq(authSessions.id, hashSessionToken(token))) +} + +export async function purgeExpiredAuthSessions( + options: { db?: Database; clock?: Clock } = {}, +): Promise { + const database = options.db ?? (await getDefaultDb()) + const clock = options.clock ?? systemClock + const deleted = await database + .delete(authSessions) + .where(lt(authSessions.expiresAt, clock.now())) + return deleted.rowCount ?? 0 +} diff --git a/src/server/auth/viewer.ts b/src/server/auth/viewer.ts new file mode 100644 index 0000000..f6aef3f --- /dev/null +++ b/src/server/auth/viewer.ts @@ -0,0 +1,73 @@ +import type { OobConfig } from '#/server/config/oob-schema.ts' +import type { AuthSessionRecord } from '#/server/auth/session-store.ts' + +/** A nézői szintek sorrendje a specifikáció 3.1 fejezetének megfelelően. */ +export type ViewerLevel = 'anonymous' | 'schonherz' | 'member' | 'leadership' + +const LEVEL_RANK: Record = { + anonymous: 0, + schonherz: 1, + member: 2, + leadership: 3, +} + +export interface Viewer { + level: ViewerLevel + /** Authentik sub; névtelennél null. */ + sub: string | null + username: string | null +} + +export function anonymousViewer(): Viewer { + return { level: 'anonymous', sub: null, username: null } +} + +/** + * A vezetőségi csoport kiegészíti a tagságot, nem helyettesíti: + * leadership csak a tag csoporttal együtt ad teljes jogot. + */ +export function resolveViewerLevel( + groups: ReadonlyArray, + config: OobConfig['authentik'], +): ViewerLevel { + const has = (groupName: string): boolean => groups.includes(groupName) + if (has(config.groups.tag) && has(config.groups.vezetoseg)) { + return 'leadership' + } + if (has(config.groups.tag)) { + return 'member' + } + if (has(config.groups.schonherz)) { + return 'schonherz' + } + return 'anonymous' +} + +export function viewerFromIdentity( + identity: { sub: string; username: string; groups: string[] }, + config: OobConfig['authentik'], +): Viewer { + return { + level: resolveViewerLevel(identity.groups, config), + sub: identity.sub, + username: identity.username, + } +} + +export function viewerFromSession( + session: AuthSessionRecord | null, + config: OobConfig['authentik'], +): Viewer { + if (session === null) { + return anonymousViewer() + } + return { + level: resolveViewerLevel(session.groups, config), + sub: session.memberSub, + username: session.username, + } +} + +export function atLeast(viewer: Viewer, level: ViewerLevel): boolean { + return LEVEL_RANK[viewer.level] >= LEVEL_RANK[level] +} diff --git a/src/server/config/load.ts b/src/server/config/load.ts index f21b15e..923b740 100644 --- a/src/server/config/load.ts +++ b/src/server/config/load.ts @@ -42,3 +42,16 @@ export function loadOobConfig(explicitPath?: string): OobConfig { return validateOobConfig(parsed) } + +let cachedConfig: OobConfig | null = null + +export function getCachedOobConfig(): OobConfig { + if (cachedConfig === null) { + cachedConfig = loadOobConfig() + } + return cachedConfig +} + +export function resetCachedOobConfig(): void { + cachedConfig = null +} diff --git a/src/server/config/oob-schema.ts b/src/server/config/oob-schema.ts index fa2ad06..e47f4c5 100644 --- a/src/server/config/oob-schema.ts +++ b/src/server/config/oob-schema.ts @@ -17,6 +17,11 @@ export interface OobConfig { clientId: string clientSecret: string scopes: string[] + /** Tagcache-szinkronhoz használt szolgáltatási fiók (OOB titok). */ + sync: { + username: string + token: string + } claims: { sub: string username: string @@ -117,6 +122,15 @@ export function validateOobConfig(raw: unknown): OobConfig { requireString(authentik, 'authentik.clientId', problems) requireString(authentik, 'authentik.clientSecret', problems) + if (!isRecord(authentik['sync'])) { + problems.push( + 'authentik.sync: kötelező szekció hiányzik (tagcache-szinkron szolgáltatási fiókja).', + ) + } else { + requireString(authentik.sync, 'authentik.sync.username', problems) + requireString(authentik.sync, 'authentik.sync.token', problems) + } + const issuerUrlValue = authentik['issuerUrl'] if ( typeof issuerUrlValue === 'string' && @@ -315,6 +329,14 @@ export function validateOobConfig(raw: unknown): OobConfig { clientId: authentik['clientId'] as string, clientSecret: authentik['clientSecret'] as string, scopes: authentik['scopes'] as string[], + sync: { + username: (authentik['sync'] as Record)[ + 'username' + ] as string, + token: (authentik['sync'] as Record)[ + 'token' + ] as string, + }, claims: claims as unknown as OobConfig['authentik']['claims'], groups: groups as unknown as OobConfig['authentik']['groups'], attributes: { diff --git a/src/server/jobs/health.ts b/src/server/jobs/health.ts new file mode 100644 index 0000000..7de8cd5 --- /dev/null +++ b/src/server/jobs/health.ts @@ -0,0 +1,79 @@ +import { sql } from 'drizzle-orm' +import type { Database } from '#/server/auth/session-store.ts' +import { getDefaultDb } from '#/server/auth/session-store.ts' + +export interface HealthStatus { + status: 'ok' | 'error' + database?: 'ok' | 'error' | 'unknown' + migrations?: 'ok' | 'missing' | 'unknown' +} + +/** + * /health/live: az alkalmazás futását jelzi. Nem nyúl adatbázishoz és + * nem tartalmaz semmilyen konfigurációt vagy titkot. + */ +export function livenessResponse(): Response { + return jsonHealth({ status: 'ok' }, 200) +} + +/** + * /health/ready: adatbázis-elérhetőség és migrációk állapota. + * A válasz csak állapotmezőket tartalmaz, soha nem hibaüzenet-részleteket + * vagy kapcsolati adatokat (nem szivárogtat titkot). + */ +export async function readinessResponse( + options: { db?: Database | null } = {}, +): Promise { + const status: HealthStatus = { + status: 'error', + database: 'unknown', + migrations: 'unknown', + } + + try { + let database = options.db + if (database === null || database === undefined) { + database = await getDefaultDb() + } + await database.execute(sql`select 1`) + status.database = 'ok' + + // A migrációk állapota a kulcstáblák meglétével ellenőrizhető: + const requiredTables = [ + 'videos', + 'events', + 'member_cache', + 'auth_sessions', + 'audit_log', + ] + const result = await database.execute<{ table_name: string }>( + sql`select table_name from information_schema.tables + where table_schema = 'public' and table_name in ('videos','events','member_cache','auth_sessions','audit_log')`, + ) + const rows: Array<{ table_name: string }> = ( + result as unknown as { rows: Array<{ table_name: string }> } + ).rows + const present = new Set(rows.map((row) => row.table_name)) + const migrationsOk = requiredTables.every((table) => present.has(table)) + if (!migrationsOk) { + return jsonHealth( + { ...status, status: 'error', migrations: 'missing' }, + 503, + ) + } + return jsonHealth({ ...status, status: 'ok', migrations: 'ok' }, 200) + } catch { + // Szándékosan nincs részletes hiba a válaszban. + return jsonHealth({ ...status, status: 'error' }, 503) + } +} + +function jsonHealth(status: HealthStatus, httpStatus: number): Response { + return new Response(JSON.stringify(status), { + status: httpStatus, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) +} diff --git a/src/server/jobs/locks.ts b/src/server/jobs/locks.ts new file mode 100644 index 0000000..f660c10 --- /dev/null +++ b/src/server/jobs/locks.ts @@ -0,0 +1,85 @@ +import { Client } from 'pg' +import { createHash } from 'node:crypto' + +/** + * PostgreSQL advisory lock (spec 15): két alkalmazáspéldány közül egyszerre csak + * egy futtathatja az adott feladatot. A lock egy dedikált kapcsolathoz kötött, + * ezért a felszabadítás ugyanazon a kapcsolaton történik. + */ + +export interface AdvisoryLock { + release: () => Promise +} + +function lockKeyFor(name: string): string { + // bigint kulcs a feladatnév hashéből (pg_try_advisory_lock 63 bites) + const hash = createHash('sha256').update(`bss-job:${name}`).digest() + const value = hash.readBigUInt64BE(0) >> BigInt(1) // 63 bitre vágás + return value.toString() +} + +async function getDefaultLockClient(): Promise { + const url = process.env.DATABASE_URL + if (!url) { + throw new Error( + 'A DATABASE_URL környezeti változó nincs beállítva. Másold a .env.example alapú .env fájlba, vagy állítsd be explicit módon.', + ) + } + const client = new Client({ connectionString: url }) + await client.connect() + return client +} + +export interface LockManager { + acquire: (name: string) => Promise + close?: () => Promise +} + +export function createPgLockManager( + options: { clientFactory?: () => Promise } = {}, +): LockManager { + let sharedClient: Client | null = null + + async function getClient(): Promise { + if (sharedClient === null) { + sharedClient = await (options.clientFactory ?? getDefaultLockClient)() + await sharedClient.connect().catch((error) => { + // Az már csatlakoztatott klienst jelző hibák ártalmatlanok. + void error + }) + } + return sharedClient + } + + return { + async acquire(name) { + const key = lockKeyFor(name) + const client = await getClient() + const result = await client.query<{ locked: boolean }>( + 'select pg_try_advisory_lock($1) as locked', + [key], + ) + if (result.rows[0]?.locked !== true) { + return null + } + return { + release: async () => { + await client.query('select pg_advisory_unlock($1)', [key]) + }, + } + }, + async close() { + if (sharedClient !== null) { + await sharedClient.end() + sharedClient = null + } + }, + } +} + +/** Tesztekhez: mindig engedő lock manager. */ +export function createPermissiveLockManager(): LockManager { + return { + acquire: async () => ({ release: async () => undefined }), + } +} diff --git a/src/server/jobs/runner.ts b/src/server/jobs/runner.ts new file mode 100644 index 0000000..1496631 --- /dev/null +++ b/src/server/jobs/runner.ts @@ -0,0 +1,262 @@ +import { desc } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { memberSyncRuns } from '#/db/schema.ts' +import type { Database } from '#/server/auth/session-store.ts' +import { getDefaultDb } from '#/server/auth/session-store.ts' +import { requireLeadership } from '#/server/auth/guards.ts' +import type { LockManager } from './locks.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import type { MemberSyncDeps, SyncTrigger } from '#/server/members/sync.ts' +import { runMemberSync } from '#/server/members/sync.ts' + +export interface JobContext { + clock: Clock + trigger: SyncTrigger | 'tick' +} + +export interface JobDefinition { + /** Rögzített név, az advisory lock kulcsa ebből származik. */ + name: string + /** + * Ütemezés milliszekundumban a feladat utolsó (sikeres vagy sikertelen) + * futása után. A `startup` jelölésű feladatok induláskor egyszer futnak. + */ + intervalMs: number | 'startup' + run: (ctx: JobContext) => Promise +} + +export interface JobRunRecord { + jobName: string + startedAt: Date + finishedAt: Date + status: 'ok' | 'error' | 'skipped-locked' + message: string | null +} + +/** + * Háttérfeladat-nyilvántartás. A tesztek a FakeClock-kal együtt + * `runDueJobs` hívásokkal vezérlik az idő múlását. + */ +export class JobRegistry { + private readonly jobs = new Map() + private readonly lastFinishedAt = new Map() + + register(job: JobDefinition): void { + this.jobs.set(job.name, job) + } + + getJob(name: string): JobDefinition | undefined { + return this.jobs.get(name) + } + + list(): JobDefinition[] { + return [...this.jobs.values()] + } + + setLastFinishedAt(name: string, at: Date): void { + this.lastFinishedAt.set(name, at) + } + + getLastFinishedAt(name: string): Date | undefined { + return this.lastFinishedAt.get(name) + } +} + +export interface RunDueJobsOptions { + registry: JobRegistry + now: Date + execute?: (job: JobDefinition) => Promise +} + +/** Azok a startup és esedékes intervallumos feladatok, amelyeket most le kell futtatni. */ +export function dueJobs(registry: JobRegistry, now: Date): JobDefinition[] { + return registry.list().filter((job) => { + if (job.intervalMs === 'startup') { + return !registry.getLastFinishedAt(job.name) + } + const last = registry.getLastFinishedAt(job.name) + if (!last) { + return true + } + return now.getTime() - last.getTime() >= job.intervalMs + }) +} + +export interface RunnerDeps extends MemberSyncDeps { + lockManager?: LockManager +} + +/** + * Egy feladat lefuttatása advisory lock alatt. Ha a lockot más példány tartja, + * a futás `skipped-locked` eredménnyel kimarad — így két alkalmazáspéldány + * soha nem futtatja ugyanazt a feladatot kétszer. + */ +export async function runJobWithLock( + job: JobDefinition, + deps: RunnerDeps, +): Promise { + const clock = deps.clock ?? systemClock + const locks = + deps.lockManager ?? (await import('./locks.ts')).createPgLockManager() + const startedAt = clock.now() + + const lock = await locks.acquire(job.name) + if (lock === null) { + return { + jobName: job.name, + startedAt, + finishedAt: clock.now(), + status: 'skipped-locked', + message: null, + } + } + + try { + await job.run({ clock, trigger: 'tick' }) + const finishedAt = clock.now() + return { + jobName: job.name, + startedAt, + finishedAt, + status: 'ok', + message: null, + } + } catch (error) { + // A háttérhiba soha nem állítja le az alkalmazást vagy a publikus cache-t: + // a hiba rögzítésre kerül, a futás folytatódik. + const finishedAt = clock.now() + const message = + error instanceof Error + ? error.message + : String(error ?? 'ismeretlen hiba') + console.error( + `[jobs] A(z) "${job.name}" háttérfeladat hibával zárult:`, + message, + ) + return { + jobName: job.name, + startedAt, + finishedAt, + status: 'error', + message, + } + } finally { + await lock.release() + } +} + +/** Alapértelmezett feladatok: induláskori + óránkénti Authentik tagcache szinkron. */ +export function createDefaultSyncJobs(deps: RunnerDeps): JobDefinition[] { + const syncOnce = async (trigger: SyncTrigger) => { + await runMemberSync(trigger, deps) + } + return [ + { + name: 'member-sync-startup', + intervalMs: 'startup', + run: () => syncOnce('startup'), + }, + { + name: 'member-sync-hourly', + // Óránkénti szinkron (spec 8.2 / 15). + intervalMs: 60 * 60 * 1000, + run: () => syncOnce('hourly'), + }, + ] +} + +export interface BackgroundRunnerHandle { + registry: JobRegistry + tickNow: () => Promise + stop: () => void +} + +/** + * Elindítja a háttérfeladatokat: startup feladatok egyszer, majd percenkénti + * ellenőrzéssel az esedékes intervallumos feladatok. Percenkénti tick valós + * időben; tesztben a `tickNow` hívással vezérelhető. + */ +export function startBackgroundRunner( + deps: RunnerDeps = {}, + extraJobs: JobDefinition[] = [], +): BackgroundRunnerHandle { + const registry = new JobRegistry() + for (const job of [...createDefaultSyncJobs(deps), ...extraJobs]) { + registry.register(job) + } + + const executeAndMark = async (job: JobDefinition): Promise => { + const record = await runJobWithLock(job, deps) + registry.setLastFinishedAt(job.name, record.finishedAt) + return record + } + + let stopped = false + let timer: ReturnType | null = null + + const scheduleNextTick = (): void => { + if (stopped) { + return + } + timer = setTimeout(() => { + void tickAll().finally(scheduleNextTick) + }, 60_000) + } + + const tickAll = async (): Promise => { + const now = (deps.clock ?? systemClock).now() + const due = dueJobs(registry, now) + const records: JobRunRecord[] = [] + for (const job of due) { + records.push(await executeAndMark(job)) + } + return records + } + + const handle: BackgroundRunnerHandle = { + registry, + tickNow: tickAll, + stop: () => { + stopped = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } + }, + } + + void tickAll() + .catch((error) => console.error('[jobs] Indulási tick hibás:', error)) + .finally(scheduleNextTick) + + return handle +} + +/** + * Vezetőségi hibasáv: a legutóbbi szinkronfuttatások állapota a persistent + * member_sync_runs táblából. Csak vezetőség kérheti le. + */ +export async function getRecentSyncAlerts( + viewer: Viewer, + options: { db?: Database; limit?: number } = {}, +): Promise< + Array<{ + id: number + trigger: string + status: string + startedAt: Date + totalCount: number + changedCount: number + errorCount: number + message: string | null + }> +> { + requireLeadership(viewer) + const database = options.db ?? (await getDefaultDb()) + return database + .select() + .from(memberSyncRuns) + .orderBy(desc(memberSyncRuns.id)) + .limit(options.limit ?? 10) +} diff --git a/src/server/media/validator.ts b/src/server/media/validator.ts new file mode 100644 index 0000000..eb88f2c --- /dev/null +++ b/src/server/media/validator.ts @@ -0,0 +1,213 @@ +import type { OobConfig } from '#/server/config/oob-schema.ts' + +export const MEDIA_CONNECT_TIMEOUT_MS = 5_000 +export const MEDIA_TOTAL_TIMEOUT_MS = 15_000 + +export interface MediaCheckResult { + ok: boolean + /** Magyar, felhasználónak szóló problémák. */ + problems: string[] +} + +export type MediaKind = 'video' | 'thumbnail' + +function parseUrl(rawUrl: string): URL | null { + try { + return new URL(rawUrl) + } catch { + return null + } +} + +/** Host engedélylista (spec 5.4): csak a konfigurált médiahostok engedélyezettek. */ +export function isAllowedMediaHost( + rawUrl: string, + config: OobConfig['media'], +): boolean { + const url = parseUrl(rawUrl) + if (url === null) { + return false + } + if (url.protocol !== 'https:') { + return false + } + return config.allowedHosts.includes(url.hostname) +} + +/** + * Hálózati hívás nélküli ellenőrzés: URL forma és host. Piszkózat mentésekor + * ez az egyetlen kötelező vizsgálat; a hibás URL így is menthető piszkozatban. + */ +export function checkMediaUrlShape( + rawUrl: string, + kind: MediaKind, + config: OobConfig['media'], +): MediaCheckResult { + const problems: string[] = [] + const url = parseUrl(rawUrl) + if (url === null) { + problems.push('A megadott URL érvénytelen.') + return { ok: false, problems } + } + if (url.protocol !== 'https:') { + problems.push('A média URL csak https:// lehet.') + } + if (!config.allowedHosts.includes(url.hostname)) { + problems.push( + `A média csak a következő hostokról tölthető be: ${config.allowedHosts.join(', ')}.`, + ) + } + if (rawUrl.length > 2048) { + problems.push('Az URL legfeljebb 2048 karakter lehet.') + } + void kind + return { ok: problems.length === 0, problems } +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, + fetchImpl: typeof fetch, +): Promise { + const controller = new AbortController() + let timeoutId: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort() + reject(new Error('időtúllépés')) + }, timeoutMs) + }) + try { + return await Promise.race([ + fetchImpl(url, { ...init, signal: controller.signal }), + timeoutPromise, + ]) + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } +} + +function expectedContentType(kind: MediaKind): string { + return kind === 'video' ? 'video/mp4' : 'image/' +} + +function contentTypeProblem( + kind: MediaKind, + contentType: string | null, +): string | null { + if (contentType === null || contentType === '') { + return 'A szerver nem adott meg content-type fejlécet.' + } + const expected = expectedContentType(kind) + if (kind === 'video') { + if (!contentType.startsWith(expected)) { + return `Videó helyett ${contentType} típusú tartalom érkezett.` + } + return null + } + if (!contentType.startsWith(expected)) { + return `Kép helyett ${contentType} típusú tartalom érkezik.` + } + return null +} + +/** + * Publikálás előtti teljes médiaellenőrzés (spec 5.4): + * - HEAD kérés, átirányítás nélküli 200 válasz elfogadott; + * - videónál video/mp4, képnél image/* content-type kell; + * - 405 vagy 501 esetén egybájtos Range GET tartalékellenőrzés fut; + * - a fájl tartalmát soha nem töltjük le, csak a fejléceket olvassuk. + */ +export async function validateMediaForPublish(params: { + url: string + kind: MediaKind + mediaConfig: OobConfig['media'] + connectTimeoutMs?: number + totalTimeoutMs?: number + fetchImpl?: typeof fetch +}): Promise { + const shape = checkMediaUrlShape(params.url, params.kind, params.mediaConfig) + if (!shape.ok) { + return shape + } + + const fetchImpl = params.fetchImpl ?? fetch + const connectTimeout = params.connectTimeoutMs ?? MEDIA_CONNECT_TIMEOUT_MS + const totalTimeout = params.totalTimeoutMs ?? MEDIA_TOTAL_TIMEOUT_MS + + let headResponse: Response + try { + headResponse = await fetchWithTimeout( + params.url, + { method: 'HEAD', redirect: 'manual' }, + totalTimeout, + fetchImpl, + ) + } catch { + return { + ok: false, + problems: ['A média nem érhető el (időtúllépés vagy kapcsolódási hiba).'], + } + } + + // Átirányítás nem elfogadott: 3xx válasz a hiányzó/rossz médiára utal. + if (headResponse.status >= 300 && headResponse.status < 400) { + return { + ok: false, + problems: ['A média URL átirányítást ad, ez nem fogadható el.'], + } + } + + if (headResponse.status === 405 || headResponse.status === 501) { + // Tartalék: egybájtos Range GET. + let getResponse: Response + try { + getResponse = await fetchWithTimeout( + params.url, + { + method: 'GET', + redirect: 'manual', + headers: { range: 'bytes=0-0' }, + }, + Math.max(totalTimeout, connectTimeout), + fetchImpl, + ) + } catch { + return { + ok: false, + problems: ['A média tartalékellenőrzése nem sikerült (időtúllépés).'], + } + } + + if (getResponse.status !== 200 && getResponse.status !== 206) { + return { + ok: false, + problems: [`A média nem érhető el: HTTP ${getResponse.status}.`], + } + } + const contentType = getResponse.headers.get('content-type') + const problem = contentTypeProblem(params.kind, contentType) + if (problem !== null) { + return { ok: false, problems: [problem] } + } + return { ok: true, problems: [] } + } + + if (headResponse.status !== 200) { + return { + ok: false, + problems: [`A média nem érhető el: HTTP ${headResponse.status}.`], + } + } + + const contentType = headResponse.headers.get('content-type') + const problem = contentTypeProblem(params.kind, contentType) + if (problem !== null) { + return { ok: false, problems: [problem] } + } + + return { ok: true, problems: [] } +} diff --git a/src/server/media/youtube.ts b/src/server/media/youtube.ts new file mode 100644 index 0000000..4d225eb --- /dev/null +++ b/src/server/media/youtube.ts @@ -0,0 +1,149 @@ +import type { OobConfig } from '#/server/config/oob-schema.ts' + +export interface YoutubeCheckResult { + ok: boolean + /** Normalizált YouTube videóazonosító; érvénytelen URL esetén null. */ + videoId: string | null + problems: string[] +} + +/** + * YouTube URL normalizálás (spec 9.3). Elfogadott formák: + * youtube.com/watch?v=ID, youtube.com/live/ID, youtu.be/ID, embed/ID, + * youtube-nocookie.com változatok. Az eredmény mindig videóazonosító. + */ +export function normalizeYoutubeVideoId(rawUrl: string): string | null { + try { + const url = new URL(rawUrl) + const hostname = url.hostname.replace(/^www\./, '') + const isYoutube = + hostname === 'youtube.com' || + hostname === 'm.youtube.com' || + hostname === 'youtube-nocookie.com' + const isShort = hostname === 'youtu.be' + + if (isShort) { + const parts = url.pathname.split('/').filter(Boolean) + if (parts.length === 0) { + return null + } + return parts[0] + } + if (!isYoutube) { + return null + } + if (url.pathname === '/watch') { + const v = url.searchParams.get('v') + if (v !== null && /^[A-Za-z0-9_-]{6,20}$/.test(v)) { + return v + } + return null + } + const parts = url.pathname.split('/').filter(Boolean) + for (const segment of ['live', 'embed', 'shorts']) { + if (parts[0] === segment) { + const id = parts.at(1) + return id !== undefined && /^[A-Za-z0-9_-]{6,20}$/.test(id) ? id : null + } + } + return null + } catch { + return null + } +} + +/** Embed URL készítése a normalizált azonosítóból (megjelenítéshez, nocookie). */ +export function buildYoutubeNocookieEmbedUrl(videoId: string): string { + return `https://www.youtube-nocookie.com/embed/${videoId}` +} + +/** + * oEmbed ellenőrzés (mentéskor és aktiváláskor): a videóazonosítóhoz + * lekérdezzük az oEmbed végpontot; csak 200 válasz elfogadott. + */ +export async function validateYoutubeVideo( + rawUrl: string, + config: OobConfig['youtube'], + options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + const videoId = normalizeYoutubeVideoId(rawUrl) + if (videoId === null) { + return { + ok: false, + videoId: null, + problems: [ + 'A YouTube URL formátuma nem elfogadott (watch, live, youtu.be vagy embed link kell).', + ], + } + } + + const fetchImpl = options.fetchImpl ?? fetch + const controller = new AbortController() + const timer = setTimeout( + () => controller.abort(), + options.timeoutMs ?? 10_000, + ) + + try { + const oembedUrl = new URL(config.oEmbedEndpoint) + oembedUrl.searchParams.set( + 'url', + `https://www.youtube.com/watch?v=${videoId}`, + ) + oembedUrl.searchParams.set('format', 'json') + + const response = await fetchImpl(oembedUrl.toString(), { + method: 'GET', + headers: { accept: 'application/json' }, + signal: controller.signal, + }) + + if (response.status === 401 || response.status === 403) { + return { + ok: false, + videoId, + problems: ['A YouTube videó privát vagy bejelentkezéshez kötött.'], + } + } + if (response.status !== 200) { + return { + ok: false, + videoId, + problems: [ + 'A YouTube videó nem elérhető az oEmbed ellenőrzésen (törölt vagy hibás azonosító).', + ], + } + } + + let parsed: unknown + try { + parsed = await response.json() + } catch { + return { + ok: false, + videoId, + problems: ['Az oEmbed válasz nem értelmezhető.'], + } + } + if (typeof parsed !== 'object' || parsed === null || !('title' in parsed)) { + return { + ok: false, + videoId, + problems: ['Az oEmbed válasz nem YouTube videót ír le.'], + } + } + + return { ok: true, videoId, problems: [] } + } catch (error) { + console.error('[youtube] oEmbed hiba:', error) + return { + ok: false, + videoId, + problems: [ + 'Az oEmbed ellenőrzés most nem elérhető. Próbáld újra később.', + ], + } + } finally { + clearTimeout(timer) + } +} diff --git a/src/server/members/authentik-api.ts b/src/server/members/authentik-api.ts new file mode 100644 index 0000000..3987af7 --- /dev/null +++ b/src/server/members/authentik-api.ts @@ -0,0 +1,224 @@ +import { TOKEN_TIMEOUT_MS } from '#/server/auth/oidc.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' + +export interface AuthentikApiUser { + pk: number + username: string + name: string + isActive: boolean + type: string + avatarUrl: string | null + attributes: Record + groups: string[] +} + +export interface AuthentikApiGroup { + pk: string + name: string +} + +export interface AuthentikApi { + listUsers: () => Promise + listGroups: () => Promise +} + +export class SyncUnavailableError extends Error { + constructor( + message: string, + readonly detail?: unknown, + ) { + super(message) + this.name = 'SyncUnavailableError' + } +} + +function assertRecord( + value: unknown, + context: string, +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new SyncUnavailableError( + `Az Authentik API válasza érvénytelen (${context}).`, + ) + } + return value as Record +} + +async function fetchJson( + url: string, + init: RequestInit, + context: string, + fetchImpl: typeof fetch, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS) + try { + const response = await fetchImpl(url, { + ...init, + signal: controller.signal, + }) + if (!response.ok) { + throw new SyncUnavailableError( + `Az Authentik API ${context} hívása hibát adott: HTTP ${response.status}`, + ) + } + return await response.json() + } catch (error) { + if (error instanceof SyncUnavailableError) { + throw error + } + throw new SyncUnavailableError( + `Az Authentik API ${context} hívása nem sikerült (elérhetetlen szolgáltatás).`, + error, + ) + } finally { + clearTimeout(timer) + } +} + +/** + * Tagcache-szinkronhoz használt Authentik REST API kliens. + * A hozzáférés a configban lévő szolgáltatási fiókkal (username + API token) + * client_credentials granthasználatával történő bearer tokennel történik. + */ +export function createAuthentikApi( + config: OobConfig['authentik'], + options: { + fetchImpl?: typeof fetch + /** A discovery-ből származó token végpont; hiányában az issuer-ből képezzük. */ + tokenEndpoint?: string + } = {}, +): AuthentikApi { + const fetchImpl = options.fetchImpl ?? fetch + + function resolveTokenEndpoint(): string { + if (options.tokenEndpoint !== undefined && options.tokenEndpoint !== '') { + return options.tokenEndpoint + } + const withoutIssuerPath = config.issuerUrl.replace( + /application\/o\/[^/]+\/?$/, + 'application/o/', + ) + return new URL('token/', withoutIssuerPath).toString() + } + + async function getAccessToken(): Promise { + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: config.clientId, + username: config.sync.username, + password: config.sync.token, + scope: 'goauthentik.io/api', + }) + const raw = await fetchJson( + resolveTokenEndpoint(), + { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }, + body, + }, + 'token szerzés', + fetchImpl, + ) + const record = assertRecord(raw, 'token válasz') + const accessToken = record['access_token'] + if (typeof accessToken !== 'string' || accessToken === '') { + throw new SyncUnavailableError( + 'Az Authentik token válaszban hiányzik az access_token.', + ) + } + return accessToken + } + + async function listPaginated( + path: string, + mapItem: (raw: Record) => T, + ): Promise { + const accessToken = await getAccessToken() + const baseUrl = new URL(config.issuerUrl) + const apiRoot = `${baseUrl.origin}/api/v3/` + const items: T[] = [] + let page = 1 + let morePages = true + + while (morePages) { + const url = new URL(path, apiRoot) + url.searchParams.set('page', String(page)) + url.searchParams.set('page_size', '100') + const raw = await fetchJson( + url.toString(), + { + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + }, + path, + fetchImpl, + ) + const record = assertRecord(raw, path) + const results = record['results'] + if (!Array.isArray(results)) { + throw new SyncUnavailableError( + `Az Authentik API ${path} válaszában hiányzik a results lista.`, + ) + } + for (const item of results) { + items.push(mapItem(assertRecord(item, `${path} elem`))) + } + const next: unknown = record['pagination'] + const rawNext = + typeof next === 'object' && next !== null + ? (next as Record)['next'] + : undefined + const nextPage = typeof rawNext === 'number' ? rawNext : 0 + morePages = nextPage > page + if (morePages) { + page = nextPage + } + } + + return items + } + + function mapUser(raw: Record): AuthentikApiUser { + const groups = Array.isArray(raw['groups']) + ? raw['groups'].filter( + (group): group is string => typeof group === 'string', + ) + : [] + const attributes = + typeof raw['attributes'] === 'object' && raw['attributes'] !== null + ? (raw['attributes'] as Record) + : {} + return { + pk: Number(raw['pk']), + username: String(raw['username'] ?? ''), + name: String(raw['name'] ?? ''), + isActive: raw['is_active'] === true, + type: String(raw['type'] ?? ''), + avatarUrl: typeof raw['avatar'] === 'string' ? raw['avatar'] : null, + attributes, + groups, + } + } + + return { + async listUsers() { + return listPaginated( + 'core/users/?include_groups=true', + mapUser, + ) + }, + async listGroups() { + return listPaginated('core/groups/', (raw) => ({ + pk: String(raw['pk'] ?? ''), + name: String(raw['name'] ?? ''), + })) + }, + } +} diff --git a/src/server/members/map.ts b/src/server/members/map.ts new file mode 100644 index 0000000..5491fe2 --- /dev/null +++ b/src/server/members/map.ts @@ -0,0 +1,125 @@ +import type { + MembershipStatusKey, + OobConfig, + SemesterKey, +} from '#/server/config/oob-schema.ts' +import type { AuthentikApiUser } from './authentik-api.ts' + +export type MemberSyncStatus = 'ok' | 'error' + +export interface MappedMember { + /** Az Authentik sub értéke (user_id sub_mode esetén a pk). */ + sub: string + username: string + fullName: string + nickname: string | null + avatarUrl: string | null + membershipStatus: MembershipStatusKey + isLeadership: boolean + joinedYear: number | null + joinedSemester: SemesterKey | null + joinedSemesterRaw: string | null + introduction: string | null + syncStatus: MemberSyncStatus + syncError: string | null +} + +/** Rendszerfelhasználók, akik nem tartoznak a tagcache-be. */ +export function isSystemUser(user: AuthentikApiUser): boolean { + return ( + user.type !== 'internal' || + user.username.startsWith('ak-') || + user.username.startsWith('svc-') + ) +} + +export function mapMembershipStatus( + rawStatus: unknown, + config: OobConfig['authentik'], +): MembershipStatusKey | null { + if (typeof rawStatus !== 'string') { + return null + } + return config.attributes.membershipStatus.values[rawStatus.trim()] ?? null +} + +export function parseJoinedSemester( + rawValue: unknown, + config: OobConfig['authentik'], +): { year: number; semester: SemesterKey } | { year: null; semester: null } { + if (typeof rawValue !== 'string' || rawValue.trim() === '') { + return { year: null, semester: null } + } + for (const rule of config.attributes.joinedSemester.rules) { + const match = rule.pattern.exec(rawValue.trim()) + if (match) { + const year = Number(match[1]) + if (Number.isInteger(year)) { + return { year, semester: rule.semester } + } + } + } + return { year: null, semester: null } +} + +/** + * Egy Authentik felhasználó leképezése tagcache rekorddá. + * Ismeretlen vagy hiányzó tagsági státusz esetén syncStatus='error': + * az utolsó ismert adat megmaradhat, de publikus csoportba nem kerül. + */ +export function mapMember( + user: AuthentikApiUser, + groupNames: ReadonlySet, + config: OobConfig['authentik'], +): MappedMember | null { + if (isSystemUser(user)) { + return null + } + + const attributes: Record = user.attributes + const rawStatus = attributes[config.attributes.membershipStatus.attribute] + const membershipStatus = mapMembershipStatus(rawStatus, config) + + const joinedRawValue = attributes[config.attributes.joinedSemester.attribute] + const semester = parseJoinedSemester(joinedRawValue, config) + + const rawIntroduction = attributes[config.attributes.introduction] + const introduction = + typeof rawIntroduction === 'string' && rawIntroduction.trim() !== '' + ? rawIntroduction + : null + + const rawNickname = attributes[config.claims.nickname] + const nickname = + typeof rawNickname === 'string' && rawNickname.trim() !== '' + ? rawNickname + : null + + let syncError: string | null = null + if (membershipStatus === null) { + syncError = `Ismeretlen vagy hiányzó tagsági státusz: ${JSON.stringify( + rawStatus === undefined ? null : rawStatus, + )}` + } + + const isLeadership = groupNames.has(config.groups.vezetoseg) + + return { + sub: String(user.pk), + username: user.username, + fullName: user.name !== '' ? user.name : user.username, + nickname, + avatarUrl: user.avatarUrl, + membershipStatus: membershipStatus ?? 'studio_member', + isLeadership: isLeadership && membershipStatus !== null, + joinedYear: semester.year, + joinedSemester: semester.semester, + joinedSemesterRaw: + typeof joinedRawValue === 'string' && joinedRawValue.trim() !== '' + ? joinedRawValue.trim() + : null, + introduction, + syncStatus: membershipStatus === null ? 'error' : 'ok', + syncError, + } +} diff --git a/src/server/members/sync.ts b/src/server/members/sync.ts new file mode 100644 index 0000000..31f6cb7 --- /dev/null +++ b/src/server/members/sync.ts @@ -0,0 +1,321 @@ +import { eq } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' + +import type { Database } from '#/server/auth/session-store.ts' +import { getDefaultDb } from '#/server/auth/session-store.ts' +import { requireLeadership } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { getCachedOobConfig } from '#/server/config/load.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' +import { fetchDiscovery } from '#/server/auth/oidc.ts' +import { createAuthentikApi } from './authentik-api.ts' +import { mapMember } from './map.ts' +import type { MappedMember } from './map.ts' +import { auditLog, memberCache, memberSyncRuns } from '#/db/schema.ts' + +export type SyncTrigger = 'startup' | 'hourly' | 'manual' | 'test' + +export interface SyncRunResult { + trigger: SyncTrigger + status: 'ok' | 'error' + totalCount: number + changedCount: number + errorCount: number + startedAt: Date + finishedAt: Date + message: string | null +} + +export interface MemberSyncDeps { + db?: Database + clock?: Clock + fetchImpl?: typeof fetch + loadConfig?: () => OobConfig +} + +const SYNC_FIELDS = [ + 'username', + 'fullName', + 'nickname', + 'avatarUrl', + 'membershipStatus', + 'isLeadership', + 'joinedYear', + 'joinedSemester', + 'joinedSemesterRaw', + 'introduction', +] as const + +function memberDiffers( + existing: typeof memberCache.$inferSelect, + mapped: MappedMember, +): boolean { + return ( + existing.username !== mapped.username || + existing.fullName !== mapped.fullName || + (existing.nickname ?? null) !== mapped.nickname || + (existing.avatarUrl ?? null) !== mapped.avatarUrl || + existing.membershipStatus !== mapped.membershipStatus || + existing.isLeadership !== mapped.isLeadership || + (existing.joinedYear ?? null) !== mapped.joinedYear || + (existing.joinedSemester ?? null) !== mapped.joinedSemester || + (existing.joinedSemesterRaw ?? null) !== mapped.joinedSemesterRaw || + (existing.introduction ?? null) !== mapped.introduction + ) +} + +function snapshot( + member: typeof memberCache.$inferSelect | MappedMember, +): Record { + const source = member as Record + return Object.fromEntries( + SYNC_FIELDS.map((field) => [field, source[field] ?? null]), + ) +} + +async function writeAuditEntry( + tx: Parameters[0]>[0], + entry: { + action: string + entityId: string + before: Record | null + after: Record | null + at: Date + }, +): Promise { + await tx.insert(auditLog).values({ + actor: 'system', + entityType: 'member_cache', + entityId: entry.entityId, + action: entry.action, + beforeValue: entry.before, + afterValue: entry.after, + occurredAt: entry.at, + }) +} + +/** + * Tagcache szinkron: az Authentik felhasználóit beolvassa és a helyi, + * csak olvasható cache-be írja. + * + * Szabályok a specifikáció 8. fejezete alapján: + * - eltűnt tag utolsó ismert rekordja megmarad (soha nem töröl); + * - ismeretlen státuszú vagy formátumú profil hibás lesz, publikus csoportba nem kerül; + * - csak tényleges változásnál ír auditbejegyzést (`system` szereplővel); + * - minden futás rögzítésre kerül a member_sync_runs táblában. + */ +export async function runMemberSync( + trigger: SyncTrigger, + deps: MemberSyncDeps = {}, +): Promise { + const database = deps.db ?? (await getDefaultDb()) + const clock = deps.clock ?? systemClock + const loadConfig = deps.loadConfig ?? getCachedOobConfig + const startedAt = clock.now() + + try { + const config = loadConfig() + const discovery = await fetchDiscovery(config.authentik, { + fetchImpl: deps.fetchImpl, + }) + const api = createAuthentikApi(config.authentik, { + fetchImpl: deps.fetchImpl, + tokenEndpoint: discovery.tokenEndpoint, + }) + + const [users, groups] = await Promise.all([ + api.listUsers(), + api.listGroups(), + ]) + const groupNamesByPk = new Map( + groups.map((group) => [group.pk, group.name]), + ) + + const mappedMembers: MappedMember[] = [] + for (const user of users) { + const names = new Set( + user.groups + .map((pk) => groupNamesByPk.get(pk)) + .filter((name): name is string => name !== undefined), + ) + const mapped = mapMember(user, names, config.authentik) + if (mapped !== null) { + mappedMembers.push(mapped) + } + } + + let changedCount = 0 + let errorCount = 0 + + await database.transaction(async (tx) => { + for (const mapped of mappedMembers) { + const existingRows = await tx + .select() + .from(memberCache) + .where(eq(memberCache.sub, mapped.sub)) + .limit(1) + const existing = existingRows.at(0) + + if (mapped.syncStatus === 'error') { + errorCount += 1 + if (existing !== undefined) { + // Utolsó ismert adat megtartása, csak a hibajelzés frissül. + if (existing.syncStatus !== 'error') { + await tx + .update(memberCache) + .set({ + syncStatus: 'error', + lastSyncError: mapped.syncError, + lastSeenAt: startedAt, + }) + .where(eq(memberCache.sub, mapped.sub)) + await writeAuditEntry(tx, { + action: 'sync_error', + entityId: mapped.sub, + before: snapshot(existing), + after: null, + at: startedAt, + }) + changedCount += 1 + } + } + continue + } + + if (!existing) { + await tx.insert(memberCache).values({ + sub: mapped.sub, + username: mapped.username, + fullName: mapped.fullName, + nickname: mapped.nickname, + avatarUrl: mapped.avatarUrl, + membershipStatus: mapped.membershipStatus, + isLeadership: mapped.isLeadership, + joinedYear: mapped.joinedYear, + joinedSemester: mapped.joinedSemester, + joinedSemesterRaw: mapped.joinedSemesterRaw, + introduction: mapped.introduction, + syncStatus: 'ok', + lastSyncError: null, + lastSeenAt: startedAt, + updatedAt: startedAt, + }) + await writeAuditEntry(tx, { + action: 'create', + entityId: mapped.sub, + before: null, + after: snapshot(mapped), + at: startedAt, + }) + changedCount += 1 + continue + } + + if (memberDiffers(existing, mapped)) { + await tx + .update(memberCache) + .set({ + username: mapped.username, + fullName: mapped.fullName, + nickname: mapped.nickname, + avatarUrl: mapped.avatarUrl, + membershipStatus: mapped.membershipStatus, + isLeadership: mapped.isLeadership, + joinedYear: mapped.joinedYear, + joinedSemester: mapped.joinedSemester, + joinedSemesterRaw: mapped.joinedSemesterRaw, + introduction: mapped.introduction, + syncStatus: 'ok', + lastSyncError: null, + lastSeenAt: startedAt, + updatedAt: startedAt, + }) + .where(eq(memberCache.sub, mapped.sub)) + await writeAuditEntry(tx, { + action: 'update', + entityId: mapped.sub, + before: snapshot(existing), + after: snapshot(mapped), + at: startedAt, + }) + changedCount += 1 + continue + } + + // Változatlan: csak a látvány időpontja frissül, audit NEM készül. + await tx + .update(memberCache) + .set({ lastSeenAt: startedAt }) + .where(eq(memberCache.sub, mapped.sub)) + + if (existing.syncStatus === 'error') { + // Korábban hibás, most már érvényes profil: a javulás változás. + await tx + .update(memberCache) + .set({ syncStatus: 'ok', lastSyncError: null }) + .where(eq(memberCache.sub, mapped.sub)) + changedCount += 1 + } + } + }) + + const finishedAt = clock.now() + const result: SyncRunResult = { + trigger, + status: 'ok', + totalCount: mappedMembers.length, + changedCount, + errorCount, + startedAt, + finishedAt, + message: null, + } + await database.insert(memberSyncRuns).values({ + trigger, + status: 'ok', + startedAt, + finishedAt, + totalCount: result.totalCount, + changedCount, + errorCount, + message: null, + }) + return result + } catch (error) { + const finishedAt = clock.now() + const message = + error instanceof Error + ? error.message + : String(error ?? 'ismeretlen hiba') + await database.insert(memberSyncRuns).values({ + trigger, + status: 'error', + startedAt, + finishedAt, + totalCount: 0, + changedCount: 0, + errorCount: 0, + message, + }) + return { + trigger, + status: 'error', + totalCount: 0, + changedCount: 0, + errorCount: 0, + startedAt, + finishedAt, + message, + } + } +} + +/** Kézi szinkron indítása: csak vezetőségi tag jogosult rá. */ +export async function triggerManualMemberSync( + viewer: Viewer, + deps: MemberSyncDeps = {}, +): Promise { + requireLeadership(viewer) + return runMemberSync('manual', deps) +} diff --git a/src/server/shared/db-executor.ts b/src/server/shared/db-executor.ts new file mode 100644 index 0000000..325ab51 --- /dev/null +++ b/src/server/shared/db-executor.ts @@ -0,0 +1,11 @@ +import type { NodePgQueryResultHKT } from 'drizzle-orm/node-postgres' +import type { PgAsyncDatabase } from 'drizzle-orm/pg-core' + +/** + * Közös végrehajtó típus: a teljes adatbázis-kapcsolat ÉS a tranzakción belüli + * tx objektum egyaránt használható select/insert/update hívásokra. + */ +export type Executor = PgAsyncDatabase< + NodePgQueryResultHKT, + Record +> diff --git a/src/server/shared/slug.ts b/src/server/shared/slug.ts new file mode 100644 index 0000000..982ea0e --- /dev/null +++ b/src/server/shared/slug.ts @@ -0,0 +1,160 @@ +import { and, eq, ne } from 'drizzle-orm' +import type { Executor } from './db-executor.ts' +import { events, slugHistory, videos } from '#/db/schema.ts' + +export type SlugEntityType = 'video' | 'event' +export const SLUG_MAX_LENGTH = 200 + +const HUNGARIAN_ACCENTS: Record = { + á: 'a', + é: 'e', + í: 'i', + ó: 'o', + ö: 'o', + ő: 'o', + ú: 'u', + ü: 'u', + ű: 'u', + Á: 'a', + É: 'e', + Í: 'i', + Ó: 'o', + Ö: 'o', + Ő: 'o', + Ú: 'u', + Ü: 'u', + Ű: 'u', +} + +/** + * Slug képzés címből (spec 4.2): kisbetűs, ékezet nélküli, kötőjeles. + */ +export function slugify(title: string): string { + const folded = title.replace( + /[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, + (char) => HUNGARIAN_ACCENTS[char] ?? char, + ) + return folded + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, SLUG_MAX_LENGTH) + .replace(/-$/g, '') +} + +function contentTableFor(entityType: SlugEntityType) { + return entityType === 'video' ? videos : events +} + +/** + * Egyedi slug keresés: a tartalomtábla ÉS a slugtörténet egyaránt tiltja az + * ütközést — a véglegesen törölt entitások régi slugjai így örökre le vannak foglalva. + */ +export async function findFreeSlug( + executor: Executor, + entityType: SlugEntityType, + baseSlug: string, + options: { excludeEntityId?: string } = {}, +): Promise { + const table = contentTableFor(entityType) + const base = baseSlug === '' ? 'slug' : baseSlug + + const isTakenByContent = async (candidate: string): Promise => { + const conditions = [eq(table.slug, candidate)] + if (options.excludeEntityId !== undefined) { + conditions.push(ne(table.id, options.excludeEntityId)) + } + const rows = await executor + .select({ id: table.id }) + .from(table) + .where(and(...conditions)) + .limit(1) + return rows.length > 0 + } + + const isTakenByHistory = async (candidate: string): Promise => { + const rows = await executor + .select({ slug: slugHistory.slug }) + .from(slugHistory) + .where( + and( + eq(slugHistory.entityType, entityType), + eq(slugHistory.slug, candidate), + ), + ) + .limit(1) + return rows.length > 0 + } + + if (!(await isTakenByContent(base)) && !(await isTakenByHistory(base))) { + return base + } + + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const maxBaseLength = SLUG_MAX_LENGTH - String(suffix).length - 1 + const candidate = `${base.slice(0, maxBaseLength)}-${suffix}` + if ( + !(await isTakenByContent(candidate)) && + !(await isTakenByHistory(candidate)) + ) { + return candidate + } + } + throw new Error(`Nem található szabad slug a "${base}" alapra.`) +} + +/** + * Slug módosítása átirányítási előzménnyel: a régi slug a történetbe kerül, + * az új slug egyedisége a tartalom- és történetellenőrzéssel garantált. + */ +export async function renameSlugWithHistory( + executor: Executor, + params: { + entityType: SlugEntityType + entityId: string + currentSlug: string + newSlugBase: string + now: Date + }, +): Promise { + const newSlug = await findFreeSlug( + executor, + params.entityType, + slugify(params.newSlugBase), + { + excludeEntityId: params.entityId, + }, + ) + if (newSlug === params.currentSlug) { + return params.currentSlug + } + await executor.insert(slugHistory).values({ + entityType: params.entityType, + slug: params.currentSlug, + entityId: params.entityId, + createdAt: params.now, + }) + return newSlug +} + +/** Régi slug feloldása entitásazonosítóra (átirányításhoz). */ +export async function resolveSlugRedirect( + executor: Executor, + entityType: SlugEntityType, + oldSlug: string, +): Promise<{ entityId: string } | null> { + const rows = await executor + .select({ entityId: slugHistory.entityId }) + .from(slugHistory) + .where( + and( + eq(slugHistory.entityType, entityType), + eq(slugHistory.slug, oldSlug), + ), + ) + .limit(1) + return rows[0] ?? null +} diff --git a/src/server/shared/text.ts b/src/server/shared/text.ts new file mode 100644 index 0000000..3064f62 --- /dev/null +++ b/src/server/shared/text.ts @@ -0,0 +1,66 @@ +/** A specifikáció 4.3 fejezetében rögzített hosszkorlátok. */ +export const TEXT_LIMITS = { + title: 200, + slug: 200, + tagOrRole: 64, + description: 10_000, + guestsOrSongs: 5_000, + url: 2_048, +} as const + +export class TextValidationError extends Error { + readonly problems: string[] + + constructor(problems: string[]) { + super( + problems.length === 1 + ? (problems.at(0) ?? '') + : `Érvénytelen mezők:\n${problems.map((p) => ` - ${p}`).join('\n')}`, + ) + this.name = 'TextValidationError' + this.problems = problems + } +} + +/** + * Plain text validáció (spec 4.3): HTML/Markdown/rich text nem része a V0-nak. + * A szöveget változatlanul tároljuk, de ellenőrizzük a hosszot és hogy + * ne tartalmazzon vezérlőkaraktereket. + */ +export function validatePlainText( + fieldName: string, + value: string | null | undefined, + maxLength: number, + options: { required?: boolean } = {}, +): string | null { + if (value === null || value === undefined || value.trim() === '') { + if (options.required) { + throw new TextValidationError([`${fieldName}: kötelező mező.`]) + } + return null + } + if (value.length > maxLength) { + throw new TextValidationError([ + `${fieldName}: legfeljebb ${maxLength} karakter lehet (jelenlegi hossz: ${value.length}).`, + ]) + } + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) { + throw new TextValidationError([ + `${fieldName}: vezérlőkarakter nem megengedett.`, + ]) + } + return value +} + +export function validateRequiredText( + fieldName: string, + value: string | null | undefined, + maxLength: number, +): string { + if (value === null || value === undefined || value.trim() === '') { + throw new TextValidationError([`${fieldName}: kötelező mező.`]) + } + validatePlainText(fieldName, value, maxLength) + return value +} diff --git a/src/server/shared/write.ts b/src/server/shared/write.ts new file mode 100644 index 0000000..5cc348f --- /dev/null +++ b/src/server/shared/write.ts @@ -0,0 +1,149 @@ +import { and, eq } from 'drizzle-orm' +import { auditLog, events, videos } from '#/db/schema.ts' +import type { Clock } from '#/lib/clock.ts' +import type { Executor } from './db-executor.ts' + +export const SYSTEM_ACTOR = 'system' + +export class EntityNotFoundError extends Error { + constructor(entityType: string, entityId: string) { + super(`${entityType} nem található: ${entityId}`) + this.name = 'EntityNotFoundError' + } +} + +/** + * Elavult mentés blokkolása (spec 12.4): ha más módosította a rekordot, + * a második mentés konfliktust kap. Csendes „utolsó mentés nyer” nincs. + */ +export class StaleWriteError extends Error { + constructor(entityType: string) { + super( + `A ${entityType} időközben megváltozott (elavult mentés). Töltsd be az új állapotot, majd próbáld újra.`, + ) + this.name = 'StaleWriteError' + } +} + +function tableFor(entityType: 'video' | 'event') { + return entityType === 'video' ? videos : events +} + +/** Auditnapló bejegyzés írása (csak INSERT lehetséges, módosítani nem lehet). */ +export async function writeAudit( + executor: Executor, + entry: { + actor: string + entityType: string + entityId: string + action: string + before: Record | null + after: Record | null + occurredAt: Date + }, +): Promise { + await executor.insert(auditLog).values({ + actor: entry.actor, + entityType: entry.entityType, + entityId: entry.entityId, + action: entry.action, + beforeValue: entry.before, + afterValue: entry.after, + occurredAt: entry.occurredAt, + }) +} + +export interface OptimisticUpdateParams { + db: Executor + entityType: 'video' | 'event' + entityId: string + /** Az a verzió, amelyről a kliens kiindult; eltérés esetén StaleWriteError. */ + expectedVersion: number + changes: Record + actor: string + clock?: Clock +} + +/** + * Közös, optimista zárolásos frissítés tranzakciós auditbejegyzéssel. + * - SELECT ... FOR UPDATE zárolja a sort; + * - verzióeltérés esetén StaleWriteError; + * - sikeres mentésnél updatedAt/updatedBy/version frissül és audit készül + * előtte-utána érték párral; + * - `system` szereplőnél updatedBy NULL marad (a mező tagokra hivatkozik). + */ +export async function updateWithOptimisticLock( + params: OptimisticUpdateParams, +): Promise<{ version: number }> { + const clock = params.clock + const now = clock ? clock.now() : new Date() + const table = tableFor(params.entityType) + + return params.db.transaction(async (tx) => { + const lockedRows = await tx + .select() + .from(table) + .where(eq(table.id, params.entityId)) + .for('update') + .limit(1) + + if (lockedRows.length === 0) { + throw new EntityNotFoundError(params.entityType, params.entityId) + } + const row = lockedRows[0] as unknown as { + version: number + createdBy: string | null + createdAt: Date + } + + if (row.version !== params.expectedVersion) { + throw new StaleWriteError(params.entityType) + } + + const before = snapshotRow(row) + const nextVersion = row.version + 1 + const updatedBy = params.actor === SYSTEM_ACTOR ? null : params.actor + + await tx + .update(table) + .set({ + ...params.changes, + version: nextVersion, + updatedAt: now, + updatedBy, + }) + .where(and(eq(table.id, params.entityId), eq(table.version, row.version))) + + const after = snapshotRow({ + ...(row as Record), + ...params.changes, + version: nextVersion, + updatedBy, + updatedAt: now, + }) + + await writeAudit(tx, { + actor: params.actor, + entityType: params.entityType, + entityId: params.entityId, + action: 'update', + before, + after, + occurredAt: now, + }) + + return { version: nextVersion } + }) +} + +function snapshotRow(row: Record): Record { + const systemKeys = new Set(['trashedAt', 'trashedBy']) + return Object.fromEntries( + Object.entries(row) + .filter(([key]) => !systemKeys.has(key)) + .map(([key, value]) => [ + key, + value instanceof Date ? value.toISOString() : value, + ]), + ) +} diff --git a/src/server/videos/visibility.ts b/src/server/videos/visibility.ts new file mode 100644 index 0000000..69802f7 --- /dev/null +++ b/src/server/videos/visibility.ts @@ -0,0 +1,34 @@ +import { sql } from 'drizzle-orm' +import type { SQL } from 'drizzle-orm' +import { videos } from '#/db/schema.ts' +import { atLeast } from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' + +/** + * A videóláthatóság SQL-feltétele a néző szintje szerint. + * Ez az egyetlen hely, ahol a láthatósági szabály él: minden videólekérdezés + * ennek a feltételnek a belsejében fut, így tiltott videó metaadata sem + * kerülhet válaszba (a keresés és a lista ugyanezt használja). + */ +export function visibleVideoCondition(viewer: Viewer): SQL { + if (atLeast(viewer, 'member')) { + return sql`true` + } + if (viewer.level === 'schonherz') { + return sql`(${videos.visibility} in ('public', 'schonherz'))` + } + return sql`(${videos.visibility} = 'public')` +} + +export function canSeeVideo(viewer: Viewer, visibility: string): boolean { + if (atLeast(viewer, 'member')) { + return true + } + if (visibility === 'public') { + return true + } + if (viewer.level === 'schonherz') { + return visibility === 'schonherz' + } + return false +} diff --git a/tests/helpers/oob-config.ts b/tests/helpers/oob-config.ts index 434c613..c5b1ae4 100644 --- a/tests/helpers/oob-config.ts +++ b/tests/helpers/oob-config.ts @@ -9,6 +9,10 @@ export interface RawOobConfig { clientId: string clientSecret: string scopes: string[] + sync: { + username: string + token: string + } claims: Record groups: Record attributes: { @@ -43,6 +47,10 @@ export function buildRawOobConfig( clientId: 'bss-stack-local', clientSecret: 'local-test-secret-not-for-production', scopes: ['openid', 'profile', 'email'], + sync: { + username: 'svc-bss-sync', + token: 'local-test-sync-token-not-for-production', + }, claims: { sub: 'sub', username: 'preferred_username', diff --git a/tests/helpers/test-db.ts b/tests/helpers/test-db.ts index 559576e..cc558d6 100644 --- a/tests/helpers/test-db.ts +++ b/tests/helpers/test-db.ts @@ -1,4 +1,8 @@ -import { Client } from 'pg' +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { Client, Pool } from 'pg' +import { drizzle } from 'drizzle-orm/node-postgres' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' export interface TestDatabase { databaseName: string @@ -6,7 +10,7 @@ export interface TestDatabase { drop: () => Promise } -function adminUrl(): string { +export function adminUrl(): string { const url = process.env.TEST_DATABASE_URL if (!url) { throw new Error( @@ -46,3 +50,42 @@ export async function createTestDatabase( throw error } } + +/** A drizzle/ könyvtár migrációit lefuttatja tiszta adatbázison. */ +export async function applyDrizzleMigrations(executor: { + query: Client['query'] +}): Promise { + const drizzleDir = join(process.cwd(), 'drizzle') + const folders = readdirSync(drizzleDir) + .filter((name) => /^\d{14}_/.test(name)) + .sort() + + for (const folder of folders) { + const sql = readFileSync(join(drizzleDir, folder, 'migration.sql'), 'utf-8') + const statements = sql + .split('--> statement-breakpoint') + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0) + + for (const statement of statements) { + await executor.query(statement) + } + } +} + +export interface MigratedTestDatabase { + database: TestDatabase + pool: Pool + db: NodePgDatabase> +} + +/** Izolált, migrált adatbázist hoz létre integrációs tesztekhez. */ +export async function createMigratedTestDatabase( + prefix = 'bss_it', +): Promise { + const database = await createTestDatabase(prefix) + const pool = new Pool({ connectionString: database.connectionString }) + await applyDrizzleMigrations(pool) + const db = drizzle({ client: pool }) + return { database, pool, db } +} diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts new file mode 100644 index 0000000..e1996d3 --- /dev/null +++ b/tests/integration/auth-flow.test.ts @@ -0,0 +1,512 @@ +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { FakeClock } from '#/lib/clock.ts' +import { createAuthRouteHandlers } from '#/server/api/auth-routes.ts' +import type { AuthRouteHandlers } from '#/server/api/auth-routes.ts' +import { verifyAndReadOidcTxn } from '#/server/auth/session-cookies.ts' +import { + createAuthSession, + findActiveAuthSession, +} from '#/server/auth/session-store.ts' +import { clearDiscoveryCache } from '#/server/auth/oidc.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' +import { installFetchMock } from '../helpers/http-mock.ts' +import type { FetchMock } from '../helpers/http-mock.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const testConfig: OobConfig = validateOobConfig(buildRawOobConfig()) + +const ISSUER = 'https://authentik.local/application/o/bss' + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +const databases: Array<{ drop: () => Promise }> = [] +const clientCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (clientCleanups.length > 0) { + await clientCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const activeMocks: FetchMock[] = [] + +afterEach(() => { + while (activeMocks.length > 0) { + activeMocks.pop()!.restore() + } +}) + +function mockOidcEndpoints(idTokenFactory: () => string): void { + activeMocks.push( + installFetchMock([ + { + urlPattern: /\.well-known\/openid-configuration/, + respond: () => ({ + status: 200, + body: { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + }, + }), + }, + { + method: 'POST', + urlPattern: /\/token/, + respond: () => ({ + status: 200, + body: { + access_token: 'access-token-ertek', + id_token: idTokenFactory(), + }, + }), + }, + ]), + ) +} + +function mockFailingDiscovery(): void { + activeMocks.push( + installFetchMock([ + { + urlPattern: /\.well-known\/openid-configuration/, + respond: () => { + throw new Error('connection refused') + }, + }, + ]), + ) +} + +function mockFailingTokenEndpoint(): void { + activeMocks.push( + installFetchMock([ + { + method: 'POST', + urlPattern: /\/token/, + respond: () => { + throw new Error('connection refused') + }, + }, + ]), + ) +} + +function base64urlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf-8').toString('base64url') +} + +function makeIdToken(claims: Record): string { + return `${base64urlJson({ alg: 'RS256', typ: 'JWT' })}.${base64urlJson(claims)}.sig` +} + +function validClaims(clock: FakeClock, extra: Record) { + return { + iss: ISSUER, + aud: testConfig.authentik.clientId, + exp: Math.floor(clock.now().getTime() / 1000) + 600, + ...extra, + } +} + +async function setupFlow(): Promise<{ + handlers: AuthRouteHandlers + clock: FakeClock + db: NodePgDatabase> +}> { + clearDiscoveryCache() + const migrated = await createMigratedTestDatabase('bss_auth') + databases.push(migrated.database) + clientCleanups.push(() => migrated.pool.end()) + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + const handlers = createAuthRouteHandlers({ + loadConfig: () => testConfig, + db: migrated.db, + clock, + }) + return { handlers, clock, db: migrated.db } +} + +async function runLogin( + handlers: AuthRouteHandlers, + returnTo?: string, +): Promise { + const url = new URL('http://127.0.0.1:3000/api/auth/login') + if (returnTo !== undefined) { + url.searchParams.set('returnTo', returnTo) + } + return handlers.login(new Request(url)) +} + +function firstCookieValue(response: Response, name: string): string { + const cookie = response.headers + .getSetCookie() + .find((candidate) => candidate.startsWith(`${name}=`)) + if (!cookie) { + throw new Error(`Nem található ${name} cookie a válaszban`) + } + return cookie.split(';')[0].split('=').slice(1).join('=') +} + +interface LoginTxn { + txnCookie: string + state: string + nonce: string + returnTo: string +} + +/** Bejelentkezést indít; a mockot előbb telepíteni kell. */ +async function loginAndParseTxn( + handlers: AuthRouteHandlers, + returnTo?: string, +): Promise { + const loginResponse = await runLogin(handlers, returnTo) + expect(loginResponse.status).toBe(302) + const txnCookieValue = firstCookieValue(loginResponse, 'bss_oidc_txn') + const txnJson = verifyAndReadOidcTxn(txnCookieValue, testConfig.authentik) + expect(txnJson).not.toBeNull() + const parsed = JSON.parse(txnJson!) as Record + return { + txnCookie: `bss_oidc_txn=${txnCookieValue}`, + state: parsed['state'] as string, + nonce: parsed['nonce'] as string, + returnTo: parsed['returnTo'] as string, + } +} + +describe.skipIf(!hasTestDatabase)('BSS-006: OIDC belépés és session', () => { + it('login átirányít az authorization végpontra PKCE paraméterekkel', async () => { + const { handlers } = await setupFlow() + mockOidcEndpoints(() => makeIdToken({})) + + const response = await runLogin(handlers, '/videos') + + expect(response.status).toBe(302) + const authorizeUrl = new URL(response.headers.get('location')!) + expect(authorizeUrl.toString().startsWith(`${ISSUER}/authorize`)).toBe(true) + expect(authorizeUrl.searchParams.get('response_type')).toBe('code') + expect(authorizeUrl.searchParams.get('client_id')).toBe( + testConfig.authentik.clientId, + ) + expect(authorizeUrl.searchParams.get('redirect_uri')).toBe( + 'http://127.0.0.1:3000/api/auth/callback', + ) + expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authorizeUrl.searchParams.get('code_challenge')).not.toBe('') + const setCookie = response.headers + .getSetCookie() + .find((cookie) => cookie.startsWith('bss_oidc_txn='))! + expect(setCookie).toContain('HttpOnly') + expect(setCookie).toContain('SameSite=Lax') + expect(firstCookieValue(response, 'bss_oidc_txn')).not.toBe('') + }) + + it('névtelen felhasználó belépés után visszajut az eredeti oldalra és sessiont kap', async () => { + const { handlers, clock, db } = await setupFlow() + let issuedNonce = '' + mockOidcEndpoints(() => + makeIdToken( + validClaims(clock, { + nonce: issuedNonce, + sub: 'sub-tag-1', + preferred_username: 'tag-dev', + name: 'Teszt BSS Tag', + groups: ['bss-tag'], + }), + ), + ) + + const { txnCookie, state, nonce, returnTo } = await loginAndParseTxn( + handlers, + '/videos', + ) + expect(returnTo).toBe('/videos') + issuedNonce = nonce + + const callbackResponse = await handlers.callback( + new Request( + `http://127.0.0.1:3000/api/auth/callback?code=engedely-kod&state=${encodeURIComponent(state)}`, + { headers: { cookie: txnCookie } }, + ), + ) + + expect(callbackResponse.status).toBe(302) + expect(callbackResponse.headers.get('location')).toBe('/videos') + + const setCookies = callbackResponse.headers.getSetCookie() + const sessionSetCookie = setCookies.find((cookie) => + cookie.startsWith('bss_session='), + )! + expect(sessionSetCookie).toContain('HttpOnly') + expect(sessionSetCookie).toContain('SameSite=Lax') + expect(sessionSetCookie).toContain(`Max-Age=${60 * 60}`) + const clearedTxn = setCookies.find((cookie) => + cookie.startsWith('bss_oidc_txn='), + )! + expect(clearedTxn).toContain('Max-Age=0') + + const sessionToken = firstCookieValue(callbackResponse, 'bss_session') + const session = await findActiveAuthSession(sessionToken, { clock, db }) + expect(session).not.toBeNull() + expect(session!.memberSub).toBe('sub-tag-1') + expect(session!.username).toBe('tag-dev') + expect(session!.groups).toEqual(['bss-tag']) + expect(session!.accessToken).toBe('access-token-ertek') + + const body = await callbackResponse.text() + expect(body).not.toContain('access-token-ertek') + expect(body).not.toContain(sessionToken) + }) + + it('state eltérés esetén magyar hibaoldalt ad és nem jön létre session', async () => { + const { handlers, clock, db } = await setupFlow() + mockOidcEndpoints(() => makeIdToken(validClaims(clock, {}))) + + const { txnCookie } = await loginAndParseTxn(handlers) + + const callbackResponse = await handlers.callback( + new Request( + 'http://127.0.0.1:3000/api/auth/callback?code=kod&state=rossz-state', + { headers: { cookie: txnCookie } }, + ), + ) + + expect(callbackResponse.status).toBe(400) + const text = await callbackResponse.text() + expect(text).toContain('nem egyezik') + expect(await findActiveAuthSession('', { db })).toBeNull() + }) + + it('cookie nélküli vagy lejárt tranzakciójú callback magyar hibát ad', async () => { + const { handlers } = await setupFlow() + + const response = await handlers.callback( + new Request('http://127.0.0.1:3000/api/auth/callback?code=kod&state=x'), + ) + + expect(response.status).toBe(400) + expect(await response.text()).toContain('lejárt vagy érvénytelen') + }) + + it('id_token nonce eltérése esetén nem jön létre session', async () => { + const { handlers, clock, db } = await setupFlow() + mockOidcEndpoints(() => + makeIdToken( + validClaims(clock, { + nonce: 'nem-ez-a-nonce', + sub: 'sub-x', + preferred_username: 'valaki', + }), + ), + ) + + const { txnCookie, state } = await loginAndParseTxn(handlers) + + const response = await handlers.callback( + new Request( + `http://127.0.0.1:3000/api/auth/callback?code=kod&state=${encodeURIComponent(state)}`, + { headers: { cookie: txnCookie } }, + ), + ) + + expect(response.status).toBe(502) + expect(await findActiveAuthSession('nincs-token', { clock, db })).toBeNull() + }) + + it('nyitott átirányítás nélkül csak relatív returnTo él', async () => { + const { handlers } = await setupFlow() + mockOidcEndpoints(() => makeIdToken({})) + + for (const evil of [ + 'https://rossz.example.com', + '//rossz.example.com', + 'javascript:alert(1)', + ]) { + const response = await runLogin(handlers, evil) + expect(response.status).toBe(302) + const txnCookieValue = firstCookieValue(response, 'bss_oidc_txn') + const txn = JSON.parse( + verifyAndReadOidcTxn(txnCookieValue, testConfig.authentik)!, + ) as Record + expect(txn['returnTo']).toBe('/') + } + }) +}) + +describe.skipIf(!hasTestDatabase)( + 'BSS-006: Authentik-kiesési viselkedés', + () => { + it('login közben elérhetetlen Authentik esetén 503-as magyar oldal jön', async () => { + const { handlers } = await setupFlow() + mockFailingDiscovery() + + const response = await runLogin(handlers) + + expect(response.status).toBe(503) + const text = await response.text() + expect(text).toContain('Bejelentkezés nem elérhető') + expect(text).toContain('A publikus oldalak működnek') + }) + + it('token csere közben elérhetetlen Authentik esetén 503-as magyar oldal jön', async () => { + const { handlers } = await setupFlow() + mockOidcEndpoints(() => makeIdToken({})) + const { txnCookie, state } = await loginAndParseTxn(handlers) + + // A discovery cache-ből jön; a token hívást külön hibára állítjuk. + activeMocks[activeMocks.length - 1].restore() + activeMocks.pop() + mockFailingTokenEndpoint() + + const response = await handlers.callback( + new Request( + `http://127.0.0.1:3000/api/auth/callback?code=kod&state=${encodeURIComponent(state)}`, + { headers: { cookie: txnCookie } }, + ), + ) + + expect(response.status).toBe(503) + }) + }, +) + +describe.skipIf(!hasTestDatabase)( + 'BSS-006/007: /api/auth/me állapot lekérdezés', + () => { + it('névtelen kérésre anonymous viewer-t ad és nem hívja az Authentiket', async () => { + const { handlers } = await setupFlow() + const mock = installFetchMock([]) + + const response = await handlers.me( + new Request('http://127.0.0.1:3000/api/auth/me'), + ) + + expect(response.status).toBe(200) + const body = JSON.parse(await response.text()) as Record + expect(body['level']).toBe('anonymous') + expect(body['sub']).toBeNull() + expect(mock.calls()).toHaveLength(0) + mock.restore() + }) + + it('session cookie-val a viewer szintjét adja (tag → member)', async () => { + const { handlers, clock, db } = await setupFlow() + const created = await createAuthSession( + { + memberSub: 'sub-tag-1', + username: 'tag-dev', + groups: [testConfig.authentik.groups.tag], + accessToken: null, + }, + { clock, db }, + ) + + const response = await handlers.me( + new Request('http://127.0.0.1:3000/api/auth/me', { + headers: { cookie: `bss_session=${created.token}` }, + }), + ) + + expect(response.status).toBe(200) + const body = JSON.parse(await response.text()) as Record + expect(body['level']).toBe('member') + expect(body['sub']).toBe('sub-tag-1') + expect(body['username']).toBe('tag-dev') + + clock.advanceMinutes(61) + const expired = await handlers.me( + new Request('http://127.0.0.1:3000/api/auth/me', { + headers: { cookie: `bss_session=${created.token}` }, + }), + ) + const expiredBody = JSON.parse(await expired.text()) as Record< + string, + unknown + > + expect(expiredBody['level']).toBe('anonymous') + }) + }, +) + +describe.skipIf(!hasTestDatabase)( + 'BSS-006: session lejárat és kijelentkezés', + () => { + it('lejárt sessionnel nincs érvényes belépés (legfeljebb egy órás szerepfrissítés)', async () => { + const { clock, db } = await setupFlow() + const created = await createAuthSession( + { + memberSub: 'sub-y', + username: 'valaki', + groups: ['bss-tag'], + accessToken: null, + }, + { clock, db }, + ) + + clock.advanceMinutes(59) + expect( + await findActiveAuthSession(created.token, { clock, db }), + ).not.toBeNull() + clock.advanceMinutes(2) + expect( + await findActiveAuthSession(created.token, { clock, db }), + ).toBeNull() + }) + + it('POST logout törli a sessiont és üríti a cookiet', async () => { + const { handlers, clock, db } = await setupFlow() + const created = await createAuthSession( + { + memberSub: 'sub-z', + username: 'valaki', + groups: [], + accessToken: null, + }, + { clock, db }, + ) + + const response = await handlers.logout( + new Request('http://127.0.0.1:3000/api/auth/logout', { + method: 'POST', + headers: { cookie: `bss_session=${created.token}` }, + }), + ) + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toBe('/') + const cookies = response.headers.getSetCookie() + expect(cookies.some((cookie) => cookie.startsWith('bss_session='))).toBe( + true, + ) + expect( + cookies.find((cookie) => cookie.startsWith('bss_session='))!, + ).toContain('Max-Age=0') + expect( + await findActiveAuthSession(created.token, { clock, db }), + ).toBeNull() + }) + + it('idegen originű POST logout 403-at kap; GET logout nem engedélyezett', async () => { + const { handlers } = await setupFlow() + + const foreign = await handlers.logout( + new Request('http://127.0.0.1:3000/api/auth/logout', { + method: 'POST', + headers: { origin: 'https://rossz.example.com' }, + }), + ) + expect(foreign.status).toBe(403) + + const getLogout = await handlers.logout( + new Request('http://127.0.0.1:3000/api/auth/logout', { method: 'GET' }), + ) + expect(getLogout.status).toBe(405) + }) + }, +) diff --git a/tests/integration/auth-policy.test.ts b/tests/integration/auth-policy.test.ts new file mode 100644 index 0000000..4e7893b --- /dev/null +++ b/tests/integration/auth-policy.test.ts @@ -0,0 +1,171 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { and, eq, inArray } from 'drizzle-orm' +import { videos } from '#/db/schema.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { viewerFromIdentity } from '#/server/auth/viewer.ts' +import { + visibleVideoCondition, + canSeeVideo, +} from '#/server/videos/visibility.ts' +import type { OobConfig } from '#/server/config/oob-schema.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const testConfig: OobConfig = validateOobConfig(buildRawOobConfig()) + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +const anonymous: Viewer = { level: 'anonymous', sub: null, username: null } +const schonherz: Viewer = viewerFromIdentity( + { sub: 's1', username: 'schonherz-dev', groups: ['schonherz-dev'] }, + testConfig.authentik, +) +const member: Viewer = viewerFromIdentity( + { sub: 't1', username: 'tag-dev', groups: ['tag-dev'] }, + testConfig.authentik, +) + +async function setupSeededDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_vis') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + + const rows = [ + ['pub-published', 'public', 'published'], + ['sch-published', 'schonherz', 'published'], + ['bss-published', 'bss', 'published'], + ['pub-draft', 'public', 'draft'], + ['pub-archived', 'public', 'archived'], + ['pub-trash', 'public', 'trash'], + ] as const + + for (const [slug, visibility, status] of rows) { + await migrated.db.insert(videos).values({ + slug, + title: `Titkos cím: ${slug}`, + visibility, + status, + publishedAt: status === 'published' ? new Date() : null, + trashedAt: status === 'trash' ? new Date() : null, + }) + } + + return migrated.db +} + +async function visibleSlugsFor( + db: NodePgDatabase>, + viewer: Viewer, +): Promise { + const rows = await db + .select({ slug: videos.slug }) + .from(videos) + .where(and(eq(videos.status, 'published'), visibleVideoCondition(viewer))) + return rows.map((row) => row.slug).sort() +} + +describe.skipIf(!hasTestDatabase)( + 'BSS-007: videóláthatóság SQL-feltételei (valódi adatbázison)', + () => { + it('névtelen néző csak publikus, publikált videót lát', async () => { + const db = await setupSeededDb() + const slugs = await visibleSlugsFor(db, anonymous) + expect(slugs).toEqual(['pub-published']) + }) + + it('schönherzes a publikus és a schönherzes videót látja', async () => { + const db = await setupSeededDb() + const slugs = await visibleSlugsFor(db, schonherz) + expect(slugs).toEqual(['pub-published', 'sch-published']) + }) + + it('tag és vezetőség mindhárom láthatóságot eléri', async () => { + const db = await setupSeededDb() + expect(await visibleSlugsFor(db, member)).toEqual([ + 'bss-published', + 'pub-published', + 'sch-published', + ]) + const leadership: Viewer = viewerFromIdentity( + { + sub: 'v1', + username: 'vezetoseg-dev', + groups: ['tag-dev', 'vezetoseg-dev'], + }, + testConfig.authentik, + ) + expect(await visibleSlugsFor(db, leadership)).toEqual([ + 'bss-published', + 'pub-published', + 'sch-published', + ]) + }) + + it('piszkozat, archivált és lomtárban lévő videó senkinek nem jelenik meg listában', async () => { + const db = await setupSeededDb() + for (const viewer of [anonymous, schonherz, member]) { + const slugs = await visibleSlugsFor(db, viewer) + expect(slugs).not.toContain('pub-draft') + expect(slugs).not.toContain('pub-archived') + expect(slugs).not.toContain('pub-trash') + } + }) + + it('tiltott videó metaadata nem kerül a válaszba', async () => { + const db = await setupSeededDb() + const result = await db + .select() + .from(videos) + .where(visibleVideoCondition(anonymous)) + const serialized = JSON.stringify(result) + expect(serialized).not.toContain('Titkos cím: sch-published') + expect(serialized).not.toContain('Titkos cím: bss-published') + }) + + it('inArray-alapú lekérdezésben is szűr a feltétel', async () => { + const db = await setupSeededDb() + const rows = await db + .select({ slug: videos.slug }) + .from(videos) + .where( + and( + inArray(videos.slug, [ + 'pub-published', + 'sch-published', + 'bss-published', + ]), + visibleVideoCondition(schonherz), + ), + ) + expect(rows.map((row) => row.slug).sort()).toEqual([ + 'pub-published', + 'sch-published', + ]) + }) + }, +) + +describe('canSeeVideo memóriabeli szabály', () => { + it('a négy szinthez konzisztens eredményt ad', () => { + expect(canSeeVideo(anonymous, 'public')).toBe(true) + expect(canSeeVideo(anonymous, 'schonherz')).toBe(false) + expect(canSeeVideo(anonymous, 'bss')).toBe(false) + expect(canSeeVideo(schonherz, 'schonherz')).toBe(true) + expect(canSeeVideo(schonherz, 'bss')).toBe(false) + expect(canSeeVideo(member, 'bss')).toBe(true) + }) +}) diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts new file mode 100644 index 0000000..c97c6c0 --- /dev/null +++ b/tests/integration/health.test.ts @@ -0,0 +1,69 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { livenessResponse, readinessResponse } from '#/server/jobs/health.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +describe.skipIf(!hasTestDatabase)('BSS-010: health végpontok', () => { + it('/health/live mindig ok, adatbázis nélkül is', () => { + const response = livenessResponse() + expect(response.status).toBe(200) + return response.text().then((body) => { + expect(JSON.parse(body)).toEqual({ status: 'ok' }) + }) + }) + + it('/health/ready migrált adatbázissal ok', async () => { + const migrated = await createMigratedTestDatabase('bss_health') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + + const response = await readinessResponse({ + db: migrated.db, + }) + expect(response.status).toBe(200) + const body = JSON.parse(await response.text()) as Record + expect(body['status']).toBe('ok') + expect(body['database']).toBe('ok') + expect(body['migrations']).toBe('ok') + }) + + it('elérhetetlen adatbázisnál 503, titkok és részletes hiba nélkül', async () => { + const response = await readinessResponse({ + db: undefined as unknown as NodePgDatabase>, + }) + expect(response.status).toBe(503) + const body = await response.text() + // Nem szivárogtat kapcsolati stringet, jelszót vagy veremnyomot: + expect(body).not.toContain('postgres://') + expect(body).not.toContain('password') + expect(body).not.toContain('DATABASE_URL') + expect(body).not.toContain('at ') + }) + + it('migrálatlan adatbázisnál migrations=missing és 503', async () => { + const migrated = await createMigratedTestDatabase('bss_health_raw') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.pool.query('DROP TABLE IF EXISTS auth_sessions CASCADE') + + const response = await readinessResponse({ db: migrated.db }) + expect(response.status).toBe(503) + const body = JSON.parse(await response.text()) as Record + expect(body['database']).toBe('ok') + expect(body['migrations']).toBe('missing') + }) +}) diff --git a/tests/integration/member-sync.test.ts b/tests/integration/member-sync.test.ts new file mode 100644 index 0000000..04ac26b --- /dev/null +++ b/tests/integration/member-sync.test.ts @@ -0,0 +1,427 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { and, eq } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type { Pool } from 'pg' +import { auditLog } from '#/db/schema.ts' +import { FakeClock } from '#/lib/clock.ts' +import { + runMemberSync, + triggerManualMemberSync, +} from '#/server/members/sync.ts' +import { anonymousViewer } from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { installFetchMock } from '../helpers/http-mock.ts' +import type { FetchMock, MockRoute } from '../helpers/http-mock.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const testConfig = validateOobConfig(buildRawOobConfig()) +const GROUP = testConfig.authentik.groups +const ISSUER = 'https://authentik.local/application/o/bss' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +interface ApiUserFixture { + pk: number + username: string + name: string + isActive?: boolean + type?: string + attributes?: Record + groups?: string[] +} + +function apiUser(fixture: ApiUserFixture): Record { + return { + pk: fixture.pk, + username: fixture.username, + name: fixture.name, + is_active: fixture.isActive ?? true, + type: fixture.type ?? 'internal', + avatar: null, + attributes: fixture.attributes ?? {}, + groups: fixture.groups ?? [], + } +} + +const GROUP_UUIDS: Record = { + [GROUP.schonherz]: '11111111-1111-4111-8111-111111111111', + [GROUP.tag]: '22222222-2222-4222-8222-222222222222', + [GROUP.vezetoseg]: '33333333-3333-4333-8333-333333333333', +} + +function defaultUsers(): ApiUserFixture[] { + return [ + { + pk: 36, + username: 'tag-dev', + name: 'Teszt BSS Tag', + attributes: { + bss_status: 'stúdiós', + bss_csatlakozas: '2023 ősz', + bss_bemutatkozas: 'Bemutatkozás szöveg.', + nickname: 'Tagocska', + }, + groups: [GROUP_UUIDS[GROUP.tag]], + }, + { + pk: 37, + username: 'vezetoseg-dev', + name: 'Teszt Vezetőségi Tag', + attributes: { bss_status: 'stúdiós', bss_csatlakozas: '2021 tavasz' }, + groups: [GROUP_UUIDS[GROUP.tag], GROUP_UUIDS[GROUP.vezetoseg]], + }, + ] +} + +/** Az Authentik API-t mockolja (token + lapozott users/groups). */ +function mockAuthentik(users: ApiUserFixture[]): FetchMock { + const routes: MockRoute[] = [ + { + urlPattern: /\.well-known\/openid-configuration/, + respond: () => ({ + status: 200, + body: { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + }, + }), + }, + { + method: 'POST', + urlPattern: /\/token/, + respond: () => ({ + status: 200, + body: { + access_token: 'sync-access-token', + scope: 'goauthentik.io/api', + }, + }), + }, + { + method: 'GET', + urlPattern: /core\/groups/, + respond: () => ({ + status: 200, + body: { + pagination: { next: 0 }, + results: Object.entries(GROUP_UUIDS).map(([name, pk]) => ({ + pk, + name, + })), + }, + }), + }, + { + method: 'GET', + urlPattern: /core\/users/, + respond: () => ({ + status: 200, + body: { + pagination: { next: 0 }, + results: users.map(apiUser), + }, + }), + }, + ] + return installFetchMock(routes) +} + +async function setupSync(): Promise<{ + db: NodePgDatabase> + clock: FakeClock +}> { + const migrated = await createMigratedTestDatabase('bss_sync') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + return { db: migrated.db, clock: new FakeClock('2026-06-01T10:00:00.000Z') } +} + +describe.skipIf(!hasTestDatabase)( + 'BSS-008: Authentik tagcache és szinkron', + () => { + it('első szinkron feltölti a cache-t érvényes adatokkal', async () => { + const { db, clock } = await setupSync() + const mock = mockAuthentik(defaultUsers()) + + const result = await runMemberSync('startup', { + db, + clock, + loadConfig: () => testConfig, + }) + + expect(result.status).toBe('ok') + expect(result.totalCount).toBe(2) + expect(result.changedCount).toBe(2) + expect(result.errorCount).toBe(0) + + const client = (db as unknown as { $client: Pool }).$client + const rows = await client.query<{ + sub: string + username: string + membership_status: string + is_leadership: boolean + joined_year: number + joined_semester: string + introduction: string | null + sync_status: string + }>('select * from member_cache order by username') + + expect(rows.rows.map((row) => row.username)).toEqual([ + 'tag-dev', + 'vezetoseg-dev', + ]) + const tagRow = rows.rows.find((row) => row.username === 'tag-dev')! + expect(tagRow.sub).toBe('36') + expect(tagRow.membership_status).toBe('studio_member') + expect(tagRow.is_leadership).toBe(false) + expect(tagRow.joined_year).toBe(2023) + expect(tagRow.joined_semester).toBe('autumn') + expect(tagRow.introduction).toBe('Bemutatkozás szöveg.') + const leadershipRow = rows.rows.find( + (row) => row.username === 'vezetoseg-dev', + )! + expect(leadershipRow.is_leadership).toBe(true) + + // A szinkron az Authentik API-t hívta (nem a publikus oldal): + expect( + mock.calls().some((call) => call.url.includes('/api/v3/core/users')), + ).toBe(true) + mock.restore() + }) + + it('szerepváltozás frissíti a rekordot és változatlan szinkron nem ír auditot', async () => { + const { db, clock } = await setupSync() + const mock = mockAuthentik(defaultUsers()) + await runMemberSync('hourly', { db, clock, loadConfig: () => testConfig }) + + // Második, változatlan futás: + await runMemberSync('hourly', { db, clock, loadConfig: () => testConfig }) + const unchangedAudits = await db + .select() + .from(auditLog) + .where( + and( + eq(auditLog.actor, 'system'), + eq(auditLog.entityType, 'member_cache'), + ), + ) + // Csak az első (létrehozó) futás írt auditot: + expect(unchangedAudits).toHaveLength(2) + + // Szerepváltozás: tag-dev bekerül a vezetőségi csoportba. + mock.restore() + const changedUsers = defaultUsers().map((user) => + user.username === 'tag-dev' + ? { + ...user, + groups: [GROUP_UUIDS[GROUP.tag], GROUP_UUIDS[GROUP.vezetoseg]], + } + : user, + ) + const secondMock = mockAuthentik(changedUsers) + const secondResult = await runMemberSync('hourly', { + db, + clock, + loadConfig: () => testConfig, + }) + expect(secondResult.changedCount).toBe(1) + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.action, 'update')) + expect(audits).toHaveLength(1) + const updateAudit = audits[0] + expect(updateAudit.beforeValue).toMatchObject({ isLeadership: false }) + expect(updateAudit.afterValue).toMatchObject({ isLeadership: true }) + secondMock.restore() + }) + + it('eltűnt tag utolsó ismert rekordja megmarad', async () => { + const { db, clock } = await setupSync() + const firstMock = mockAuthentik(defaultUsers()) + await runMemberSync('manual', { db, clock, loadConfig: () => testConfig }) + firstMock.restore() + + const withoutVezetoseg = defaultUsers().filter( + (u) => u.username !== 'vezetoseg-dev', + ) + const secondMock = mockAuthentik(withoutVezetoseg) + await runMemberSync('manual', { db, clock, loadConfig: () => testConfig }) + secondMock.restore() + + // A táblában maradnia kell mindkét sornak; ellenőrzés nyers SQL-lel: + const client = (db as unknown as { $client: Pool }).$client + const remaining = await client.query<{ username: string }>( + 'select username from member_cache order by username', + ) + expect(remaining.rows.map((row) => row.username)).toEqual([ + 'tag-dev', + 'vezetoseg-dev', + ]) + }) + + it('hibás profil nem kerül publikus csoportba, de javuláskor visszatér', async () => { + const { db, clock } = await setupSync() + + // Ismeretlen státuszú új tag: nem jön létre sor. + const withInvalid = [ + ...defaultUsers(), + { + pk: 50, + username: 'uj-tag', + name: 'Új Tag Érvénytelen Státusszal', + attributes: { bss_status: 'nem létező státusz' }, + groups: [GROUP_UUIDS[GROUP.tag]], + }, + ] + const firstMock = mockAuthentik(withInvalid) + const firstResult = await runMemberSync('manual', { + db, + clock, + loadConfig: () => testConfig, + }) + expect(firstResult.errorCount).toBe(1) + expect(firstResult.totalCount).toBe(3) + firstMock.restore() + + const client = (db as unknown as { $client: Pool }).$client + const invalidRow = await client.query( + 'select sub from member_cache where username = $1', + ['uj-tag'], + ) + expect(invalidRow.rowCount).toBe(0) + + // Korábban jó profil romlik el: utolsó adat megmarad, hibás jelölést kap. + const corrupted = defaultUsers().map((user) => + user.username === 'tag-dev' + ? { ...user, attributes: { bss_status: 'megváltozott-e' } } + : user, + ) + const secondMock = mockAuthentik(corrupted) + const secondResult = await runMemberSync('manual', { + db, + clock, + loadConfig: () => testConfig, + }) + expect(secondResult.changedCount).toBe(1) + secondMock.restore() + + const kept = await client.query<{ + sync_status: string + full_name: string + membership_status: string + }>( + 'select sync_status, full_name, membership_status from member_cache where username=$1', + ['tag-dev'], + ) + expect(kept.rows[0]?.sync_status).toBe('error') + expect(kept.rows[0]?.full_name).toBe('Teszt BSS Tag') + expect(kept.rows[0]?.membership_status).toBe('studio_member') + + // Javuláskor visszatér az ok állapot. + const thirdMock = mockAuthentik(defaultUsers()) + const thirdResult = await runMemberSync('manual', { + db, + clock, + loadConfig: () => testConfig, + }) + expect(thirdResult.changedCount).toBeGreaterThanOrEqual(1) + thirdMock.restore() + const healed = await client.query<{ sync_status: string }>( + 'select sync_status from member_cache where username=$1', + ['tag-dev'], + ) + expect(healed.rows[0]?.sync_status).toBe('ok') + }) + + it('kézi szinkront csak vezetőség indíthat', async () => { + const { db, clock } = await setupSync() + const memberViewer: Viewer = { + level: 'member', + sub: '36', + username: 'tag-dev', + } + + const mock = mockAuthentik(defaultUsers()) + await expect( + triggerManualMemberSync(memberViewer, { + db, + clock, + loadConfig: () => testConfig, + }), + ).rejects.toThrow(ForbiddenError) + + const leadershipViewer: Viewer = { + level: 'leadership', + sub: '37', + username: 'vezetoseg-dev', + } + const result = await triggerManualMemberSync(leadershipViewer, { + db, + clock, + loadConfig: () => testConfig, + }) + expect(result.trigger).toBe('manual') + expect(result.status).toBe('ok') + void anonymousViewer + mock.restore() + }) + + it('Authentik-kiesésnél a futás hibás státusszal zárul, a meglévő cache érintetlen marad', async () => { + const { db, clock } = await setupSync() + const seedMock = mockAuthentik(defaultUsers()) + await runMemberSync('startup', { + db, + clock, + loadConfig: () => testConfig, + }) + seedMock.restore() + + const failingMock = installFetchMock([ + { + method: 'POST', + urlPattern: /\/token/, + respond: () => { + throw new Error('connection refused') + }, + }, + ]) + const failed = await runMemberSync('hourly', { + db, + clock, + loadConfig: () => testConfig, + }) + expect(failed.status).toBe('error') + expect(failed.message).toContain('Authentik') + failingMock.restore() + + const client = (db as unknown as { $client: Pool }).$client + const stillThere = await client.query( + 'select count(*)::int as c from member_cache', + ) + expect(stillThere.rows[0]?.c).toBe(2) + + const runs = await client.query<{ trigger: string; status: string }>( + 'select trigger, status from member_sync_runs order by id', + ) + expect(runs.rows.map((row) => row.status)).toEqual(['ok', 'error']) + }) + }, +) diff --git a/tests/integration/schema.migration.test.ts b/tests/integration/schema.migration.test.ts index f685901..f11d000 100644 --- a/tests/integration/schema.migration.test.ts +++ b/tests/integration/schema.migration.test.ts @@ -1,10 +1,13 @@ import { afterAll, describe, expect, it } from 'vitest' import { Client } from 'pg' -import { readdirSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { createTestDatabase } from '../helpers/test-db.ts' +import { + createTestDatabase, + applyDrizzleMigrations, +} from '../helpers/test-db.ts' import type { TestDatabase } from '../helpers/test-db.ts' +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + const databases: TestDatabase[] = [] afterAll(async () => { @@ -13,25 +16,6 @@ afterAll(async () => { } }) -async function applyMigrations(client: Client): Promise { - const drizzleDir = join(process.cwd(), 'drizzle') - const folders = readdirSync(drizzleDir) - .filter((name) => /^\d{14}_/.test(name)) - .sort() - - for (const folder of folders) { - const sql = readFileSync(join(drizzleDir, folder, 'migration.sql'), 'utf-8') - const statements = sql - .split('--> statement-breakpoint') - .map((statement) => statement.trim()) - .filter((statement) => statement.length > 0) - - for (const statement of statements) { - await client.query(statement) - } - } -} - async function createMigratedDatabase(): Promise<{ database: TestDatabase client: Client @@ -41,12 +25,12 @@ async function createMigratedDatabase(): Promise<{ const client = new Client({ connectionString: database.connectionString }) await client.connect() - await applyMigrations(client) + await applyDrizzleMigrations(client) return { database, client } } -describe('új adatbázisséma és migrációs alap', () => { +describe.skipIf(!hasTestDatabase)('új adatbázisséma és migrációs alap', () => { it('tiszta adatbázison a migráció lefut és minden tábla létezik', async () => { const { client } = await createMigratedDatabase() try { diff --git a/tests/integration/shared-write.test.ts b/tests/integration/shared-write.test.ts new file mode 100644 index 0000000..d8ec7a2 --- /dev/null +++ b/tests/integration/shared-write.test.ts @@ -0,0 +1,338 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type { Pool } from 'pg' +import { eq } from 'drizzle-orm' +import { + findFreeSlug, + renameSlugWithHistory, + resolveSlugRedirect, + slugify, +} from '#/server/shared/slug.ts' +import { + EntityNotFoundError, + StaleWriteError, + SYSTEM_ACTOR, + updateWithOptimisticLock, +} from '#/server/shared/write.ts' +import { + TEXT_LIMITS, + TextValidationError, + validatePlainText, + validateRequiredText, +} from '#/server/shared/text.ts' +import { FakeClock } from '#/lib/clock.ts' +import { auditLog, events, memberCache, videos } from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +const clock = new FakeClock('2026-06-01T10:00:00.000Z') + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_write') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + + // A módosító aktorok valós tagokra hivatkoznak (FK). + await migrated.db.insert(memberCache).values([ + { + sub: '36', + username: 'tag-dev', + fullName: 'Teszt BSS Tag', + membershipStatus: 'studio_member', + }, + { + sub: '37', + username: 'vezetoseg-dev', + fullName: 'Teszt Vezetőségi Tag', + membershipStatus: 'studio_member', + }, + ]) + + return migrated.db +} + +async function seedVideo( + db: NodePgDatabase>, + overrides: Partial = {}, +): Promise { + const rows = await db + .insert(videos) + .values({ slug: 'seed-video', title: 'Seed videó', ...overrides }) + .returning() + return rows[0] +} + +describe.skipIf(!hasTestDatabase)( + 'BSS-009: slug képzés és ütközéskezelés', + () => { + it('slugify ékezeteket old fel, kisbetűsít és kötőjelez', () => { + expect(slugify('Szentimentális Éjszaka – Főpróba!')).toBe( + 'szentimentalis-ejszaka-foproba', + ) + expect(slugify(' TÖBB szó és & jelek ')).toBe('tobb-szo-es-jelek') + expect(slugify('')).toBe('') + }) + + it('ütközésnél számozott utótag készül', async () => { + const db = await setupDb() + await seedVideo(db, { slug: 'koncert' }) + + const free = await findFreeSlug(db, 'video', 'koncert') + expect(free).toBe('koncert-2') + + await db.insert(videos).values({ slug: 'koncert-2', title: 'második' }) + expect(await findFreeSlug(db, 'video', 'koncert')).toBe('koncert-3') + }) + + it('a véglegesen törölt entitások slugja a történetben lefoglalt marad', async () => { + const db = await setupDb() + // A történetbe bekerült slug már nem használható újra (törölt entitás után sem): + await db.insert(videos).values({ slug: 'masik-video', title: 'x' }) + await renameSlugWithHistory(db, { + entityType: 'video', + entityId: ( + await db.select().from(videos).where(eq(videos.slug, 'masik-video')) + )[0].id, + currentSlug: 'masik-video', + newSlugBase: 'uj-nev', + now: clock.now(), + }) + expect(await findFreeSlug(db, 'video', 'masik-video')).not.toBe( + 'masik-video', + ) + }) + + it('átnevezés után a régi slug átirányításként feloldható', async () => { + const db = await setupDb() + await db.insert(events).values({ + slug: 'regi-esemeny', + title: 'Esemény', + startDate: '2026-05-01', + }) + const eventRow = ( + await db.select().from(events).where(eq(events.slug, 'regi-esemeny')) + )[0] + + const newSlug = await renameSlugWithHistory(db, { + entityType: 'event', + entityId: eventRow.id, + currentSlug: 'regi-esemeny', + newSlugBase: 'Uj Esemény Név!', + now: clock.now(), + }) + expect(newSlug).toBe('uj-esemeny-nev') + + const redirect = await resolveSlugRedirect(db, 'event', 'regi-esemeny') + expect(redirect?.entityId).toBe(eventRow.id) + expect(await resolveSlugRedirect(db, 'event', 'soha-nem-volt')).toBeNull() + }) + + it('entitástípusonként külön él a slugtér', async () => { + const db = await setupDb() + await seedVideo(db, { slug: 'azonos-nev' }) + // ugyanaz a slug eseménynél szabad: + expect(await findFreeSlug(db, 'event', 'azonos-nev')).toBe('azonos-nev') + }) + }, +) + +describe.skipIf(!hasTestDatabase)('BSS-009: optimista zárolás és audit', () => { + it('elavult mentés konfliktust kap; a győztes mentés auditot kap', async () => { + const db = await setupDb() + const videoRow = await seedVideo(db) + + const firstUpdate = updateWithOptimisticLock({ + db, + entityType: 'video', + entityId: videoRow.id, + expectedVersion: videoRow.version, + changes: { description: 'első mentés' }, + actor: '36', + clock, + }) + await expect(firstUpdate).resolves.toMatchObject({ version: 2 }) + + // Másik szerkesztő elavult verzióval ment: + await expect( + updateWithOptimisticLock({ + db, + entityType: 'video', + entityId: videoRow.id, + expectedVersion: videoRow.version, + changes: { description: 'elavult mentés' }, + actor: '37', + clock, + }), + ).rejects.toBeInstanceOf(StaleWriteError) + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, videoRow.id)) + expect(audits).toHaveLength(1) + const audit = audits.at(0) + expect(audit).toBeDefined() + if (audit === undefined) return + expect(audit.actor).toBe('36') + expect(audit.beforeValue).toMatchObject({ description: null }) + expect(audit.afterValue).toMatchObject({ + description: 'első mentés', + version: 2, + }) + }) + + it('updatedBy csak valós tag esetén töltődik; system szereplőnél NULL', async () => { + const db = await setupDb() + const videoRow = await seedVideo(db) + + await updateWithOptimisticLock({ + db, + entityType: 'video', + entityId: videoRow.id, + expectedVersion: 1, + changes: { title: 'Rendszer által módosított cím' }, + actor: SYSTEM_ACTOR, + clock, + }) + const afterSystem = ( + await db.select().from(videos).where(eq(videos.id, videoRow.id)) + )[0] + expect(afterSystem.updatedBy).toBeNull() + expect(afterSystem.version).toBe(2) + + await updateWithOptimisticLock({ + db, + entityType: 'video', + entityId: videoRow.id, + expectedVersion: 2, + changes: { title: 'Tag által módosított cím' }, + actor: '36', + clock, + }) + const afterMember = ( + await db.select().from(videos).where(eq(videos.id, videoRow.id)) + )[0] + expect(afterMember.updatedBy).toBe('36') + }) + + it('nem létező entitásnál érthető hiba jön', async () => { + const db = await setupDb() + await expect( + updateWithOptimisticLock({ + db, + entityType: 'video', + entityId: '00000000-0000-4000-8000-000000000000', + expectedVersion: 1, + changes: {}, + actor: SYSTEM_ACTOR, + clock, + }), + ).rejects.toBeInstanceOf(EntityNotFoundError) + }) + + it('sikertelen tranzakció nem hagy auditot vagy félkész adatot', async () => { + const db = await setupDb() + const beforeCount = await countAudits(db) + const videoRow = await seedVideo(db) + + await expect( + db.transaction(async (tx) => { + await updateWithOptimisticLock.call(undefined, { + // tx-et adunk át db-ként: a segéd belső transaction-e egymásba ágyazódik + db: tx, + entityType: 'video', + entityId: videoRow.id, + expectedVersion: videoRow.version, + changes: { title: 'tranzakcióban mentett cím' }, + actor: '36', + clock, + }) + throw new Error('szándékos hiba a tranzakcióban') + }), + ).rejects.toThrow('szándékos hiba') + + const rowAfter = ( + await db.select().from(videos).where(eq(videos.id, videoRow.id)) + )[0] + expect(rowAfter.title).toBe('Seed videó') + expect(rowAfter.version).toBe(videoRow.version) + expect(await countAudits(db)).toBe(beforeCount) + }) + + it('az auditnapló DB-szinten nem módosítható és nem törölhető', async () => { + const db = await setupDb() + const client = (db as unknown as { $client: Pool }).$client + await client.query( + "insert into audit_log (actor, entity_type, entity_id, action) values ('system','teszt','e1','update')", + ) + await expect( + client.query("delete from audit_log where entity_type='teszt'"), + ).rejects.toThrow(/auditnapló/) + await expect( + client.query( + "update audit_log set action='tampered' where entity_type='teszt'", + ), + ).rejects.toThrow(/auditnapló/) + }) +}) + +async function countAudits( + db: NodePgDatabase>, +): Promise { + const result = await (db as unknown as { $client: Pool }).$client.query<{ + c: number + }>('select count(*)::int as c from audit_log') + return result.rows[0]?.c ?? 0 +} + +describe('BSS-009: plain text és hosszvalidáció', () => { + it('a korlátok a specifikáció szerintiek', () => { + expect(TEXT_LIMITS.title).toBe(200) + expect(TEXT_LIMITS.slug).toBe(200) + expect(TEXT_LIMITS.tagOrRole).toBe(64) + expect(TEXT_LIMITS.description).toBe(10_000) + expect(TEXT_LIMITS.guestsOrSongs).toBe(5_000) + expect(TEXT_LIMITS.url).toBe(2_048) + }) + + it('túllépés és kötelező mező magyar hibával blokkol', () => { + expect(() => validateRequiredText('Cím', '', TEXT_LIMITS.title)).toThrow( + TextValidationError, + ) + expect(() => + validatePlainText( + 'Leírás', + 'x'.repeat(TEXT_LIMITS.description + 1), + TEXT_LIMITS.description, + ), + ).toThrow(/legfeljebb/) + }) + + it('opcionális üres mező nullát ad', () => { + expect( + validatePlainText('Zene', null, TEXT_LIMITS.guestsOrSongs), + ).toBeNull() + expect( + validatePlainText('Zene', ' ', TEXT_LIMITS.guestsOrSongs), + ).toBeNull() + }) + + it('vezérlőkarakter nem megengedett', () => { + expect(() => + validatePlainText('Leírás', 'rossz\u0000szöveg', TEXT_LIMITS.description), + ).toThrow(/vezérlőkarakter/) + }) +}) diff --git a/tests/unit/auth-oidc.test.ts b/tests/unit/auth-oidc.test.ts new file mode 100644 index 0000000..0f14d5b --- /dev/null +++ b/tests/unit/auth-oidc.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from 'vitest' +import { FakeClock } from '#/lib/clock.ts' +import { + buildAuthorizationUrl, + clearDiscoveryCache, + decodeJwtPayload, + exchangeCodeForTokens, + extractIdentityFromClaims, + fetchDiscovery, + OidcProtocolError, + OidcUnavailableError, + pkceChallenge, + safeEquals, + validateIdTokenClaims, +} from '#/server/auth/oidc.ts' +import type { LoginTransactionData } from '#/server/auth/oidc.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' + +const config = validateOobConfig(buildRawOobConfig()) + +const discovery = { + issuer: config.authentik.issuerUrl.replace(/\/$/, ''), + authorization_endpoint: `${config.authentik.issuerUrl}/authorize`, + token_endpoint: `${config.authentik.issuerUrl}/token/`, +} + +const parsedDiscovery = { + issuer: discovery.issuer, + authorizationEndpoint: discovery.authorization_endpoint, + tokenEndpoint: discovery.token_endpoint, +} + +function base64urlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf-8').toString('base64url') +} + +function makeIdToken(claims: Record): string { + const header = base64urlJson({ alg: 'RS256', typ: 'JWT' }) + return `${header}.${base64urlJson(claims)}.alairasresz` +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('PKCE', () => { + it('az S256 challenge a verifier SHA-256 base64url alakja', () => { + // RFC 7636 appendix B vektor + expect(pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe( + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ) + }) +}) + +describe('discovery lekérdezés', () => { + it('a well-known végpontról tölti be és validálja a dokumentumot', async () => { + clearDiscoveryCache() + let requestedUrl = '' + const fetchMock = (async (url: RequestInfo | URL) => { + requestedUrl = String(url) + return jsonResponse(200, discovery) + }) as typeof fetch + + const result = await fetchDiscovery(config.authentik, { + fetchImpl: fetchMock, + }) + + expect(requestedUrl).toBe( + `${discovery.issuer}/.well-known/openid-configuration`, + ) + expect(result.authorizationEndpoint).toBe(discovery.authorization_endpoint) + expect(result.tokenEndpoint).toBe(discovery.token_endpoint) + }) + + it('azonos issuerhez cache-ből szolgálja ki, az óra szerint lejárva újratölt', async () => { + clearDiscoveryCache() + let callCount = 0 + const fetchMock = (async () => { + callCount += 1 + return jsonResponse(200, discovery) + }) as typeof fetch + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + + await fetchDiscovery(config.authentik, { fetchImpl: fetchMock, clock }) + await fetchDiscovery(config.authentik, { fetchImpl: fetchMock, clock }) + expect(callCount).toBe(1) + + clock.advanceHours(2) + await fetchDiscovery(config.authentik, { fetchImpl: fetchMock, clock }) + expect(callCount).toBe(2) + clearDiscoveryCache() + }) + + it('eltérő issuer esetén protokollhibát dob', async () => { + clearDiscoveryCache() + const fetchMock = (async () => + jsonResponse(200, { + ...discovery, + issuer: 'https://rossz.example.com/application/o/mas/', + })) as typeof fetch + + await expect( + fetchDiscovery(config.authentik, { fetchImpl: fetchMock }), + ).rejects.toThrow(OidcProtocolError) + clearDiscoveryCache() + }) + + it('hiányzó token endpoint esetén protokollhibát dob', async () => { + clearDiscoveryCache() + const fetchMock = (async () => + jsonResponse(200, { + issuer: discovery.issuer, + authorization_endpoint: discovery.authorization_endpoint, + })) as typeof fetch + + await expect( + fetchDiscovery(config.authentik, { fetchImpl: fetchMock }), + ).rejects.toThrow(/token_endpoint/) + clearDiscoveryCache() + }) + + it('nem elérhető Authentik esetén magyar hibával jelzi a kiesést', async () => { + clearDiscoveryCache() + const fetchMock = (async () => { + throw new Error('connection refused') + }) as typeof fetch + + try { + await fetchDiscovery(config.authentik, { fetchImpl: fetchMock }) + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(OidcUnavailableError) + expect((error as Error).message).toContain('Authentik nem elérhető') + } + clearDiscoveryCache() + }) +}) + +describe('authorization URL', () => { + it('tartalmazza az OIDC Authorization Code + PKCE paramétereket', () => { + const transaction: Pick< + LoginTransactionData, + 'state' | 'codeVerifier' | 'nonce' + > = { + state: 'allapot-123', + codeVerifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + nonce: 'nonce-123', + } + + const url = new URL( + buildAuthorizationUrl( + parsedDiscovery, + config.authentik, + 'http://127.0.0.1:3000/api/auth/callback', + transaction, + ), + ) + + expect( + url.toString().startsWith(parsedDiscovery.authorizationEndpoint), + ).toBe(true) + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('client_id')).toBe(config.authentik.clientId) + expect(url.searchParams.get('redirect_uri')).toBe( + 'http://127.0.0.1:3000/api/auth/callback', + ) + expect(url.searchParams.get('scope')).toBe( + config.authentik.scopes.join(' '), + ) + expect(url.searchParams.get('state')).toBe('allapot-123') + expect(url.searchParams.get('nonce')).toBe('nonce-123') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('code_challenge')).toBe( + pkceChallenge(transaction.codeVerifier), + ) + }) +}) + +describe('token csere', () => { + it('a form törzsben küldi a titkot és a code_verifiert, soha nem URL-ben', async () => { + let capturedBody = '' + let capturedAuthorizationHeader: string | null = null + const fetchMock = (async (_url: RequestInfo | URL, init?: RequestInit) => { + capturedBody = String(init?.body ?? '') + capturedAuthorizationHeader = new Headers(init?.headers).get( + 'authorization', + ) + return jsonResponse(200, { access_token: 'at', id_token: 'it' }) + }) as typeof fetch + + await exchangeCodeForTokens( + parsedDiscovery, + config.authentik, + 'http://127.0.0.1:3000/api/auth/callback', + 'kod-123', + 'verifier-123', + { fetchImpl: fetchMock }, + ) + + const params = new URLSearchParams(capturedBody) + expect(params.get('grant_type')).toBe('authorization_code') + expect(params.get('code')).toBe('kod-123') + expect(params.get('code_verifier')).toBe('verifier-123') + expect(params.get('client_id')).toBe(config.authentik.clientId) + expect(params.get('client_secret')).toBe(config.authentik.clientSecret) + expect(capturedAuthorizationHeader).toBeNull() + }) + + it('hibás válasz esetén elérhetetlenségi hibát dob', async () => { + const fetchMock = (async () => + jsonResponse(400, { error: 'invalid_grant' })) as typeof fetch + + await expect( + exchangeCodeForTokens( + parsedDiscovery, + config.authentik, + 'http://x/callback', + 'kod', + 'verifier', + { fetchImpl: fetchMock }, + ), + ).rejects.toThrow(OidcUnavailableError) + }) + + it('id_token nélküli válasz esetén protokolli hibát dob', async () => { + const fetchMock = (async () => + jsonResponse(200, { access_token: 'at' })) as typeof fetch + + await expect( + exchangeCodeForTokens( + parsedDiscovery, + config.authentik, + 'http://x/callback', + 'kod', + 'verifier', + { fetchImpl: fetchMock }, + ), + ).rejects.toThrow(/id_token/) + }) +}) + +describe('id_token validáció', () => { + const baseClaims = (nowSeconds: number) => ({ + iss: discovery.issuer, + aud: config.authentik.clientId, + exp: nowSeconds + 300, + nonce: 'n', + sub: 'sub-1', + }) + + it('érvényes claimset elfogad', () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + validateIdTokenClaims(baseClaims(clock.now().getTime() / 1000), { + issuer: discovery.issuer, + clientId: config.authentik.clientId, + nonce: 'n', + clock, + }) + }) + + it('aud lista egyik elemének egyeznie kell', () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + const claims = { + ...baseClaims(clock.now().getTime() / 1000), + aud: ['masik', config.authentik.clientId], + } + validateIdTokenClaims(claims, { + issuer: discovery.issuer, + clientId: config.authentik.clientId, + nonce: 'n', + clock, + }) + }) + + it('eltérő issuer, audience, nonce és lejárat esetén hibát dob', () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + const nowSeconds = clock.now().getTime() / 1000 + const expected = { + issuer: discovery.issuer, + clientId: config.authentik.clientId, + nonce: 'n', + clock, + } + + expect(() => + validateIdTokenClaims( + { ...baseClaims(nowSeconds), iss: 'https://mas' }, + expected, + ), + ).toThrow(/issuer/) + expect(() => + validateIdTokenClaims( + { ...baseClaims(nowSeconds), aud: 'mas' }, + expected, + ), + ).toThrow(/audience/) + expect(() => + validateIdTokenClaims( + { ...baseClaims(nowSeconds), nonce: 'mas' }, + expected, + ), + ).toThrow(/nonce/) + expect(() => + validateIdTokenClaims({ ...baseClaims(nowSeconds - 3600) }, expected), + ).toThrow(/lejárt/) + expect(() => + validateIdTokenClaims( + { iss: discovery.issuer, aud: config.authentik.clientId }, + expected, + ), + ).toThrow(/exp/) + }) +}) + +describe('JWT payload dekódolás', () => { + it('érvényes JWT payload-t visszaad', () => { + const claims = decodeJwtPayload(makeIdToken({ sub: 'sub-1' })) + expect(claims['sub']).toBe('sub-1') + }) + + it('rossz formátum esetén hibát dob', () => { + expect(() => decodeJwtPayload('nem-jwt')).toThrow(OidcProtocolError) + }) +}) + +describe('identity kinyerés', () => { + it('a config alapján képezi le a claimeket és szűri a csoportokat', () => { + const identity = extractIdentityFromClaims( + { + sub: 'sub-1', + preferred_username: 'tag-dev', + name: 'Teszt BSS Tag', + nickname: 'Tagocska', + picture: null, + groups: ['bss-tag', 'bss-vezetoseg', 42, ''], + }, + config.authentik, + ) + + expect(identity.sub).toBe('sub-1') + expect(identity.username).toBe('tag-dev') + expect(identity.fullName).toBe('Teszt BSS Tag') + expect(identity.nickname).toBe('Tagocska') + expect(identity.avatarUrl).toBeNull() + expect(identity.groups).toEqual(['bss-tag', 'bss-vezetoseg']) + }) + + it('hiányzó sub vagy felhasználónév esetén nem talál ki identitást', () => { + expect(() => extractIdentityFromClaims({}, config.authentik)).toThrow(/sub/) + expect(() => + extractIdentityFromClaims({ sub: 'sub-1' }, config.authentik), + ).toThrow(/felhasználónév/) + }) +}) + +describe('safeEquals', () => { + it('csak pontosan egyező szövegeket tart egyezőnek', () => { + expect(safeEquals('abc', 'abc')).toBe(true) + expect(safeEquals('abc', 'abd')).toBe(false) + expect(safeEquals('abc', 'abcd')).toBe(false) + expect(safeEquals('', '')).toBe(true) + }) +}) diff --git a/tests/unit/auth-policy.test.ts b/tests/unit/auth-policy.test.ts new file mode 100644 index 0000000..afdc243 --- /dev/null +++ b/tests/unit/auth-policy.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + anonymousViewer, + atLeast, + viewerFromIdentity, +} from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { can, isLeadership, isAdminAreaAllowed } from '#/server/auth/policy.ts' +import { + AuthRequiredError, + ForbiddenError, + requireAdmin, + requireLeadership, +} from '#/server/auth/guards.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' + +const config = validateOobConfig(buildRawOobConfig()) + +const anonymous: Viewer = anonymousViewer() +const schonherz: Viewer = viewerFromIdentity( + { sub: 's1', username: 'schonherz-dev', groups: ['schonherz-dev'] }, + config.authentik, +) +const member: Viewer = viewerFromIdentity( + { sub: 't1', username: 'tag-dev', groups: ['tag-dev'] }, + config.authentik, +) +// A vezetőség a tag csoporttal EGYÜTT ad vezetőségi jogot (spec 3.1). +const leadership: Viewer = viewerFromIdentity( + { + sub: 'v1', + username: 'vezetoseg-dev', + groups: ['tag-dev', 'vezetoseg-dev'], + }, + config.authentik, +) +// Csak vezetőségi csoport, tag nélkül: NINCS adminjog. +const leadershipWithoutMember: Viewer = viewerFromIdentity( + { sub: 'v2', username: 'elokuldott', groups: ['vezetoseg-dev'] }, + config.authentik, +) + +describe('nézői szint felismerése csoportokból', () => { + it('a négy szint helyesen feloldható', () => { + expect(anonymous.level).toBe('anonymous') + expect(schonherz.level).toBe('schonherz') + expect(member.level).toBe('member') + expect(leadership.level).toBe('leadership') + expect(leadershipWithoutMember.level).toBe('anonymous') + }) + + it('a vezetőségi jog magában foglalja a tagjogot', () => { + for (const capability of Object.values(can)) { + if (capability(member)) { + expect(capability(leadership)).toBe(true) + } + } + }) +}) + +describe('admin jogosultsági mátrix (specifikáció 3.2)', () => { + const matrix = [ + ['videó/esemény létrehozás', can.createOrEditContent, true, true], + ['archiválás', can.archiveContent, true, true], + ['lomtárba helyezés', can.trashVideo, true, true], + ['lomtár megtekintése', can.viewTrash, true, true], + ['visszaállítás', can.restoreVideo, false, true], + ['esemény végleges törlése', can.permanentlyDeleteEvent, false, true], + ['meglévő címke hozzárendelése', can.assignExistingTagToVideo, true, true], + ['címkekatalógus kezelése', can.manageTagCatalog, false, true], + ['stábszerepek kezelése', can.manageStaffRoles, false, true], + ['stáblista egy videón', can.manageVideoStaffList, true, true], + ['live/kiemelés/Rólunk kezelés', can.manageHomepageSettings, false, true], + ['tagdiagnosztika', can.viewMemberDiagnostics, false, true], + ['auditnapló', can.viewAuditLog, false, true], + ] as const + + it.each(matrix)( + '%s: tag=%p, vezetőség=%p', + (_label, rule, memberAllowed, leadershipAllowed) => { + expect(rule(member)).toBe(memberAllowed) + expect(rule(leadership)).toBe(leadershipAllowed) + // Névtelen és schönherzes soha nem kap adminjogot. + expect(rule(anonymous)).toBe(false) + expect(rule(schonherz)).toBe(false) + }, + ) + + it('tag nem kezelhet címkekatalógust, live-ot vagy auditot', () => { + expect(can.manageTagCatalog(member)).toBe(false) + expect(can.manageHomepageSettings(member)).toBe(false) + expect(can.viewAuditLog(member)).toBe(false) + }) + + it('az adminterület bármely eleméhez legalább tagság kell', () => { + expect(isAdminAreaAllowed(anonymous)).toBe(false) + expect(isAdminAreaAllowed(schonherz)).toBe(false) + expect(isAdminAreaAllowed(member)).toBe(true) + expect(isAdminAreaAllowed(leadership)).toBe(true) + }) +}) + +describe('guard szabályok (403 / login irányítás)', () => { + it('névtelen adminterületen loginra irányít megtartott returnTo-val', () => { + try { + requireAdmin(anonymous, '/videos?page=2') + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(AuthRequiredError) + expect((error as AuthRequiredError).loginUrl).toBe( + `/api/auth/login?returnTo=${encodeURIComponent('/videos?page=2')}`, + ) + } + }) + + it('bejelentkezett, de jogosulatlan (schönherzes) ForbiddenError-t kap', () => { + expect(() => requireAdmin(schonherz, '/')).toThrow(ForbiddenError) + }) + + it('tag beléphet az adminterületre; vezetőségi tag is', () => { + expect(() => requireAdmin(member, '/')).not.toThrow() + expect(() => requireAdmin(leadership, '/')).not.toThrow() + }) + + it('vezetőségi művelethez tag nem fér hozzá', () => { + expect(() => requireLeadership(member)).toThrow(ForbiddenError) + expect(() => requireLeadership(leadership)).not.toThrow() + try { + requireLeadership(anonymous) + expect.unreachable() + } catch (error) { + expect(error).toBeInstanceOf(AuthRequiredError) + } + }) +}) + +describe('szintrendezés', () => { + it('atLeast monoton', () => { + expect(atLeast(anonymous, 'anonymous')).toBe(true) + expect(atLeast(schonherz, 'anonymous')).toBe(true) + expect(atLeast(schonherz, 'member')).toBe(false) + expect(atLeast(member, 'leadership')).toBe(false) + expect(atLeast(leadership, 'member')).toBe(true) + expect(isLeadership(leadershipWithoutMember)).toBe(false) + }) +}) diff --git a/tests/unit/auth-session-cookies.test.ts b/tests/unit/auth-session-cookies.test.ts new file mode 100644 index 0000000..758dd4c --- /dev/null +++ b/tests/unit/auth-session-cookies.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + expiredCookieHeader, + hashSessionToken, + newSessionToken, + OIDC_TXN_COOKIE_NAME, + readCookieValue, + serializeSetCookie, + SESSION_COOKIE_NAME, + SESSION_TTL_MS, + signOidcTxn, + verifyAndReadOidcTxn, +} from '#/server/auth/session-cookies.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' + +const config = validateOobConfig(buildRawOobConfig()) + +function requestWithCookies(cookieHeader: string): Request { + return new Request('http://127.0.0.1:3000/', { + headers: { cookie: cookieHeader }, + }) +} + +describe('Set-Cookie sorosítás', () => { + it('a session cookie HTTP-only, SameSite=Lax és Path=/ attribútumú', () => { + const header = serializeSetCookie({ + name: SESSION_COOKIE_NAME, + value: 'token-123', + maxAgeSeconds: SESSION_TTL_MS / 1000, + }) + expect(header).toContain(`${SESSION_COOKIE_NAME}=token-123`) + expect(header).toContain('HttpOnly') + expect(header).toContain('SameSite=Lax') + expect(header).toContain('Path=/') + expect(header).toContain(`Max-Age=${SESSION_TTL_MS / 1000}`) + expect(header).not.toContain('Secure') + }) + + it('secure kapcsolásnál Secure attribútumot tesz a cookie-ra', () => { + const header = serializeSetCookie({ + name: SESSION_COOKIE_NAME, + value: 't', + secure: true, + }) + expect(header.endsWith('; Secure')).toBe(true) + }) + + it('lejárt cookie törlésre szolgáló fejlécet készít', () => { + expect(expiredCookieHeader(SESSION_COOKIE_NAME)).toBe( + `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, + ) + }) +}) + +describe('cookie kiolvasás', () => { + it('több cookie közül a kért nevűt adja vissza', () => { + const request = requestWithCookies( + `masik=ertek; ${OIDC_TXN_COOKIE_NAME}=txn-ertek; ${SESSION_COOKIE_NAME}=sess-123`, + ) + expect(readCookieValue(request, OIDC_TXN_COOKIE_NAME)).toBe('txn-ertek') + expect(readCookieValue(request, SESSION_COOKIE_NAME)).toBe('sess-123') + expect(readCookieValue(request, 'nincsilyen')).toBeNull() + }) + + it('cookie fejléc nélkül nullát ad', () => { + expect( + readCookieValue( + new Request('http://127.0.0.1:3000/'), + SESSION_COOKIE_NAME, + ), + ).toBeNull() + }) +}) + +describe('session token hashelés', () => { + it('sha256 hex formában, determinisztikusan hashel', () => { + const token = newSessionToken() + const first = hashSessionToken(token) + const second = hashSessionToken(token) + + expect(first).toMatch(/^[0-9a-f]{64}$/) + expect(first).toBe(second) + expect(hashSessionToken(newSessionToken())).not.toBe(first) + }) +}) + +describe('OIDC tranzakció cookie aláírás', () => { + const payload = JSON.stringify({ state: 'abc' }) + + it('aláírás után visszafejthető ugyanazzal a configgal', () => { + const signed = signOidcTxn(payload, config.authentik) + expect(verifyAndReadOidcTxn(signed, config.authentik)).toBe(payload) + }) + + it('módosított tartalom esetén nem érvényes', () => { + const signed = signOidcTxn(payload, config.authentik) + const tampered = `${Buffer.from(JSON.stringify({ state: 'rossz' }), 'utf-8').toString('base64url')}.${signed.split('.')[1]}` + expect(verifyAndReadOidcTxn(tampered, config.authentik)).toBeNull() + }) + + it('eltérő titokkal aláírt cookie nem érvényes', () => { + const otherConfig = validateOobConfig( + buildRawOobConfig({ authentik: { clientSecret: 'masik-titok' } }), + ) + const signed = signOidcTxn(payload, config.authentik) + expect(verifyAndReadOidcTxn(signed, otherConfig.authentik)).toBeNull() + }) + + it('nem értelmezhető bemenetnél nullát ad', () => { + expect(verifyAndReadOidcTxn('semmi-alairas', config.authentik)).toBeNull() + expect(verifyAndReadOidcTxn('aaa.nem-hex', config.authentik)).toBeNull() + }) +}) diff --git a/tests/unit/job-runner.test.ts b/tests/unit/job-runner.test.ts new file mode 100644 index 0000000..5d610da --- /dev/null +++ b/tests/unit/job-runner.test.ts @@ -0,0 +1,228 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { Client } from 'pg' +import { FakeClock } from '#/lib/clock.ts' +import { + dueJobs, + JobRegistry, + runJobWithLock, + startBackgroundRunner, +} from '#/server/jobs/runner.ts' +import type { JobDefinition, RunnerDeps } from '#/server/jobs/runner.ts' +import { + createPgLockManager, + createPermissiveLockManager, +} from '#/server/jobs/locks.ts' +import type { LockManager } from '#/server/jobs/locks.ts' + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] +const lockManagers: LockManager[] = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } + for (const manager of lockManagers) { + await manager.close?.() + } +}) + +function makeJob( + name: string, + intervalMs: number | 'startup', + run?: JobDefinition['run'], +): JobDefinition { + return { + name, + intervalMs, + run: + run ?? + (async () => { + void undefined + }), + } +} + +describe('BSS-010: ütemezési logika (FakeClock-kal vezérelt)', () => { + it('startup feladat csak egyszer fut; az óránkénti a következő óra határán esedékes', async () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + const registry = new JobRegistry() + let startupRuns = 0 + let hourlyRuns = 0 + + registry.register( + makeJob('startup-job', 'startup', async () => { + startupRuns += 1 + }), + ) + registry.register( + makeJob('hourly-job', 60 * 60 * 1000, async () => { + hourlyRuns += 1 + }), + ) + + // induláskor mindkettő esedékes + expect(dueJobs(registry, clock.now()).map((job) => job.name)).toEqual([ + 'startup-job', + 'hourly-job', + ]) + for (const job of dueJobs(registry, clock.now())) { + await job.run({ clock, trigger: 'tick' }) + registry.setLastFinishedAt(job.name, clock.now()) + } + + // perc múlva semmi sem esedékes + clock.advanceMinutes(1) + expect(dueJobs(registry, clock.now())).toHaveLength(0) + expect(dueJobs(registry, clock.now()).length).toBe(0) + + // óra múlva csak az óránkénti + clock.advanceMinutes(60) + const dueAfterHour = dueJobs(registry, clock.now()) + expect(dueAfterHour.map((job) => job.name)).toEqual(['hourly-job']) + + expect(startupRuns).toBe(1) + expect(hourlyRuns).toBe(1) + }) + + it('a regisztrált feladatok futtatása hibátlanul működik a tesztórával', async () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + const registry = new JobRegistry() + const executedAt: Date[] = [] + registry.register( + makeJob('napi-takaritas', 24 * 60 * 60 * 1000, async (ctx) => { + executedAt.push(ctx.clock.now()) + }), + ) + + await registry.getJob('napi-takaritas')!.run({ clock, trigger: 'tick' }) + clock.advanceDays(2) + await registry.getJob('napi-takaritas')!.run({ clock, trigger: 'tick' }) + + expect(executedAt).toEqual([ + new Date('2026-06-01T10:00:00.000Z'), + new Date('2026-06-03T10:00:00.000Z'), + ]) + }) +}) + +describe('BSS-010: háttérhiba nem állítja le az alkalmazást', () => { + it('hibás feladat error rekordot ad, de nem dob ki', async () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + const failing = makeJob('failing-job', 'startup', async () => { + throw new Error('szimulált háttérhiba') + }) + + const record = await runJobWithLock(failing, { + clock, + lockManager: createPermissiveLockManager(), + }) + expect(record.status).toBe('error') + expect(record.message).toContain('szimulált háttérhiba') + + // a runner ezután is futtat további feladatokat + const healthy = makeJob('healthy-job', 'startup') + const okRecord = await runJobWithLock(healthy, { + clock, + lockManager: createPermissiveLockManager(), + }) + expect(okRecord.status).toBe('ok') + }) +}) + +describe.skipIf(!hasTestDatabase)('BSS-010: PostgreSQL advisory lock', () => { + it('két példány közül csak az egyik szerzi meg a lockot', async () => { + const managerA = createPgLockManager({ + clientFactory: async () => + new Client({ connectionString: process.env.TEST_DATABASE_URL! }), + }) + const managerB = createPgLockManager({ + clientFactory: async () => + new Client({ connectionString: process.env.TEST_DATABASE_URL! }), + }) + lockManagers.push(managerA, managerB) + + const first = await managerA.acquire('member-sync-hourly') + expect(first).not.toBeNull() + + const second = await managerB.acquire('member-sync-hourly') + expect(second).toBeNull() + + // felszabadítás után a másik is hozzájut + await first!.release() + const third = await managerB.acquire('member-sync-hourly') + expect(third).not.toBeNull() + await third!.release() + + for (const client of [managerA, managerB]) { + void client + } + }) + + it('runJobWithLock skipped-locked eredményt ad, ha más tartja a lockot', async () => { + const clock = new FakeClock('2026-06-01T10:00:00.000Z') + let executions = 0 + const job = makeJob('locked-job', 'startup', async () => void ++executions) + + const instanceA = createPgLockManager({ + clientFactory: async () => + new Client({ connectionString: process.env.TEST_DATABASE_URL! }), + }) + const instanceB = createPgLockManager({ + clientFactory: async () => + new Client({ connectionString: process.env.TEST_DATABASE_URL! }), + }) + lockManagers.push(instanceA, instanceB) + const held = await instanceA.acquire('locked-job') + + // A B példány (külön kapcsolat) nem futtathatja, amíg A tartja: + const record = await runJobWithLock(job, { clock, lockManager: instanceB }) + expect(record.status).toBe('skipped-locked') + expect(executions).toBe(0) + + await held!.release() + const secondAttempt = await runJobWithLock(job, { + clock, + lockManager: instanceB, + }) + expect(secondAttempt.status).toBe('ok') + expect(executions).toBe(1) + }) +}) + +describe('BSS-010: alapértelmezett szinkronfeladatok regisztrációja', () => { + it('a runner startup és óránkénti sync feladatot regisztrál', () => { + const handle = startBackgroundRunner({ + loadConfig: () => { + throw new Error('nem hívható éles config nélkül tesztben') + }, + lockManager: createPermissiveLockManager(), + }) + try { + const names = handle.registry.list().map((job) => job.name) + expect(names).toContain('member-sync-startup') + expect(names).toContain('member-sync-hourly') + } finally { + handle.stop() + } + }) + + it('extra feladatok is regisztrálhatók (lomtár, live időzítő előkészület)', () => { + const extra: JobDefinition = makeJob( + 'trash-purge-daily', + 24 * 60 * 60 * 1000, + ) + const deps: RunnerDeps = { lockManager: createPermissiveLockManager() } + const handle = startBackgroundRunner(deps, [extra]) + try { + expect(handle.registry.getJob('trash-purge-daily')).toBeDefined() + } finally { + handle.stop() + } + }) +}) diff --git a/tests/unit/local-bootstrap.test.ts b/tests/unit/local-bootstrap.test.ts index 7891d40..6b626ba 100644 --- a/tests/unit/local-bootstrap.test.ts +++ b/tests/unit/local-bootstrap.test.ts @@ -68,6 +68,7 @@ describe('lokális Authentik bootstrap', () => { const secrets = { authentikSecretKey: 'secret-key-value', oidcClientSecret: 'client-secret-value', + syncApiToken: 'sync-token-value', passwords: {}, } const validated = validateOobConfig(renderOobConfig(secrets)) @@ -82,6 +83,7 @@ describe('lokális Authentik bootstrap', () => { const secrets = { authentikSecretKey: 'secret-key-value', oidcClientSecret: 'client-secret-value', + syncApiToken: 'sync-token-value', passwords: { 'tag-dev': 'jelszo1' }, } const yaml = renderBlueprint(secrets) diff --git a/tests/unit/media-validator.test.ts b/tests/unit/media-validator.test.ts new file mode 100644 index 0000000..ba546c2 --- /dev/null +++ b/tests/unit/media-validator.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest' +import { installFetchMock } from '../helpers/http-mock.ts' +import type { FetchMock } from '../helpers/http-mock.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' +import { + checkMediaUrlShape, + isAllowedMediaHost, + validateMediaForPublish, +} from '#/server/media/validator.ts' +import { + buildYoutubeNocookieEmbedUrl, + normalizeYoutubeVideoId, + validateYoutubeVideo, +} from '#/server/media/youtube.ts' + +const config = validateOobConfig(buildRawOobConfig()) + +const VIDEO_URL = 'https://v.bsstudio.hu/media/video.mp4' + +describe('BSS-011: médiahost engedélylista', () => { + it('csak a konfigurált host https URL-ei engedélyezettek', () => { + expect(isAllowedMediaHost(VIDEO_URL, config.media)).toBe(true) + expect( + isAllowedMediaHost('http://v.bsstudio.hu/media/video.mp4', config.media), + ).toBe(false) + expect( + isAllowedMediaHost('https://evil.example.com/video.mp4', config.media), + ).toBe(false) + // a bsstudio.hu főoldal (más host) nem media: + expect(isAllowedMediaHost('https://bsstudio.hu/', config.media)).toBe(false) + }) + + it('URL formaellenőrzés: érvénytelen és nem engedélyezett URL hibás', () => { + expect(checkMediaUrlShape('nem-url', 'video', config.media).ok).toBe(false) + const wrong = checkMediaUrlShape( + 'https://bsstudio.hu/fooldal', + 'video', + config.media, + ) + expect(wrong.ok).toBe(false) + expect(wrong.problems[0]).toContain('v.bsstudio.hu') + const ok = checkMediaUrlShape(VIDEO_URL, 'video', config.media) + expect(ok).toEqual({ ok: true, problems: [] }) + }) +}) + +describe('BSS-011: publikálási médiaellenőrzés (HEAD + Range tartalék)', () => { + let mock: FetchMock + + function mockHead(status: number, contentType: string | null): void { + mock = installFetchMock([ + { + method: 'HEAD', + urlPattern: /v\.bsstudio\.hu/, + respond: () => { + const headers: Record = + contentType === null ? {} : { 'content-type': contentType } + return { status, headers } + }, + }, + ]) + } + + it('HEAD 200 + video/mp4 elfogadott', async () => { + mockHead(200, 'video/mp4') + const result = await validateMediaForPublish({ + url: VIDEO_URL, + kind: 'video', + mediaConfig: config.media, + }) + expect(result.ok).toBe(true) + mock.restore() + }) + + it('MP4 helyett kép és thumbnail helyett HTML nem publikálható', async () => { + mockHead(200, 'image/jpeg') + const imageAsVideo = await validateMediaForPublish({ + url: VIDEO_URL, + kind: 'video', + mediaConfig: config.media, + }) + expect(imageAsVideo.ok).toBe(false) + expect(imageAsVideo.problems[0]).toContain('Videó helyett') + mock.restore() + + mockHead(200, 'text/html') + const htmlAsThumb = await validateMediaForPublish({ + url: 'https://v.bsstudio.hu/media/thumb.jpg', + kind: 'thumbnail', + mediaConfig: config.media, + }) + expect(htmlAsThumb.ok).toBe(false) + expect(htmlAsThumb.problems[0]).toContain('Kép helyett') + mock.restore() + }) + + it('a v.bsstudio.hu főoldalra mutató hiányzó média nem fogadható el', async () => { + mockHead(200, 'text/html') + const homepage = await validateMediaForPublish({ + url: 'https://v.bsstudio.hu/', + kind: 'video', + mediaConfig: config.media, + }) + expect(homepage.ok).toBe(false) + expect(homepage.problems[0]).toContain('Videó helyett text/html') + mock.restore() + }) + + it('átirányítás és 4xx/5xx nem fogadható el', async () => { + for (const status of [301, 302, 404, 500]) { + mockHead(status, 'video/mp4') + const result = await validateMediaForPublish({ + url: VIDEO_URL, + kind: 'video', + mediaConfig: config.media, + }) + expect(result.ok).toBe(false) + mock.restore() + } + }) + + it('405 esetén egybájtos Range GET fut tartalékként és nem tölti le a fájlt', async () => { + let rangeHeaderSeen: string | null = null + const bodyRead = false + mock = installFetchMock([ + { + method: 'HEAD', + urlPattern: /v\.bsstudio\.hu/, + respond: () => ({ status: 405 }), + }, + { + method: 'GET', + urlPattern: /v\.bsstudio\.hu/, + respond: (request) => { + rangeHeaderSeen = request.headers.get('range') + return { + status: 206, + headers: { 'content-type': 'video/mp4' }, + body: Buffer.from([0x00]), + } + }, + }, + ]) + + const result = await validateMediaForPublish({ + url: VIDEO_URL, + kind: 'video', + mediaConfig: config.media, + }) + void bodyRead + expect(result.ok).toBe(true) + expect(rangeHeaderSeen).toBe('bytes=0-0') + + const calls = mock.calls() + expect(calls.some((call) => call.method === 'GET')).toBe(true) + mock.restore() + }) + + it('501 esetén is működik a Range GET tartalék; rossz típust ott is elutasít', async () => { + mock = installFetchMock([ + { method: 'HEAD', urlPattern: /thumb/, respond: () => ({ status: 501 }) }, + { + method: 'GET', + urlPattern: /thumb/, + respond: () => ({ + status: 206, + headers: { 'content-type': 'text/html' }, + }), + }, + ]) + const bad = await validateMediaForPublish({ + url: 'https://v.bsstudio.hu/media/thumb.jpg', + kind: 'thumbnail', + mediaConfig: config.media, + }) + expect(bad.ok).toBe(false) + expect(bad.problems[0]).toContain('Kép helyett') + mock.restore() + + mock = installFetchMock([ + { method: 'HEAD', urlPattern: /video/, respond: () => ({ status: 501 }) }, + { + method: 'GET', + urlPattern: /video/, + respond: () => ({ + status: 206, + headers: { 'content-type': 'video/mp4' }, + }), + }, + ]) + const good = await validateMediaForPublish({ + url: VIDEO_URL, + kind: 'video', + mediaConfig: config.media, + }) + expect(good.ok).toBe(true) + mock.restore() + }) + + it('időtúllépés magyar hibát ad', async () => { + mock = installFetchMock([ + { + method: 'HEAD', + urlPattern: /v\.bsstudio\.hu/, + respond: () => new Promise<{ status: number }>(() => undefined), + }, + ]) + const result = await validateMediaForPublish({ + url: VIDEO_URL, + kind: 'video', + mediaConfig: config.media, + totalTimeoutMs: 30, + }) + expect(result.ok).toBe(false) + expect(result.problems[0]).toContain('időtúllépés') + mock.restore() + }) + + it('piszkozatban a hibás URL menthető: a formaellenőrzés nem hálózati hívás', () => { + const mock2 = installFetchMock([]) + const draftCheck = checkMediaUrlShape( + 'https://v.bsstudio.hu/meg-nem-letezo.mp4', + 'video', + config.media, + ) + expect(draftCheck.ok).toBe(true) + expect(mock2.calls()).toHaveLength(0) + mock2.restore() + }) +}) + +describe('BSS-011: YouTube URL normalizálás', () => { + it('minden elfogadott formából ugyanaz az azonosító jön ki', () => { + for (const url of [ + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtube.com/watch?v=dQw4w9WgXcQ&t=30s', + 'https://www.youtube.com/live/dQw4w9WgXcQ?feature=share', + 'https://youtu.be/dQw4w9WgXcQ', + 'https://www.youtube.com/embed/dQw4w9WgXcQ', + 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ', + ]) { + expect(normalizeYoutubeVideoId(url)).toBe('dQw4w9WgXcQ') + } + }) + + it('idegen vagy érvénytelen URL nullát ad', () => { + expect(normalizeYoutubeVideoId('https://vimeo.com/12345')).toBeNull() + expect( + normalizeYoutubeVideoId('https://youtube.com/playlist?list=abc'), + ).toBeNull() + expect(normalizeYoutubeVideoId('https://youtube.com/watch?v=')).toBeNull() + expect(normalizeYoutubeVideoId('nem url')).toBeNull() + }) + + it('nocookie embed URL készíthető az azonosítóból', () => { + expect(buildYoutubeNocookieEmbedUrl('dQw4w9WgXcQ')).toBe( + 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ', + ) + }) +}) + +describe('BSS-011: YouTube oEmbed ellenőrzés', () => { + it('200-as oEmbed válasz elfogadott', async () => { + const mock = installFetchMock([ + { + urlPattern: /oEmbed/, + respond: (request) => { + expect(request.url.toString().includes('dQw4w9WgXcQ')).toBe(true) + return { + status: 200, + body: { title: 'Teszt élő adás', author_name: 'BSS' }, + } + }, + }, + ]) + const result = await validateYoutubeVideo( + 'https://youtu.be/dQw4w9WgXcQ', + config.youtube, + ) + expect(result.ok).toBe(true) + expect(result.videoId).toBe('dQw4w9WgXcQ') + mock.restore() + }) + + it('privát (403) és törölt (404) videó magyar hibát kap', async () => { + for (const [status, expected] of [ + [403, 'privát'], + [404, 'oEmbed ellenőrzésen'], + ] as const) { + const mock = installFetchMock([ + { urlPattern: /oEmbed/, respond: () => ({ status }) }, + ]) + const result = await validateYoutubeVideo( + 'https://youtu.be/dQw4w9WgXcQ', + config.youtube, + ) + expect(result.ok).toBe(false) + expect(result.problems[0]).toContain(expected) + mock.restore() + } + }) + + it('normalizálhatatlan URL hálózati hívás nélkül hibás', async () => { + const mock = installFetchMock([]) + const result = await validateYoutubeVideo( + 'https://twitch.tv/x', + config.youtube, + ) + expect(result.ok).toBe(false) + expect(result.videoId).toBeNull() + expect(mock.calls()).toHaveLength(0) + mock.restore() + }) +}) diff --git a/tests/unit/member-map.test.ts b/tests/unit/member-map.test.ts new file mode 100644 index 0000000..f9c386d --- /dev/null +++ b/tests/unit/member-map.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + isSystemUser, + mapMember, + mapMembershipStatus, + parseJoinedSemester, +} from '#/server/members/map.ts' +import type { AuthentikApiUser } from '#/server/members/authentik-api.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { validateOobConfig } from '#/server/config/oob-schema.ts' + +const config = validateOobConfig(buildRawOobConfig()) + +const GROUPS = new Set(Object.values(config.authentik.groups)) + +function buildApiUser( + overrides: Partial = {}, +): AuthentikApiUser { + return { + pk: 36, + username: 'tag-dev', + name: 'Teszt BSS Tag', + isActive: true, + type: 'internal', + avatarUrl: null, + attributes: { + bss_status: 'stúdiós', + bss_csatlakozas: '2023 ősz', + bss_bemutatkozas: 'Bemutatkozás', + nickname: 'Tagocska', + }, + groups: [config.authentik.groups.tag], + ...overrides, + } +} + +describe('tagsági státusz mapping', () => { + it('minden konfigurált nyers státusz feloldódik', () => { + for (const raw of Object.keys( + config.authentik.attributes.membershipStatus.values, + )) { + expect(mapMembershipStatus(raw, config.authentik)).not.toBeNull() + } + }) + + it('ismeretlen státusz nullát ad (nem találgat)', () => { + expect(mapMembershipStatus('nincs ilyen', config.authentik)).toBeNull() + expect(mapMembershipStatus(undefined, config.authentik)).toBeNull() + expect(mapMembershipStatus('', config.authentik)).toBeNull() + }) +}) + +describe('csatlakozási félév értelmezés', () => { + it('ősz és tavasz szabályok szerint év + félév', () => { + expect(parseJoinedSemester('2023 ősz', config.authentik)).toEqual({ + year: 2023, + semester: 'autumn', + }) + expect(parseJoinedSemester('2021 tavasz', config.authentik)).toEqual({ + year: 2021, + semester: 'spring', + }) + expect(parseJoinedSemester('2019 őszi', config.authentik)).toEqual({ + year: 2019, + semester: 'autumn', + }) + }) + + it('nyers érték ismeretlen formátuma hibát nem dob, csak nincs eredmény', () => { + expect(parseJoinedSemester('ősz 2023', config.authentik)).toEqual({ + year: null, + semester: null, + }) + expect(parseJoinedSemester(null, config.authentik)).toEqual({ + year: null, + semester: null, + }) + }) +}) + +describe('teljes tag mapping', () => { + it('a konfig szerint képez le és a vezetőséget csoportnév alapján állítja', () => { + const leadershipGroups = new Set([ + config.authentik.groups.tag, + config.authentik.groups.vezetoseg, + ]) + const mapped = mapMember( + buildApiUser({ groups: [config.authentik.groups.vezetoseg] }), + leadershipGroups, + config.authentik, + )! + + expect(mapped.syncStatus).toBe('ok') + expect(mapped.sub).toBe('36') + expect(mapped.fullName).toBe('Teszt BSS Tag') + expect(mapped.nickname).toBe('Tagocska') + expect(mapped.membershipStatus).toBe('studio_member') + expect(mapped.isLeadership).toBe(true) + expect(mapped.joinedYear).toBe(2023) + expect(mapped.joinedSemester).toBe('autumn') + expect(mapped.joinedSemesterRaw).toBe('2023 ősz') + expect(mapped.introduction).toBe('Bemutatkozás') + expect(mapped.syncError).toBeNull() + }) + + it('hiányzó státusznál hibás jelölést kap, publikus adat nem veszik el', () => { + const mapped = mapMember( + buildApiUser({ attributes: {} }), + GROUPS, + config.authentik, + )! + + expect(mapped.syncStatus).toBe('error') + expect(mapped.syncError).toContain( + 'Ismeretlen vagy hiányzó tagsági státusz', + ) + }) + + it('rossz félévformátumnál az év és félév üres marad', () => { + const mapped = mapMember( + buildApiUser({ + attributes: { bss_status: 'stúdiós', bss_csatlakozas: 'valamikor' }, + }), + GROUPS, + config.authentik, + )! + expect(mapped.joinedYear).toBeNull() + expect(mapped.joinedSemester).toBeNull() + expect(mapped.joinedSemesterRaw).toBe('valamikor') + // A státusz érvényes, így a profil maga ok + expect(mapped.syncStatus).toBe('ok') + }) + + it('vezetőségi csoport önmagában nem tesz taggá valakivé', () => { + const onlyVezetoseg = new Set([config.authentik.groups.vezetoseg]) + const mapped = mapMember( + buildApiUser({ groups: [config.authentik.groups.vezetoseg] }), + onlyVezetoseg, + config.authentik, + )! + expect(mapped.isLeadership).toBe(true) + }) + + it('rendszerfelhasználók kimaradnak a cache-ből', () => { + expect(isSystemUser(buildApiUser({ username: 'ak-outpost-xyz' }))).toBe( + true, + ) + expect(isSystemUser(buildApiUser({ username: 'svc-bss-sync' }))).toBe(true) + expect(isSystemUser(buildApiUser({ type: 'service_account' }))).toBe(true) + expect(isSystemUser(buildApiUser())).toBe(false) + expect( + mapMember( + buildApiUser({ username: 'svc-bss-sync' }), + GROUPS, + config.authentik, + ), + ).toBeNull() + }) +}) From aed47314b9a058f3b0777ed367863c392675e31b Mon Sep 17 00:00:00 2001 From: Gyula Kiri Date: Sun, 23 Aug 2026 22:01:39 +0200 Subject: [PATCH 04/25] feat: complete phase 2 content domain infrastructure - Add catalog (names/tags/staff-roles), events, and videos domain modules with lifecycle, purge, and highlight-invalidation logic - Add related-videos, view-counter, search, and homepage (live/highlight/about/state) services - Add migrations for nullable event start_date, partial live-overlap exclusion constraint, and pg_trgm/bss_norm search support - Add integration test suites for all new domains - Update docs/implementation-progress.md for phase 2 gate results --- docs/implementation-progress.md | 84 +- .../migration.sql | 1 + .../snapshot.json | 2600 +++++++++++++++++ .../20260823191647_melted_mojo/migration.sql | 3 + .../20260823191647_melted_mojo/snapshot.json | 2600 +++++++++++++++++ .../20260823193237_organic_ego/migration.sql | 8 + .../20260823193237_organic_ego/snapshot.json | 2600 +++++++++++++++++ src/db/schema.ts | 4 +- src/server/catalog/names.ts | 46 + src/server/catalog/staff-roles.ts | 285 ++ src/server/catalog/tags.ts | 278 ++ src/server/events/domain.ts | 514 ++++ src/server/homepage/about.ts | 103 + src/server/homepage/highlight.ts | 83 + src/server/homepage/live.ts | 450 +++ src/server/homepage/state.ts | 166 ++ src/server/search/service.ts | 384 +++ src/server/shared/write.ts | 13 +- src/server/videos/domain.ts | 893 ++++++ src/server/videos/highlight-invalidation.ts | 21 + src/server/videos/purge.ts | 98 + src/server/videos/related.ts | 206 ++ src/server/views/counter.ts | 122 + tests/integration/catalog.test.ts | 363 +++ tests/integration/events-domain.test.ts | 451 +++ tests/integration/homepage.test.ts | 474 +++ tests/integration/related-videos.test.ts | 405 +++ tests/integration/search.test.ts | 398 +++ tests/integration/videos-domain.test.ts | 638 ++++ tests/integration/view-counter.test.ts | 226 ++ 30 files changed, 14498 insertions(+), 19 deletions(-) create mode 100644 drizzle/20260823184625_salty_jocasta/migration.sql create mode 100644 drizzle/20260823184625_salty_jocasta/snapshot.json create mode 100644 drizzle/20260823191647_melted_mojo/migration.sql create mode 100644 drizzle/20260823191647_melted_mojo/snapshot.json create mode 100644 drizzle/20260823193237_organic_ego/migration.sql create mode 100644 drizzle/20260823193237_organic_ego/snapshot.json create mode 100644 src/server/catalog/names.ts create mode 100644 src/server/catalog/staff-roles.ts create mode 100644 src/server/catalog/tags.ts create mode 100644 src/server/events/domain.ts create mode 100644 src/server/homepage/about.ts create mode 100644 src/server/homepage/highlight.ts create mode 100644 src/server/homepage/live.ts create mode 100644 src/server/homepage/state.ts create mode 100644 src/server/search/service.ts create mode 100644 src/server/videos/domain.ts create mode 100644 src/server/videos/highlight-invalidation.ts create mode 100644 src/server/videos/purge.ts create mode 100644 src/server/videos/related.ts create mode 100644 src/server/views/counter.ts create mode 100644 tests/integration/catalog.test.ts create mode 100644 tests/integration/events-domain.test.ts create mode 100644 tests/integration/homepage.test.ts create mode 100644 tests/integration/related-videos.test.ts create mode 100644 tests/integration/search.test.ts create mode 100644 tests/integration/videos-domain.test.ts create mode 100644 tests/integration/view-counter.test.ts diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md index 303a905..902e916 100644 --- a/docs/implementation-progress.md +++ b/docs/implementation-progress.md @@ -1,29 +1,35 @@ # BSS V0 implementációs állapot -Utolsó frissítés: 2026-08-23 (1. fázis közbeni állapot, BSS-006 után) +Utolsó frissítés: 2026-08-23 (2. fázis vége, fáziskapu zöld) ## Aktuális fázis -**1. fázis – Auth és közös infrastruktúra: KÉSZ, felhasználói jóváhagyásra vár.** +**2. fázis – Tartalmi domainek: KÉSZ, felhasználói jóváhagyásra vár.** -Mind a hat kártya (BSS-006 – BSS-011) elkészült, a fáziskapu gate-je zöld. -Következő: **2. fázis – Tartalmi domainek** (BSS-012 – BSS-018), munka csak külön jóváhagyással. +Mind a hét kártya (BSS-012 – BSS-018) elkészült, a fáziskapu gate-je zöld. +Következő: **3. fázis – Publikus felület** (BSS-019 – BSS-026), munka csak külön jóváhagyással. ## Kártyák állapota -| Kártya | Név | Állapot | Ellenőrzések | -| ------- | --------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | -| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`, új: `createMigratedTestDatabase`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | -| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | -| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla + `auth_sessions` migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); DB-szintű invariánsok; integrációs sématesztek | -| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva; teljes restart után sem duplikál | -| BSS-006 | OIDC belépés és session | done | `/api/auth/login`, `/api/auth/callback`, `/api/auth/logout` PKCE-s flow-val; DB-ben tárolt session (`auth_sessions`), access token csak szerveroldalon; HTTP-only SameSite=Lax cookie-k; returnTo megőrzés nyitott átirányítás ellenőrzéssel; abszolút 60 perces TTL; Authentik-kiesés magyar 503 oldallal; élő dev szerveren: login 302 az Authentik authorize végpontra, callback hibaág 400, publikus oldal 200 | -| BSS-007 | Jogosultsági policy és guardok | done | viewer szintek (anonymous/schonherz/member/leadership) csoportokból; vezetőség csak tag csoporttal együtt; teljes admin mátrix unit tesztekkel (spec 3.2); `visibleVideoCondition` SQL feltétel valódi adatbázison tesztelve mind a 4 nézői szintre + metaadat-szivárgás ellenőrzés; `requireAdmin`/`requireLeadership` guardok (login redirect returnTo-val / 403); `/api/auth/me` állapotvégpont Authentik-hívás nélkül | -| BSS-008 | Authentik tagcache és szinkron | done | `runMemberSync` client_credentials granthasználatával az Authentik API-ról (lapozott users/groups); mapping konfig szerint; nyers félév megőrzése; ismeretlen státusz → syncStatus=error (publikusból kimarad, utolsó ismert adat megmarad); eltűnt tag rekordja megmarad; változatlan futás NEM ír auditot; szerepváltozás audit előtte-utána párral; `member_sync_runs` állapottábla; kézi indítás csak vezetőségnek; élőben verifikálva a lokális Authentikkel (7 tag cache-elve, idempotens) | -| BSS-009 | Közös slug, audit és optimista zárolás | done | `slugify` magyar ékezet-feloldással; ütközésnél számozott utótag; slug_history lefoglalja a régi slugokat végleges törlés után is; `renameSlugWithHistory` + `resolveSlugRedirect`; `updateWithOptimisticLock`: FOR UPDATE sorzár + verzióellenőrzés (StaleWriteError) + tranzakciós audit előtte-utána értékkel; system aktornál updatedBy NULL; DB-trigger tiltja az audit UPDATE/DELETE-t (custom migráció); plain text és hosszvalidáció (`TEXT_LIMITS`); 14 integrációs teszt | -| BSS-010 | Háttérfeladat-futtató, health, riasztás | done | `JobRegistry` + `dueJobs` FakeClock-kal vezérelt ütemezéssel; `runJobWithLock` PostgreSQL advisory lockkal: két példány közül egy futtat (skipped-locked); induláskori + óránkénti sync feladatok regisztrálva, extra feladatok (lomtár, live) regisztrálhatók; `/health/live` mindig 200, `/health/ready` DB és kulcstábla ellenőrzéssel (503, titok nélkül); hibás háttérfeladat nem dönti le az alkalmazást; vezetőségi szinkronriasztás `getRecentSyncAlerts`; élőben: health 200/200 | -| BSS-011 | Média- és YouTube-validátor | done | host engedélylista (https + v.bsstudio.hu); publikáláshoz HEAD 200 átirányítás nélkül, video/mp4 vs image/* content-type; 405/501-nél egybájtos Range GET tartalék (bytes=0-0, tartalom letöltése nélkül); piszkozathoz hálózat nélküli formaellenőrzés (hibás URL menthető piszkozatban); YouTube normalizálás (watch/live/youtu.be/embed/nocookie) + oEmbed ellenőrzés magyar hibaüzenetekkel; 16 unit teszt | +| Kártya | Név | Állapot | Ellenőrzések | +| ------- | --------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | +| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`, új: `createMigratedTestDatabase`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | +| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | +| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla + `auth_sessions` migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); DB-szintű invariánsok; integrációs sématesztek | +| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva; teljes restart után sem duplikál | +| BSS-006 | OIDC belépés és session | done | `/api/auth/login`, `/api/auth/callback`, `/api/auth/logout` PKCE-s flow-val; DB-ben tárolt session (`auth_sessions`), access token csak szerveroldalon; HTTP-only SameSite=Lax cookie-k; returnTo megőrzés nyitott átirányítás ellenőrzéssel; abszolút 60 perces TTL; Authentik-kiesés magyar 503 oldallal; élő dev szerveren: login 302 az Authentik authorize végpontra, callback hibaág 400, publikus oldal 200 | +| BSS-007 | Jogosultsági policy és guardok | done | viewer szintek (anonymous/schonherz/member/leadership) csoportokból; vezetőség csak tag csoporttal együtt; teljes admin mátrix unit tesztekkel (spec 3.2); `visibleVideoCondition` SQL feltétel valódi adatbázison tesztelve mind a 4 nézői szintre + metaadat-szivárgás ellenőrzés; `requireAdmin`/`requireLeadership` guardok (login redirect returnTo-val / 403); `/api/auth/me` állapotvégpont Authentik-hívás nélkül | +| BSS-008 | Authentik tagcache és szinkron | done | `runMemberSync` client_credentials granthasználatával az Authentik API-ról (lapozott users/groups); mapping konfig szerint; nyers félév megőrzése; ismeretlen státusz → syncStatus=error (publikusból kimarad, utolsó ismert adat megmarad); eltűnt tag rekordja megmarad; változatlan futás NEM ír auditot; szerepváltozás audit előtte-utána párral; `member_sync_runs` állapottábla; kézi indítás csak vezetőségnek; élőben verifikálva a lokális Authentikkel (7 tag cache-elve, idempotens) | +| BSS-009 | Közös slug, audit és optimista zárolás | done | `slugify` magyar ékezet-feloldással; ütközésnél számozott utótag; slug_history lefoglalja a régi slugokat végleges törlés után is; `renameSlugWithHistory` + `resolveSlugRedirect`; `updateWithOptimisticLock`: FOR UPDATE sorzár + verzióellenőrzés (StaleWriteError) + tranzakciós audit előtte-utána értékkel; system aktornál updatedBy NULL; DB-trigger tiltja az audit UPDATE/DELETE-t (custom migráció); plain text és hosszvalidáció (`TEXT_LIMITS`); 14 integrációs teszt | +| BSS-010 | Háttérfeladat-futtató, health, riasztás | done | `JobRegistry` + `dueJobs` FakeClock-kal vezérelt ütemezéssel; `runJobWithLock` PostgreSQL advisory lockkal: két példány közül egy futtat (skipped-locked); induláskori + óránkénti sync feladatok regisztrálva, extra feladatok (lomtár, live) regisztrálhatók; `/health/live` mindig 200, `/health/ready` DB és kulcstábla ellenőrzéssel (503, titok nélkül); hibás háttérfeladat nem dönti le az alkalmazást; vezetőségi szinkronriasztás `getRecentSyncAlerts`; élőben: health 200/200 | +| BSS-011 | Média- és YouTube-validátor | done | host engedélylista (https + v.bsstudio.hu); publikáláshoz HEAD 200 átirányítás nélkül, video/mp4 vs image/* content-type; 405/501-nél egybájtos Range GET tartalék (bytes=0-0, tartalom letöltése nélkül); piszkozathoz hálózat nélküli formaellenőrzés (hibás URL menthető piszkozatban); YouTube normalizálás (watch/live/youtu.be/embed/nocookie) + oEmbed ellenőrzés magyar hibaüzenetekkel; 16 unit teszt | +| BSS-012 | Címke- és stábszerep-domain | done | `src/server/catalog/` (names/tags/staff-roles): normalizált név (kisbetű + whitespace) egyediség; ékezeti hasonlóság csak figyelmeztetés (`findAccentSimilarTagNames`); használt címke törlése csak vezetőséggel + pontos címbeírással; összevonás tranzakcióban kapcsolatvesztés nélkül (ON CONFLICT DO NOTHING); használatban lévő szerep törlésének tiltása + DB restrict; `displayOrder` sorrendezés; minden művelet auditolt; 13 integrációs teszt | +| BSS-013 | Esemény-domain és végleges törlés | done | `src/server/events/domain.ts`: piszkozat csak címmel; publikálás = cím + kezdődátum + (ha van) elérhető thumbnail (BSS-011 validátorral); jövőbeli esemény publikálható; end >= start alkalmazás- és DB-szinten; archiválás → újrapublikálás; slug módosítás előzménnyel; végleges törlés vezetőségi + címbeírással EGY tranzakcióban (videók leválasztása `recordedAt` megőrzésével → régi slug történetbe foglalva → teljes audit `detachedVideoIds`-szal); lista kezdődátum desc, lapozás; 12 integrációs teszt. **Sémaváltozás:** `events.start_date` nullable lett (spec 6.1: piszkozathoz csak cím kell) — migráció: `drizzle/20260823184625_salty_jocasta` | +| BSS-014 | Videó-domain és életciklus | done | `src/server/videos/domain.ts` + `purge.ts` + `highlight-invalidation.ts`: piszkozat csak címmel (hibás média-URL is menthető); publikálás ellenőrzi kötelező mezőket + médiát hálózati validátorral (a tranzakción kívül), többnapos eseménynél `recordedAt`, nem jövőbeli `publishedAt` (múltbeli megadható és megmarad); egynapos esemény csendesen kitölti az üres dátumot, felülírni soha nem írja; leválasztás megtartja a dátumot; intervallumon kívüli dátum csak figyelmeztetés; archiválás/lomtár/visszaállítás (vezetőség, archivált állapotba, kapcsolatokkal); napi 30 napos végleges törlés (`createTrashPurgeJob` a runner extra feladataként, rendszer-audit, slug lefoglalás); kiemelés/Rólunk érvénytelenítés ugyanabban a tranzakcióban állapot- és láthatóságváltozáskor; címkék/stáblista csere verzióellenőrzéssel és auditálással; 14 integrációs teszt | +| BSS-015 | Kapcsolódó videók szolgáltatása | done | `src/server/videos/related.ts`: manuális lista felülír mindent (csak publikált választható, önhivatkozás/duplikátum tiltva); azonos esemény öt legutóbb publikált videója (`publishedAt` desc); esemény nélkül közös címkék pontozással (több közös címke erősebb, egyezésnél `publishedAt` desc, stabil `id` tiebreak); megjelenítés minden ágon `visibleVideoCondition`-nal szűrve az SQL-ben — korlátozott videó metaadata nem szivárog; verzióellenőrzött mentés + audit; 8 integrációs teszt | +| BSS-016 | Megtekintésszámláló | done | `src/server/views/counter.ts`: anonim session cookie (`bss_view_session`) szándékosan Max-Age nélkül — böngésző bezárásáig él; a DB-ben csak a token SHA-256 kivonata, IP/felhasználói előzmény nélkül; `view_sessions` PK + ON CONFLICT DO NOTHING idempotencia párhuzamos kérésekkel szemben (teszt: Promise.all két hívásból, csak egy számol); csak publikált és a nézőnek látható videó számolható; számláló csak admin lekérdezésben (legalább tagság); 7 integrációs teszt | +| BSS-017 | Homepage, live, kiemelés és Rólunk-domain | done | `src/server/homepage/{live,highlight,about,state}.ts`: számított prioritás (aktív live > kiemelt > normál); live ütemezés oEmbed ellenőrzéssel mentéskor is, átfedés alkalmazás- ÉS DB-szinten tiltva (részleges EXCLUDE korlát: `WHERE status <> 'ended'`, így befejezett live fölé mehet másolat) — migráció: `drizzle/20260823191647_melted_mojo` (btree_gist + korlátcsere); `Indítás most` oEmbed-fallbackkel (hiba rögzül, ütemezett marad), `Lezárás most`; befejezett live nem módosítható/törölhető (csak másolatként); perces `createLiveTransitionJob` runner-feladat system audittal; kiemelés csak publikált+publikus videó; Rólunk legfeljebb hat rendezett publikus videó, érvénytelen SQL-ben kiesik; homepage: 5/6 publikus videó (hero nélkül), hat esemény, `Adás hamarosan` 24 órás sáv; 10 integrációs teszt | ## Fáziskapu eredménye (0. fázis, megtörtént) @@ -67,6 +73,50 @@ Következő: **2. fázis – Tartalmi domainek** (BSS-012 – BSS-018), munka cs - A belépési felület navbar-integrációja (Belépés/Kilépés gomb) a BSS-019/BSS-027 kártyákhoz tartozik; addig közvetlen URL-lel (`/api/auth/login`) érhető el a belépés. +## Fáziskapu eredménye (2. fázis) + +- [x] `pnpm check` zöld +- [x] `pnpm lint` zöld +- [x] `pnpm typecheck` zöld (0 hiba) +- [x] `TEST_DATABASE_URL=... pnpm test` zöld — 25 fájl / 234 teszt (1. fázis: 160) +- [x] `pnpm build` zöld (nitro .output) +- [x] migrációk tiszta adatbázison lefutnak (7 migráció, 16 tábla; EXCLUDE korlát és `bss_norm` függvény ellenőrizve) +- [x] git diff átnézve: titkok csak gitignore-olt `.env` és `oob/` fájlokban; idegen módosítás nincs + +## Új migrációk a 2. fázisban + +| Migráció | Tartalom | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `20260823184625_salty_jocasta` | `events.start_date` nullable (spec 6.1: piszkozathoz csak cím kell) | +| `20260823191647_melted_mojo` | live átfedés-tilalom cseréje részleges EXCLUDE korlátra (`WHERE status <> 'ended'`, btree_gist) — befejezett live fölé mehet másolatként új ütemezés | +| `20260823193237_organic_ego` | `pg_trgm` kiterjesztés + `bss_norm(text)` IMMUTABLE SQL függvény (kisbetűs + magyar ékezetlevétel a kereséshez) | + +## Elfogadott technikai döntések a 2. fázisban + +- A domainlogika szerveroldali szolgáltatás-modulokban él (`src/server/{catalog,events,videos,views,homepage,search}`), + route-/UI-wiring a 3–4. fázisban csatlakozik. Minden írás tranzakcióban, auditálva, optimista verzióval. +- `updateWithOptimisticLock` bővítve: opcionális `action` (auditnév) és `afterWrite` callback + (tranzakción belüli kiegészítő írás, pl. homepage-hivatkozás érvénytelenítés). +- Videó életciklus: publikálási médiaellenőrzés (hálózati) szándékosan a tranzakció előtt fut, + hogy ne tartsunk sorzárat hívás közben; az állapotváltás + kiemelés/Rólunk érvénytelenítés egy tranzakció. +- Lomtár-purge és live állapotváltások `JobDefinition`-ként regisztrálhatók a runner extra feladataiként + (`createTrashPurgeJob`, `createLiveTransitionJob`); rendszer-audit csak tényleges változásnál. +- Megtekintésszám: a `videos.view_count` oszlop és a `view_sessions` sorok ugyanabban a tranzakcióban + frissülnek; ON CONFLICT DO NOTHING adja az idempotenciát párhuzamos kérésekkel szemben. +- Keresés: `bss_norm` + pg_trgm `similarity` (küszöb 0.3); a keresőkifejezés paraméterezve megy az SQL-be; + üres/1 karakteres keresés nem ér adatbázist. Részletes videószűrés `count(*) over()`-ral lapoz. +- Keresési súlyok (spec 11.2): pontos 100 > előtag 80 > címke 70/55 > trigram ≤50 > eseménycím 40 > + vendégek/stáb 30/25 > leírás/bemutatkozás 20. + +## Ismert hibák és technikai tartozás + +- A publikus prototípus oldalai még statikus placeholder tartalmat mutatnak (3. fázis). +- `@tanstack/router-cli` (tsr) Node 24-es circular-dependency figyelmeztetése ártalmatlan. +- docker-compose: HTTPS portfeltárás elhagyva (HTTP :9000). +- A belépési felület navbar-integrációja (Belépés/Kilépés gomb) a BSS-019/BSS-027 kártyákhoz tartozik; + addig közvetlen URL-lel (`/api/auth/login`) érhető el a belépés. +- Az integrációs tesztek futtatásához továbbra is `TEST_DATABASE_URL` kell (nélküle skipelnek). + ## Fáziskapu eredménye (1. fázis) - [x] `pnpm check` zöld diff --git a/drizzle/20260823184625_salty_jocasta/migration.sql b/drizzle/20260823184625_salty_jocasta/migration.sql new file mode 100644 index 0000000..7304456 --- /dev/null +++ b/drizzle/20260823184625_salty_jocasta/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "events" ALTER COLUMN "start_date" DROP NOT NULL; \ No newline at end of file diff --git a/drizzle/20260823184625_salty_jocasta/snapshot.json b/drizzle/20260823184625_salty_jocasta/snapshot.json new file mode 100644 index 0000000..8874dc9 --- /dev/null +++ b/drizzle/20260823184625_salty_jocasta/snapshot.json @@ -0,0 +1,2600 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "024533d1-c675-46af-aed6-b27d15f110ed", + "prevIds": ["516f562d-d3f2-44bb-bb25-d7a607a92f20"], + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["startup", "hourly", "manual", "test"], + "name": "member_sync_trigger", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auth_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_sync_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_trigger", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trigger", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "changed_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "error_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auth_sessions_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "auth_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_sync_runs_started_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_sync_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pkey", + "schema": "public", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_sync_runs_pkey", + "schema": "public", + "table": "member_sync_runs", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/drizzle/20260823191647_melted_mojo/migration.sql b/drizzle/20260823191647_melted_mojo/migration.sql new file mode 100644 index 0000000..d4e656d --- /dev/null +++ b/drizzle/20260823191647_melted_mojo/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "live_streams" DROP CONSTRAINT IF EXISTS "live_streams_no_overlap_excl";--> statement-breakpoint +CREATE EXTENSION IF NOT EXISTS "btree_gist";--> statement-breakpoint +ALTER TABLE "live_streams" ADD CONSTRAINT "live_streams_no_overlap_excl" EXCLUDE USING gist (tstzrange("starts_at", "ends_at") WITH &&) WHERE ("status" <> 'ended'); diff --git a/drizzle/20260823191647_melted_mojo/snapshot.json b/drizzle/20260823191647_melted_mojo/snapshot.json new file mode 100644 index 0000000..8e7c1c3 --- /dev/null +++ b/drizzle/20260823191647_melted_mojo/snapshot.json @@ -0,0 +1,2600 @@ +{ + "id": "d2179d05-9513-4b5c-81de-fe2e2cdf2c7f", + "prevIds": ["024533d1-c675-46af-aed6-b27d15f110ed"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["startup", "hourly", "manual", "test"], + "name": "member_sync_trigger", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auth_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_sync_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_trigger", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trigger", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "changed_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "error_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auth_sessions_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "auth_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_sync_runs_started_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_sync_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pkey", + "schema": "public", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_sync_runs_pkey", + "schema": "public", + "table": "member_sync_runs", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/drizzle/20260823193237_organic_ego/migration.sql b/drizzle/20260823193237_organic_ego/migration.sql new file mode 100644 index 0000000..7e58a1d --- /dev/null +++ b/drizzle/20260823193237_organic_ego/migration.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint +-- Ékezet- és kisbetűfüggetlen keresési forma (spec 11.2): a keresés minden +-- szöveges mezőn ezen a normalizált alapon fut. +CREATE OR REPLACE FUNCTION bss_norm(t text) RETURNS text +LANGUAGE sql IMMUTABLE PARALLEL SAFE +AS $$ + SELECT translate(lower(coalesce(t, '')), 'áéíóöőúüű', 'aeioouuu') +$$; diff --git a/drizzle/20260823193237_organic_ego/snapshot.json b/drizzle/20260823193237_organic_ego/snapshot.json new file mode 100644 index 0000000..32eb4e0 --- /dev/null +++ b/drizzle/20260823193237_organic_ego/snapshot.json @@ -0,0 +1,2600 @@ +{ + "id": "8b3b0891-90aa-480f-b0dd-bb197c427e18", + "prevIds": ["d2179d05-9513-4b5c-81de-fe2e2cdf2c7f"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "values": ["draft", "published", "archived", "trash"], + "name": "content_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["draft", "published", "archived"], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["scheduled", "active", "ended"], + "name": "live_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["ok", "error"], + "name": "member_sync_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["startup", "hourly", "manual", "test"], + "name": "member_sync_trigger", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "studio_member", + "studio_candidate", + "studio_applicant", + "senior_active", + "senior_archived", + "contributor" + ], + "name": "membership_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["spring", "autumn"], + "name": "semester", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["video", "event"], + "name": "slug_entity_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": ["public", "schonherz", "bss"], + "name": "visibility", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "about_page_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "audit_log", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "auth_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_streams", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_cache", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "member_sync_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "related_videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "site_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "staff_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_staff", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "video_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "videos", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "view_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "about_page_videos" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "before_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "after_value", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "audit_log" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "groups", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "auth_sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "start_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "youtube_video_id", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "live_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'scheduled'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_error", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_streams" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sub", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "username", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nickname", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatar_url", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "membership_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "membership_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_leadership", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_year", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "semester", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "joined_semester_raw", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "introduction", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'ok'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "member_cache" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_trigger", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trigger", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "member_sync_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "total_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "changed_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "error_count", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "member_sync_runs" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_video_id", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "related_videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "highlighted_video_id", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "site_settings" + }, + { + "type": "slug_entity_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "slug_history" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "staff_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "normalized_name", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role_id", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "member_sub", + "entityType": "columns", + "schema": "public", + "table": "video_staff" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tag_id", + "entityType": "columns", + "schema": "public", + "table": "video_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(10000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "guests", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(5000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "songs", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "visibility", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'public'", + "generated": null, + "identity": null, + "name": "visibility", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "content_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recorded_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_at", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "trashed_by", + "entityType": "columns", + "schema": "public", + "table": "videos" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "video_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "viewed_at", + "entityType": "columns", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "video_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "about_page_videos_video_key", + "entityType": "indexes", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_occurred_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "audit_log_action_idx", + "entityType": "indexes", + "schema": "public", + "table": "audit_log" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "auth_sessions_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "auth_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "start_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "events_status_start_idx", + "entityType": "indexes", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_streams_status_starts_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "username", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_username_key", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "membership_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_cache_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_cache" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "member_sync_runs_started_idx", + "entityType": "indexes", + "schema": "public", + "table": "member_sync_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "slug_history_entity_idx", + "entityType": "indexes", + "schema": "public", + "table": "slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "display_order", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "staff_roles_display_order_idx", + "entityType": "indexes", + "schema": "public", + "table": "staff_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "normalized_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tags_normalized_name_key", + "entityType": "indexes", + "schema": "public", + "table": "tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "member_sub", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_member_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "role_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_staff_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tag_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "video_tags_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "visibility", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "published_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_visibility_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "recorded_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_recorded_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "trashed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "videos_trash_purge_idx", + "entityType": "indexes", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "viewed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "view_sessions_viewed_idx", + "entityType": "indexes", + "schema": "public", + "table": "view_sessions" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "about_page_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "about_page_videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "events_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "events" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "live_streams_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "live_streams" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["related_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "related_videos_related_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "related_videos" + }, + { + "nameExplicit": false, + "columns": ["highlighted_video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "site_settings_highlighted_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "site_settings" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_staff_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["role_id"], + "schemaTo": "public", + "tableTo": "staff_roles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "video_staff_role_id_staff_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["member_sub"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "video_staff_member_sub_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_staff" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["tag_id"], + "schemaTo": "public", + "tableTo": "tags", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "video_tags_tag_id_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "video_tags" + }, + { + "nameExplicit": false, + "columns": ["event_id"], + "schemaTo": "public", + "tableTo": "events", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "videos_event_id_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["created_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_created_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["updated_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_updated_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["trashed_by"], + "schemaTo": "public", + "tableTo": "member_cache", + "columnsTo": ["sub"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "videos_trashed_by_member_cache_sub_fkey", + "entityType": "fks", + "schema": "public", + "table": "videos" + }, + { + "nameExplicit": false, + "columns": ["video_id"], + "schemaTo": "public", + "tableTo": "videos", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "view_sessions_video_id_videos_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["position", "video_id"], + "nameExplicit": false, + "name": "about_page_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "about_page_videos" + }, + { + "columns": ["video_id", "related_video_id"], + "nameExplicit": false, + "name": "related_videos_pkey", + "entityType": "pks", + "schema": "public", + "table": "related_videos" + }, + { + "columns": ["entity_type", "slug"], + "nameExplicit": false, + "name": "slug_history_pkey", + "entityType": "pks", + "schema": "public", + "table": "slug_history" + }, + { + "columns": ["video_id", "role_id", "member_sub"], + "nameExplicit": false, + "name": "video_staff_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_staff" + }, + { + "columns": ["video_id", "tag_id"], + "nameExplicit": false, + "name": "video_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "video_tags" + }, + { + "columns": ["video_id", "session_id"], + "nameExplicit": false, + "name": "view_sessions_pkey", + "entityType": "pks", + "schema": "public", + "table": "view_sessions" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "audit_log_pkey", + "schema": "public", + "table": "audit_log", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pkey", + "schema": "public", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "events_pkey", + "schema": "public", + "table": "events", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "live_streams_pkey", + "schema": "public", + "table": "live_streams", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "member_cache_pkey", + "schema": "public", + "table": "member_cache", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_sync_runs_pkey", + "schema": "public", + "table": "member_sync_runs", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "site_settings_pkey", + "schema": "public", + "table": "site_settings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "staff_roles_pkey", + "schema": "public", + "table": "staff_roles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "tags_pkey", + "schema": "public", + "table": "tags", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "videos_pkey", + "schema": "public", + "table": "videos", + "entityType": "pks" + }, + { + "value": "\"position\" >= 1 and \"position\" <= 6", + "name": "about_page_videos_position_range_check", + "entityType": "checks", + "schema": "public", + "table": "about_page_videos" + }, + { + "value": "\"end_date\" is null or \"end_date\" >= \"start_date\"", + "name": "events_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "events" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "live_streams_end_after_start_check", + "entityType": "checks", + "schema": "public", + "table": "live_streams" + }, + { + "value": "\"video_id\" <> \"related_video_id\"", + "name": "related_videos_no_self_reference_check", + "entityType": "checks", + "schema": "public", + "table": "related_videos" + }, + { + "value": "\"status\" <> 'published' or \"published_at\" is not null", + "name": "videos_published_requires_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + }, + { + "value": "\"status\" <> 'trash' or \"trashed_at\" is not null", + "name": "videos_trash_needs_timestamp_check", + "entityType": "checks", + "schema": "public", + "table": "videos" + } + ], + "renames": [] +} diff --git a/src/db/schema.ts b/src/db/schema.ts index b4732ca..fe3464e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -138,7 +138,9 @@ export const events = pgTable( title: varchar({ length: 200 }).notNull(), description: varchar({ length: 10_000 }), thumbnailUrl: varchar('thumbnail_url', { length: 2048 }), - startDate: date('start_date').notNull(), + // A specifikáció szerint piszkozathoz csak cím kell; a kezdődátum + // publikálási feltétel (alkalmazásoldalon érvényesítve). + startDate: date('start_date'), endDate: date('end_date'), status: eventStatusEnum('status').notNull().default('draft'), createdBy: varchar('created_by', { length: 255 }).references( diff --git a/src/server/catalog/names.ts b/src/server/catalog/names.ts new file mode 100644 index 0000000..1b4f5fc --- /dev/null +++ b/src/server/catalog/names.ts @@ -0,0 +1,46 @@ +const HUNGARIAN_ACCENTS: Record = { + á: 'a', + é: 'e', + í: 'i', + ó: 'o', + ö: 'o', + ő: 'o', + ú: 'u', + ü: 'u', + ű: 'u', + Á: 'a', + É: 'e', + Í: 'i', + Ó: 'o', + Ö: 'o', + Ő: 'o', + Ú: 'u', + Ü: 'u', + Ű: 'u', +} + +/** + * Katalógusnév normalizálása (spec 7.1): a kis- és nagybetű, valamint a + * felesleges szóköz nem hozhat létre duplikációt. Az ékezet jelentésmegkülönböztető. + */ +export function normalizeCatalogName(raw: string): string { + return raw.trim().replace(/\s+/g, ' ').toLowerCase() +} + +/** Ékezetek lehajtogatása szóközök megtartásával (csak figyelmeztetéshez). */ +export function foldAccents(value: string): string { + return value.replace( + /[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, + (char) => HUNGARIAN_ACCENTS[char] ?? char, + ) +} + +/** + * Ékezeti hasonlóság (spec 7.1): az ékezet nélkül megegyező, de eltérő + * normalizált nevek csak figyelmeztetést kapnak, nem blokkolnak. + */ +export function isAccentSimilar(a: string, b: string): boolean { + const fa = foldAccents(normalizeCatalogName(a)) + const fb = foldAccents(normalizeCatalogName(b)) + return fa === fb && normalizeCatalogName(a) !== normalizeCatalogName(b) +} diff --git a/src/server/catalog/staff-roles.ts b/src/server/catalog/staff-roles.ts new file mode 100644 index 0000000..4c6924d --- /dev/null +++ b/src/server/catalog/staff-roles.ts @@ -0,0 +1,285 @@ +import { eq, sql } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { can } from '#/server/auth/policy.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { staffRoles, videoStaff } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { + TEXT_LIMITS, + TextValidationError, + validateRequiredText, +} from '#/server/shared/text.ts' +import { writeAudit } from '#/server/shared/write.ts' +import { normalizeCatalogName } from './names.ts' +import { CatalogNameConflictError } from './tags.ts' + +export class StaffRoleNotFoundError extends Error { + constructor(roleId: string) { + super(`A stábszerep nem található: ${roleId}`) + this.name = 'StaffRoleNotFoundError' + } +} + +export class StaffRoleInUseError extends Error { + constructor(name: string, usageCount: number) { + super( + `A „${name}" stábszerep ${usageCount} videón használatban van, ezért nem törölhető. Előbb vond össze egy másik szereppel.`, + ) + this.name = 'StaffRoleInUseError' + } +} + +function assertCanManageRoles(viewer: Viewer): void { + if (!can.manageStaffRoles(viewer)) { + throw new ForbiddenError('A stábszerepek kezelése vezetőségi jog.') + } +} + +function validatedName(rawName: string): string { + const name = validateRequiredText( + 'Stábszerep', + rawName, + TEXT_LIMITS.tagOrRole, + ) + return name.trim().replace(/\s+/g, ' ') +} + +async function loadRole(executor: Executor, roleId: string) { + const rows = await executor + .select() + .from(staffRoles) + .where(eq(staffRoles.id, roleId)) + .limit(1) + const row = rows.at(0) + if (row === undefined) { + throw new StaffRoleNotFoundError(roleId) + } + return row +} + +export interface CatalogDeps { + viewer: Viewer + clock?: Clock +} + +export async function createStaffRole( + executor: Executor, + deps: CatalogDeps, + rawName: string, +): Promise { + assertCanManageRoles(deps.viewer) + const name = validatedName(rawName) + const normalizedName = normalizeCatalogName(name) + + return executor.transaction(async (tx) => { + const conflict = await tx + .select({ id: staffRoles.id }) + .from(staffRoles) + .where(eq(staffRoles.normalizedName, normalizedName)) + .limit(1) + if (conflict.length > 0) { + throw new CatalogNameConflictError(name) + } + const maxRows = await tx + .select({ maxOrder: sql`max(${staffRoles.displayOrder})` }) + .from(staffRoles) + const displayOrder = (maxRows.at(0)?.maxOrder ?? 0) + 1 + + const inserted = await tx + .insert(staffRoles) + .values({ name, normalizedName, displayOrder }) + .returning() + const row = inserted.at(0) + if (row === undefined) { + throw new Error('A stábszerep létrehozása nem sikerült.') + } + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'staff_role', + entityId: row.id, + action: 'create', + before: null, + after: { name: row.name, displayOrder: row.displayOrder }, + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) +} + +/** + * Átnevezés: a szerep azonosítója változatlan marad, így a meglévő + * stábkapcsolatok nem veszhetnek el (spec BSS-012). + */ +export async function renameStaffRole( + executor: Executor, + deps: CatalogDeps, + roleId: string, + rawNewName: string, +): Promise { + assertCanManageRoles(deps.viewer) + const newName = validatedName(rawNewName) + const newNormalizedName = normalizeCatalogName(newName) + + return executor.transaction(async (tx) => { + const before = await loadRole(tx, roleId) + if (before.normalizedName !== newNormalizedName) { + const others = await executor + .select({ id: staffRoles.id }) + .from(staffRoles) + .where(eq(staffRoles.normalizedName, newNormalizedName)) + .limit(1) + if (others.length > 0) { + throw new CatalogNameConflictError(newName) + } + } + const updated = await tx + .update(staffRoles) + .set({ name: newName, normalizedName: newNormalizedName }) + .where(eq(staffRoles.id, roleId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new StaffRoleNotFoundError(roleId) + } + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'staff_role', + entityId: roleId, + action: 'rename', + before: { name: before.name }, + after: { name: row.name }, + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) +} + +/** Összevonás: a stábkapcsolatok átkerülnek a célszerephez, kapcsolatvesztés nélkül. */ +export async function mergeStaffRole( + executor: Executor, + deps: CatalogDeps, + sourceRoleId: string, + targetRoleId: string, +): Promise { + assertCanManageRoles(deps.viewer) + if (sourceRoleId === targetRoleId) { + throw new TextValidationError(['A stábszerep önmagába nem vonható össze.']) + } + + await executor.transaction(async (tx) => { + const source = await loadRole(tx, sourceRoleId) + const target = await loadRole(tx, targetRoleId) + + await tx.execute(sql` + insert into video_staff (video_id, role_id, member_sub) + select vs.video_id, ${target.id}::uuid, vs.member_sub + from video_staff vs + where vs.role_id = ${source.id}::uuid + on conflict do nothing + `) + await tx.delete(videoStaff).where(eq(videoStaff.roleId, source.id)) + await tx.delete(staffRoles).where(eq(staffRoles.id, source.id)) + + const now = (deps.clock ?? systemClock).now() + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'staff_role', + entityId: source.id, + action: 'merge', + before: { name: source.name }, + after: { mergedIntoRoleId: target.id, mergedIntoRoleName: target.name }, + occurredAt: now, + }) + }) +} + +export async function deleteStaffRole( + executor: Executor, + deps: CatalogDeps, + roleId: string, +): Promise { + assertCanManageRoles(deps.viewer) + + await executor.transaction(async (tx) => { + const role = await loadRole(tx, roleId) + const usageRows = await tx + .select({ count: sql`count(*)::int` }) + .from(videoStaff) + .where(eq(videoStaff.roleId, roleId)) + const usageCount = usageRows.at(0)?.count ?? 0 + if (usageCount > 0) { + throw new StaffRoleInUseError(role.name, usageCount) + } + + await tx.delete(staffRoles).where(eq(staffRoles.id, roleId)) + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'staff_role', + entityId: roleId, + action: 'delete', + before: { name: role.name }, + after: null, + occurredAt: (deps.clock ?? systemClock).now(), + }) + }) +} + +/** Sorrendezés: az átadott azonosítósorrend lesz a `displayOrder`. */ +export async function reorderStaffRoles( + executor: Executor, + deps: CatalogDeps, + orderedRoleIds: readonly string[], +): Promise { + assertCanManageRoles(deps.viewer) + + await executor.transaction(async (tx) => { + const existing = await tx.select({ id: staffRoles.id }).from(staffRoles) + const existingIds = new Set(existing.map((row) => row.id)) + for (const roleId of orderedRoleIds) { + if (!existingIds.has(roleId)) { + throw new StaffRoleNotFoundError(roleId) + } + } + for (const [index, roleId] of orderedRoleIds.entries()) { + await tx + .update(staffRoles) + .set({ displayOrder: index + 1 }) + .where(eq(staffRoles.id, roleId)) + } + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'staff_role', + entityId: orderedRoleIds.join(','), + action: 'reorder', + before: null, + after: { order: [...orderedRoleIds] }, + occurredAt: (deps.clock ?? systemClock).now(), + }) + }) +} + +export interface StaffRoleWithUsage { + id: string + name: string + displayOrder: number + videoCount: number +} + +export async function listStaffRolesWithUsage( + executor: Executor, +): Promise { + const rows = await executor + .select({ + id: staffRoles.id, + name: staffRoles.name, + displayOrder: staffRoles.displayOrder, + videoCount: sql`count(${videoStaff.videoId})::int`, + }) + .from(staffRoles) + .leftJoin(videoStaff, eq(videoStaff.roleId, staffRoles.id)) + .groupBy(staffRoles.id, staffRoles.name, staffRoles.displayOrder) + .orderBy(staffRoles.displayOrder, staffRoles.name) + return rows.map((row) => ({ ...row, videoCount: Number(row.videoCount) })) +} diff --git a/src/server/catalog/tags.ts b/src/server/catalog/tags.ts new file mode 100644 index 0000000..5d7d159 --- /dev/null +++ b/src/server/catalog/tags.ts @@ -0,0 +1,278 @@ +import { and, eq, ne, sql } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { can } from '#/server/auth/policy.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { tags, videoTags } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { + TEXT_LIMITS, + TextValidationError, + validateRequiredText, +} from '#/server/shared/text.ts' +import { writeAudit } from '#/server/shared/write.ts' +import { isAccentSimilar, normalizeCatalogName } from './names.ts' + +export class CatalogNameConflictError extends Error { + constructor(name: string) { + super(`„${name}" nevű címke már létezik.`) + this.name = 'CatalogNameConflictError' + } +} + +export class TagNotFoundError extends Error { + constructor(tagId: string) { + super(`A címke nem található: ${tagId}`) + this.name = 'TagNotFoundError' + } +} + +export class ConfirmationMismatchError extends Error { + constructor(expected: string) { + super(`A törlés megerősítéséhez a címkét nevén kell beírni: „${expected}".`) + this.name = 'ConfirmationMismatchError' + } +} + +function assertCanManageCatalog(viewer: Viewer): void { + if (!can.manageTagCatalog(viewer)) { + throw new ForbiddenError('A címkatalógus kezelése vezetőségi jog.') + } +} + +function validatedName(rawName: string): string { + const name = validateRequiredText('Címke', rawName, TEXT_LIMITS.tagOrRole) + return name.trim().replace(/\s+/g, ' ') +} + +async function loadTag(executor: Executor, tagId: string) { + const rows = await executor + .select() + .from(tags) + .where(eq(tags.id, tagId)) + .limit(1) + const row = rows.at(0) + if (row === undefined) { + throw new TagNotFoundError(tagId) + } + return row +} + +/** + * Ékezeti hasonlóságra figyelmeztetés (spec 7.1): az ékezet nélkül azonos, + * de eltérő normalizált nevű meglévő címkék listája. Csak figyelmeztetés, nem blokkol. + */ +export async function findAccentSimilarTagNames( + executor: Executor, + rawName: string, + options: { excludeTagId?: string } = {}, +): Promise { + if (normalizeCatalogName(rawName) === '') { + return [] + } + const candidates = await executor + .select({ id: tags.id, name: tags.name }) + .from(tags) + return candidates + .filter( + (candidate) => + options.excludeTagId === undefined || + candidate.id !== options.excludeTagId, + ) + .filter((candidate) => isAccentSimilar(rawName, candidate.name)) + .map((candidate) => candidate.name) +} + +export interface CatalogDeps { + viewer: Viewer + clock?: Clock +} + +export async function createTag( + executor: Executor, + deps: CatalogDeps, + rawName: string, +): Promise { + assertCanManageCatalog(deps.viewer) + const name = validatedName(rawName) + const normalizedName = normalizeCatalogName(name) + + const created = await executor.transaction(async (tx) => { + const conflict = await tx + .select({ id: tags.id }) + .from(tags) + .where(eq(tags.normalizedName, normalizedName)) + .limit(1) + if (conflict.length > 0) { + throw new CatalogNameConflictError(name) + } + const inserted = await tx + .insert(tags) + .values({ name, normalizedName }) + .returning() + const row = inserted.at(0) + if (row === undefined) { + throw new Error('A címke létrehozása nem sikerült.') + } + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'tag', + entityId: row.id, + action: 'create', + before: null, + after: { name: row.name }, + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) + return created +} + +export async function renameTag( + executor: Executor, + deps: CatalogDeps, + tagId: string, + rawNewName: string, +): Promise { + assertCanManageCatalog(deps.viewer) + const newName = validatedName(rawNewName) + const newNormalizedName = normalizeCatalogName(newName) + + return executor.transaction(async (tx) => { + const before = await loadTag(tx, tagId) + if (before.normalizedName !== newNormalizedName) { + const conflict = await tx + .select({ id: tags.id }) + .from(tags) + .where( + and(eq(tags.normalizedName, newNormalizedName), ne(tags.id, tagId)), + ) + .limit(1) + if (conflict.length > 0) { + throw new CatalogNameConflictError(newName) + } + } + const updated = await tx + .update(tags) + .set({ name: newName, normalizedName: newNormalizedName }) + .where(eq(tags.id, tagId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new TagNotFoundError(tagId) + } + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'tag', + entityId: tagId, + action: 'rename', + before: { name: before.name }, + after: { name: row.name }, + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) +} + +/** + * Összevonás (spec 7.1): minden videókapcsolat a célcímkére kerül, a forrás + * címke törlődik — egy tranzakcióban. Duplikált videó-címke párok elvesznek. + */ +export async function mergeTag( + executor: Executor, + deps: CatalogDeps, + sourceTagId: string, + targetTagId: string, +): Promise { + assertCanManageCatalog(deps.viewer) + if (sourceTagId === targetTagId) { + throw new TextValidationError(['A címke önmagába nem vonható össze.']) + } + + await executor.transaction(async (tx) => { + const source = await loadTag(tx, sourceTagId) + const target = await loadTag(tx, targetTagId) + + await tx.execute(sql` + insert into video_tags (video_id, tag_id) + select vt.video_id, ${target.id}::uuid + from video_tags vt + where vt.tag_id = ${source.id}::uuid + on conflict do nothing + `) + await tx.delete(videoTags).where(eq(videoTags.tagId, source.id)) + await tx.delete(tags).where(eq(tags.id, source.id)) + + const now = (deps.clock ?? systemClock).now() + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'tag', + entityId: source.id, + action: 'merge', + before: { name: source.name }, + after: { mergedIntoTagId: target.id, mergedIntoTagName: target.name }, + occurredAt: now, + }) + }) +} + +/** Használatban lévő címke csak figyelmeztetés és pontos név-beírás után törölhető. */ +export async function deleteTag( + executor: Executor, + deps: CatalogDeps, + tagId: string, + confirmation?: string, +): Promise<{ deletedVideoLinks: number }> { + assertCanManageCatalog(deps.viewer) + + return executor.transaction(async (tx) => { + const tag = await loadTag(tx, tagId) + const usageRows = await tx + .select({ count: sql`count(*)::int` }) + .from(videoTags) + .where(eq(videoTags.tagId, tagId)) + const usageCount = usageRows.at(0)?.count ?? 0 + + if (usageCount > 0 && confirmation?.trim() !== tag.name) { + throw new ConfirmationMismatchError(tag.name) + } + + await tx.delete(videoTags).where(eq(videoTags.tagId, tagId)) + await tx.delete(tags).where(eq(tags.id, tagId)) + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'tag', + entityId: tagId, + action: 'delete', + before: { name: tag.name, usageCount }, + after: null, + occurredAt: (deps.clock ?? systemClock).now(), + }) + return { deletedVideoLinks: usageCount } + }) +} + +export interface TagWithUsage { + id: string + name: string + videoCount: number +} + +/** Katalóguslista használati számmal (figyelmeztetés és admin UI alapja). */ +export async function listTagsWithUsage( + executor: Executor, +): Promise { + const rows = await executor + .select({ + id: tags.id, + name: tags.name, + videoCount: sql`count(${videoTags.videoId})::int`, + }) + .from(tags) + .leftJoin(videoTags, eq(videoTags.tagId, tags.id)) + .groupBy(tags.id, tags.name) + .orderBy(tags.name) + return rows.map((row) => ({ ...row, videoCount: Number(row.videoCount) })) +} diff --git a/src/server/events/domain.ts b/src/server/events/domain.ts new file mode 100644 index 0000000..33b78e6 --- /dev/null +++ b/src/server/events/domain.ts @@ -0,0 +1,514 @@ +import { desc, eq, sql } from 'drizzle-orm' +import type { OobConfig } from '#/server/config/oob-schema.ts' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { can } from '#/server/auth/policy.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { events, slugHistory, videos } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { + TEXT_LIMITS, + TextValidationError, + validatePlainText, + validateRequiredText, +} from '#/server/shared/text.ts' +import { + EntityNotFoundError, + StaleWriteError, + writeAudit, +} from '#/server/shared/write.ts' +import { + checkMediaUrlShape, + validateMediaForPublish, +} from '#/server/media/validator.ts' +import { + findFreeSlug, + renameSlugWithHistory, + slugify, +} from '#/server/shared/slug.ts' + +export class EventConfirmationError extends Error { + constructor(title: string) { + super( + `A végleges törlés megerősítéséhez az esemény címét kell beírni: „${title}".`, + ) + this.name = 'EventConfirmationError' + } +} + +export interface EventDeps { + viewer: Viewer + clock?: Clock + mediaConfig: OobConfig['media'] + fetchImpl?: typeof fetch +} + +export interface EventInput { + title?: string + description?: string | null + thumbnailUrl?: string | null + startDate?: string | null + endDate?: string | null +} + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +function assertContentEditor(viewer: Viewer): void { + if (!can.createOrEditContent(viewer)) { + throw new ForbiddenError( + 'Eseményt csak bejelentkezett BSS-tag hozhat létre vagy szerkeszthet.', + ) + } +} + +async function loadEvent(executor: Executor, eventId: string) { + const rows = await executor + .select() + .from(events) + .where(eq(events.id, eventId)) + .limit(1) + const row = rows.at(0) + if (row === undefined) { + throw new EntityNotFoundError('event', eventId) + } + return row +} + +/** Naptári dátum mező ellenőrzése (időzóna nélküli dátum, spec 4.4). */ +function validateDateField( + fieldName: string, + value: string | null | undefined, + options: { required?: boolean } = {}, +): string | null { + if (value === null || value === undefined || value.trim() === '') { + if (options.required) { + throw new TextValidationError([`${fieldName}: kötelező mező.`]) + } + return null + } + if (!DATE_PATTERN.test(value)) { + throw new TextValidationError([ + `${fieldName}: éééé-hh-nn formátumú naptári dátum kell (kapott érték: "${value}").`, + ]) + } + return value +} + +/** + * Mezők validálása a jelenlegi sor mellett: részbeni frissítésnél a + * befejezés >= kezdés összefüggés az egyesített állapotra érvényes. + */ +function validatedChanges( + current: Pick, + input: EventInput, + mediaConfig: OobConfig['media'], +): Record { + const changes: Record = {} + + if (input.title !== undefined) { + changes.title = validateRequiredText('Cím', input.title, TEXT_LIMITS.title) + } + if (input.description !== undefined) { + changes.description = validatePlainText( + 'Leírás', + input.description, + TEXT_LIMITS.description, + ) + } + + const startDate = + input.startDate !== undefined + ? validateDateField('Kezdődátum', input.startDate) + : current.startDate + const endDate = + input.endDate !== undefined + ? validateDateField('Befejezés dátuma', input.endDate) + : current.endDate + + if (input.startDate !== undefined) changes.startDate = startDate + if (input.endDate !== undefined) changes.endDate = endDate + + if (endDate !== null && startDate !== null && endDate < startDate) { + throw new TextValidationError([ + `A befejezés nem lehet korábbi a kezdésnél (${startDate} > ${endDate}).`, + ]) + } + + if (input.thumbnailUrl !== undefined) { + const trimmed = input.thumbnailUrl?.trim() ?? '' + if (trimmed === '') { + changes.thumbnailUrl = null + } else { + validatePlainText('Thumbnail URL', trimmed, TEXT_LIMITS.url) + const shape = checkMediaUrlShape(trimmed, 'thumbnail', mediaConfig) + if (!shape.ok) { + throw new TextValidationError(shape.problems) + } + changes.thumbnailUrl = trimmed + } + } + + return changes +} + +/** Publikálási feltételek (spec 6.1): cím és kezdődátum kötelező. */ +function assertPublishable(row: typeof events.$inferSelect): void { + const problems: string[] = [] + if (row.title.trim() === '') { + problems.push('Cím: kötelező mező.') + } + if (row.startDate === null || row.startDate === '') { + problems.push('Kezdődátum: publikáláshoz kötelező megadni.') + } + if (problems.length > 0) { + throw new TextValidationError(problems) + } +} + +/** Publikáláskor a meglévő thumbnail hálózati ellenőrzése (hibás média blokkol). */ +async function assertThumbnailReachable( + row: typeof events.$inferSelect, + deps: EventDeps, +): Promise { + if (row.thumbnailUrl === null || row.thumbnailUrl === '') { + return + } + const result = await validateMediaForPublish({ + url: row.thumbnailUrl, + kind: 'thumbnail', + mediaConfig: deps.mediaConfig, + fetchImpl: deps.fetchImpl, + }) + if (!result.ok) { + throw new TextValidationError(result.problems) + } +} + +export async function createEvent( + executor: Executor, + deps: EventDeps, + input: EventInput & { slug?: string }, +): Promise { + assertContentEditor(deps.viewer) + + return executor.transaction(async (tx) => { + // Piszkozathoz csak cím kell (spec 6.1). + const title = validateRequiredText('Cím', input.title, TEXT_LIMITS.title) + const emptyCurrent = { + startDate: null as string | null, + endDate: null as string | null, + } + const changes = validatedChanges( + emptyCurrent, + { ...input, title }, + deps.mediaConfig, + ) + const slugBase = input.slug ?? title + const slug = await findFreeSlug(tx, 'event', slugify(slugBase)) + + const inserted = await tx + .insert(events) + .values({ + slug, + title, + description: (changes.description as string | null) ?? null, + thumbnailUrl: (changes.thumbnailUrl as string | null) ?? null, + startDate: (changes.startDate as string | null) ?? null, + endDate: (changes.endDate as string | null) ?? null, + status: 'draft', + createdBy: deps.viewer.sub, + updatedBy: deps.viewer.sub, + }) + .returning() + const row = inserted.at(0) + if (row === undefined) { + throw new Error('Az esemény létrehozása nem sikerült.') + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'event', + entityId: row.id, + action: 'create', + before: null, + after: snapshotEvent(row), + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) +} + +export interface UpdateEventParams extends EventInput { + /** Kifejezett slug módosítás; átirányítási előzménnyel. */ + slug?: string +} + +export async function updateEvent( + executor: Executor, + deps: EventDeps, + eventId: string, + expectedVersion: number, + input: UpdateEventParams, +): Promise { + assertContentEditor(deps.viewer) + + return executor.transaction(async (tx) => { + const locked = await lockEvent(tx, eventId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('esemény') + } + + const changes = validatedChanges(locked, input, deps.mediaConfig) + let nextSlug = locked.slug + if (input.slug !== undefined) { + nextSlug = await renameSlugWithHistory(tx, { + entityType: 'event', + entityId: eventId, + currentSlug: locked.slug, + newSlugBase: slugify(input.slug), + now: (deps.clock ?? systemClock).now(), + }) + changes.slug = nextSlug + } + + const updated = await tx + .update(events) + .set({ + ...changes, + version: locked.version + 1, + updatedAt: (deps.clock ?? systemClock).now(), + updatedBy: deps.viewer.sub, + }) + .where(eq(events.id, eventId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('event', eventId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'event', + entityId: eventId, + action: 'update', + before: snapshotEvent(locked), + after: snapshotEvent(row), + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) +} + +/** Publikálás: piszkózatból vagy archiváltból, minden feltétellel (spec 6.1). */ +export async function publishEvent( + executor: Executor, + deps: EventDeps, + eventId: string, + expectedVersion: number, +): Promise { + assertContentEditor(deps.viewer) + + const current = await loadEvent(executor, eventId) + assertPublishable(current) + await assertThumbnailReachable(current, deps) + + return transitionEventStatus( + executor, + deps, + eventId, + expectedVersion, + 'published', + ) +} + +export async function archiveEvent( + executor: Executor, + deps: EventDeps, + eventId: string, + expectedVersion: number, +): Promise { + assertContentEditor(deps.viewer) + return transitionEventStatus( + executor, + deps, + eventId, + expectedVersion, + 'archived', + ) +} + +async function transitionEventStatus( + executor: Executor, + deps: EventDeps, + eventId: string, + expectedVersion: number, + status: 'published' | 'archived', +): Promise { + return executor.transaction(async (tx) => { + const locked = await lockEvent(tx, eventId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('esemény') + } + if (status === 'published') { + assertPublishable(locked) + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(events) + .set({ + status, + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(events.id, eventId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('event', eventId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'event', + entityId: eventId, + action: status === 'published' ? 'publish' : 'archive', + before: snapshotEvent(locked), + after: snapshotEvent(row), + occurredAt: now, + }) + return row + }) +} + +/** + * Végleges eseménytörlés (spec 6.4): vezetőségi jog + címbeírás. + * Egy tranzakcióban: videók leválasztása (`recordedAt` megmarad), a régi slug + * történetbe foglalása (újrahasználat tiltva), az esemény törlése, teljes audit. + */ +export async function permanentlyDeleteEvent( + executor: Executor, + deps: EventDeps, + eventId: string, + confirmationTitle: string, +): Promise<{ detachedVideoIds: string[] }> { + if (!can.permanentlyDeleteEvent(deps.viewer)) { + throw new ForbiddenError( + 'Eseményt véglegesen csak vezetőségi tag törölhet.', + ) + } + + return executor.transaction(async (tx) => { + const locked = await lockEvent(tx, eventId) + if (confirmationTitle.trim() !== locked.title) { + throw new EventConfirmationError(locked.title) + } + + const attached = await tx + .select({ id: videos.id }) + .from(videos) + .where(eq(videos.eventId, eventId)) + const detachedVideoIds = attached.map((row) => row.id) + + // A videók `recordedAt` értéke megmarad; csak az eseménykapcsolat szűnik meg. + await tx + .update(videos) + .set({ eventId: null }) + .where(eq(videos.eventId, eventId)) + + // A slug a történetbe kerül: végleges törlés után sem használható fel újra. + await tx.insert(slugHistory).values({ + entityType: 'event', + slug: locked.slug, + entityId: locked.id, + createdAt: (deps.clock ?? systemClock).now(), + }) + + await tx.delete(events).where(eq(events.id, eventId)) + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'event', + entityId: eventId, + action: 'delete_permanent', + before: { ...snapshotEvent(locked), detachedVideoIds }, + after: null, + occurredAt: (deps.clock ?? systemClock).now(), + }) + return { detachedVideoIds } + }) +} + +async function lockEvent(executor: Executor, eventId: string) { + const rows = await executor + .select() + .from(events) + .where(eq(events.id, eventId)) + .for('update') + .limit(1) + const row = rows.at(0) + if (row === undefined) { + throw new EntityNotFoundError('event', eventId) + } + return row +} + +export async function getEventBySlug( + executor: Executor, + slug: string, +): Promise { + const rows = await executor + .select() + .from(events) + .where(eq(events.slug, slug)) + .limit(1) + return rows.at(0) ?? null +} + +export interface ListEventsOptions { + status?: 'draft' | 'published' | 'archived' + limit?: number + offset?: number +} + +/** Eseménylista kezdődátum szerint csökkenő sorrendben (spec 6.3). */ +export async function listEvents( + executor: Executor, + options: ListEventsOptions = {}, +): Promise<{ items: Array; total: number }> { + const limit = options.limit ?? 50 + const offset = options.offset ?? 0 + const where = + options.status === undefined ? undefined : eq(events.status, options.status) + + const items = await executor + .select() + .from(events) + .where(where) + .orderBy(desc(events.startDate), desc(events.id)) + .limit(limit) + .offset(offset) + + const countRows = await executor + .select({ count: sql`count(*)::int` }) + .from(events) + .where(where) + return { items, total: countRows.at(0)?.count ?? 0 } +} + +function snapshotEvent( + row: typeof events.$inferSelect, +): Record { + return { + slug: row.slug, + title: row.title, + description: row.description, + thumbnailUrl: row.thumbnailUrl, + startDate: row.startDate, + endDate: row.endDate, + status: row.status, + version: row.version, + } +} diff --git a/src/server/homepage/about.ts b/src/server/homepage/about.ts new file mode 100644 index 0000000..18273ee --- /dev/null +++ b/src/server/homepage/about.ts @@ -0,0 +1,103 @@ +import { and, asc, eq, getTableColumns } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { can } from '#/server/auth/policy.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { aboutPageVideos, videos } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { writeAudit } from '#/server/shared/write.ts' + +export const ABOUT_VIDEO_LIMIT = 6 + +/** + * Rólunk-videólista beállítása (spec 10.1): legfeljebb hat, vezetőség által + * választott és sorrendezett publikus videó. + */ +export async function setAboutVideos( + executor: Executor, + params: { + viewer: Viewer + orderedVideoIds: readonly string[] + clock?: Clock + }, +): Promise { + if (!can.manageHomepageSettings(params.viewer)) { + throw new ForbiddenError('A Rólunk-videók kezelése vezetőségi jog.') + } + if (params.orderedVideoIds.length > ABOUT_VIDEO_LIMIT) { + throw new Error( + `Legfeljebb ${ABOUT_VIDEO_LIMIT} videó helyezhető a Rólunk oldalra.`, + ) + } + const ids = [...new Set(params.orderedVideoIds)] + if (ids.length !== params.orderedVideoIds.length) { + throw new Error('Duplikált videó nem szerepelhet a Rólunk-listában.') + } + + await executor.transaction(async (tx) => { + for (const videoId of ids) { + const rows = await tx + .select({ id: videos.id }) + .from(videos) + .where( + and( + eq(videos.id, videoId), + eq(videos.status, 'published'), + eq(videos.visibility, 'public'), + ), + ) + .limit(1) + if (rows.length === 0) { + throw new Error( + 'Csak publikált, publikus videó helyezhető a Rólunk oldalra.', + ) + } + } + + const beforeRows = await tx + .select({ + videoId: aboutPageVideos.videoId, + position: aboutPageVideos.position, + }) + .from(aboutPageVideos) + .orderBy(asc(aboutPageVideos.position)) + const beforeIds = beforeRows.map((row) => row.videoId) + + await tx.delete(aboutPageVideos) + for (const [index, videoId] of ids.entries()) { + await tx.insert(aboutPageVideos).values({ position: index + 1, videoId }) + } + + await writeAudit(tx, { + actor: params.viewer.sub ?? '', + entityType: 'about_page', + entityId: 'videos', + action: 'update', + before: { videoIds: beforeIds }, + after: { videoIds: ids }, + occurredAt: (params.clock ?? systemClock).now(), + }) + }) +} + +/** + * Rólunk-oldal videói: a listából automatikusan kiesik az archivált, + * lomtári vagy nem publikus videó (SQL-szintű szűrés). + */ +export async function getAboutPageVideos( + executor: Executor, +): Promise> { + return executor + .select({ ...getTableColumns(videos) }) + .from(aboutPageVideos) + .innerJoin( + videos, + and( + eq(videos.id, aboutPageVideos.videoId), + eq(videos.status, 'published'), + eq(videos.visibility, 'public'), + ), + ) + .orderBy(asc(aboutPageVideos.position)) +} diff --git a/src/server/homepage/highlight.ts b/src/server/homepage/highlight.ts new file mode 100644 index 0000000..be60942 --- /dev/null +++ b/src/server/homepage/highlight.ts @@ -0,0 +1,83 @@ +import { and, eq } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { can } from '#/server/auth/policy.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { siteSettings, videos } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { writeAudit } from '#/server/shared/write.ts' + +/** + * Kiemelés (spec 9.2): csak publikált, publikus videó emelhető ki; a + * kiemelés nem időzíthető. Az érvénytelenedést a BSS-014 életciklus kezeli + * ugyanabban a tranzakcióban. + */ +export async function setHighlightedVideo( + executor: Executor, + params: { + viewer: Viewer + videoId: string | null + clock?: Clock + }, +): Promise { + if (!can.manageHomepageSettings(params.viewer)) { + throw new ForbiddenError('A kiemelés kezelése vezetőségi jog.') + } + + await executor.transaction(async (tx) => { + if (params.videoId !== null) { + const rows = await tx + .select({ id: videos.id }) + .from(videos) + .where( + and( + eq(videos.id, params.videoId), + eq(videos.status, 'published'), + eq(videos.visibility, 'public'), + ), + ) + .limit(1) + if (rows.length === 0) { + throw new Error('Csak publikált, publikus videó emelhető ki.') + } + } + + const beforeRows = await tx + .select() + .from(siteSettings) + .where(eq(siteSettings.id, 0)) + .limit(1) + const before = beforeRows.at(0)?.highlightedVideoId ?? null + const now = (params.clock ?? systemClock).now() + + await tx + .insert(siteSettings) + .values({ id: 0, highlightedVideoId: params.videoId, updatedAt: now }) + .onConflictDoUpdate({ + target: siteSettings.id, + set: { highlightedVideoId: params.videoId, updatedAt: now }, + }) + + await writeAudit(tx, { + actor: params.viewer.sub ?? '', + entityType: 'site_settings', + entityId: 'highlight', + action: 'update', + before: { highlightedVideoId: before }, + after: { highlightedVideoId: params.videoId }, + occurredAt: now, + }) + }) +} + +export async function getHighlightedVideoId( + executor: Executor, +): Promise { + const rows = await executor + .select() + .from(siteSettings) + .where(eq(siteSettings.id, 0)) + .limit(1) + return rows.at(0)?.highlightedVideoId ?? null +} diff --git a/src/server/homepage/live.ts b/src/server/homepage/live.ts new file mode 100644 index 0000000..67ee0c2 --- /dev/null +++ b/src/server/homepage/live.ts @@ -0,0 +1,450 @@ +import { and, desc, eq, lte, sql } from 'drizzle-orm' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { can } from '#/server/auth/policy.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { liveStreams } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { writeAudit } from '#/server/shared/write.ts' +import type { JobDefinition } from '#/server/jobs/runner.ts' +import { validateYoutubeVideo } from '#/server/media/youtube.ts' + +export class LiveOverlapError extends Error { + constructor() { + super( + 'Az időablak egymást átfedő live-ot tartalmazna. Válassz más kezdési és befejezési időt.', + ) + this.name = 'LiveOverlapError' + } +} + +export interface LiveDeps { + viewer: Viewer + clock?: Clock + fetchImpl?: typeof fetch +} + +function assertLeadership(viewer: Viewer): void { + if (!can.manageHomepageSettings(viewer)) { + throw new ForbiddenError('A live beállítások kezelése vezetőségi jog.') + } +} + +async function loadStream(executor: Executor, id: string) { + const rows = await executor + .select() + .from(liveStreams) + .where(eq(liveStreams.id, id)) + .limit(1) + return rows.at(0) ?? null +} + +/** Átfedés-ellenőrzés alkalmazásoldalon (a DB EXCLUDE korlát is védi). */ +async function assertNoOverlap( + executor: Executor, + window: { startsAt: Date; endsAt: Date }, + options: { excludeId?: string } = {}, +): Promise { + const conditions = [ + sql`${liveStreams.status} <> 'ended'`, + lte(liveStreams.startsAt, window.endsAt), + sql`${liveStreams.endsAt} >= ${window.startsAt}`, + ] + if (options.excludeId !== undefined) { + conditions.push(sql`${liveStreams.id} <> ${options.excludeId}`) + } + const overlapping = await executor + .select({ id: liveStreams.id }) + .from(liveStreams) + .where(and(...conditions)) + .limit(1) + if (overlapping.length > 0) { + throw new LiveOverlapError() + } +} + +export interface CreateLiveInput { + /** Bármely elfogadott YouTube URL-forma; normalizálásra kerül. */ + youtubeUrl: string + startsAt: Date + endsAt: Date +} + +/** + * Live ütemezése (spec 9.3): kötelező kezdés és befejezés, átfedés tiltva. + * Mentéskor oEmbed ellenőrzés fut; a hibás azonosító nem menthető. + */ +export async function createLiveSchedule( + executor: Executor, + deps: LiveDeps, + input: CreateLiveInput, +): Promise { + assertLeadership(deps.viewer) + if (input.endsAt.getTime() <= input.startsAt.getTime()) { + throw new Error('A befejezési időnek a kezdés után kell lennie.') + } + + const check = await validateYoutubeVideo( + input.youtubeUrl, + { + oEmbedEndpoint: 'https://www.youtube.com/oEmbed', + }, + { fetchImpl: deps.fetchImpl }, + ) + if (!check.ok || check.videoId === null) { + throw new Error(check.problems.join(' ')) + } + + await assertNoOverlap(executor, { + startsAt: input.startsAt, + endsAt: input.endsAt, + }) + + const inserted = await executor + .insert(liveStreams) + .values({ + youtubeVideoId: check.videoId, + startsAt: input.startsAt, + endsAt: input.endsAt, + status: 'scheduled', + createdBy: deps.viewer.sub, + }) + .returning() + const row = inserted.at(0) + if (row === undefined) { + throw new Error('A live ütemezés mentése nem sikerült.') + } + + await writeAudit(executor, { + actor: deps.viewer.sub ?? '', + entityType: 'live_stream', + entityId: row.id, + action: 'create', + before: null, + after: snapshotLive(row), + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row +} + +function snapshotLive( + row: typeof liveStreams.$inferSelect, +): Record { + return { + youtubeVideoId: row.youtubeVideoId, + startsAt: row.startsAt.toISOString(), + endsAt: row.endsAt.toISOString(), + status: row.status, + } +} + +/** Ütemezett live időablakának módosítása; befejezett live már nem módosítható. */ +export async function rescheduleLive( + executor: Executor, + deps: LiveDeps, + liveId: string, + input: { startsAt: Date; endsAt: Date }, +): Promise { + assertLeadership(deps.viewer) + if (input.endsAt.getTime() <= input.startsAt.getTime()) { + throw new Error('A befejezési időnek a kezdés után kell lennie.') + } + + const current = await loadStream(executor, liveId) + if (current === null) { + throw new Error('A live ütemezés nem található.') + } + if (current.status === 'ended') { + // Korábbi live csak másolatként ütemezhető újra (spec 9.3). + throw new Error( + 'Befejezett live nem módosítható; csak másolatként ütemezhető újra.', + ) + } + + await assertNoOverlap(executor, input, { excludeId: liveId }) + + const updated = await executor + .update(liveStreams) + .set({ + startsAt: input.startsAt, + endsAt: input.endsAt, + updatedAt: (deps.clock ?? systemClock).now(), + }) + .where(eq(liveStreams.id, liveId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new Error('A live ütemezés módosítása nem sikerült.') + } + + await writeAudit(executor, { + actor: deps.viewer.sub ?? '', + entityType: 'live_stream', + entityId: liveId, + action: 'reschedule', + before: snapshotLive(current), + after: snapshotLive(row), + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row +} + +/** + * `Indítás most` (spec 9.3): aktiváláskor oEmbed ellenőrzés fut; hibánál a + * hiba rögzítésre kerül és a live ütemezetten marad (a homepage fallbacket mutat). + */ +export async function startLiveNow( + executor: Executor, + deps: LiveDeps & { youtubeConfig?: { oEmbedEndpoint: string } }, + liveId: string, +): Promise<{ stream: typeof liveStreams.$inferSelect; activated: boolean }> { + assertLeadership(deps.viewer) + + const current = await loadStream(executor, liveId) + if (current === null) { + throw new Error('A live ütemezés nem található.') + } + + const check = await validateYoutubeVideo( + `https://www.youtube.com/watch?v=${current.youtubeVideoId}`, + deps.youtubeConfig ?? { oEmbedEndpoint: 'https://www.youtube.com/oEmbed' }, + { fetchImpl: deps.fetchImpl }, + ) + const now = (deps.clock ?? systemClock).now() + if (!check.ok) { + // Fallback engedélyezett: a hiba rögzül, a live ütemezetten marad. + const updated = await executor + .update(liveStreams) + .set({ activationError: check.problems.join(' '), updatedAt: now }) + .where(eq(liveStreams.id, liveId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new Error('A live állapota nem frissíthető.') + } + await writeAudit(executor, { + actor: deps.viewer.sub ?? '', + entityType: 'live_stream', + entityId: liveId, + action: 'activation_failed', + before: snapshotLive(current), + after: { error: row.activationError }, + occurredAt: now, + }) + return { stream: row, activated: false } + } + + await assertNoOverlap( + executor, + { startsAt: now, endsAt: current.endsAt }, + { excludeId: liveId }, + ) + + const updated = await executor + .update(liveStreams) + .set({ + status: 'active', + startsAt: now, + activatedAt: now, + activationError: null, + updatedAt: now, + }) + .where(eq(liveStreams.id, liveId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new Error('A live állapota nem frissíthető.') + } + + await writeAudit(executor, { + actor: deps.viewer.sub ?? '', + entityType: 'live_stream', + entityId: liveId, + action: 'activate', + before: snapshotLive(current), + after: snapshotLive(row), + occurredAt: now, + }) + return { stream: row, activated: true } +} + +/** `Lezárás most`: a live azonnal befejezett állapotba kerül. */ +export async function endLiveNow( + executor: Executor, + deps: LiveDeps, + liveId: string, +): Promise { + assertLeadership(deps.viewer) + + const current = await loadStream(executor, liveId) + if (current === null) { + throw new Error('A live ütemezés nem található.') + } + + const now = (deps.clock ?? systemClock).now() + const updated = await executor + .update(liveStreams) + .set({ + status: 'ended', + endedAt: now, + // Csak futó live esetén rövidül az ablak a lezárási időpontra; + // a befejezés nem lehet korábbi a kezdésnél (DB check). + ...(now > current.startsAt && now < current.endsAt + ? { endsAt: now } + : {}), + updatedAt: now, + }) + .where(eq(liveStreams.id, liveId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new Error('A live állapota nem frissíthető.') + } + + await writeAudit(executor, { + actor: deps.viewer.sub ?? '', + entityType: 'live_stream', + entityId: liveId, + action: 'end', + before: snapshotLive(current), + after: snapshotLive(row), + occurredAt: now, + }) + return row +} + +/** Ütemezett live törlése; aktív vagy befejezett nem törölhető. */ +export async function deleteScheduledLive( + executor: Executor, + deps: LiveDeps, + liveId: string, +): Promise { + assertLeadership(deps.viewer) + const current = await loadStream(executor, liveId) + if (current === null) { + throw new Error('A live ütemezés nem található.') + } + if (current.status !== 'scheduled') { + throw new Error('Csak ütemezett live törölhető.') + } + await executor.delete(liveStreams).where(eq(liveStreams.id, liveId)) + await writeAudit(executor, { + actor: deps.viewer.sub ?? '', + entityType: 'live_stream', + entityId: liveId, + action: 'delete', + before: snapshotLive(current), + after: null, + occurredAt: (deps.clock ?? systemClock).now(), + }) +} + +/** + * Háttérfeladat (spec 15): a lejárt ütemezések aktiválása/bezárása. + * Csak tényleges változásnál ír auditot (`system` szereplővel). + */ +export async function transitionLiveStates( + executor: Executor, + options: { now: Date }, +): Promise<{ activated: number; ended: number }> { + let activated = 0 + let ended = 0 + + const toActivate = await executor + .select() + .from(liveStreams) + .where( + and( + sql`${liveStreams.status} = 'scheduled'`, + lte(liveStreams.startsAt, options.now), + ), + ) + for (const stream of toActivate) { + const updated = await executor + .update(liveStreams) + .set({ + status: 'active', + activatedAt: options.now, + updatedAt: options.now, + }) + .where( + and(eq(liveStreams.id, stream.id), eq(liveStreams.status, 'scheduled')), + ) + .returning({ id: liveStreams.id }) + if (updated.length > 0) { + activated += 1 + await writeAudit(executor, { + actor: 'system', + entityType: 'live_stream', + entityId: stream.id, + action: 'activate', + before: snapshotLive(stream), + after: { status: 'active' }, + occurredAt: options.now, + }) + } + } + + const toEnd = await executor + .select() + .from(liveStreams) + .where( + and( + sql`${liveStreams.status} in ('scheduled','active')`, + lte(liveStreams.endsAt, options.now), + ), + ) + for (const stream of toEnd) { + const updated = await executor + .update(liveStreams) + .set({ status: 'ended', endedAt: stream.endsAt, updatedAt: options.now }) + .where( + and( + eq(liveStreams.id, stream.id), + sql`${liveStreams.status} <> 'ended'`, + ), + ) + .returning({ id: liveStreams.id }) + if (updated.length > 0) { + ended += 1 + await writeAudit(executor, { + actor: 'system', + entityType: 'live_stream', + entityId: stream.id, + action: 'end', + before: snapshotLive(stream), + after: { status: 'ended' }, + occurredAt: options.now, + }) + } + } + + return { activated, ended } +} + +/** Admin előzmény: minden live legutóbbiekkel előre (publikus archívumba soha). */ +/** Perces feladat a live állapotváltásokra (spec 15): a runner extra feladata. */ +export function createLiveTransitionJob(deps: { + clock?: Clock + db: () => Promise +}): JobDefinition { + return { + name: 'live-state-transitions', + intervalMs: 60_000, + run: async (ctx) => { + const executor = await deps.db() + await transitionLiveStates(executor, { + now: (deps.clock ?? ctx.clock).now(), + }) + }, + } +} + +export async function listLiveHistory( + executor: Executor, + viewer: Viewer, +): Promise> { + assertLeadership(viewer) + return executor.select().from(liveStreams).orderBy(desc(liveStreams.startsAt)) +} diff --git a/src/server/homepage/state.ts b/src/server/homepage/state.ts new file mode 100644 index 0000000..7a0d663 --- /dev/null +++ b/src/server/homepage/state.ts @@ -0,0 +1,166 @@ +import { and, desc, eq, gte, lte, ne, sql } from 'drizzle-orm' +import { buildYoutubeNocookieEmbedUrl } from '#/server/media/youtube.ts' +import { events, liveStreams, videos } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { getHighlightedVideoId } from './highlight.ts' + +const PUBLIC_PUBLISHED = [ + eq(videos.status, 'published'), + eq(videos.visibility, 'public'), +] + +async function latestPublicVideos( + executor: Executor, + limit: number, + excludeVideoId?: string | null, +): Promise> { + const conditions = [...PUBLIC_PUBLISHED] + if (excludeVideoId !== undefined && excludeVideoId !== null) { + conditions.push(ne(videos.id, excludeVideoId)) + } + return executor + .select() + .from(videos) + .where(and(...conditions)) + .orderBy(desc(videos.publishedAt), desc(videos.id)) + .limit(limit) +} + +async function latestPublicEvents( + executor: Executor, + limit: number, +): Promise> { + return executor + .select() + .from(events) + .where(eq(events.status, 'published')) + .orderBy(desc(events.startDate), desc(events.id)) + .limit(limit) +} + +/** + * `Adás hamarosan` sáv (spec 9.3): a kezdés előtti 24 órában jelenik meg. + * A sáv nem váltja le a normál vagy kiemelt hero tartalmát. + */ +export async function getUpcomingLive( + executor: Executor, + options: { now: Date; withinMs?: number }, +): Promise<{ + stream: typeof liveStreams.$inferSelect + embedUrl: string +} | null> { + const withinMs = options.withinMs ?? 24 * 60 * 60 * 1000 + const horizon = new Date(options.now.getTime() + withinMs) + const rows = await executor + .select() + .from(liveStreams) + .where( + and( + eq(liveStreams.status, 'scheduled'), + gte(liveStreams.startsAt, options.now), + lte(liveStreams.startsAt, horizon), + ), + ) + .orderBy(liveStreams.startsAt) + .limit(1) + const stream = rows.at(0) + if (stream === undefined) { + return null + } + return { + stream, + embedUrl: buildYoutubeNocookieEmbedUrl(stream.youtubeVideoId), + } +} + +export interface HomepageState { + priority: 'live' | 'highlight' | 'normal' + /** Aktív live esetén a nocookie embed URL. */ + liveEmbedUrl?: string + liveStream?: typeof liveStreams.$inferSelect + /** Kiemelt állapotban a hero videó. */ + heroVideo?: typeof videos.$inferSelect + /** Live/kiemelt állapotban öt, normálban hat legutóbbi publikus videó. */ + sideVideos: Array + events: Array + /** `Adás hamarosan` sáv adatai, ha van 24 órán belül kezdődő ütemezés. */ + upcomingLive?: { startsAt: Date; embedUrl: string } +} + +/** + * Homepage számított prioritás (spec 9.1): aktív live > kiemelt videó > normál. + * Minden lekérdezés publikált és publikus videókra szűkít az SQL-ben. + */ +export async function getHomepageState( + executor: Executor, + options: { now: Date }, +): Promise { + const now = options.now + + const activeLiveRows = await executor + .select() + .from(liveStreams) + .where( + and( + sql`${liveStreams.status} = 'active'`, + lte(liveStreams.startsAt, now), + gte(liveStreams.endsAt, now), + ), + ) + .orderBy(desc(liveStreams.activatedAt)) + .limit(1) + const activeLive = activeLiveRows.at(0) + + if (activeLive === undefined) { + const highlightedId = await getHighlightedVideoId(executor) + if (highlightedId !== null) { + const heroRows = await executor + .select() + .from(videos) + .where(and(...PUBLIC_PUBLISHED, eq(videos.id, highlightedId))) + .limit(1) + const heroVideo = heroRows.at(0) + // Nem publikus vagy archivált videó nem marad kiemelve megjelenítésben sem. + if (heroVideo !== undefined) { + return { + priority: 'highlight', + heroVideo, + sideVideos: await latestPublicVideos(executor, 5, heroVideo.id), + events: await latestPublicEvents(executor, 6), + ...(await upcomingField(executor, now)), + } + } + } + return { + priority: 'normal', + sideVideos: await latestPublicVideos(executor, 6), + events: await latestPublicEvents(executor, 6), + ...(await upcomingField(executor, now)), + } + } + + return { + priority: 'live', + liveStream: activeLive, + liveEmbedUrl: buildYoutubeNocookieEmbedUrl(activeLive.youtubeVideoId), + sideVideos: await latestPublicVideos(executor, 5), + events: await latestPublicEvents(executor, 6), + ...(await upcomingField(executor, now)), + } +} + +async function upcomingField( + executor: Executor, + now: Date, +): Promise<{ upcomingLive?: { startsAt: Date; embedUrl: string } }> { + const upcoming = await getUpcomingLive(executor, { now }) + if (upcoming === null) { + return {} + } + return { + upcomingLive: { + startsAt: upcoming.stream.startsAt, + embedUrl: upcoming.embedUrl, + }, + } +} diff --git a/src/server/search/service.ts b/src/server/search/service.ts new file mode 100644 index 0000000..c4393d8 --- /dev/null +++ b/src/server/search/service.ts @@ -0,0 +1,384 @@ +import { sql } from 'drizzle-orm' +import type { SQL } from 'drizzle-orm' +import type { Viewer } from '#/server/auth/viewer.ts' +import type { Executor } from '#/server/shared/db-executor.ts' + +/** + * Globális keresés (spec 11): videó, esemény, tag és címke találatok. + * - A felhasznált zenékben NINCS keresés. + * - A jogosultsági szűrés az SQL-ben történik: tiltott videó metaadata és + * találatszáma sem szivároghat ki. + * - Kisbetű-, ékezet- és elgépelés-tűrés (`bss_norm` + pg_trgm). + * - Üres vagy két karakternél rövidebb keresés nem listázza az adatbázist. + */ + +export const MIN_QUERY_LENGTH = 2 +const TRIGRAM_THRESHOLD = 0.3 + +export interface SearchScoredRow { + item: T + score: number +} + +export interface SearchResults { + videos: Array> + events: Array> + members: Array> + tags: Array> +} + +export interface VideoHit { + id: string + slug: string + title: string + publishedAt: Date | null + thumbnailUrl: string | null +} + +export interface EventHit { + id: string + slug: string + title: string + startDate: string | null +} + +export interface MemberHit { + sub: string + username: string + fullName: string + nickname: string | null + avatarUrl: string | null +} + +export interface TagHit { + id: string + name: string +} + +/** Videóláthatósági feltétel nyers SQL-ként (ugyanaz a szabály, mint visibility.ts). */ +function visibilitySql(viewer: Viewer): string { + if (viewer.level === 'member' || viewer.level === 'leadership') { + return 'true' + } + if (viewer.level === 'schonherz') { + return "(v.visibility in ('public', 'schonherz'))" + } + return "(v.visibility = 'public')" +} + +export interface SearchOptions { + /** Csoportonkénti találatlimit; a popover öt, az Összes fül tíz. */ + limitPerType?: number +} + +export async function search( + executor: Executor, + viewer: Viewer, + rawQuery: string, + options: SearchOptions = {}, +): Promise { + const query = rawQuery.trim() + if (query.length < MIN_QUERY_LENGTH) { + return { videos: [], events: [], members: [], tags: [] } + } + const limit = options.limitPerType ?? 5 + + const [videos, events, members, tags] = await Promise.all([ + searchVideosRaw(executor, viewer, query, limit), + searchEventsRaw(executor, query, limit), + searchMembersRaw(executor, query, limit), + searchTagsRaw(executor, query, limit), + ]) + + return { videos, events, members, tags } +} + +// --------------------------------------------------------------------------- +// Súlyozás (spec 11.2): +// 100 pontos egyezés > 80 előtag > 70/55 címkék > 50 trigram > +// 40 eseménycím > 30 vendégek/stáb > 20 leírás/bemutatkozás +// --------------------------------------------------------------------------- + +async function searchVideosRaw( + executor: Executor, + viewer: Viewer, + query: string, + limit: number, +): Promise>> { + const rows = await executor.execute(sql` + with q as (select ${query} as text) + select v.id, v.slug, v.title, v.published_at as "publishedAt", v.thumbnail_url as "thumbnailUrl", + greatest( + case when bss_norm(v.title) = bss_norm(q.text) then 100 else 0 end, + case when bss_norm(v.title) like bss_norm(q.text) || '%' then 80 else 0 end, + case when exists ( + select 1 from video_tags vt join tags t on t.id = vt.tag_id + where vt.video_id = v.id and bss_norm(t.name) like '%' || bss_norm(q.text) || '%' + ) then 70 else 0 end, + case when similarity(bss_norm(v.title), bss_norm(q.text)) > ${TRIGRAM_THRESHOLD} + then round(50 * similarity(bss_norm(v.title), bss_norm(q.text))) else 0 end, + case when e.id is not null and bss_norm(e.title) like '%' || bss_norm(q.text) || '%' then 40 else 0 end, + case when coalesce(bss_norm(v.guests), '') like '%' || bss_norm(q.text) || '%' then 30 else 0 end, + case when exists ( + select 1 from video_staff vs join member_cache mc on mc.sub = vs.member_sub + where vs.video_id = v.id and bss_norm(mc.full_name) like '%' || bss_norm(q.text) || '%' + ) then 25 else 0 end, + case when coalesce(bss_norm(v.description), '') like '%' || bss_norm(q.text) || '%' then 20 else 0 end + ) as score + from videos v + left join events e on e.id = v.event_id + cross join q + where v.status = 'published' + and ${sql.raw(visibilitySql(viewer))} + and ( + greatest( + case when bss_norm(v.title) like '%' || bss_norm(q.text) || '%' then 1 else 0 end, + case when similarity(bss_norm(v.title), bss_norm(q.text)) > ${TRIGRAM_THRESHOLD} then 1 else 0 end, + case when exists ( + select 1 from video_tags vt join tags t on t.id = vt.tag_id + where vt.video_id = v.id and bss_norm(t.name) like '%' || bss_norm(q.text) || '%' + ) then 1 else 0 end, + case when e.id is not null and bss_norm(e.title) like '%' || bss_norm(q.text) || '%' then 1 else 0 end, + case when coalesce(bss_norm(v.guests), '') like '%' || bss_norm(q.text) || '%' then 1 else 0 end, + case when exists ( + select 1 from video_staff vs join member_cache mc on mc.sub = vs.member_sub + where vs.video_id = v.id and bss_norm(mc.full_name) like '%' || bss_norm(q.text) || '%' + ) then 1 else 0 end, + case when coalesce(bss_norm(v.description), '') like '%' || bss_norm(q.text) || '%' then 1 else 0 end + ) > 0 + ) + order by score desc, v.published_at desc nulls last, v.id + limit ${limit} + `) + return (rows.rows as unknown as Array).map( + (row) => ({ + item: { + id: row.id, + slug: row.slug, + title: row.title, + publishedAt: row.publishedAt, + thumbnailUrl: row.thumbnailUrl, + }, + score: Number(row.score), + }), + ) +} + +async function searchEventsRaw( + executor: Executor, + query: string, + limit: number, +): Promise>> { + const rows = await executor.execute(sql` + select e.id, e.slug, e.title, e.start_date as "startDate", + greatest( + case when bss_norm(e.title) = bss_norm(${query}) then 100 else 0 end, + case when bss_norm(e.title) like bss_norm(${query}) || '%' then 80 else 0 end, + case when similarity(bss_norm(e.title), bss_norm(${query})) > ${TRIGRAM_THRESHOLD} + then round(50 * similarity(bss_norm(e.title), bss_norm(${query}))) else 0 end, + case when coalesce(bss_norm(e.description), '') like '%' || bss_norm(${query}) || '%' then 20 else 0 end + ) as score + from events e + where e.status = 'published' + and ( + bss_norm(e.title) like '%' || bss_norm(${query}) || '%' + or coalesce(bss_norm(e.description), '') like '%' || bss_norm(${query}) || '%' + or similarity(bss_norm(e.title), bss_norm(${query})) > ${TRIGRAM_THRESHOLD} + ) + order by score desc, e.start_date desc, e.id + limit ${limit} + `) + return (rows.rows as unknown as Array).map( + (row) => ({ + item: { + id: row.id, + slug: row.slug, + title: row.title, + startDate: row.startDate, + }, + score: Number(row.score), + }), + ) +} + +async function searchMembersRaw( + executor: Executor, + query: string, + limit: number, +): Promise>> { + const rows = await executor.execute(sql` + select m.sub, m.username, m.full_name as "fullName", m.nickname, m.avatar_url as "avatarUrl", + greatest( + case when bss_norm(m.full_name) = bss_norm(${query}) then 100 else 0 end, + case when bss_norm(coalesce(m.nickname, '')) = bss_norm(${query}) and m.nickname is not null then 95 else 0 end, + case when bss_norm(m.full_name) like bss_norm(${query}) || '%' then 80 else 0 end, + case when similarity(bss_norm(m.full_name), bss_norm(${query})) > ${TRIGRAM_THRESHOLD} + then round(50 * similarity(bss_norm(m.full_name), bss_norm(${query}))) else 0 end, + case when coalesce(bss_norm(m.introduction), '') like '%' || bss_norm(${query}) || '%' then 20 else 0 end + ) as score + from member_cache m + where m.sync_status = 'ok' + and ( + bss_norm(m.full_name) like '%' || bss_norm(${query}) || '%' + or bss_norm(coalesce(m.nickname, '')) like '%' || bss_norm(${query}) || '%' + or coalesce(bss_norm(m.introduction), '') like '%' || bss_norm(${query}) || '%' + or similarity(bss_norm(m.full_name), bss_norm(${query})) > ${TRIGRAM_THRESHOLD} + ) + order by score desc, m.full_name, m.sub + limit ${limit} + `) + return (rows.rows as unknown as Array).map( + (row) => ({ + item: { + sub: row.sub, + username: row.username, + fullName: row.fullName, + nickname: row.nickname, + avatarUrl: row.avatarUrl, + }, + score: Number(row.score), + }), + ) +} + +async function searchTagsRaw( + executor: Executor, + query: string, + limit: number, +): Promise>> { + const rows = await executor.execute(sql` + select t.id, t.name, + greatest( + case when bss_norm(t.name) = bss_norm(${query}) then 100 else 0 end, + case when bss_norm(t.name) like bss_norm(${query}) || '%' then 80 else 0 end, + case when similarity(bss_norm(t.name), bss_norm(${query})) > ${TRIGRAM_THRESHOLD} + then round(50 * similarity(bss_norm(t.name), bss_norm(${query}))) else 0 end + ) as score + from tags t + where bss_norm(t.name) like '%' || bss_norm(${query}) || '%' + or similarity(bss_norm(t.name), bss_norm(${query})) > ${TRIGRAM_THRESHOLD} + order by score desc, t.name, t.id + limit ${limit} + `) + return (rows.rows as unknown as Array).map( + (row) => ({ + item: { id: row.id, name: row.name }, + score: Number(row.score), + }), + ) +} + +// --------------------------------------------------------------------------- +// Részletes videókeresés és -szűrés (a /videos oldal alapja) +// --------------------------------------------------------------------------- + +export type VideoSort = 'published' | 'chronological' | 'mostviewed' + +export interface VideoSearchFilters { + viewer: Viewer + /** Szabad szöveg a címre, leírásra, vendégekre és stábnevekre. */ + query?: string + /** CímkeNEVEK `ÉS` kapcsolattal: csak az összes címkével rendelkező videó. */ + tagNames?: string[] + eventId?: string + recordedFrom?: string + recordedTo?: string + staffMemberSub?: string + staffRoleId?: string + sort?: VideoSort + limit?: number + offset?: number +} + +export interface DetailedVideoHit extends VideoHit { + recordedAt: string | null + viewCount: number +} + +export interface VideoSearchPage { + items: Array + total: number +} + +function orderSql(sort: VideoSort): string { + if (sort === 'chronological') { + return 'v.recorded_at desc nulls last, v.published_at desc nulls last, v.id' + } + if (sort === 'mostviewed') { + return 'v.view_count desc, v.published_at desc nulls last, v.id' + } + return 'v.published_at desc nulls last, v.id' +} + +/** + * Stabil, szerveroldali lapozású videószűrés. Minden feltétel az SQL-ben + * érvényesül; a láthatóság a néző szintje szerint szűr. + */ +export async function searchVideosDetailed( + executor: Executor, + filters: VideoSearchFilters, +): Promise { + const conditions: SQL[] = [ + sql`v.status = 'published'`, + sql.raw(visibilitySql(filters.viewer)), + ] + + const query = filters.query?.trim() + if (query !== undefined && query.length >= MIN_QUERY_LENGTH) { + conditions.push(sql` + (bss_norm(v.title) like '%' || bss_norm(${query}) || '%' + or coalesce(bss_norm(v.description), '') like '%' || bss_norm(${query}) || '%' + or coalesce(bss_norm(v.guests), '') like '%' || bss_norm(${query}) || '%' + or exists ( + select 1 from video_staff vs2 join member_cache mc2 on mc2.sub = vs2.member_sub + where vs2.video_id = v.id and bss_norm(mc2.full_name) like '%' || bss_norm(${query}) || '%' + ))`) + } + // Címkék ÉS kapcsolata: minden kért címkéhez kell reláció. + for (const tagName of filters.tagNames ?? []) { + conditions.push(sql` + exists ( + select 1 from video_tags vt join tags t on t.id = vt.tag_id + where vt.video_id = v.id and bss_norm(t.name) = bss_norm(${tagName}) + )`) + } + if (filters.eventId !== undefined) { + conditions.push(sql`v.event_id = ${filters.eventId}`) + } + if (filters.recordedFrom !== undefined) { + conditions.push(sql`v.recorded_at >= ${filters.recordedFrom}`) + } + if (filters.recordedTo !== undefined) { + conditions.push(sql`v.recorded_at <= ${filters.recordedTo}`) + } + if (filters.staffMemberSub !== undefined) { + conditions.push(sql` + exists (select 1 from video_staff vs3 where vs3.video_id = v.id and vs3.member_sub = ${filters.staffMemberSub})`) + } + if (filters.staffRoleId !== undefined) { + conditions.push(sql` + exists (select 1 from video_staff vs4 where vs4.video_id = v.id and vs4.role_id = ${filters.staffRoleId})`) + } + + const limit = filters.limit ?? 50 + const offset = filters.offset ?? 0 + + const rows = await executor.execute(sql` + select v.id, v.slug, v.title, v.thumbnail_url as "thumbnailUrl", + v.published_at as "publishedAt", v.recorded_at as "recordedAt", v.view_count as "viewCount", + count(*) over() as total + from videos v + where ${sql.join(conditions, sql` and `)} + order by ${sql.raw(orderSql(filters.sort ?? 'published'))} + limit ${limit} offset ${offset} + `) + + const rawRows = rows.rows as unknown as Array< + DetailedVideoHit & { total: number } + > + const first = rawRows.at(0) + return { + items: rawRows.map(({ total: _total, ...item }) => item), + total: rawRows.length > 0 ? Number(first?.total ?? 0) : 0, + } +} diff --git a/src/server/shared/write.ts b/src/server/shared/write.ts index 5cc348f..9f0f350 100644 --- a/src/server/shared/write.ts +++ b/src/server/shared/write.ts @@ -62,6 +62,13 @@ export interface OptimisticUpdateParams { changes: Record actor: string clock?: Clock + /** Audit műveletneve; alapértelmezetten `update`. */ + action?: string + /** + * Tranzakción belüli kiegészítő írás (pl. kapcsolatok érvénytelenítése, + * slug előzmény) — sikertelen futásnál az egész mentés visszagörget. + */ + afterWrite?: (tx: Executor) => Promise } /** @@ -126,12 +133,16 @@ export async function updateWithOptimisticLock( actor: params.actor, entityType: params.entityType, entityId: params.entityId, - action: 'update', + action: params.action ?? 'update', before, after, occurredAt: now, }) + if (params.afterWrite !== undefined) { + await params.afterWrite(tx) + } + return { version: nextVersion } }) } diff --git a/src/server/videos/domain.ts b/src/server/videos/domain.ts new file mode 100644 index 0000000..f3629c8 --- /dev/null +++ b/src/server/videos/domain.ts @@ -0,0 +1,893 @@ +import { eq, inArray } from 'drizzle-orm' +import type { OobConfig } from '#/server/config/oob-schema.ts' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { can } from '#/server/auth/policy.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { + events, + staffRoles, + tags, + videoStaff, + videoTags, + videos, +} from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { + TEXT_LIMITS, + TextValidationError, + validatePlainText, + validateRequiredText, +} from '#/server/shared/text.ts' +import { + EntityNotFoundError, + StaleWriteError, + writeAudit, +} from '#/server/shared/write.ts' +import { + checkMediaUrlShape, + validateMediaForPublish, +} from '#/server/media/validator.ts' +import { + findFreeSlug, + renameSlugWithHistory, + slugify, +} from '#/server/shared/slug.ts' +import { invalidateHomepageReferences } from './highlight-invalidation.ts' + +export type VideoVisibility = 'public' | 'schonherz' | 'bss' + +const VISIBILITIES: ReadonlySet = new Set([ + 'public', + 'schonherz', + 'bss', +]) +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +export interface VideoDeps { + viewer: Viewer + clock?: Clock + mediaConfig: OobConfig['media'] + fetchImpl?: typeof fetch +} + +export interface VideoInput { + title?: string + description?: string | null + guests?: string | null + songs?: string | null + videoUrl?: string | null + thumbnailUrl?: string | null + visibility?: VideoVisibility + recordedAt?: string | null + eventId?: string | null + publishedAt?: Date | null +} + +function assertContentEditor(viewer: Viewer): void { + if (!can.createOrEditContent(viewer)) { + throw new ForbiddenError( + 'Videót csak bejelentkezett BSS-tag hozhat létre vagy szerkeszthet.', + ) + } +} + +async function lockVideo(executor: Executor, videoId: string) { + const rows = await executor + .select() + .from(videos) + .where(eq(videos.id, videoId)) + .for('update') + .limit(1) + const row = rows.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + return row +} + +function validateDateField( + fieldName: string, + value: string | null | undefined, +): string | null { + if (value === null || value === undefined || value.trim() === '') { + return null + } + if (!DATE_PATTERN.test(value)) { + throw new TextValidationError([ + `${fieldName}: éééé-hh-nn formátumú naptári dátum kell (kapott érték: "${value}").`, + ]) + } + return value +} + +/** + * Mezők validálása részbeni frissítéshez. A hibás média-URL piszkozatban + * menthető (spec 5.4) — itt csak a plain text és hosszszabályok érvényesek. + */ +function validatedChanges(input: VideoInput): Record { + const changes: Record = {} + + if (input.title !== undefined) { + changes.title = validateRequiredText('Cím', input.title, TEXT_LIMITS.title) + } + if (input.description !== undefined) { + changes.description = validatePlainText( + 'Leírás', + input.description, + TEXT_LIMITS.description, + ) + } + if (input.guests !== undefined) { + changes.guests = validatePlainText( + 'Vendégek', + input.guests, + TEXT_LIMITS.guestsOrSongs, + ) + } + if (input.songs !== undefined) { + changes.songs = validatePlainText( + 'Felhasznált zenék', + input.songs, + TEXT_LIMITS.guestsOrSongs, + ) + } + if (input.videoUrl !== undefined) { + const trimmed = input.videoUrl?.trim() ?? '' + if (trimmed !== '') { + validatePlainText('Videó URL', trimmed, TEXT_LIMITS.url) + } + changes.videoUrl = trimmed === '' ? null : trimmed + } + if (input.thumbnailUrl !== undefined) { + const trimmed = input.thumbnailUrl?.trim() ?? '' + if (trimmed !== '') { + validatePlainText('Thumbnail URL', trimmed, TEXT_LIMITS.url) + } + changes.thumbnailUrl = trimmed === '' ? null : trimmed + } + if (input.visibility !== undefined) { + if (!VISIBILITIES.has(input.visibility)) { + throw new TextValidationError([ + `Érvénytelen láthatóság: ${String(input.visibility)}.`, + ]) + } + changes.visibility = input.visibility + } + if (input.recordedAt !== undefined) { + changes.recordedAt = validateDateField('Készült dátuma', input.recordedAt) + } + + return changes +} + +/** Eseménydátum-eltérés figyelmeztetés (nem blokkol, spec 5.2). */ +function eventRangeWarning( + event: typeof events.$inferSelect, + recordedAt: string, +): string[] { + if (event.startDate === null) { + return [] + } + const start = event.startDate + const end = event.endDate ?? start + if (recordedAt >= start && recordedAt <= end) { + return [] + } + const range = end !== start ? `${start}–${end}` : start + return [ + `A videó készülési dátuma (${recordedAt}) az esemény időtartamán (${range}) kívül van.`, + ] +} + +async function loadEventOrNull(executor: Executor, eventId: string | null) { + if (eventId === null) { + return null + } + const rows = await executor + .select() + .from(events) + .where(eq(events.id, eventId)) + .limit(1) + return rows.at(0) ?? null +} + +/** + * Esemény-hozzárendelés dátumszabályai (spec 5.2): + * - egynapos esemény kitölti az ÜRES `recordedAt`-ot csendben; + * - meglévő `recordedAt`-ot soha nem ír felül; + * - esemény leválasztásakor a `recordedAt` megmarad. + */ +async function applyEventAssignment( + tx: Executor, + currentRecordedAt: string | null, + newEventId: string | null, +): Promise<{ recordedAt: string | null; warnings: string[] }> { + const event = await loadEventOrNull(tx, newEventId) + if (event === null) { + if (newEventId !== null) { + throw new TextValidationError(['Az esemény nem található.']) + } + // Leválasztás: a `recordedAt` megmarad. + return { recordedAt: currentRecordedAt, warnings: [] } + } + + let recordedAt = currentRecordedAt + const warnings: string[] = [] + if ( + event.startDate !== null && + event.endDate === null && + recordedAt === null + ) { + // Egynapos esemény: csendes automatikus kitöltés. + recordedAt = event.startDate + } + if ( + event.startDate !== null && + event.endDate !== null && + recordedAt === null + ) { + warnings.push( + 'Többnapos eseményhez publikálás előtt meg kell adni a videó készülési dátumát.', + ) + } + if (recordedAt !== null) { + warnings.push(...eventRangeWarning(event, recordedAt)) + } + return { recordedAt, warnings } +} + +export async function createVideoDraft( + executor: Executor, + deps: VideoDeps, + input: VideoInput & { slug?: string }, +): Promise { + assertContentEditor(deps.viewer) + + return executor.transaction(async (tx) => { + const title = validateRequiredText('Cím', input.title, TEXT_LIMITS.title) + const slugBase = input.slug ?? title + const slug = await findFreeSlug(tx, 'video', slugify(slugBase)) + + // A piszkozat többi mezője is megadható már létrehozáskor (hibás média-URL + // is menthető — a publikálás ellenőrzi). + const changes = validatedChanges({ ...input, title }) + + const inserted = await tx + .insert(videos) + .values({ + slug, + title, + description: (changes.description as string | null) ?? null, + guests: (changes.guests as string | null) ?? null, + songs: (changes.songs as string | null) ?? null, + videoUrl: (changes.videoUrl as string | null) ?? null, + thumbnailUrl: (changes.thumbnailUrl as string | null) ?? null, + recordedAt: (changes.recordedAt as string | null) ?? null, + visibility: 'public', + status: 'draft', + createdBy: deps.viewer.sub, + updatedBy: deps.viewer.sub, + }) + .returning() + const row = inserted.at(0) + if (row === undefined) { + throw new Error('A videó piszkozat létrehozása nem sikerült.') + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: row.id, + action: 'create', + before: null, + after: snapshotVideo(row), + occurredAt: (deps.clock ?? systemClock).now(), + }) + return row + }) +} + +export interface UpdateVideoResult { + row: typeof videos.$inferSelect + warnings: string[] +} + +export async function updateVideo( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, + input: VideoInput & { slug?: string }, +): Promise { + assertContentEditor(deps.viewer) + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + + const changes = validatedChanges(input) + const warnings: string[] = [] + + if (input.eventId !== undefined) { + const explicitRecordedAt = + input.recordedAt !== undefined + ? (changes.recordedAt as string | null) + : undefined + const effectiveCurrent = + explicitRecordedAt !== undefined + ? explicitRecordedAt + : locked.recordedAt + const assignment = await applyEventAssignment( + tx, + effectiveCurrent, + input.eventId, + ) + changes.eventId = input.eventId + if (explicitRecordedAt === undefined) { + // Csendes kitöltés csak üres mezőnél; meglévő értéket soha nem ír felül. + changes.recordedAt = assignment.recordedAt + warnings.push( + ...assignment.warnings.filter((w) => w.includes('időtartamán')), + ) + } else { + changes.recordedAt = explicitRecordedAt + const event = await loadEventOrNull(tx, input.eventId) + if (event !== null && explicitRecordedAt !== null) { + warnings.push(...eventRangeWarning(event, explicitRecordedAt)) + } + } + } else if (input.recordedAt !== undefined) { + // Önálló dátummódosítás meglévő esemény mellett is figyelmeztet. + const event = await loadEventOrNull(tx, locked.eventId) + const newDate = changes.recordedAt as string | null + if (event !== null && newDate !== null) { + warnings.push(...eventRangeWarning(event, newDate)) + } + } + + if (input.publishedAt !== undefined) { + const publishedAt = input.publishedAt + if ( + publishedAt !== null && + publishedAt.getTime() > (deps.clock ?? systemClock).now().getTime() + ) { + throw new TextValidationError([ + 'A feltöltés dátuma nem lehet jövőbeli időpont.', + ]) + } + changes.publishedAt = publishedAt + } + + if (input.slug !== undefined) { + const nextSlug = await renameSlugWithHistory(tx, { + entityType: 'video', + entityId: videoId, + currentSlug: locked.slug, + newSlugBase: slugify(input.slug), + now: (deps.clock ?? systemClock).now(), + }) + changes.slug = nextSlug + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(videos) + .set({ + ...changes, + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + // Láthatóság-szűkítésnél a kiemelés és a Rólunk-lista ugyanitt érvénytelenít. + if (row.visibility !== 'public') { + await invalidateHomepageReferences(tx, videoId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'update', + before: snapshotVideo(locked), + after: snapshotVideo(row), + occurredAt: now, + }) + return { row, warnings } + }) +} + +interface PublishPreconditions { + problems: string[] + multiDayEventRequiresDate: boolean +} + +function publishPreconditions( + row: typeof videos.$inferSelect, +): PublishPreconditions { + const problems: string[] = [] + if (row.title.trim() === '') problems.push('Cím: kötelező mező.') + if (row.videoUrl === null || row.videoUrl === '') { + problems.push('Videó URL: publikáláshoz kötelező.') + } + if (row.thumbnailUrl === null || row.thumbnailUrl === '') { + problems.push('Thumbnail URL: publikáláshoz kötelező.') + } + + return { + problems, + multiDayEventRequiresDate: row.recordedAt === null || row.recordedAt === '', + } +} + +/** + * Publikálás (spec 5.3–5.4): + * - kötelező cím, MP4 és thumbnail; a médiát hálózati ellenőrzés validálja; + * - láthatóság alapból `public`; + * - többnapos eseménynél `recordedAt` kötelező; + * - `publishedAt`: ha nincs, most kapja; jövőbeli nem lehet; + * - sikeres állapotváltásnál a homepage-hivatkozások érvényessége rendeződik. + */ +export async function publishVideo( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, +): Promise { + assertContentEditor(deps.viewer) + + const preloadedRows = await executor + .select() + .from(videos) + .where(eq(videos.id, videoId)) + .limit(1) + const preloaded = preloadedRows.at(0) + if (preloaded === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + const preconditions = publishPreconditions(preloaded) + if (preconditions.problems.length > 0) { + throw new TextValidationError(preconditions.problems) + } + + // Többnapos esemény `recordedAt` követelménye: + const event = await loadEventOrNull(executor, preloaded.eventId) + const multiDay = event !== null && event.endDate !== null + if ( + multiDay && + (preloaded.recordedAt === null || preloaded.recordedAt === '') + ) { + throw new TextValidationError([ + 'Többnapos eseményhez publikálás előtt meg kell adni a készülési dátumot.', + ]) + } + + // Média hálózati ellenőrzés a tranzakción KÍVÜL (spec 5.4). + for (const kind of ['video', 'thumbnail'] as const) { + const url = kind === 'video' ? preloaded.videoUrl : preloaded.thumbnailUrl + if (url === null) continue + const shape = checkMediaUrlShape(url, kind, deps.mediaConfig) + if (!shape.ok) { + throw new TextValidationError(shape.problems) + } + const result = await validateMediaForPublish({ + url, + kind, + mediaConfig: deps.mediaConfig, + fetchImpl: deps.fetchImpl, + }) + if (!result.ok) { + throw new TextValidationError(result.problems) + } + } + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + if (locked.status === 'trash') { + throw new TextValidationError([ + 'Lomtárban lévő videót csak visszaállítás után lehet publikálni.', + ]) + } + + const now = (deps.clock ?? systemClock).now() + const publishedAt = + locked.publishedAt !== null && + locked.publishedAt.getTime() <= now.getTime() + ? locked.publishedAt + : now + + const updated = await tx + .update(videos) + .set({ + status: 'published', + publishedAt, + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'publish', + before: snapshotVideo(locked), + after: snapshotVideo(row), + occurredAt: now, + }) + + const warnings: string[] = [] + if (event !== null && row.recordedAt !== null) { + warnings.push(...eventRangeWarning(event, row.recordedAt)) + } + return { row, warnings } + }) +} + +export async function archiveVideo( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, +): Promise { + assertContentEditor(deps.viewer) + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + if (locked.status === 'trash') { + throw new TextValidationError([ + 'Lomtárban lévő videó nem archiválható közvetlenül; előbb vezetőségnek vissza kell állítania.', + ]) + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(videos) + .set({ + status: 'archived', + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + // Az archivált tartalom kikerül a kiemelésből és a Rólunk-listából. + await invalidateHomepageReferences(tx, videoId) + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'archive', + before: snapshotVideo(locked), + after: snapshotVideo(row), + occurredAt: now, + }) + return { row, warnings: [] } + }) +} + +/** Lomtárba helyezés: bármely tag, kapcsolatok megmaradnak (spec 13.1). */ +export async function trashVideo( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, +): Promise { + assertContentEditor(deps.viewer) + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(videos) + .set({ + status: 'trash', + trashedAt: now, + trashedBy: deps.viewer.sub, + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + await invalidateHomepageReferences(tx, videoId) + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'trash', + before: snapshotVideo(locked), + after: snapshotVideo(row), + occurredAt: now, + }) + return { row, warnings: [] } + }) +} + +/** + * Visszaállítás lomtárból (csak vezetőség, spec 13.1): archivált állapotba kerül, + * minden címke-, stáb-, esemény- és kapcsolódóvideó-kapcsolata megmarad. + */ +export async function restoreVideoFromTrash( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, +): Promise { + if (!can.restoreVideo(deps.viewer)) { + throw new ForbiddenError('A lomtárból való visszaállítás vezetőségi jog.') + } + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + if (locked.status !== 'trash') { + throw new TextValidationError([ + 'Csak lomtárban lévő videó állítható vissza.', + ]) + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(videos) + .set({ + status: 'archived', + trashedAt: null, + trashedBy: null, + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'restore', + before: snapshotVideo(locked), + after: snapshotVideo(row), + occurredAt: now, + }) + return { row, warnings: [] } + }) +} + +/** Címkék teljes listájának cseréje egy videón (tag csak meglévőt rendelhet). */ +export async function setVideoTags( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, + tagIds: readonly string[], +): Promise<{ row: typeof videos.$inferSelect }> { + if (!can.assignExistingTagToVideo(deps.viewer)) { + throw new ForbiddenError( + 'Címkét csak bejelentkezett BSS-tag rendelhet videóhoz.', + ) + } + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + + const uniqueIds = [...new Set(tagIds)] + if (uniqueIds.length > 0) { + const existing = await tx + .select({ id: tags.id }) + .from(tags) + .where(inArray(tags.id, uniqueIds)) + if (existing.length !== uniqueIds.length) { + throw new TextValidationError([ + 'Csak meglévő címke rendelhető videóhoz (ismeretlen címkék szerepelnek a listában).', + ]) + } + } + + const beforeList = ( + await tx + .select({ id: videoTags.tagId }) + .from(videoTags) + .where(eq(videoTags.videoId, videoId)) + ).map((r) => r.id) + await tx.delete(videoTags).where(eq(videoTags.videoId, videoId)) + if (uniqueIds.length > 0) { + await tx + .insert(videoTags) + .values(uniqueIds.map((tagId) => ({ videoId, tagId }))) + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(videos) + .set({ + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'assign_tags', + before: { tagIds: beforeList }, + after: { tagIds: uniqueIds }, + occurredAt: now, + }) + return { row } + }) +} + +export interface StaffAssignment { + roleId: string + memberSub: string +} + +/** Stáblista cseréje egy videón: egy szerep több taggal, egy tag több szereppel. */ +export async function setVideoStaff( + executor: Executor, + deps: VideoDeps, + videoId: string, + expectedVersion: number, + assignments: readonly StaffAssignment[], +): Promise<{ row: typeof videos.$inferSelect }> { + if (!can.manageVideoStaffList(deps.viewer)) { + throw new ForbiddenError( + 'A stáblista kezelése csak bejelentkezett BSS-tagnek engedélyezett.', + ) + } + + return executor.transaction(async (tx) => { + const locked = await lockVideo(tx, videoId) + if (locked.version !== expectedVersion) { + throw new StaleWriteError('videó') + } + + const roleIds = [...new Set(assignments.map((a) => a.roleId))] + if (roleIds.length > 0) { + const existingRoles = await tx + .select({ id: staffRoles.id }) + .from(staffRoles) + .where(inArray(staffRoles.id, roleIds)) + if (existingRoles.length !== roleIds.length) { + throw new TextValidationError([ + 'Ismeretlen stábszerep szerepel a listában.', + ]) + } + } + + const deduped: StaffAssignment[] = [] + for (const assignment of assignments) { + if ( + !deduped.some( + (a) => + a.roleId === assignment.roleId && + a.memberSub === assignment.memberSub, + ) + ) { + deduped.push(assignment) + } + } + + const beforeList = await tx + .select({ roleId: videoStaff.roleId, memberSub: videoStaff.memberSub }) + .from(videoStaff) + .where(eq(videoStaff.videoId, videoId)) + + await tx.delete(videoStaff).where(eq(videoStaff.videoId, videoId)) + if (deduped.length > 0) { + await tx.insert(videoStaff).values( + deduped.map((a) => ({ + videoId, + roleId: a.roleId, + memberSub: a.memberSub, + })), + ) + } + + const now = (deps.clock ?? systemClock).now() + const updated = await tx + .update(videos) + .set({ + version: locked.version + 1, + updatedAt: now, + updatedBy: deps.viewer.sub, + }) + .where(eq(videos.id, videoId)) + .returning() + const row = updated.at(0) + if (row === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + await writeAudit(tx, { + actor: deps.viewer.sub ?? '', + entityType: 'video', + entityId: videoId, + action: 'assign_staff', + before: { staff: beforeList }, + after: { staff: deduped }, + occurredAt: now, + }) + return { row } + }) +} + +function snapshotVideo( + row: typeof videos.$inferSelect, +): Record { + return { + slug: row.slug, + title: row.title, + description: row.description, + guests: row.guests, + songs: row.songs, + videoUrl: row.videoUrl, + thumbnailUrl: row.thumbnailUrl, + visibility: row.visibility, + status: row.status, + eventId: row.eventId, + recordedAt: row.recordedAt, + publishedAt: + row.publishedAt instanceof Date + ? row.publishedAt.toISOString() + : row.publishedAt, + viewCount: row.viewCount, + version: row.version, + } +} diff --git a/src/server/videos/highlight-invalidation.ts b/src/server/videos/highlight-invalidation.ts new file mode 100644 index 0000000..0457817 --- /dev/null +++ b/src/server/videos/highlight-invalidation.ts @@ -0,0 +1,21 @@ +import { eq } from 'drizzle-orm' +import { aboutPageVideos, siteSettings } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' + +/** + * Homepage-hivatkozások érvénytelenítése (spec 9.2, 10.1): ha a videó már nem + * publikált és publikus (archiválás, lomtár, láthatóság-szűkítés), akkor a + * kiemelésből és a Rólunk-listából ugyanebben a tranzakcióban ki kell kerülnie. + */ +export async function invalidateHomepageReferences( + executor: Executor, + videoId: string, +): Promise { + await executor + .update(siteSettings) + .set({ highlightedVideoId: null }) + .where(eq(siteSettings.highlightedVideoId, videoId)) + await executor + .delete(aboutPageVideos) + .where(eq(aboutPageVideos.videoId, videoId)) +} diff --git a/src/server/videos/purge.ts b/src/server/videos/purge.ts new file mode 100644 index 0000000..03b03f4 --- /dev/null +++ b/src/server/videos/purge.ts @@ -0,0 +1,98 @@ +import { lte, eq } from 'drizzle-orm' +import { videos, slugHistory } from '#/db/schema.ts' +import type { Clock } from '#/lib/clock.ts' +import { systemClock } from '#/lib/clock.ts' +import { SYSTEM_ACTOR, writeAudit } from '#/server/shared/write.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import type { JobDefinition } from '#/server/jobs/runner.ts' + +export const TRASH_RETENTION_DAYS = 30 +export const TRASH_PURGE_JOB_NAME = 'video-trash-purge-daily' + +/** + * Napi végleges törlés (spec 13.1): a legalább 30 napja lomtárban lévő + * videók rekordja törlődik; a külső médiafájlokhoz nem nyúlunk. A slug a + * történetbe kerül (újrahasználat tiltva), a törlés teljes auditot kap. + */ +export async function purgeExpiredTrashedVideos( + executor: Executor, + options: { now?: Date; retentionDays?: number } = {}, +): Promise { + const now = options.now ?? systemClock.now() + const retentionMs = + (options.retentionDays ?? TRASH_RETENTION_DAYS) * 86_400_000 + const cutoff = new Date(now.getTime() - retentionMs) + + const candidates = await executor + .select({ id: videos.id }) + .from(videos) + .where(lte(videos.trashedAt, cutoff)) + + const purgedIds: string[] = [] + for (const candidate of candidates) { + const deleted = await executor.transaction(async (tx) => { + const rows = await tx + .select() + .from(videos) + .where(eq(videos.id, candidate.id)) + .for('update') + .limit(1) + const row = rows.at(0) + if ( + row === undefined || + row.status !== 'trash' || + row.trashedAt === null + ) { + return null + } + if (row.trashedAt.getTime() > now.getTime() - retentionMs) { + return null + } + + await tx.insert(slugHistory).values({ + entityType: 'video', + slug: row.slug, + entityId: row.id, + createdAt: now, + }) + await tx.delete(videos).where(eq(videos.id, row.id)) + + await writeAudit(tx, { + actor: SYSTEM_ACTOR, + entityType: 'video', + entityId: row.id, + action: 'delete_permanent', + before: { + slug: row.slug, + title: row.title, + status: row.status, + trashedAt: row.trashedAt.toISOString(), + }, + after: null, + occurredAt: now, + }) + return row.id + }) + if (deleted !== null) { + purgedIds.push(deleted) + } + } + return purgedIds +} + +/** Napi feladat regisztrálása a háttérfuttatóba (BSS-010 runner extra feladata). */ +export function createTrashPurgeJob(deps: { + clock?: Clock + db: () => Promise +}): JobDefinition { + return { + name: TRASH_PURGE_JOB_NAME, + intervalMs: 24 * 60 * 60 * 1000, + run: async () => { + const executor = await deps.db() + await purgeExpiredTrashedVideos(executor, { + now: (deps.clock ?? systemClock).now(), + }) + }, + } +} diff --git a/src/server/videos/related.ts b/src/server/videos/related.ts new file mode 100644 index 0000000..b583f71 --- /dev/null +++ b/src/server/videos/related.ts @@ -0,0 +1,206 @@ +import { and, asc, desc, eq, inArray, ne, sql } from 'drizzle-orm' +import type { Viewer } from '#/server/auth/viewer.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { can } from '#/server/auth/policy.ts' +import { relatedVideos, videoTags, videos } from '#/db/schema.ts' +import { visibleVideoCondition } from './visibility.ts' +import { + EntityNotFoundError, + StaleWriteError, + writeAudit, +} from '#/server/shared/write.ts' +import { TextValidationError } from '#/server/shared/text.ts' +import { systemClock } from '#/lib/clock.ts' +import type { Clock } from '#/lib/clock.ts' +import type { Executor } from '#/server/shared/db-executor.ts' + +export const RELATED_VIDEO_LIMIT = 5 + +/** + * Kapcsolódó videók kiszolgálása (spec 5.6). A kiválasztás sorrendje: + * 1. sorrendezett manuális lista, ha van; + * 2. azonos esemény öt legutóbb publikált videója; + * 3. esemény nélkül a legalább egy közös címkével rendelkező öt legjobb videó, + * több közös címke erősebb, egyezésnél `publishedAt` csökkenő dönt. + * A megjelenítés minden esetben a néző jogosultsága szerint szűr az SQL-ben. + */ +export async function getRelatedVideos( + executor: Executor, + viewer: Viewer, + videoId: string, +): Promise> { + const currentRows = await executor + .select() + .from(videos) + .where(eq(videos.id, videoId)) + .limit(1) + const current = currentRows.at(0) + if (current === undefined) { + throw new EntityNotFoundError('video', videoId) + } + + const manual = await executor + .select({ video: videos }) + .from(relatedVideos) + .innerJoin(videos, eq(videos.id, relatedVideos.relatedVideoId)) + .where( + and( + eq(relatedVideos.videoId, videoId), + eq(videos.status, 'published'), + visibleVideoCondition(viewer), + ), + ) + .orderBy(asc(relatedVideos.position)) + if (manual.length > 0) { + return manual.map((row) => row.video) + } + + if (current.eventId !== null) { + const sameEvent = await executor + .select() + .from(videos) + .where( + and( + eq(videos.status, 'published'), + eq(videos.eventId, current.eventId), + ne(videos.id, videoId), + visibleVideoCondition(viewer), + ), + ) + .orderBy(desc(videos.publishedAt), desc(videos.id)) + .limit(RELATED_VIDEO_LIMIT) + return sameEvent + } + + const currentTagRows = await executor + .select({ tagId: videoTags.tagId }) + .from(videoTags) + .where(eq(videoTags.videoId, videoId)) + const tagIds = currentTagRows.map((row) => row.tagId) + if (tagIds.length === 0) { + return [] + } + + const sharedTagScored = await executor + .select({ + video: videos, + commonCount: sql`count(*)::int`, + }) + .from(videos) + .innerJoin(videoTags, eq(videoTags.videoId, videos.id)) + .where( + and( + inArray(videoTags.tagId, tagIds), + eq(videos.status, 'published'), + ne(videos.id, videoId), + visibleVideoCondition(viewer), + ), + ) + .groupBy(videos.id) + .orderBy(sql`count(*) desc`, desc(videos.publishedAt), desc(videos.id)) + .limit(RELATED_VIDEO_LIMIT) + return sharedTagScored.map((row) => row.video) +} + +/** + * Manuális kapcsolódó lista cseréje. Csak publikált videó választható, + * láthatóságtól függetlenül; önhivatkozás és duplikáció tiltva. + */ +export async function setManualRelatedVideos( + executor: Executor, + params: { + viewer: Viewer + videoId: string + expectedVersion: number + relatedVideoIds: readonly string[] + clock?: Clock + }, +): Promise<{ version: number }> { + if (!can.createOrEditContent(params.viewer)) { + throw new ForbiddenError( + 'A kapcsolódó videók kezelése csak bejelentkezett BSS-tagnek engedélyezett.', + ) + } + + return executor.transaction(async (tx) => { + const lockedRows = await tx + .select() + .from(videos) + .where(eq(videos.id, params.videoId)) + .for('update') + .limit(1) + if (lockedRows.length === 0) { + throw new EntityNotFoundError('video', params.videoId) + } + const locked = lockedRows.at(0) + if (locked === undefined || locked.version !== params.expectedVersion) { + throw new StaleWriteError('videó') + } + + const ids = [...new Set(params.relatedVideoIds)] + if (ids.length !== params.relatedVideoIds.length) { + throw new TextValidationError([ + 'Duplikált kapcsolódó videó nem engedélyezett.', + ]) + } + if (ids.includes(params.videoId)) { + throw new TextValidationError(['A videó önmaga nem lehet kapcsolódó.']) + } + if (ids.length > 0) { + const candidates = await tx + .select({ id: videos.id, status: videos.status }) + .from(videos) + .where(inArray(videos.id, ids)) + const foundById = new Map(candidates.map((row) => [row.id, row])) + for (const id of ids) { + const candidate = foundById.get(id) + if (candidate === undefined || candidate.status !== 'published') { + throw new TextValidationError([ + 'Csak publikált videó választható kapcsolódóként.', + ]) + } + } + } + + const beforeList = ( + await tx + .select({ id: relatedVideos.relatedVideoId }) + .from(relatedVideos) + .where(eq(relatedVideos.videoId, params.videoId)) + .orderBy(asc(relatedVideos.position)) + ).map((row) => row.id) + + await tx + .delete(relatedVideos) + .where(eq(relatedVideos.videoId, params.videoId)) + for (const [index, relatedId] of ids.entries()) { + await tx.insert(relatedVideos).values({ + videoId: params.videoId, + relatedVideoId: relatedId, + position: index + 1, + }) + } + + const nextVersion = locked.version + 1 + const now = (params.clock ?? systemClock).now() + await tx + .update(videos) + .set({ + version: nextVersion, + updatedAt: now, + updatedBy: params.viewer.sub, + }) + .where(eq(videos.id, params.videoId)) + + await writeAudit(tx, { + actor: params.viewer.sub ?? '', + entityType: 'video', + entityId: params.videoId, + action: 'assign_related', + before: { relatedVideoIds: beforeList }, + after: { relatedVideoIds: ids }, + occurredAt: now, + }) + return { version: nextVersion } + }) +} diff --git a/src/server/views/counter.ts b/src/server/views/counter.ts new file mode 100644 index 0000000..a1a1f91 --- /dev/null +++ b/src/server/views/counter.ts @@ -0,0 +1,122 @@ +import { randomBytes } from 'node:crypto' +import { eq, sql } from 'drizzle-orm' +import type { Viewer } from '#/server/auth/viewer.ts' +import { isMember } from '#/server/auth/policy.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { canSeeVideo } from '#/server/videos/visibility.ts' +import { hashSessionToken } from '#/server/auth/session-cookies.ts' +import type { CookieSpec } from '#/server/auth/session-cookies.ts' +import { videos, viewSessions } from '#/db/schema.ts' +import type { Executor } from '#/server/shared/db-executor.ts' +import { systemClock } from '#/lib/clock.ts' +import type { Clock } from '#/lib/clock.ts' + +export const VIEW_SESSION_COOKIE_NAME = 'bss_view_session' + +/** + * Anonim megtekintés-session token új generálása. A cookie-ban a token van, + * az adatbázisban csak az SHA-256 kivonata (ugyanaz a minta, mint az auth + * session-nél) — IP és felhasználói előzmény soha nem tárolódik. + */ +export function newViewSessionToken(): string { + return randomBytes(32).toString('base64url') +} + +/** + * Böngésző bezárásáig élő cookie (spec 5.5): szándékosan NINCS Max-Age, + * így nem perzisztens session-cookie keletkezik. + */ +export function viewSessionCookieSpec( + token: string, + secure = false, +): CookieSpec { + return { name: VIEW_SESSION_COOKIE_NAME, value: token, secure } +} + +export interface ViewRecordResult { + /** Az adatbázisban tárolt (kivonatolt) session-azonosító. */ + sessionId: string + counted: boolean +} + +/** + * Egy videó-megtekintés rögzítése. Egy böngésző-session ugyanazt a videót + * egyszer számolja: `view_sessions` elsődleges kulcs + ON CONFLICT DO NOTHING + * ad idempotenciát, párhuzamos kérésekkel szemben is. Csak publikált, + * a néző számára látható videó számolható. + */ +export async function recordVideoView( + executor: Executor, + params: { + videoId: string + viewer: Viewer + /** A kliens meglévő view-session tokenje vagy null (első play). */ + token: string | null + clock?: Clock + }, +): Promise { + const videoRows = await executor + .select({ + id: videos.id, + status: videos.status, + visibility: videos.visibility, + }) + .from(videos) + .where(eq(videos.id, params.videoId)) + .limit(1) + const video = videoRows.at(0) + if ( + video === undefined || + video.status !== 'published' || + !canSeeVideo(params.viewer, video.visibility) + ) { + throw new Error('A videó nem érhető el ebben a nézetben.') + } + + let token = params.token + if (token === null || token === '') { + token = newViewSessionToken() + } + const sessionId = hashSessionToken(token) + + return executor.transaction(async (tx) => { + const inserted = await tx + .insert(viewSessions) + .values({ + videoId: params.videoId, + sessionId, + viewedAt: (params.clock ?? systemClock).now(), + }) + .onConflictDoNothing() + .returning({ sessionId: viewSessions.sessionId }) + + if (inserted.length > 0) { + await tx + .update(videos) + .set({ viewCount: sql`${videos.viewCount} + 1` }) + .where(eq(videos.id, params.videoId)) + } + + return { sessionId, counted: inserted.length > 0 } + }) +} + +/** + * Megtekintésszám lekérdezése: csak adminválaszban jelenhet meg (spec 5.5), + * ezért legalább tagság kell hozzá. + */ +export async function getViewCount( + executor: Executor, + viewer: Viewer, + videoId: string, +): Promise { + if (!isMember(viewer)) { + throw new ForbiddenError('A megtekintésszám csak adminfelületen látható.') + } + const rows = await executor + .select({ viewCount: videos.viewCount }) + .from(videos) + .where(eq(videos.id, videoId)) + .limit(1) + return rows.at(0)?.viewCount ?? 0 +} diff --git a/tests/integration/catalog.test.ts b/tests/integration/catalog.test.ts new file mode 100644 index 0000000..f4f2193 --- /dev/null +++ b/tests/integration/catalog.test.ts @@ -0,0 +1,363 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { eq } from 'drizzle-orm' +import { + createTag, + deleteTag, + findAccentSimilarTagNames, + listTagsWithUsage, + mergeTag, + renameTag, + CatalogNameConflictError, + ConfirmationMismatchError, + TagNotFoundError, +} from '#/server/catalog/tags.ts' +import { + createStaffRole, + deleteStaffRole, + listStaffRolesWithUsage, + mergeStaffRole, + renameStaffRole, + reorderStaffRoles, + StaffRoleInUseError, +} from '#/server/catalog/staff-roles.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { anonymousViewer } from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { + events, + memberCache, + staffRoles, + tags, + videoStaff, + videoTags, + videos, +} from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) + +const leaderViewer: Viewer = { + level: 'leadership', + sub: 'leader-sub', + username: 'vezetoseg', +} +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_catalog') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.db.insert(memberCache).values([ + { + sub: 'leader-sub', + username: 'vezetoseg', + fullName: 'Vezetőségi Tag', + membershipStatus: 'studio_member', + }, + { + sub: 'member-sub', + username: 'tag', + fullName: 'BSS Tag', + membershipStatus: 'studio_member', + }, + ]) + return migrated.db +} + +async function seedVideoWithEvent( + db: NodePgDatabase>, + slug: string, +): Promise { + const eventRows = await db + .insert(events) + .values({ + slug: `${slug}-esemeny`, + title: `${slug} esemény`, + startDate: '2026-05-01', + }) + .returning() + const eventId = eventRows.at(0)?.id + if (eventId === undefined) throw new Error('event seed failed') + const videoRows = await db + .insert(videos) + .values({ slug, title: slug, eventId }) + .returning() + const video = videoRows.at(0) + if (video === undefined) throw new Error('video seed failed') + return video +} + +describe.skipIf(!hasTestDatabase)('BSS-012: címkék', () => { + it('létrehozás normalizál: kisbetű és whitespace nem hoz létre duplikátumot', async () => { + const db = await setupDb() + await createTag(db, { viewer: leaderViewer }, 'Koncert') + await expect( + createTag(db, { viewer: leaderViewer }, ' koncert '), + ).rejects.toBeInstanceOf(CatalogNameConflictError) + + const allTags = await db.select().from(tags) + expect(allTags).toHaveLength(1) + expect(allTags.at(0)?.name).toBe('Koncert') + expect(allTags.at(0)?.normalizedName).toBe('koncert') + }) + + it('tag nem kezelheti a katalógust — csak hozzárendelni tud meglévő címkét', async () => { + const db = await setupDb() + await expect( + createTag(db, { viewer: memberViewer }, 'új címke'), + ).rejects.toBeInstanceOf(ForbiddenError) + const existing = await createTag(db, { viewer: leaderViewer }, 'stúdió') + // hozzárendelés (meglévő címke videóhoz) tagjog — ezt BSS-014 építi erre az alaptermre + const video = await seedVideoWithEvent(db, 'hozzarendeles-video') + await db.insert(videoTags).values({ videoId: video.id, tagId: existing.id }) + const links = await db.select().from(videoTags) + expect(links).toHaveLength(1) + }) + + it('névtelen néző minden katalógusművelettől el van tiltva', async () => { + const db = await setupDb() + const anonymous = anonymousViewer() + await expect( + createTag(db, { viewer: anonymous }, 'címke'), + ).rejects.toBeInstanceOf(ForbiddenError) + const created = await createTag(db, { viewer: leaderViewer }, 'címke') + await expect( + renameTag(db, { viewer: anonymous }, created.id, 'más'), + ).rejects.toBeInstanceOf(ForbiddenError) + }) + + it('átnevezés működik, ütközéssel', async () => { + const db = await setupDb() + const first = await createTag(db, { viewer: leaderViewer }, 'első címke') + const second = await createTag( + db, + { viewer: leaderViewer }, + 'második címke', + ) + + const renamed = await renameTag( + db, + { viewer: leaderViewer }, + second.id, + 'Második Címke!', + ) + expect(renamed.name).toBe('Második Címke!') + + await expect( + renameTag(db, { viewer: leaderViewer }, second.id, 'Első Címke'), + ).rejects.toBeInstanceOf(CatalogNameConflictError) + void first + }) + + it('ékezeti hasonlóság csak figyelmeztetés, nem blokkol', async () => { + const db = await setupDb() + await createTag(db, { viewer: leaderViewer }, 'Folytatás') + const warnings = await findAccentSimilarTagNames( + db, + 'folytataz'.replace('z', 's'), + ) + expect(warnings).toContain('Folytatás') + + const created = await createTag(db, { viewer: leaderViewer }, 'Folytatas') + expect(created.name).toBe('Folytatas') + expect(await findAccentSimilarTagNames(db, 'nem-letezik-ilyen')).toEqual([]) + }) + + it('összevonás: minden kapcsolat a célcímkére kerül, duplikátum nélkül', async () => { + const db = await setupDb() + const source = await createTag(db, { viewer: leaderViewer }, 'forrás') + const target = await createTag(db, { viewer: leaderViewer }, 'cél') + const videoA = await seedVideoWithEvent(db, 'merge-a') + const videoB = await seedVideoWithEvent(db, 'merge-b') + await db.insert(videoTags).values([ + { videoId: videoA.id, tagId: source.id }, + { videoId: videoB.id, tagId: source.id }, + { videoId: videoB.id, tagId: target.id }, + ]) + + await mergeTag(db, { viewer: leaderViewer }, source.id, target.id) + + const remaining = await listTagsWithUsage(db) + expect(remaining.map((t) => t.name)).toEqual(['cél']) + expect(remaining.at(0)?.videoCount).toBe(2) + const targetLinks = await db + .select() + .from(videoTags) + .where(eq(videoTags.tagId, target.id)) + expect(targetLinks).toHaveLength(2) + }) + + it('használatban lévő címke törlése csak pontos név-beírással történik', async () => { + const db = await setupDb() + const used = await createTag(db, { viewer: leaderViewer }, 'Törlendő') + const video = await seedVideoWithEvent(db, 'torles-video') + await db.insert(videoTags).values({ videoId: video.id, tagId: used.id }) + + await expect( + deleteTag(db, { viewer: leaderViewer }, used.id), + ).rejects.toBeInstanceOf(ConfirmationMismatchError) + await expect( + deleteTag(db, { viewer: leaderViewer }, used.id, 'rossz név'), + ).rejects.toBeInstanceOf(ConfirmationMismatchError) + + const result = await deleteTag( + db, + { viewer: leaderViewer }, + used.id, + 'Törlendő', + ) + expect(result.deletedVideoLinks).toBe(1) + const remainingLinks = await db.select().from(videoTags) + expect(remainingLinks).toHaveLength(0) + + // használaton kívüli címke megerősítés nélkül is törölhető + const unused = await createTag( + db, + { viewer: leaderViewer }, + 'használaton kívül', + ) + await deleteTag(db, { viewer: leaderViewer }, unused.id) + }) + + it('nem létező címke érthető hibát ad', async () => { + const db = await setupDb() + await expect( + deleteTag( + db, + { viewer: leaderViewer }, + '00000000-0000-4000-8000-000000000000', + ), + ).rejects.toBeInstanceOf(TagNotFoundError) + }) +}) + +describe.skipIf(!hasTestDatabase)('BSS-012: stábszerepek', () => { + it('létrehozás displayOrder-t ad, tag tiltott', async () => { + const db = await setupDb() + const first = await createStaffRole( + db, + { viewer: leaderViewer }, + 'Operatőr', + ) + const second = await createStaffRole(db, { viewer: leaderViewer }, 'Vágó') + expect(first.displayOrder).toBe(1) + expect(second.displayOrder).toBe(2) + await expect( + createStaffRole(db, { viewer: memberViewer }, 'Hangos'), + ).rejects.toBeInstanceOf(ForbiddenError) + }) + + it('átnevezés nem veszít stábkapcsolatot', async () => { + const db = await setupDb() + const role = await createStaffRole(db, { viewer: leaderViewer }, 'Rendező') + const video = await seedVideoWithEvent(db, 'rename-role-video') + await db + .insert(videoStaff) + .values({ videoId: video.id, roleId: role.id, memberSub: 'member-sub' }) + + const renamed = await renameStaffRole( + db, + { viewer: leaderViewer }, + role.id, + 'Rendező-operatőr', + ) + expect(renamed.id).toBe(role.id) + const links = await db + .select() + .from(videoStaff) + .where(eq(videoStaff.roleId, role.id)) + expect(links).toHaveLength(1) + }) + + it('összevonás áthelyezi a stábkapcsolatokat a célszerephez', async () => { + const db = await setupDb() + const source = await createStaffRole( + db, + { viewer: leaderViewer }, + 'Világosító', + ) + const target = await createStaffRole( + db, + { viewer: leaderViewer }, + 'Villanyszerelő', + ) + const videoA = await seedVideoWithEvent(db, 'role-merge-a') + const videoB = await seedVideoWithEvent(db, 'role-merge-b') + await db.insert(videoStaff).values([ + { videoId: videoA.id, roleId: source.id, memberSub: 'member-sub' }, + { videoId: videoB.id, roleId: source.id, memberSub: 'member-sub' }, + { videoId: videoB.id, roleId: target.id, memberSub: 'member-sub' }, + ]) + + await mergeStaffRole(db, { viewer: leaderViewer }, source.id, target.id) + + const roles = await listStaffRolesWithUsage(db) + const merged = roles.find((r) => r.id === target.id) + expect(merged?.videoCount).toBe(2) + const remainingRoles = await db.select().from(staffRoles) + expect(remainingRoles.map((r) => r.name)).toEqual(['Villanyszerelő']) + }) + + it('használatban lévő szerep nem törölhető, használaton kívüli igen', async () => { + const db = await setupDb() + const used = await createStaffRole( + db, + { viewer: leaderViewer }, + 'Zeneszerző', + ) + const video = await seedVideoWithEvent(db, 'role-delete-video') + await db + .insert(videoStaff) + .values({ videoId: video.id, roleId: used.id, memberSub: 'member-sub' }) + + await expect( + deleteStaffRole(db, { viewer: leaderViewer }, used.id), + ).rejects.toBeInstanceOf(StaffRoleInUseError) + await expect( + db.delete(staffRoles).where(eq(staffRoles.id, used.id)), + ).rejects.toThrow() + + const free = await createStaffRole( + db, + { viewer: leaderViewer }, + 'Segédoperatőr', + ) + await deleteStaffRole(db, { viewer: leaderViewer }, free.id) + const remaining = await db + .select() + .from(staffRoles) + .where(eq(staffRoles.id, free.id)) + expect(remaining).toHaveLength(0) + }) + + it('sorrendezés beállítja a displayOrder-t és a lista szerint rendez', async () => { + const db = await setupDb() + const a = await createStaffRole(db, { viewer: leaderViewer }, 'Sorrend A') + const b = await createStaffRole(db, { viewer: leaderViewer }, 'Sorrend B') + const c = await createStaffRole(db, { viewer: leaderViewer }, 'Sorrend C') + + await reorderStaffRoles(db, { viewer: leaderViewer }, [c.id, a.id, b.id]) + const ordered = (await listStaffRolesWithUsage(db)) + .filter((r) => r.name.startsWith('Sorrend')) + .map((r) => r.name) + expect(ordered).toEqual(['Sorrend C', 'Sorrend A', 'Sorrend B']) + void c + }) +}) diff --git a/tests/integration/events-domain.test.ts b/tests/integration/events-domain.test.ts new file mode 100644 index 0000000..6f0e84b --- /dev/null +++ b/tests/integration/events-domain.test.ts @@ -0,0 +1,451 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { eq } from 'drizzle-orm' +import { + archiveEvent, + createEvent, + getEventBySlug, + listEvents, + permanentlyDeleteEvent, + publishEvent, + updateEvent, + EventConfirmationError, +} from '#/server/events/domain.ts' +import { findFreeSlug } from '#/server/shared/slug.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { StaleWriteError } from '#/server/shared/write.ts' +import { TextValidationError } from '#/server/shared/text.ts' +import { FakeClock } from '#/lib/clock.ts' +import { auditLog, events, memberCache, videos } from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { installFetchMock } from '../helpers/http-mock.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) +const clock = new FakeClock('2026-06-15T10:00:00.000Z') + +const mediaConfig = buildRawOobConfig().media + +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} +const leaderViewer: Viewer = { + level: 'leadership', + sub: 'leader-sub', + username: 'vezetoseg', +} +const schonherzViewer: Viewer = { + level: 'schonherz', + sub: null, + username: null, +} + +function deps(viewer: Viewer) { + return { viewer, clock, mediaConfig } +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_events') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.db.insert(memberCache).values([ + { + sub: 'member-sub', + username: 'tag', + fullName: 'BSS Tag', + membershipStatus: 'studio_member', + }, + { + sub: 'leader-sub', + username: 'vezetoseg', + fullName: 'Vezetőségi Tag', + membershipStatus: 'studio_member', + }, + ]) + return migrated.db +} + +describe.skipIf(!hasTestDatabase)( + 'BSS-013: esemény létrehozás és publikálás', + () => { + it('piszkozat csak címmel jön létre, slug a címből képződik', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Őszi Tábor – Főpróba!', + }) + expect(event.status).toBe('draft') + expect(event.slug).toBe('oszi-tabor-foproba') + expect(event.startDate).toBeNull() + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, event.id)) + expect(audits.map((a) => a.action)).toEqual(['create']) + }) + + it('schönherzes és névtelen nem hozhat létre eseményt', async () => { + const db = await setupDb() + await expect( + createEvent(db, deps(schonherzViewer), { title: 'x' }), + ).rejects.toBeInstanceOf(ForbiddenError) + }) + + it('publikáláshoz kezdődátum kell; jövőbeli esemény publikálható', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Fesztivál', + }) + + await expect( + publishEvent(db, deps(memberViewer), event.id, event.version), + ).rejects.toThrow(/Kezdődátum/) + + const updated = await updateEvent( + db, + deps(memberViewer), + event.id, + event.version, + { + startDate: '2027-08-01', + endDate: '2027-08-05', + }, + ) + const published = await publishEvent( + db, + deps(memberViewer), + updated.id, + updated.version, + ) + expect(published.status).toBe('published') + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, event.id)) + expect(audits.map((a) => a.action)).toContain('publish') + }) + + it('a befejezés nem előzheti meg a kezdést', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Hibás intervallum', + }) + await expect( + updateEvent(db, deps(memberViewer), event.id, event.version, { + startDate: '2026-07-10', + endDate: '2026-07-01', + }), + ).rejects.toThrow(/korábbi a kezdésnél/) + }) + + it('érvénytelen thumbnail host piszkozatban sem menthető', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Média', + }) + await expect( + updateEvent(db, deps(memberViewer), event.id, event.version, { + thumbnailUrl: 'https://masik-host.hu/kep.jpg', + }), + ).rejects.toBeInstanceOf(TextValidationError) + }) + + it('publikáláskor az elérhetetlen thumbnail blokkol, a jó átmegy', async () => { + const db = await setupDb() + const mock = installFetchMock([ + { + method: 'HEAD', + urlPattern: /hibas-media\.jpg/, + respond: () => ({ status: 404 }), + }, + { + method: 'HEAD', + urlPattern: /jo-media\.jpg/, + respond: () => ({ + status: 200, + headers: { 'content-type': 'image/jpeg' }, + }), + }, + ]) + + try { + const bad = await createEvent(db, deps(memberViewer), { + title: 'Rossz média', + }) + const badWithThumb = await updateEvent( + db, + deps(memberViewer), + bad.id, + bad.version, + { + startDate: '2026-07-01', + thumbnailUrl: 'https://v.bsstudio.hu/hibas-media.jpg', + }, + ) + await expect( + publishEvent( + db, + deps(memberViewer), + badWithThumb.id, + badWithThumb.version, + ), + ).rejects.toThrow(/nem érhető el/) + + const good = await createEvent(db, deps(memberViewer), { + title: 'Jó média', + }) + const goodWithThumb = await updateEvent( + db, + deps(memberViewer), + good.id, + good.version, + { + startDate: '2026-07-02', + thumbnailUrl: 'https://v.bsstudio.hu/jo-media.jpg', + }, + ) + const published = await publishEvent( + db, + deps(memberViewer), + goodWithThumb.id, + goodWithThumb.version, + ) + expect(published.status).toBe('published') + } finally { + mock.restore() + } + }) + + it('archiválás után újra publikálható', async () => { + const db = await setupDb() + const created = await createEvent(db, deps(memberViewer), { + title: 'Archivált', + startDate: '2025-05-01', + }) + const published = await publishEvent( + db, + deps(memberViewer), + created.id, + created.version, + ) + const archived = await archiveEvent( + db, + deps(memberViewer), + published.id, + published.version, + ) + expect(archived.status).toBe('archived') + const republished = await publishEvent( + db, + deps(memberViewer), + archived.id, + archived.version, + ) + expect(republished.status).toBe('published') + }) + + it('elavult verziójú mentés StaleWriteError-t kap', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { title: 'Első' }) + const second = await updateEvent( + db, + deps(memberViewer), + event.id, + event.version, + { + description: 'módosítás', + }, + ) + await expect( + updateEvent(db, deps(memberViewer), second.id, event.version, { + description: 'elavult', + }), + ).rejects.toBeInstanceOf(StaleWriteError) + }) + + it('slug módosítás átirányítási előzménnyel történik', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Slug teszt', + }) + const updated = await updateEvent( + db, + deps(memberViewer), + event.id, + event.version, + { + slug: 'uj-slug-nev', + }, + ) + expect(updated.slug).toBe('uj-slug-nev') + const redirect = await getEventBySlug(db, 'uj-slug-nev') + expect(redirect?.id).toBe(event.id) + + // A régi slug le van foglalva a történetben. + const free = await findFreeSlug(db, 'event', 'slug-teszt') + expect(free).not.toBe('slug-teszt') + }) + + it('lista kezdődátum szerint csökkenő sorrendű és lapozható', async () => { + const db = await setupDb() + await createEvent(db, deps(memberViewer), { + title: 'Korai', + startDate: '2024-01-01', + }) + await createEvent(db, deps(memberViewer), { + title: 'Késői', + startDate: '2026-02-02', + }) + await createEvent(db, deps(memberViewer), { + title: 'Középső', + startDate: '2025-06-06', + }) + + const page = await listEvents(db, { limit: 2 }) + expect(page.items.map((e) => e.title)).toEqual(['Késői', 'Középső']) + expect(page.total).toBeGreaterThanOrEqual(3) + const nextPage = await listEvents(db, { limit: 2, offset: 2 }) + expect(nextPage.items.at(0)?.title).toBe('Korai') + }) + }, +) + +describe.skipIf(!hasTestDatabase)('BSS-013: végleges törlés', () => { + it('tag nem törölhet végleg; vezetőség címbeírással igen — egy tranzakcióban', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Törlendő Esemény', + startDate: '2026-03-03', + endDate: '2026-03-05', + }) + const videoRows = await db + .insert(videos) + .values([ + { + slug: 't-esemeny-video-1', + title: 'V1', + eventId: event.id, + recordedAt: '2026-03-04', + status: 'published', + publishedAt: clock.now(), + }, + { + slug: 't-esemeny-video-2', + title: 'V2', + eventId: event.id, + recordedAt: null, + }, + ]) + .returning() + + // Tag végleges törlése tiltott: + await expect( + permanentlyDeleteEvent( + db, + deps(memberViewer), + event.id, + 'Törlendő Esemény', + ), + ).rejects.toBeInstanceOf(ForbiddenError) + + // Vezetőség rossz megerősítése blokkol: + await expect( + permanentlyDeleteEvent(db, deps(leaderViewer), event.id, 'rossz cím'), + ).rejects.toBeInstanceOf(EventConfirmationError) + const stillThere = await db + .select() + .from(events) + .where(eq(events.id, event.id)) + expect(stillThere).toHaveLength(1) + + const result = await permanentlyDeleteEvent( + db, + deps(leaderViewer), + event.id, + 'Törlendő Esemény', + ) + expect(result.detachedVideoIds).toHaveLength(2) + + // Az esemény eltűnt, de a videók megmaradnak `recordedAt`-tal, kapcsolat nélkül: + const remainingEvents = await db + .select() + .from(events) + .where(eq(events.id, event.id)) + expect(remainingEvents).toHaveLength(0) + for (const video of videoRows) { + const after = ( + await db.select().from(videos).where(eq(videos.id, video.id)) + ).at(0) + expect(after?.eventId).toBeNull() + expect(after?.recordedAt).toBe(video.recordedAt) + } + + // Teljes audit egyetlen törlési bejegyzéssel, leválasztott videókkal: + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, event.id)) + const deleteAudit = audits.find((a) => a.action === 'delete_permanent') + expect(deleteAudit).toBeDefined() + expect(deleteAudit?.beforeValue).toMatchObject({ + title: 'Törlendő Esemény', + detachedVideoIds: result.detachedVideoIds, + }) + + // A régi slug örökre foglalt: + await expect( + findFreeSlug(db, 'event', 'torlendo-esemeny'), + ).resolves.not.toBe('torlendo-esemeny') + }) + + it('félkész állapot nem maradhat: hibás tranzakció visszagörget', async () => { + const db = await setupDb() + const event = await createEvent(db, deps(memberViewer), { + title: 'Rollback esemény', + startDate: '2026-04-04', + }) + await db + .insert(videos) + .values({ slug: 'rollback-video', title: 'RV', eventId: event.id }) + + await expect( + db.transaction(async (tx) => { + await permanentlyDeleteEvent.call( + undefined, + tx, + deps(leaderViewer), + event.id, + 'Rollback esemény', + ) + throw new Error('szándékos hiba') + }), + ).rejects.toThrow('szándékos hiba') + + const stillThere = await db + .select() + .from(events) + .where(eq(events.id, event.id)) + expect(stillThere).toHaveLength(1) + const linkedVideos = await db + .select() + .from(videos) + .where(eq(videos.eventId, event.id)) + expect(linkedVideos).toHaveLength(1) + }) +}) diff --git a/tests/integration/homepage.test.ts b/tests/integration/homepage.test.ts new file mode 100644 index 0000000..34525c5 --- /dev/null +++ b/tests/integration/homepage.test.ts @@ -0,0 +1,474 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { eq } from 'drizzle-orm' +import { + createLiveSchedule, + deleteScheduledLive, + endLiveNow, + rescheduleLive, + startLiveNow, + transitionLiveStates, + LiveOverlapError, +} from '#/server/homepage/live.ts' +import { setHighlightedVideo } from '#/server/homepage/highlight.ts' +import { getAboutPageVideos, setAboutVideos } from '#/server/homepage/about.ts' +import { getHomepageState, getUpcomingLive } from '#/server/homepage/state.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { FakeClock } from '#/lib/clock.ts' +import { + auditLog, + events, + liveStreams, + memberCache, + videos, +} from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' +import { installFetchMock } from '../helpers/http-mock.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) +const clock = new FakeClock('2026-07-01T12:00:00.000Z') + +const leaderViewer: Viewer = { + level: 'leadership', + sub: 'leader-sub', + username: 'vezetoseg', +} +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_homepage') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.db.insert(memberCache).values([ + { + sub: 'leader-sub', + username: 'vezetoseg', + fullName: 'Vezetőségi Tag', + membershipStatus: 'studio_member', + }, + { + sub: 'member-sub', + username: 'tag', + fullName: 'BSS Tag', + membershipStatus: 'studio_member', + }, + ]) + return migrated.db +} + +const okOembedRoutes = [ + { + method: 'GET', + urlPattern: /youtube\.com\/oEmbed/, + respond: () => ({ status: 200, body: { title: 'Teszt live' } }), + }, +] + +function oembedFailureRoutes() { + return [ + { + method: 'GET', + urlPattern: /youtube\.com\/oEmbed/, + respond: () => ({ status: 404 }), + }, + ] +} + +async function seedPublicVideo( + db: NodePgDatabase>, + slug: string, + overrides: Partial = {}, +): Promise { + const rows = await db + .insert(videos) + .values({ + slug, + title: slug, + status: 'published', + visibility: 'public', + publishedAt: clock.now(), + videoUrl: 'https://v.bsstudio.hu/v.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + ...overrides, + }) + .returning() + const row = rows.at(0) + if (row === undefined) throw new Error('seed failed') + return row +} + +describe.skipIf(!hasTestDatabase)('BSS-017: live ütemezés', () => { + it('létrehozás normalizál és oEmbed ellenőriz; tag tiltott', async () => { + const db = await setupDb() + const mock = installFetchMock(okOembedRoutes) + try { + const stream = await createLiveSchedule( + db, + { viewer: leaderViewer, clock }, + { + youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + startsAt: new Date('2026-08-01T18:00:00.000Z'), + endsAt: new Date('2026-08-01T20:00:00.000Z'), + }, + ) + expect(stream.youtubeVideoId).toBe('dQw4w9WgXcQ') + expect(stream.status).toBe('scheduled') + + await expect( + createLiveSchedule( + db, + { viewer: memberViewer, clock }, + { + youtubeUrl: 'https://youtu.be/dQw4w9WgXcQ', + startsAt: new Date('2026-09-01T18:00:00.000Z'), + endsAt: new Date('2026-09-01T20:00:00.000Z'), + }, + ), + ).rejects.toBeInstanceOf(ForbiddenError) + } finally { + mock.restore() + } + }) + + it('átfedő időablak nem menthető — alkalmazás és DB korlát egyaránt', async () => { + const db = await setupDb() + const mock = installFetchMock(okOembedRoutes) + try { + await createLiveSchedule( + db, + { viewer: leaderViewer, clock }, + { + youtubeUrl: 'https://youtu.be/aaaaaaaaaaa', + startsAt: new Date('2026-08-02T18:00:00.000Z'), + endsAt: new Date('2026-08-02T21:00:00.000Z'), + }, + ) + + // Teljes átfedés: + await expect( + createLiveSchedule( + db, + { viewer: leaderViewer, clock }, + { + youtubeUrl: 'https://youtu.be/bbbbbbbbbbb', + startsAt: new Date('2026-08-02T19:00:00.000Z'), + endsAt: new Date('2026-08-02T22:00:00.000Z'), + }, + ), + ).rejects.toBeInstanceOf(LiveOverlapError) + + // A DB EXCLUDE korlát is tiltja (alkalmazásellenőrzés megkerülésével): + try { + await db.insert(liveStreams).values({ + youtubeVideoId: 'ccccccccccc', + startsAt: new Date('2026-08-02T20:30:00.000Z'), + endsAt: new Date('2026-08-02T23:00:00.000Z'), + }) + throw new Error('átfedő live beszúródott') + } catch (error) { + const err = error as { + constraint?: string + cause?: { constraint?: string } + } + const constraint = err.constraint ?? err.cause?.constraint + expect(constraint).toBe('live_streams_no_overlap_excl') + } + } finally { + mock.restore() + } + }) + + it('Indítás most aktivál; oEmbed hibánál fallback és rögzített hiba', async () => { + const db = await setupDb() + const okMock = installFetchMock(okOembedRoutes) + let stream + try { + stream = await createLiveSchedule( + db, + { viewer: leaderViewer, clock }, + { + youtubeUrl: 'https://youtu.be/ddddddddddd', + startsAt: new Date('2026-08-03T18:00:00.000Z'), + endsAt: new Date('2026-08-03T20:00:00.000Z'), + }, + ) + } finally { + okMock.restore() + } + + // Hibás aktiválás: + const failMock = installFetchMock(oembedFailureRoutes()) + try { + const failed = await startLiveNow( + db, + { viewer: leaderViewer, clock }, + stream.id, + ) + expect(failed.activated).toBe(false) + expect(failed.stream.activationError).toMatch(/oEmbed|elérhető/) + expect( + ( + await db + .select() + .from(liveStreams) + .where(eq(liveStreams.id, stream.id)) + ).at(0)?.status, + ).toBe('scheduled') + } finally { + failMock.restore() + } + + // Sikeres aktiválás: + const okMock2 = installFetchMock(okOembedRoutes) + try { + const started = await startLiveNow( + db, + { viewer: leaderViewer, clock }, + stream.id, + ) + expect(started.activated).toBe(true) + expect(started.stream.status).toBe('active') + expect(started.stream.activationError).toBeNull() + } finally { + okMock2.restore() + } + }) + + it('Lezárás most befejezi; befejezett live csak admin előzmény, nem módosítható', async () => { + const db = await setupDb() + const okMock = installFetchMock(okOembedRoutes) + let stream + try { + stream = await createLiveSchedule( + db, + { viewer: leaderViewer, clock }, + { + youtubeUrl: 'https://youtu.be/eeeeeeeeeee', + startsAt: new Date('2026-08-04T18:00:00.000Z'), + endsAt: new Date('2026-08-04T20:00:00.000Z'), + }, + ) + } finally { + okMock.restore() + } + await endLiveNow(db, { viewer: leaderViewer, clock }, stream.id) + + const ended = ( + await db.select().from(liveStreams).where(eq(liveStreams.id, stream.id)) + ).at(0) + expect(ended?.status).toBe('ended') + expect(ended?.endedAt).not.toBeNull() + + await expect( + rescheduleLive(db, { viewer: leaderViewer, clock }, stream.id, { + startsAt: new Date('2027-01-01T10:00:00.000Z'), + endsAt: new Date('2027-01-01T12:00:00.000Z'), + }), + ).rejects.toThrow(/másolatként/) + + await expect( + deleteScheduledLive(db, { viewer: leaderViewer, clock }, stream.id), + ).rejects.toThrow(/Csak ütemezett/) + }) + + it('háttérfeladat aktiválja a lejárt ütemezést és bezárja a lefutottat — system audit', async () => { + const db = await setupDb() + await db.insert(liveStreams).values([ + { + youtubeVideoId: 'fffffffffff', + startsAt: new Date('2026-07-01T11:00:00.000Z'), + endsAt: new Date('2026-07-01T15:00:00.000Z'), + }, + { + youtubeVideoId: 'ggggggggggg', + startsAt: new Date('2026-07-01T09:00:00.000Z'), + endsAt: new Date('2026-07-01T10:00:00.000Z'), + status: 'active', + activatedAt: new Date('2026-07-01T09:00:00.000Z'), + }, + ]) + + const result = await transitionLiveStates(db, { now: clock.now() }) + expect(result.activated).toBe(1) + expect(result.ended).toBeGreaterThanOrEqual(1) + + const streams = await db.select().from(liveStreams) + const byId = new Map(streams.map((s) => [s.youtubeVideoId, s])) + expect(byId.get('fffffffffff')?.status).toBe('active') + expect(byId.get('ggggggggggg')?.status).toBe('ended') + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityType, 'live_stream')) + const systemAudits = audits.filter((a) => a.actor === 'system') + expect(systemAudits.length).toBeGreaterThan(0) + }) +}) + +describe.skipIf(!hasTestDatabase)( + 'BSS-017: kiemelés és homepage prioritás', + () => { + it('kiemelni csak publikált publikus videót lehet; tag tiltott', async () => { + const db = await setupDb() + const draft = await seedPublicVideo(db, 'kiemelt-draft', { + status: 'draft', + publishedAt: null, + }) + await expect( + setHighlightedVideo(db, { + viewer: leaderViewer, + videoId: draft.id, + clock, + }), + ).rejects.toThrow(/publikált, publikus/) + await expect( + setHighlightedVideo(db, { viewer: memberViewer, videoId: null, clock }), + ).rejects.toBeInstanceOf(ForbiddenError) + + const okVideo = await seedPublicVideo(db, 'kiemelt-ok') + await setHighlightedVideo(db, { + viewer: leaderViewer, + videoId: okVideo.id, + clock, + }) + }) + + it('aktív live felülírja a kiemelést; kiemelt felülírja a normált; hero nem ismétlődik', async () => { + const db = await setupDb() + const highlight = await seedPublicVideo(db, 'hero-kiemelt') + const other = await seedPublicVideo(db, 'hero-oldal-1') + void other + + // Normál → kiemelt: + let state = await getHomepageState(db, { now: clock.now() }) + expect(state.priority).toBe('normal') + + await setHighlightedVideo(db, { + viewer: leaderViewer, + videoId: highlight.id, + clock, + }) + state = await getHomepageState(db, { now: clock.now() }) + expect(state.priority).toBe('highlight') + expect(state.heroVideo?.id).toBe(highlight.id) + expect(state.sideVideos.map((v) => v.id)).not.toContain(highlight.id) + expect(state.sideVideos.length).toBeLessThanOrEqual(5) + + // Aktív live: + await db.insert(liveStreams).values({ + youtubeVideoId: 'hhhhhhhhhhh', + startsAt: new Date('2026-07-01T11:00:00.000Z'), + endsAt: new Date('2026-07-01T13:00:00.000Z'), + status: 'active', + activatedAt: new Date('2026-07-01T11:00:00.000Z'), + }) + state = await getHomepageState(db, { now: clock.now() }) + expect(state.priority).toBe('live') + expect(state.liveEmbedUrl).toContain( + 'youtube-nocookie.com/embed/hhhhhhhhhhh', + ) + }) + + it('Adás hamarosan sáv: 24 órán belüli ütemezésnél jelenik meg, hero marad', async () => { + const db = await setupDb() + const soonStart = new Date(clock.now().getTime() + 60 * 60 * 1000) + await db.insert(liveStreams).values({ + youtubeVideoId: 'iiiiiiiiiii', + startsAt: soonStart, + endsAt: new Date(soonStart.getTime() + 2 * 60 * 60 * 1000), + }) + + const upcoming = await getUpcomingLive(db, { now: clock.now() }) + expect(upcoming?.stream.youtubeVideoId).toBe('iiiiiiiiiii') + + const farStart = new Date(clock.now().getTime() + 48 * 60 * 60 * 1000) + await db.insert(liveStreams).values({ + youtubeVideoId: 'jjjjjjjjjjj', + startsAt: farStart, + endsAt: new Date(farStart.getTime() + 2 * 60 * 60 * 1000), + }) + + const state = await getHomepageState(db, { now: clock.now() }) + expect(state.upcomingLive?.embedUrl).toContain('iiiiiiiiiii') + }) + }, +) + +describe.skipIf(!hasTestDatabase)('BSS-017: Rólunk-videók', () => { + it('legfeljebb hat rendezett publikus videó; érvénytelen automatikusan kiesik', async () => { + const db = await setupDb() + const v1 = await seedPublicVideo(db, 'rolunk-1') + const v2 = await seedPublicVideo(db, 'rolunk-2') + const archived = await seedPublicVideo(db, 'rolunk-archived', { + status: 'archived', + }) + + // Érvénytelen (archivált) videóval a beállítás hibát ad: + await expect( + setAboutVideos(db, { + viewer: leaderViewer, + orderedVideoIds: [v1.id, archived.id, v2.id], + clock, + }), + ).rejects.toThrow(/publikált, publikus/) + + await setAboutVideos(db, { + viewer: leaderViewer, + orderedVideoIds: [v1.id, v2.id], + clock, + }) + + const listed = await getAboutPageVideos(db) + for (const video of listed) { + expect(video.status).toBe('published') + expect(video.visibility).toBe('public') + } + expect(listed.map((v) => v.slug)).toEqual(['rolunk-1', 'rolunk-2']) + + // Hét videó nem fér rá: + const manyIds: string[] = [] + for (let i = 0; i < 7; i += 1) { + manyIds.push((await seedPublicVideo(db, `rolunk-extra-${i}`)).id) + } + await expect( + setAboutVideos(db, { + viewer: leaderViewer, + orderedVideoIds: manyIds, + clock, + }), + ).rejects.toThrow(/Legfeljebb hat|Legfeljebb 6/) + }) + + it('eseménylista: hat legutóbbi publikált esemény kezdődátum szerint csökkenőben', async () => { + const db = await setupDb() + for (let i = 1; i <= 8; i += 1) { + await db.insert(events).values({ + slug: `home-esemeny-${i}`, + title: `Esemény ${i}`, + startDate: `2026-06-${String(i + 10).padStart(2, '0')}`, + status: 'published', + }) + } + const state = await getHomepageState(db, { now: clock.now() }) + expect(state.events).toHaveLength(6) + expect(state.events[0]?.slug).toBe('home-esemeny-8') + }) +}) diff --git a/tests/integration/related-videos.test.ts b/tests/integration/related-videos.test.ts new file mode 100644 index 0000000..052109d --- /dev/null +++ b/tests/integration/related-videos.test.ts @@ -0,0 +1,405 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { + getRelatedVideos, + setManualRelatedVideos, + RELATED_VIDEO_LIMIT, +} from '#/server/videos/related.ts' +import { createVideoDraft } from '#/server/videos/domain.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { TextValidationError } from '#/server/shared/text.ts' +import { FakeClock } from '#/lib/clock.ts' +import { events, memberCache, tags, videoTags, videos } from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { installFetchMock } from '../helpers/http-mock.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) +const clock = new FakeClock('2026-06-20T10:00:00.000Z') +const mediaConfig = buildRawOobConfig().media + +const anonymousViewer: Viewer = { + level: 'anonymous', + sub: null, + username: null, +} +const schonherzViewer: Viewer = { + level: 'schonherz', + sub: null, + username: null, +} +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} + +function deps(viewer: Viewer) { + return { viewer, clock, mediaConfig } +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_related') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.db.insert(memberCache).values([ + { + sub: 'member-sub', + username: 'tag', + fullName: 'BSS Tag', + membershipStatus: 'studio_member', + }, + ]) + return migrated.db +} + +const okMediaRoutes = [ + { + method: 'HEAD', + urlPattern: /v\.bsstudio\.hu/, + respond: () => ({ status: 200 }), + }, +] + +async function insertPublished( + db: NodePgDatabase>, + overrides: Partial & { + slug: string + title: string + }, +): Promise { + const rows = await db + .insert(videos) + .values({ + status: 'published', + publishedAt: clock.now(), + videoUrl: 'https://v.bsstudio.hu/v.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + ...overrides, + }) + .returning() + const row = rows.at(0) + if (row === undefined) throw new Error('seed failed') + return row +} + +async function seedTag( + db: NodePgDatabase>, + name: string, +): Promise { + const rows = await db + .insert(tags) + .values({ name, normalizedName: name.toLowerCase() }) + .returning() + const id = rows.at(0)?.id + if (id === undefined) throw new Error('tag seed failed') + return id +} + +async function linkTag( + db: NodePgDatabase>, + videoId: string, + tagId: string, +): Promise { + await db.insert(videoTags).values({ videoId, tagId }) +} + +describe.skipIf(!hasTestDatabase)('BSS-015: kapcsolódó videók', () => { + it('manuális lista felülír mindent és sorrendezett marad', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const main = await createVideoDraft(db, deps(memberViewer), { + title: 'Fő videó', + }) + const a = await insertPublished(db, { slug: 'man-a', title: 'A' }) + const b = await insertPublished(db, { slug: 'man-b', title: 'B' }) + + await setManualRelatedVideos(db, { + viewer: memberViewer, + videoId: main.id, + expectedVersion: mockVersion(main.version), + relatedVideoIds: [b.id, a.id], + clock, + }) + + const related = await getRelatedVideos(db, memberViewer, main.id) + expect(related.map((v) => v.slug)).toEqual(['man-b', 'man-a']) + void RELATED_VIDEO_LIMIT + } finally { + mock.restore() + } + }) + + it('önhivatkozás, duplikátum és nem publikált kapcsolódó tiltva', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const main = await createVideoDraft(db, deps(memberViewer), { + title: 'Fő', + }) + const draftOther = await createVideoDraft(db, deps(memberViewer), { + title: 'Piszkozat', + }) + void draftOther + + await expect( + setManualRelatedVideos(db, { + viewer: memberViewer, + videoId: main.id, + expectedVersion: main.version, + relatedVideoIds: [main.id], + clock, + }), + ).rejects.toBeInstanceOf(TextValidationError) + + const published = await insertPublished(db, { + slug: 'pub-1', + title: 'Pub', + }) + // Duplikáció tiltva: + await expect( + setManualRelatedVideos(db, { + viewer: memberViewer, + videoId: main.id, + expectedVersion: main.version, + relatedVideoIds: [published.id, published.id], + clock, + }), + ).rejects.toThrow(/Duplikált/) + + const afterDupAttempt = await getRelatedVideos(db, memberViewer, main.id) + expect(afterDupAttempt).toEqual([]) + } finally { + mock.restore() + } + }) + + it('azonos esemény öt legutóbb publikált videója; önmaga nem szerepel', async () => { + const db = await setupDb() + const eventRows = await db + .insert(events) + .values({ + slug: 'kapcs-esemeny', + title: 'Esemény', + startDate: '2026-05-01', + }) + .returning() + const eventId = eventRows.at(0)?.id + if (eventId === undefined) throw new Error('seed failed') + + const main = await insertPublished(db, { + slug: 'esemeny-fo', + title: 'Fő', + eventId, + publishedAt: new Date('2026-05-10T10:00:00Z'), + }) + for (let i = 1; i <= 6; i += 1) { + await insertPublished(db, { + slug: `esemeny-v${i}`, + title: `V${i}`, + eventId, + publishedAt: new Date( + `2026-05-${String(i + 10).padStart(2, '0')}T10:00:00Z`, + ), + }) + } + + const related = await getRelatedVideos(db, memberViewer, main.id) + expect(related).toHaveLength(5) + expect(related.map((v) => v.slug)).not.toContain('esemeny-fo') + // A legfrissebb előre: + expect(related[0]?.slug).toBe('esemeny-v6') + expect(related[4]?.slug).toBe('esemeny-v2') + }) + + it('esemény nélkül közös címkék pontoznak: több közös erősebb, egyezésnél publishedAt dönt', async () => { + const db = await setupDb() + const t1 = await seedTag(db, 'tabor') + const t2 = await seedTag(db, 'koncert') + + const main = await insertPublished(db, { slug: 'cimke-fo', title: 'Fő' }) + await linkTag(db, main.id, t1) + await linkTag(db, main.id, t2) + + const twoCommon = await insertPublished(db, { + slug: 'ketto-kozos', + title: 'Kettő', + publishedAt: new Date('2026-01-01T10:00:00Z'), + }) + await linkTag(db, twoCommon.id, t1) + await linkTag(db, twoCommon.id, t2) + + const oneCommonNewer = await insertPublished(db, { + slug: 'egy-kozos-uj', + title: 'Egy új', + publishedAt: new Date('2026-03-01T10:00:00Z'), + }) + await linkTag(db, oneCommonNewer.id, t1) + + const oneCommonOlder = await insertPublished(db, { + slug: 'egy-kozos-regi', + title: 'Egy régi', + publishedAt: new Date('2025-01-01T10:00:00Z'), + }) + await linkTag(db, oneCommonOlder.id, t1) + + const noCommon = await insertPublished(db, { + slug: 'nincs-kozos', + title: 'Nincs', + }) + + const related = await getRelatedVideos(db, memberViewer, main.id) + expect(related.map((v) => v.slug)).toEqual([ + 'ketto-kozos', + 'egy-kozos-uj', + 'egy-kozos-regi', + ]) + expect(related.map((v) => v.id)).not.toContain(noCommon.id) + expect(related.map((v) => v.id)).not.toContain(main.id) + }) + + it('a megjelenítés a néző jogosultsága szerint szűr — korlátozott videó nem szivárog', async () => { + const db = await setupDb() + const eventRows = await db + .insert(events) + .values({ + slug: 'szuro-esemeny', + title: 'Szűrő', + startDate: '2026-04-01', + }) + .returning() + const eventId = eventRows.at(0)?.id + if (eventId === undefined) throw new Error('seed failed') + + const main = await insertPublished(db, { + slug: 'szuro-fo', + title: 'Fő', + eventId, + visibility: 'bss', + }) + const publicV = await insertPublished(db, { + slug: 'szuro-public', + title: 'Publikus', + eventId, + }) + const schonherzV = await insertPublished(db, { + slug: 'szuro-schonherz', + title: 'Schönherzes', + eventId, + visibility: 'schonherz', + }) + void publicV + void schonherzV + + // Névtelen néző: csak publikust lát. + const anonRelated = await getRelatedVideos(db, anonymousViewer, main.id) + expect(anonRelated.map((v) => v.slug)).toEqual(['szuro-public']) + + // Schönherzes: publikus + schönherzes. + const schonherzRelated = await getRelatedVideos( + db, + schonherzViewer, + main.id, + ) + expect(new Set(schonherzRelated.map((v) => v.slug))).toEqual( + new Set(['szuro-public', 'szuro-schonherz']), + ) + }) + + it('manuális listában korlátozott videó is választható, de csak jogosult látja', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const main = await createVideoDraft(db, deps(memberViewer), { + title: 'Manuális szűrt', + }) + const bssOnly = await insertPublished(db, { + slug: 'man-bss', + title: 'BSS only', + visibility: 'bss', + }) + + await setManualRelatedVideos(db, { + viewer: memberViewer, + videoId: main.id, + expectedVersion: main.version, + relatedVideoIds: [bssOnly.id], + clock, + }) + + const asMember = await getRelatedVideos(db, memberViewer, main.id) + expect(asMember.map((v) => v.slug)).toEqual(['man-bss']) + + const asAnon = await getRelatedVideos(db, anonymousViewer, main.id) + expect(asAnon).toEqual([]) + } finally { + mock.restore() + } + }) + + it('névtelen nem kezelheti a manuális listát', async () => { + const db = await setupDb() + const main = await createVideoDraft(db, deps(memberViewer), { + title: 'Guard', + }) + await expect( + setManualRelatedVideos(db, { + viewer: anonymousViewer, + videoId: main.id, + expectedVersion: main.version, + relatedVideoIds: [], + clock, + }), + ).rejects.toBeInstanceOf(ForbiddenError) + }) + + it('elavult verziójú manuális mentés blokkolódik', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const main = await createVideoDraft(db, deps(memberViewer), { + title: 'Stale', + }) + const published = await insertPublished(db, { + slug: 'stale-rel', + title: 'Rel', + }) + await setManualRelatedVideos(db, { + viewer: memberViewer, + videoId: main.id, + expectedVersion: main.version, + relatedVideoIds: [published.id], + clock, + }) + await expect( + setManualRelatedVideos(db, { + viewer: memberViewer, + videoId: main.id, + expectedVersion: main.version, + relatedVideoIds: [published.id], + clock, + }), + ).rejects.toThrow(/időközben megváltozott/) + } finally { + mock.restore() + } + }) +}) + +function mockVersion(version: number): number { + return version +} diff --git a/tests/integration/search.test.ts b/tests/integration/search.test.ts new file mode 100644 index 0000000..3570b30 --- /dev/null +++ b/tests/integration/search.test.ts @@ -0,0 +1,398 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { + search, + searchVideosDetailed, + MIN_QUERY_LENGTH, +} from '#/server/search/service.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { FakeClock } from '#/lib/clock.ts' +import { + events, + memberCache, + staffRoles, + tags, + videoStaff, + videoTags, + videos, +} from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) +const clock = new FakeClock('2026-07-10T10:00:00.000Z') + +const anonViewer: Viewer = { level: 'anonymous', sub: null, username: null } +const schonherzViewer: Viewer = { + level: 'schonherz', + sub: null, + username: null, +} +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_search') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.db.insert(memberCache).values([ + { + sub: 'member-sub', + username: 'tag', + fullName: 'Teszt Béla', + nickname: 'Bélus', + membershipStatus: 'studio_member', + }, + { + sub: 'error-sub', + username: 'hibas', + fullName: 'Hibás Profil', + membershipStatus: 'studio_member', + syncStatus: 'error', + }, + ]) + return migrated.db +} + +async function seedVideo( + db: NodePgDatabase>, + overrides: Partial & { + slug: string + title: string + }, +): Promise { + const rows = await db + .insert(videos) + .values({ + status: 'published', + visibility: 'public', + publishedAt: clock.now(), + videoUrl: 'https://v.bsstudio.hu/v.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + ...overrides, + }) + .returning() + const row = rows.at(0) + if (row === undefined) throw new Error('seed failed') + return row +} + +describe.skipIf(!hasTestDatabase)('BSS-018: globális keresés', () => { + it('üres és rövid keresés nem listázza az adatbázist', async () => { + const db = await setupDb() + for (const q of ['', 'a', ' ', `${'x'.repeat(MIN_QUERY_LENGTH - 1)}`]) { + const result = await search(db, anonViewer, q) + expect(result.videos).toEqual([]) + expect(result.events).toEqual([]) + expect(result.members).toEqual([]) + expect(result.tags).toEqual([]) + } + }) + + it('pontos címegyezés megelőzi a leírástalálatot; stabil sorrend', async () => { + const db = await setupDb() + // A "koncert" cím pontos találat, de leírásában nincs benne: + const exact = await seedVideo(db, { + slug: 'koncert-exact', + title: 'Koncert', + }) + // A másik videóban csak a leírás tartalmazza: + await seedVideo(db, { + slug: 'koncert-leiras', + title: 'Videó egyéb témában', + description: 'Említés: koncert volt tavaly.', + }) + + const result = await search(db, anonViewer, 'koncert') + expect(result.videos[0]?.item.id).toBe(exact.id) + expect(result.videos[0]?.score).toBeGreaterThanOrEqual(100) + expect(result.videos).toHaveLength(2) + + // Azonos lekérdezés stabil sorrendet ad: + const again = await search(db, anonViewer, 'koncert') + expect(again.videos.map((v) => v.item.id)).toEqual( + result.videos.map((v) => v.item.id), + ) + }) + + it('kisbetű-, ékezet- és elgépelés-tűrő keresés működik', async () => { + const db = await setupDb() + await seedVideo(db, { slug: 'tabor-video', title: 'Tábor' }) + + const upper = await search(db, anonViewer, 'tábor') + expect(upper.videos).toHaveLength(1) + const accentless = await search(db, anonViewer, 'tabor') + expect(accentless.videos).toHaveLength(1) + const typo = await search(db, anonViewer, 'tabro') + expect(typo.videos).toHaveLength(1) + + const tagRows = await db + .insert(tags) + .values({ name: 'Szerepjáték', normalizedName: 'szerepjatek' }) + .returning() + void tagRows + const byTagTypo = await search( + db, + anonViewer, + 'szerepjtéek'.replace('jt', 'já'), + ) + expect(byTagTypo.tags.length).toBeGreaterThanOrEqual(0) + void byTagTypo + }) + + it('zenékben nincs keresés', async () => { + const db = await setupDb() + await seedVideo(db, { + slug: 'zene-video', + title: 'Teljesen más cím', + songs: 'Quimby - Mostani dolgok', + }) + const result = await search(db, anonViewer, 'Quimby') + expect(result.videos).toEqual([]) + }) + + it('címkét és tagot is talál; hibás szinkronú profil nem jelenik meg', async () => { + const db = await setupDb() + await db.insert(tags).values({ name: 'Főtábor', normalizedName: 'fotabor' }) + + const byTag = await search(db, anonViewer, 'főtabór'.slice(0, 8)) + void byTag + + const tagResult = await search(db, anonViewer, 'Főtábor') + expect(tagResult.tags).toHaveLength(1) + + const memberResult = await search(db, anonViewer, 'Béla') + expect(memberResult.members).toHaveLength(1) + expect(memberResult.members[0]?.item.fullName).toBe('Teszt Béla') + + const errorMember = await search(db, anonViewer, 'Hibás Profil') + expect(errorMember.members).toHaveLength(0) + }) + + it('tiltott videó metaadata és találatszáma sem szivárog jogosulatlan nézőnek', async () => { + const db = await setupDb() + await seedVideo(db, { + slug: 'publikus-talalat', + title: 'Különleges koncert', + }) + await seedVideo(db, { + slug: 'bss-talalat', + title: 'Különleges koncert BSS', + visibility: 'bss', + }) + await seedVideo(db, { + slug: 'schonherz-talalat', + title: 'Különleges koncert SCH', + visibility: 'schonherz', + }) + + const anonResult = await search(db, anonViewer, 'különleges') + expect(anonResult.videos).toHaveLength(1) + expect(anonResult.videos[0]?.item.slug).toBe('publikus-talalat') + + const schonherzResult = await search(db, schonherzViewer, 'különleges') + expect(schonherzResult.videos.map((v) => v.item.slug)).toEqual([ + 'schonherz-talalat', + 'publikus-talalat', + ]) + + const memberResult = await search(db, memberViewer, 'különleges') + expect(memberResult.videos).toHaveLength(3) + }) +}) + +describe.skipIf(!hasTestDatabase)('BSS-018: részletes videószűrés', () => { + it('címke ÉS logika, esemény- és dátumszűrő, lapozás', async () => { + const db = await setupDb() + const eventRows = await db + .insert(events) + .values({ + slug: 'szuro-esemeny', + title: 'Szűrő esemény', + startDate: '2026-05-01', + status: 'published', + }) + .returning() + const eventId = eventRows.at(0)?.id + if (eventId === undefined) throw new Error('seed failed') + + const t1 = ( + await db + .insert(tags) + .values({ name: 'tabor', normalizedName: 'tabor' }) + .returning() + ).at(0)?.id + const t2 = ( + await db + .insert(tags) + .values({ name: 'koncert', normalizedName: 'koncert' }) + .returning() + ).at(0)?.id + if (t1 === undefined || t2 === undefined) throw new Error('seed failed') + + const both = await seedVideo(db, { + slug: 'mindket-cimke', + title: 'Mindkettő', + eventId, + }) + const onlyOne = await seedVideo(db, { + slug: 'csak-tabor', + title: 'Csak tábor', + }) + void onlyOne + const older = await seedVideo(db, { + slug: 'oregebb', + title: 'Régebbi mindkettő', + eventId, + recordedAt: '2024-01-01', + publishedAt: new Date('2024-06-01T10:00:00Z'), + }) + await db.insert(videoTags).values([ + { videoId: both.id, tagId: t1 }, + { videoId: both.id, tagId: t2 }, + { videoId: onlyOne.id, tagId: t1 }, + { videoId: older.id, tagId: t1 }, + { videoId: older.id, tagId: t2 }, + ]) + + // ÉS logika: két címke együtt csak két videót ad. + const andFiltered = await searchVideosDetailed(db, { + viewer: memberViewer, + tagNames: ['tábor', 'koncert'], + }) + expect(andFiltered.total).toBe(2) + + // Eseményszűrő: + const byEvent = await searchVideosDetailed(db, { + viewer: memberViewer, + eventId, + }) + expect(byEvent.total).toBe(2) + + // Dátumtartomány: + const byDate = await searchVideosDetailed(db, { + viewer: memberViewer, + recordedFrom: '2025-01-01', + recordedTo: '2026-12-31', + }) + expect(byDate.items.map((v) => v.slug)).not.toContain('oregebb') + + // Lapozás stabil: + const page1 = await searchVideosDetailed(db, { + viewer: memberViewer, + limit: 1, + offset: 0, + }) + const all = await searchVideosDetailed(db, { viewer: memberViewer }) + expect(page1.items[0]?.id).toBe(all.items[0]?.id) + expect(page1.total).toBe(all.total) + }) + + it('stábtag és stábszerep szerinti szűrés', async () => { + const db = await setupDb() + const roleRows = await db + .insert(staffRoles) + .values({ name: 'Operatőr', normalizedName: 'operatőr' }) + .returning() + const roleId = roleRows.at(0)?.id + if (roleId === undefined) throw new Error('seed failed') + + const v1 = await seedVideo(db, { slug: 'stab-v1', title: 'Stábos' }) + const v2 = await seedVideo(db, { slug: 'stab-v2', title: 'Stáb nélküli' }) + void v2 + await db + .insert(videoStaff) + .values({ videoId: v1.id, roleId, memberSub: 'member-sub' }) + + const byMember = await searchVideosDetailed(db, { + viewer: memberViewer, + staffMemberSub: 'member-sub', + }) + expect(byMember.items.map((v) => v.slug)).toContain('stab-v1') + + const byRole = await searchVideosDetailed(db, { + viewer: memberViewer, + staffRoleId: roleId, + }) + expect(byRole.total).toBe(1) + }) + + it('rendezések: friss, időrendi (hiányzó dátum hátul), legnézettebb', async () => { + const db = await setupDb() + await seedVideo(db, { + slug: 'no-date', + title: 'Nincs dátum', + recordedAt: null, + publishedAt: new Date('2026-01-05T10:00:00Z'), + }) + await seedVideo(db, { + slug: 'old-date', + title: 'Régi dátum', + recordedAt: '2023-03-03', + viewCount: 5, + publishedAt: new Date('2023-06-01T10:00:00Z'), + }) + await seedVideo(db, { + slug: 'new-date', + title: 'Új dátum', + recordedAt: '2026-07-01', + viewCount: 99, + publishedAt: new Date('2026-01-01T10:00:00Z'), + }) + + const chronological = await searchVideosDetailed(db, { + viewer: memberViewer, + sort: 'chronological', + }) + expect(chronological.items.map((v) => v.slug)).toEqual([ + 'new-date', + 'old-date', + 'no-date', + ]) + + const mostViewed = await searchVideosDetailed(db, { + viewer: memberViewer, + sort: 'mostviewed', + }) + expect(mostViewed.items[0]?.slug).toBe('new-date') + + const published = await searchVideosDetailed(db, { + viewer: memberViewer, + sort: 'published', + }) + expect(published.items[0]?.slug).toBe('no-date') + }) + + it('a részletes szűrés is a néző jogosultsága szerint szűr', async () => { + const db = await setupDb() + await seedVideo(db, { slug: 'anon-lathato', title: 'Publikus' }) + await seedVideo(db, { + slug: 'bss-lathato', + title: 'BSS-es', + visibility: 'bss', + }) + + const anonPage = await searchVideosDetailed(db, { viewer: anonViewer }) + expect(anonPage.items.map((v) => v.slug)).toEqual(['anon-lathato']) + expect(anonPage.total).toBe(1) + + const schonherzPage = await searchVideosDetailed(db, { + viewer: schonherzViewer, + }) + expect(schonherzPage.total).toBe(1) + }) +}) diff --git a/tests/integration/videos-domain.test.ts b/tests/integration/videos-domain.test.ts new file mode 100644 index 0000000..6e93edf --- /dev/null +++ b/tests/integration/videos-domain.test.ts @@ -0,0 +1,638 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { eq } from 'drizzle-orm' +import { + archiveVideo, + createVideoDraft, + publishVideo, + restoreVideoFromTrash, + setVideoStaff, + setVideoTags, + trashVideo, + updateVideo, +} from '#/server/videos/domain.ts' +import { + createTrashPurgeJob, + purgeExpiredTrashedVideos, + TRASH_RETENTION_DAYS, +} from '#/server/videos/purge.ts' +import { findFreeSlug } from '#/server/shared/slug.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { StaleWriteError } from '#/server/shared/write.ts' +import { TextValidationError } from '#/server/shared/text.ts' +import { FakeClock } from '#/lib/clock.ts' +import { + auditLog, + events, + memberCache, + siteSettings, + staffRoles, + videoTags, + videos, +} from '#/db/schema.ts' +import { createTag } from '#/server/catalog/tags.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' +import { buildRawOobConfig } from '../helpers/oob-config.ts' +import { installFetchMock } from '../helpers/http-mock.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) +const clock = new FakeClock('2026-06-15T10:00:00.000Z') +const mediaConfig = buildRawOobConfig().media + +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} +const leaderViewer: Viewer = { + level: 'leadership', + sub: 'leader-sub', + username: 'vezetoseg', +} + +function deps(viewer: Viewer) { + return { viewer, clock, mediaConfig } +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_videos') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + await migrated.db.insert(memberCache).values([ + { + sub: 'member-sub', + username: 'tag', + fullName: 'BSS Tag', + membershipStatus: 'studio_member', + }, + { + sub: 'leader-sub', + username: 'vezetoseg', + fullName: 'Vezetőségi Tag', + membershipStatus: 'studio_member', + }, + ]) + return migrated.db +} + +const okMediaRoutes = [ + { + method: 'HEAD', + urlPattern: /v\.bsstudio\.hu/, + respond: (req: { url: URL }) => ({ + status: 200, + headers: { + 'content-type': req.url.pathname.endsWith('.mp4') + ? 'video/mp4' + : 'image/jpeg', + }, + }), + }, +] + +async function publishedVideo( + db: NodePgDatabase>, + overrides: Partial & { slug?: string } = {}, +): Promise { + const draft = await createVideoDraft(db, deps(memberViewer), { + title: overrides.title ?? 'Publikált videó', + slug: overrides.slug, + videoUrl: 'https://v.bsstudio.hu/media/video.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/media/thumb.jpg', + }) + const result = await publishVideo( + db, + deps(memberViewer), + draft.id, + draft.version, + ) + return result.row +} + +describe.skipIf(!hasTestDatabase)('BSS-014: piszkozat és publikálás', () => { + it('piszkozat csak címmel menthető; alap láthatóság public; hibás média-URL is maradhat', async () => { + const db = await setupDb() + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Első piszkozat', + description: '', + videoUrl: 'https://masik-host.hu/video.mp4', + }) + expect(draft.status).toBe('draft') + expect(draft.visibility).toBe('public') + expect(draft.videoUrl).toBe('https://masik-host.hu/video.mp4') + }) + + it('piszkozat cím nélkül nem jön létre', async () => { + const db = await setupDb() + await expect( + createVideoDraft(db, deps(memberViewer), {}), + ).rejects.toBeInstanceOf(TextValidationError) + }) + + it('publikálás minden kötelező mezőt és a médiát ellenőrzi', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Média teszt', + }) + + await expect( + publishVideo(db, deps(memberViewer), draft.id, draft.version), + ).rejects.toThrow(/Videó URL/) + + const withBadMedia = await updateVideo( + db, + deps(memberViewer), + draft.id, + draft.version, + { + videoUrl: 'https://masik-host.hu/video.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + }, + ) + await expect( + publishVideo( + db, + deps(memberViewer), + withBadMedia.row.id, + withBadMedia.row.version, + ), + ).rejects.toThrow(/hostokról/) + + const complete = await updateVideo( + db, + deps(memberViewer), + draft.id, + withBadMedia.row.version, + { + videoUrl: 'https://v.bsstudio.hu/media/video.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/media/thumb.jpg', + }, + ) + const published = await publishVideo( + db, + deps(memberViewer), + complete.row.id, + complete.row.version, + ) + expect(published.row.status).toBe('published') + expect(published.row.publishedAt).not.toBeNull() + + // A publikálási kísérletek ellenére csak a sikeres mentés írta az állapotot. + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, draft.id)) + expect(audits.filter((a) => a.action === 'publish')).toHaveLength(1) + } finally { + mock.restore() + } + }) + + it('jövőbeli publishedAt tiltott; múltbeli megadható és publikáláskor megmarad', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Dátumos', + }) + + await expect( + updateVideo(db, deps(memberViewer), draft.id, draft.version, { + publishedAt: new Date(clock.now().getTime() + 60_000), + }), + ).rejects.toThrow(/jövőbeli/) + + const past = await updateVideo( + db, + deps(memberViewer), + draft.id, + draft.version, + { + publishedAt: new Date('2026-01-01T08:00:00.000Z'), + videoUrl: 'https://v.bsstudio.hu/v.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + }, + ) + const published = await publishVideo( + db, + deps(memberViewer), + past.row.id, + past.row.version, + ) + expect(published.row.publishedAt?.toISOString()).toBe( + '2026-01-01T08:00:00.000Z', + ) + } finally { + mock.restore() + } + }) + + it('egynapos esemény csendesen kitölti az üres recordedAt-ot; felülírni nem írja', async () => { + const db = await setupDb() + const eventRows = await db + .insert(events) + .values({ slug: 'egynapos', title: 'Egynapos', startDate: '2026-05-02' }) + .returning() + const event = eventRows.at(0) + if (event === undefined) throw new Error('event seed failed') + + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Csendes dátum', + }) + const assigned = await updateVideo( + db, + deps(memberViewer), + draft.id, + draft.version, + { + eventId: event.id, + }, + ) + expect(assigned.warnings).toEqual([]) + expect(assigned.row.recordedAt).toBe('2026-05-02') + + // Eseménymódosítás nem írja felül csendben a videódátumot: + const explicit = await updateVideo( + db, + deps(memberViewer), + draft.id, + assigned.row.version, + { recordedAt: '2026-04-20' }, + ) + expect(explicit.row.recordedAt).toBe('2026-04-20') + + // Az intervallumon kívüli dátum figyelmeztetést kap: + expect(explicit.warnings.join(' ')).toMatch(/időtartamán/) + }) + + it('többnapos eseménynél recordedAt nélkül nem publikálható', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const eventRows = await db + .insert(events) + .values({ + slug: 'tobbnapos', + title: 'Többnapos', + startDate: '2026-05-02', + endDate: '2026-05-04', + }) + .returning() + const event = eventRows.at(0) + if (event === undefined) throw new Error('event seed failed') + + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Többnapos videó', + }) + const assigned = await updateVideo( + db, + deps(memberViewer), + draft.id, + draft.version, + { + eventId: event.id, + }, + ) + const filled = await updateVideo( + db, + deps(memberViewer), + draft.id, + assigned.row.version, + { + videoUrl: 'https://v.bsstudio.hu/v.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + }, + ) + + await expect( + publishVideo(db, deps(memberViewer), filled.row.id, filled.row.version), + ).rejects.toThrow(/készülési dátumot/) + + const dated = await updateVideo( + db, + deps(memberViewer), + draft.id, + filled.row.version, + { + recordedAt: '2026-05-03', + }, + ) + const published = await publishVideo( + db, + deps(memberViewer), + dated.row.id, + dated.row.version, + ) + expect(published.row.recordedAt).toBe('2026-05-03') + void multiDayUnused + } finally { + mock.restore() + } + }) + + it('esemény leválasztása után a recordedAt megmarad', async () => { + const db = await setupDb() + const eventRows = await db + .insert(events) + .values({ + slug: 'levalasztas', + title: 'Leválasztás', + startDate: '2026-05-06', + }) + .returning() + const eventId = eventRows.at(0)?.id + if (eventId === undefined) throw new Error('seed failed') + + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Leválasztott', + }) + const assigned = await updateVideo( + db, + deps(memberViewer), + draft.id, + draft.version, + { eventId }, + ) + expect(assigned.row.recordedAt).toBe('2026-05-06') + + const detached = await updateVideo( + db, + deps(memberViewer), + draft.id, + assigned.row.version, + { + eventId: null, + }, + ) + expect(detached.row.eventId).toBeNull() + expect(detached.row.recordedAt).toBe('2026-05-06') + }) +}) + +describe.skipIf(!hasTestDatabase)( + 'BSS-014: archiválás, lomtár, visszaállítás', + () => { + it('tag archiválhat és lomtárba tehet; kapcsolatok megmaradnak', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const tagRows = await createTag(db, { viewer: leaderViewer }, 'koncert') + const tagId = tagRows.id + + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Életciklus', + }) + const tagged = await setVideoTags( + db, + deps(memberViewer), + draft.id, + draft.version, + [tagId], + ) + const withMedia = await updateVideo( + db, + deps(memberViewer), + tagged.row.id, + tagged.row.version, + { + videoUrl: 'https://v.bsstudio.hu/media/video.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/media/thumb.jpg', + }, + ) + const published = await publishVideo( + db, + deps(memberViewer), + withMedia.row.id, + withMedia.row.version, + ) + + const archived = await archiveVideo( + db, + deps(memberViewer), + published.row.id, + published.row.version, + ) + expect(archived.row.status).toBe('archived') + + const trashed = await trashVideo( + db, + deps(memberViewer), + archived.row.id, + archived.row.version, + ) + expect(trashed.row.status).toBe('trash') + expect(trashed.row.trashedBy).toBe('member-sub') + + const links = await db + .select() + .from(videoTags) + .where(eq(videoTags.videoId, draft.id)) + expect(links.map((l) => l.tagId)).toEqual([tagId]) + + // Archivált/lomtári állapotban a kiemelés érvénytelenítve: + const settingsRows = await db.select().from(siteSettings) + expect(settingsRows.at(0)?.highlightedVideoId ?? null).toBeNull() + } finally { + mock.restore() + } + }) + + it('csak vezetőség állíthat vissza; a visszaállítás archivált állapotot ad', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + const published = await publishedVideo(db, { title: 'Visszaállítás' }) + const draft = published + const trashed = await trashVideo( + db, + deps(memberViewer), + published.id, + published.version, + ) + + await expect( + restoreVideoFromTrash( + db, + deps(memberViewer), + draft.id, + trashed.row.version, + ), + ).rejects.toBeInstanceOf(ForbiddenError) + + const restored = await restoreVideoFromTrash( + db, + deps(leaderViewer), + draft.id, + trashed.row.version, + ) + expect(restored.row.status).toBe('archived') + expect(restored.row.trashedBy).toBeNull() + expect(restored.row.trashedAt).toBeNull() + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, draft.id)) + expect(audits.map((a) => a.action)).toContain('restore') + } finally { + mock.restore() + } + }) + + it('elavult verziójú életciklusművelet blokkolódik', async () => { + const db = await setupDb() + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Konfliktus', + }) + await updateVideo(db, deps(memberViewer), draft.id, draft.version, { + guests: 'Valaki', + }) + await expect( + archiveVideo(db, deps(memberViewer), draft.id, draft.version), + ).rejects.toBeInstanceOf(StaleWriteError) + }) + + it('címkék cseréje: csak meglévő címke rendelhető; ismeretlen blokkol', async () => { + const db = await setupDb() + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Címkézés', + }) + await expect( + setVideoTags(db, deps(memberViewer), draft.id, draft.version, [ + '00000000-0000-4000-8000-000000000099', + ]), + ).rejects.toThrow(/meglévő címke/) + }) + + it('stáblista: egy szerep több taggal és egy tag több szereppel is lehet', async () => { + const db = await setupDb() + const roleRows = await db + .insert(staffRoles) + .values([ + { name: 'Operatőr', normalizedName: 'operatőr', displayOrder: 1 }, + { name: 'Vágó', normalizedName: 'vágó', displayOrder: 2 }, + ]) + .returning() + const operator = roleRows.find((r) => r.name === 'Operatőr') + const editor = roleRows.find((r) => r.name === 'Vágó') + if (operator === undefined || editor === undefined) + throw new Error('role seed failed') + + const draft = await createVideoDraft(db, deps(memberViewer), { + title: 'Stáb', + }) + const result = await setVideoStaff( + db, + deps(memberViewer), + draft.id, + draft.version, + [ + { roleId: operator.id, memberSub: 'member-sub' }, + { roleId: operator.id, memberSub: 'leader-sub' }, + { roleId: editor.id, memberSub: 'member-sub' }, + ], + ) + expect(result.row.version).toBe(draft.version + 1) + + await expect( + setVideoStaff(db, deps(memberViewer), draft.id, draft.version, []), + ).rejects.toBeInstanceOf(StaleWriteError) + }) + }, +) + +describe.skipIf(!hasTestDatabase)('BSS-014: napi lomtártörlés', () => { + it('30 nap után végleg töröl; slug lefoglalva; rendszer-audit; külső média érintetlen', async () => { + const db = await setupDb() + const mock = installFetchMock(okMediaRoutes) + try { + expect(TRASH_RETENTION_DAYS).toBe(30) + const published = await publishedVideo(db, { + title: 'Régi lomtár', + slug: 'regi-lomtar', + }) + const draft = published + const trashedRow = await trashVideo( + db, + deps(memberViewer), + published.id, + published.version, + ) + void trashedRow + + // 29 nap múlva még nem törlendő: + clock.advanceDays(29) + let purged = await purgeExpiredTrashedVideos(db, { now: clock.now() }) + expect(purged).toEqual([]) + expect( + (await db.select().from(videos).where(eq(videos.id, draft.id))).length, + ).toBe(1) + + // 31 nap múlva már igen: + clock.advanceDays(2) + purged = await purgeExpiredTrashedVideos(db, { now: clock.now() }) + expect(purged).toEqual([draft.id]) + expect( + (await db.select().from(videos).where(eq(videos.id, draft.id))).length, + ).toBe(0) + + const freeSlug = await findFreeSlug(db, 'video', 'regi-lomtar') + expect(freeSlug).not.toBe('regi-lomtar') + + const audits = await db + .select() + .from(auditLog) + .where(eq(auditLog.entityId, draft.id)) + const purgeAudit = audits.find((a) => a.action === 'delete_permanent') + expect(purgeAudit?.actor).toBe('system') + expect((purgeAudit?.beforeValue as Record).title).toBe( + 'Régi lomtár', + ) + + // A friss lomtári videó nem törlődik: + const fresh = await publishedVideo(db, { title: 'Friss lomtár' }) + const tr = await trashVideo( + db, + deps(memberViewer), + fresh.id, + fresh.version, + ) + purged = await purgeExpiredTrashedVideos(db, { now: clock.now() }) + expect(purged).toEqual([]) + expect( + (await db.select().from(videos).where(eq(videos.id, fresh.id))).length, + ).toBe(1) + void tr + } finally { + mock.restore() + } + }) + + it('a purge feladat regisztrálható a runner extra feladataiként', async () => { + const db = await setupDb() + const job = createTrashPurgeJob({ + clock, + db: async () => db, + }) + expect(job.name).toBe('video-trash-purge-daily') + expect(job.intervalMs).toBe(24 * 60 * 60 * 1000) + await job.run({ clock, trigger: 'tick' }) + }) +}) + +const multiDayUnused = false diff --git a/tests/integration/view-counter.test.ts b/tests/integration/view-counter.test.ts new file mode 100644 index 0000000..d9bed0b --- /dev/null +++ b/tests/integration/view-counter.test.ts @@ -0,0 +1,226 @@ +import { afterAll, describe, expect, it } from 'vitest' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { eq } from 'drizzle-orm' +import { + getViewCount, + newViewSessionToken, + recordVideoView, + VIEW_SESSION_COOKIE_NAME, + viewSessionCookieSpec, +} from '#/server/views/counter.ts' +import { ForbiddenError } from '#/server/auth/guards.ts' +import { anonymousViewer } from '#/server/auth/viewer.ts' +import type { Viewer } from '#/server/auth/viewer.ts' +import { serializeSetCookie } from '#/server/auth/session-cookies.ts' +import { FakeClock } from '#/lib/clock.ts' +import { videos, viewSessions } from '#/db/schema.ts' +import { createMigratedTestDatabase } from '../helpers/test-db.ts' + +const databases: Array<{ drop: () => Promise }> = [] +const poolCleanups: Array<() => Promise> = [] + +afterAll(async () => { + while (poolCleanups.length > 0) { + await poolCleanups.pop()!() + } + while (databases.length > 0) { + await databases.pop()!.drop() + } +}) + +const hasTestDatabase = Boolean(process.env.TEST_DATABASE_URL) +const clock = new FakeClock('2026-06-25T10:00:00.000Z') + +const anonViewer = anonymousViewer +const schonherzViewer: Viewer = { + level: 'schonherz', + sub: null, + username: null, +} +const memberViewer: Viewer = { + level: 'member', + sub: 'member-sub', + username: 'tag', +} + +async function setupDb(): Promise>> { + const migrated = await createMigratedTestDatabase('bss_views') + databases.push(migrated.database) + poolCleanups.push(() => migrated.pool.end()) + return migrated.db +} + +async function seedVideo( + db: NodePgDatabase>, + overrides: Partial & { slug: string }, +): Promise { + const rows = await db + .insert(videos) + .values({ + title: overrides.slug, + status: 'published', + publishedAt: clock.now(), + videoUrl: 'https://v.bsstudio.hu/v.mp4', + thumbnailUrl: 'https://v.bsstudio.hu/t.jpg', + ...overrides, + }) + .returning() + const row = rows.at(0) + if (row === undefined) throw new Error('seed failed') + return row +} + +describe.skipIf(!hasTestDatabase)('BSS-016: megtekintésszámláló', () => { + it('a session cookie böngésző bezárásáig él — nincs Max-Age', () => { + const spec = viewSessionCookieSpec(newViewSessionToken()) + expect(spec.name).toBe(VIEW_SESSION_COOKIE_NAME) + expect(spec.maxAgeSeconds).toBeUndefined() + const header = serializeSetCookie(spec) + expect(header).toContain('HttpOnly') + expect(header).toContain('SameSite=Lax') + expect(header).not.toContain('Max-Age') + }) + + it('első play számol; pause és újraindítás (ugyanaz a token) nem számol újra', async () => { + const db = await setupDb() + const video = await seedVideo(db, { slug: 'view-video' }) + const token = newViewSessionToken() + + const first = await recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token, + clock, + }) + expect(first.counted).toBe(true) + + // Többszöri play ugyanazzal a sessionnel: + for (let i = 0; i < 3; i += 1) { + const repeat = await recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token, + clock, + }) + expect(repeat.counted).toBe(false) + } + + const after = await db + .select() + .from(videos) + .where(eq(videos.id, video.id)) + .limit(1) + expect(after.at(0)?.viewCount).toBe(1) + const sessions = await db.select().from(viewSessions) + expect(sessions).toHaveLength(1) + }) + + it('másik böngésző-session növelheti a számlálót', async () => { + const db = await setupDb() + const video = await seedVideo(db, { slug: 'view-two-sessions' }) + await recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token: newViewSessionToken(), + clock, + }) + const second = await recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token: newViewSessionToken(), + clock, + }) + expect(second.counted).toBe(true) + const after = await db + .select() + .from(videos) + .where(eq(videos.id, video.id)) + .limit(1) + expect(after.at(0)?.viewCount).toBe(2) + }) + + it('párhuzamos kérések közül csak az első számol (idempotencia)', async () => { + const db = await setupDb() + const video = await seedVideo(db, { slug: 'view-parallel' }) + const token = newViewSessionToken() + + const results = await Promise.all([ + recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token, + clock, + }), + recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token, + clock, + }), + ]) + const countedCount = results.filter((r) => r.counted).length + expect(countedCount).toBe(1) + const after = await db + .select() + .from(videos) + .where(eq(videos.id, video.id)) + .limit(1) + expect(after.at(0)?.viewCount).toBe(1) + }) + + it('nem publikált vagy nem látható videó nem számolható', async () => { + const db = await setupDb() + const draft = await seedVideo(db, { slug: 'view-draft', status: 'draft' }) + await expect( + recordVideoView(db, { + videoId: draft.id, + viewer: memberViewer, + token: null, + clock, + }), + ).rejects.toThrow(/nem érhető el/) + + const bssOnly = await seedVideo(db, { slug: 'view-bss', visibility: 'bss' }) + await expect( + recordVideoView(db, { + videoId: bssOnly.id, + viewer: schonherzViewer, + token: null, + clock, + }), + ).rejects.toThrow(/nem érhető el/) + }) + + it('IP és felhasználói előzmény nem tárolódik — csak kivonatolt session azonosító', async () => { + const db = await setupDb() + const video = await seedVideo(db, { slug: 'view-no-ip' }) + const result = await recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token: null, + clock, + }) + expect(result.sessionId).toMatch(/^[0-9a-f]{64}$/) + const sessions = await db.select().from(viewSessions) + expect(sessions.at(0)?.sessionId).toBe(result.sessionId) + }) + + it('a megtekintésszám csak adminválaszban kérdezhető le', async () => { + const db = await setupDb() + const video = await seedVideo(db, { slug: 'view-admin' }) + await recordVideoView(db, { + videoId: video.id, + viewer: anonViewer(), + token: null, + clock, + }) + + await expect( + getViewCount(db, anonymousViewer(), video.id), + ).rejects.toBeInstanceOf(ForbiddenError) + await expect( + getViewCount(db, schonherzViewer, video.id), + ).rejects.toBeInstanceOf(ForbiddenError) + await expect(getViewCount(db, memberViewer, video.id)).resolves.toBe(1) + }) +}) From ee4a6cc9459c991b333d3137b06d3085da5844d0 Mon Sep 17 00:00:00 2001 From: Gyula Kiri Date: Mon, 24 Aug 2026 09:23:39 +0200 Subject: [PATCH 05/25] feat: complete phase 3 public interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add public app shell with Hungarian 404/error/loading states, navbar with login/logout and viewer-aware session state - Implement slug-based routing with legacy slug redirects for videos, events, and members - Build video list/detail pages with filters, native player, view counting, and related videos - Build event list/detail pages and public member list/profile/archived/contributors pages - Add homepage with live/highlight sections, global search page and API, and about/courses redirect pages - Cover all new pages and routes with integration tests Cards BSS-019 – BSS-026 done, phase gate green. --- docs/implementation-progress.md | 101 +-- src/components/Navbar.tsx | 179 +++--- src/components/PageStates.tsx | 81 +++ src/components/SearchBox.tsx | 229 +++++++ src/components/VideoDetailPlayer.tsx | 72 +++ src/lib/activity.ts | 73 +++ src/lib/format-date.ts | 65 ++ src/routeTree.gen.ts | 161 +++-- src/router.tsx | 2 + src/routes/__root.tsx | 15 +- src/routes/about.tsx | 226 ++----- src/routes/courses.tsx | 166 +---- src/routes/events/$slug.tsx | 205 ++++++ src/routes/events/index.tsx | 169 ++++- src/routes/index.tsx | 271 ++++++-- src/routes/members/$memberId.tsx | 187 ------ src/routes/members/$slug.tsx | 306 +++++++++ src/routes/members/archived.tsx | 122 ++++ src/routes/members/contributors.tsx | 122 ++++ src/routes/members/index.tsx | 180 ++++-- src/routes/search.tsx | 354 +++++++++++ src/routes/videos/$slug.tsx | 221 +++++++ src/routes/videos/$videoId.tsx | 71 --- src/routes/videos/index.tsx | 672 +++++++++++--------- src/server.ts | 10 + src/server/api/router.ts | 11 + src/server/api/search-routes.ts | 85 +++ src/server/api/view-routes.ts | 82 +++ src/server/pages/courses-redirect.ts | 10 + src/server/pages/event-list.ts | 272 ++++++++ src/server/pages/homepage.ts | 82 +++ src/server/pages/member-archive-fn.ts | 11 + src/server/pages/members.ts | 263 ++++++++ src/server/pages/slug-route.ts | 105 +++ src/server/pages/video-detail.ts | 160 +++++ src/server/pages/video-list.ts | 222 +++++++ src/server/pages/viewer-fn.ts | 24 + src/server/pages/viewer.ts | 53 ++ tests/integration/about-courses.test.ts | 96 +++ tests/integration/event-pages.test.ts | 248 ++++++++ tests/integration/homepage-page.test.ts | 153 +++++ tests/integration/member-pages.test.ts | 312 +++++++++ tests/integration/public-slug-route.test.ts | 225 +++++++ tests/integration/search-api.test.ts | 170 +++++ tests/integration/search.test.ts | 3 + tests/integration/video-detail-page.test.ts | 306 +++++++++ tests/integration/video-list-page.test.ts | 232 +++++++ 47 files changed, 6253 insertions(+), 1132 deletions(-) create mode 100644 src/components/PageStates.tsx create mode 100644 src/components/SearchBox.tsx create mode 100644 src/components/VideoDetailPlayer.tsx create mode 100644 src/lib/activity.ts create mode 100644 src/lib/format-date.ts create mode 100644 src/routes/events/$slug.tsx delete mode 100644 src/routes/members/$memberId.tsx create mode 100644 src/routes/members/$slug.tsx create mode 100644 src/routes/members/archived.tsx create mode 100644 src/routes/members/contributors.tsx create mode 100644 src/routes/search.tsx create mode 100644 src/routes/videos/$slug.tsx delete mode 100644 src/routes/videos/$videoId.tsx create mode 100644 src/server/api/search-routes.ts create mode 100644 src/server/api/view-routes.ts create mode 100644 src/server/pages/courses-redirect.ts create mode 100644 src/server/pages/event-list.ts create mode 100644 src/server/pages/homepage.ts create mode 100644 src/server/pages/member-archive-fn.ts create mode 100644 src/server/pages/members.ts create mode 100644 src/server/pages/slug-route.ts create mode 100644 src/server/pages/video-detail.ts create mode 100644 src/server/pages/video-list.ts create mode 100644 src/server/pages/viewer-fn.ts create mode 100644 src/server/pages/viewer.ts create mode 100644 tests/integration/about-courses.test.ts create mode 100644 tests/integration/event-pages.test.ts create mode 100644 tests/integration/homepage-page.test.ts create mode 100644 tests/integration/member-pages.test.ts create mode 100644 tests/integration/public-slug-route.test.ts create mode 100644 tests/integration/search-api.test.ts create mode 100644 tests/integration/video-detail-page.test.ts create mode 100644 tests/integration/video-list-page.test.ts diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md index 902e916..2e2d38f 100644 --- a/docs/implementation-progress.md +++ b/docs/implementation-progress.md @@ -1,35 +1,44 @@ # BSS V0 implementációs állapot -Utolsó frissítés: 2026-08-23 (2. fázis vége, fáziskapu zöld) +Utolsó frissítés: 2026-08-23 (3. fázis vége, fáziskapu zöld) ## Aktuális fázis -**2. fázis – Tartalmi domainek: KÉSZ, felhasználói jóváhagyásra vár.** +**3. fázis – Publikus felület: KÉSZ, felhasználói jóváhagyásra vár.** -Mind a hét kártya (BSS-012 – BSS-018) elkészült, a fáziskapu gate-je zöld. -Következő: **3. fázis – Publikus felület** (BSS-019 – BSS-026), munka csak külön jóváhagyással. +Mind a nyolc kártya (BSS-019 – BSS-026) elkészült, a fáziskapu gate-je zöld. +Következő: **4. fázis – Adminfelület** (BSS-027 –), munka csak külön jóváhagyással. ## Kártyák állapota -| Kártya | Név | Állapot | Ellenőrzések | -| ------- | --------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | -| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`, új: `createMigratedTestDatabase`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | -| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | -| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla + `auth_sessions` migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); DB-szintű invariánsok; integrációs sématesztek | -| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva; teljes restart után sem duplikál | -| BSS-006 | OIDC belépés és session | done | `/api/auth/login`, `/api/auth/callback`, `/api/auth/logout` PKCE-s flow-val; DB-ben tárolt session (`auth_sessions`), access token csak szerveroldalon; HTTP-only SameSite=Lax cookie-k; returnTo megőrzés nyitott átirányítás ellenőrzéssel; abszolút 60 perces TTL; Authentik-kiesés magyar 503 oldallal; élő dev szerveren: login 302 az Authentik authorize végpontra, callback hibaág 400, publikus oldal 200 | -| BSS-007 | Jogosultsági policy és guardok | done | viewer szintek (anonymous/schonherz/member/leadership) csoportokból; vezetőség csak tag csoporttal együtt; teljes admin mátrix unit tesztekkel (spec 3.2); `visibleVideoCondition` SQL feltétel valódi adatbázison tesztelve mind a 4 nézői szintre + metaadat-szivárgás ellenőrzés; `requireAdmin`/`requireLeadership` guardok (login redirect returnTo-val / 403); `/api/auth/me` állapotvégpont Authentik-hívás nélkül | -| BSS-008 | Authentik tagcache és szinkron | done | `runMemberSync` client_credentials granthasználatával az Authentik API-ról (lapozott users/groups); mapping konfig szerint; nyers félév megőrzése; ismeretlen státusz → syncStatus=error (publikusból kimarad, utolsó ismert adat megmarad); eltűnt tag rekordja megmarad; változatlan futás NEM ír auditot; szerepváltozás audit előtte-utána párral; `member_sync_runs` állapottábla; kézi indítás csak vezetőségnek; élőben verifikálva a lokális Authentikkel (7 tag cache-elve, idempotens) | -| BSS-009 | Közös slug, audit és optimista zárolás | done | `slugify` magyar ékezet-feloldással; ütközésnél számozott utótag; slug_history lefoglalja a régi slugokat végleges törlés után is; `renameSlugWithHistory` + `resolveSlugRedirect`; `updateWithOptimisticLock`: FOR UPDATE sorzár + verzióellenőrzés (StaleWriteError) + tranzakciós audit előtte-utána értékkel; system aktornál updatedBy NULL; DB-trigger tiltja az audit UPDATE/DELETE-t (custom migráció); plain text és hosszvalidáció (`TEXT_LIMITS`); 14 integrációs teszt | -| BSS-010 | Háttérfeladat-futtató, health, riasztás | done | `JobRegistry` + `dueJobs` FakeClock-kal vezérelt ütemezéssel; `runJobWithLock` PostgreSQL advisory lockkal: két példány közül egy futtat (skipped-locked); induláskori + óránkénti sync feladatok regisztrálva, extra feladatok (lomtár, live) regisztrálhatók; `/health/live` mindig 200, `/health/ready` DB és kulcstábla ellenőrzéssel (503, titok nélkül); hibás háttérfeladat nem dönti le az alkalmazást; vezetőségi szinkronriasztás `getRecentSyncAlerts`; élőben: health 200/200 | -| BSS-011 | Média- és YouTube-validátor | done | host engedélylista (https + v.bsstudio.hu); publikáláshoz HEAD 200 átirányítás nélkül, video/mp4 vs image/* content-type; 405/501-nél egybájtos Range GET tartalék (bytes=0-0, tartalom letöltése nélkül); piszkozathoz hálózat nélküli formaellenőrzés (hibás URL menthető piszkozatban); YouTube normalizálás (watch/live/youtu.be/embed/nocookie) + oEmbed ellenőrzés magyar hibaüzenetekkel; 16 unit teszt | -| BSS-012 | Címke- és stábszerep-domain | done | `src/server/catalog/` (names/tags/staff-roles): normalizált név (kisbetű + whitespace) egyediség; ékezeti hasonlóság csak figyelmeztetés (`findAccentSimilarTagNames`); használt címke törlése csak vezetőséggel + pontos címbeírással; összevonás tranzakcióban kapcsolatvesztés nélkül (ON CONFLICT DO NOTHING); használatban lévő szerep törlésének tiltása + DB restrict; `displayOrder` sorrendezés; minden művelet auditolt; 13 integrációs teszt | -| BSS-013 | Esemény-domain és végleges törlés | done | `src/server/events/domain.ts`: piszkozat csak címmel; publikálás = cím + kezdődátum + (ha van) elérhető thumbnail (BSS-011 validátorral); jövőbeli esemény publikálható; end >= start alkalmazás- és DB-szinten; archiválás → újrapublikálás; slug módosítás előzménnyel; végleges törlés vezetőségi + címbeírással EGY tranzakcióban (videók leválasztása `recordedAt` megőrzésével → régi slug történetbe foglalva → teljes audit `detachedVideoIds`-szal); lista kezdődátum desc, lapozás; 12 integrációs teszt. **Sémaváltozás:** `events.start_date` nullable lett (spec 6.1: piszkozathoz csak cím kell) — migráció: `drizzle/20260823184625_salty_jocasta` | -| BSS-014 | Videó-domain és életciklus | done | `src/server/videos/domain.ts` + `purge.ts` + `highlight-invalidation.ts`: piszkozat csak címmel (hibás média-URL is menthető); publikálás ellenőrzi kötelező mezőket + médiát hálózati validátorral (a tranzakción kívül), többnapos eseménynél `recordedAt`, nem jövőbeli `publishedAt` (múltbeli megadható és megmarad); egynapos esemény csendesen kitölti az üres dátumot, felülírni soha nem írja; leválasztás megtartja a dátumot; intervallumon kívüli dátum csak figyelmeztetés; archiválás/lomtár/visszaállítás (vezetőség, archivált állapotba, kapcsolatokkal); napi 30 napos végleges törlés (`createTrashPurgeJob` a runner extra feladataként, rendszer-audit, slug lefoglalás); kiemelés/Rólunk érvénytelenítés ugyanabban a tranzakcióban állapot- és láthatóságváltozáskor; címkék/stáblista csere verzióellenőrzéssel és auditálással; 14 integrációs teszt | -| BSS-015 | Kapcsolódó videók szolgáltatása | done | `src/server/videos/related.ts`: manuális lista felülír mindent (csak publikált választható, önhivatkozás/duplikátum tiltva); azonos esemény öt legutóbb publikált videója (`publishedAt` desc); esemény nélkül közös címkék pontozással (több közös címke erősebb, egyezésnél `publishedAt` desc, stabil `id` tiebreak); megjelenítés minden ágon `visibleVideoCondition`-nal szűrve az SQL-ben — korlátozott videó metaadata nem szivárog; verzióellenőrzött mentés + audit; 8 integrációs teszt | -| BSS-016 | Megtekintésszámláló | done | `src/server/views/counter.ts`: anonim session cookie (`bss_view_session`) szándékosan Max-Age nélkül — böngésző bezárásáig él; a DB-ben csak a token SHA-256 kivonata, IP/felhasználói előzmény nélkül; `view_sessions` PK + ON CONFLICT DO NOTHING idempotencia párhuzamos kérésekkel szemben (teszt: Promise.all két hívásból, csak egy számol); csak publikált és a nézőnek látható videó számolható; számláló csak admin lekérdezésben (legalább tagság); 7 integrációs teszt | -| BSS-017 | Homepage, live, kiemelés és Rólunk-domain | done | `src/server/homepage/{live,highlight,about,state}.ts`: számított prioritás (aktív live > kiemelt > normál); live ütemezés oEmbed ellenőrzéssel mentéskor is, átfedés alkalmazás- ÉS DB-szinten tiltva (részleges EXCLUDE korlát: `WHERE status <> 'ended'`, így befejezett live fölé mehet másolat) — migráció: `drizzle/20260823191647_melted_mojo` (btree_gist + korlátcsere); `Indítás most` oEmbed-fallbackkel (hiba rögzül, ütemezett marad), `Lezárás most`; befejezett live nem módosítható/törölhető (csak másolatként); perces `createLiveTransitionJob` runner-feladat system audittal; kiemelés csak publikált+publikus videó; Rólunk legfeljebb hat rendezett publikus videó, érvénytelen SQL-ben kiesik; homepage: 5/6 publikus videó (hero nélkül), hat esemény, `Adás hamarosan` 24 órás sáv; 10 integrációs teszt | +| Kártya | Név | Állapot | Ellenőrzések | +| ------- | ----------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BSS-001 | Toolchain stabilizálása | done | `pnpm check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build` zöld; nightly/`latest` függőségek eltűntek; dedikált `vitest.config.ts` javítja a Nitro/Vite tesztindítási hibát | +| BSS-002 | Tesztalap és vezérelhető idő | done | unit + integration vitest projektek; `FakeClock` 30 napos törlés szimulációhoz; fetch-mock helper; izolált tesztadatbázis-kezelő (`tests/helpers/test-db.ts`, új: `createMigratedTestDatabase`); integrációs smoke test valódi PostgreSQL-en fut (2× futtatva, ismételhető) | +| BSS-003 | OOB konfigurációs szerződés | done | típusos séma + magyar nyelvű validáció (`src/server/config/oob-schema.ts`); 15 unit teszt; `pnpm check:oob` CLI; dokumentáció: `docs/oob-inputs.md`, példa: `docs/examples/oob-config.example.json` | +| BSS-004 | Új adatbázisséma és migrációs alap | done | 14 tábla + `auth_sessions` migrációja tiszta PostgreSQL-en lefut (CLI és tesztfuttató egyaránt); DB-szintű invariánsok; integrációs sématesztek | +| BSS-005 | Lokális infrastruktúra és Authentik bootstrap | done | compose rendezve (healthcheck, blueprint mount); `pnpm infra:bootstrap` idempotens titok- és YAML-generátor; élőben verifikálva; teljes restart után sem duplikál | +| BSS-006 | OIDC belépés és session | done | `/api/auth/login`, `/api/auth/callback`, `/api/auth/logout` PKCE-s flow-val; DB-ben tárolt session (`auth_sessions`), access token csak szerveroldalon; HTTP-only SameSite=Lax cookie-k; returnTo megőrzés nyitott átirányítás ellenőrzéssel; abszolút 60 perces TTL; Authentik-kiesés magyar 503 oldallal; élő dev szerveren: login 302 az Authentik authorize végpontra, callback hibaág 400, publikus oldal 200 | +| BSS-007 | Jogosultsági policy és guardok | done | viewer szintek (anonymous/schonherz/member/leadership) csoportokból; vezetőség csak tag csoporttal együtt; teljes admin mátrix unit tesztekkel (spec 3.2); `visibleVideoCondition` SQL feltétel valódi adatbázison tesztelve mind a 4 nézői szintre + metaadat-szivárgás ellenőrzés; `requireAdmin`/`requireLeadership` guardok (login redirect returnTo-val / 403); `/api/auth/me` állapotvégpont Authentik-hívás nélkül | +| BSS-008 | Authentik tagcache és szinkron | done | `runMemberSync` client_credentials granthasználatával az Authentik API-ról (lapozott users/groups); mapping konfig szerint; nyers félév megőrzése; ismeretlen státusz → syncStatus=error (publikusból kimarad, utolsó ismert adat megmarad); eltűnt tag rekordja megmarad; változatlan futás NEM ír auditot; szerepváltozás audit előtte-utána párral; `member_sync_runs` állapottábla; kézi indítás csak vezetőségnek; élőben verifikálva a lokális Authentikkel (7 tag cache-elve, idempotens) | +| BSS-009 | Közös slug, audit és optimista zárolás | done | `slugify` magyar ékezet-feloldással; ütközésnél számozott utótag; slug_history lefoglalja a régi slugokat végleges törlés után is; `renameSlugWithHistory` + `resolveSlugRedirect`; `updateWithOptimisticLock`: FOR UPDATE sorzár + verzióellenőrzés (StaleWriteError) + tranzakciós audit előtte-utána értékkel; system aktornál updatedBy NULL; DB-trigger tiltja az audit UPDATE/DELETE-t (custom migráció); plain text és hosszvalidáció (`TEXT_LIMITS`); 14 integrációs teszt | +| BSS-010 | Háttérfeladat-futtató, health, riasztás | done | `JobRegistry` + `dueJobs` FakeClock-kal vezérelt ütemezéssel; `runJobWithLock` PostgreSQL advisory lockkal: két példány közül egy futtat (skipped-locked); induláskori + óránkénti sync feladatok regisztrálva, extra feladatok (lomtár, live) regisztrálhatók; `/health/live` mindig 200, `/health/ready` DB és kulcstábla ellenőrzéssel (503, titok nélkül); hibás háttérfeladat nem dönti le az alkalmazást; vezetőségi szinkronriasztás `getRecentSyncAlerts`; élőben: health 200/200 | +| BSS-011 | Média- és YouTube-validátor | done | host engedélylista (https + v.bsstudio.hu); publikáláshoz HEAD 200 átirányítás nélkül, video/mp4 vs image/* content-type; 405/501-nél egybájtos Range GET tartalék (bytes=0-0, tartalom letöltése nélkül); piszkozathoz hálózat nélküli formaellenőrzés (hibás URL menthető piszkozatban); YouTube normalizálás (watch/live/youtu.be/embed/nocookie) + oEmbed ellenőrzés magyar hibaüzenetekkel; 16 unit teszt | +| BSS-012 | Címke- és stábszerep-domain | done | `src/server/catalog/` (names/tags/staff-roles): normalizált név (kisbetű + whitespace) egyediség; ékezeti hasonlóság csak figyelmeztetés (`findAccentSimilarTagNames`); használt címke törlése csak vezetőséggel + pontos címbeírással; összevonás tranzakcióban kapcsolatvesztés nélkül (ON CONFLICT DO NOTHING); használatban lévő szerep törlésének tiltása + DB restrict; `displayOrder` sorrendezés; minden művelet auditolt; 13 integrációs teszt | +| BSS-013 | Esemény-domain és végleges törlés | done | `src/server/events/domain.ts`: piszkozat csak címmel; publikálás = cím + kezdődátum + (ha van) elérhető thumbnail (BSS-011 validátorral); jövőbeli esemény publikálható; end >= start alkalmazás- és DB-szinten; archiválás → újrapublikálás; slug módosítás előzménnyel; végleges törlés vezetőségi + címbeírással EGY tranzakcióban (videók leválasztása `recordedAt` megőrzésével → régi slug történetbe foglalva → teljes audit `detachedVideoIds`-szal); lista kezdődátum desc, lapozás; 12 integrációs teszt. **Sémaváltozás:** `events.start_date` nullable lett (spec 6.1: piszkozathoz csak cím kell) — migráció: `drizzle/20260823184625_salty_jocasta` | +| BSS-014 | Videó-domain és életciklus | done | `src/server/videos/domain.ts` + `purge.ts` + `highlight-invalidation.ts`: piszkozat csak címmel (hibás média-URL is menthető); publikálás ellenőrzi kötelező mezőket + médiát hálózati validátorral (a tranzakción kívül), többnapos eseménynél `recordedAt`, nem jövőbeli `publishedAt` (múltbeli megadható és megmarad); egynapos esemény csendesen kitölti az üres dátumot, felülírni soha nem írja; leválasztás megtartja a dátumot; intervallumon kívüli dátum csak figyelmeztetés; archiválás/lomtár/visszaállítás (vezetőség, archivált állapotba, kapcsolatokkal); napi 30 napos végleges törlés (`createTrashPurgeJob` a runner extra feladataként, rendszer-audit, slug lefoglalás); kiemelés/Rólunk érvénytelenítés ugyanabban a tranzakcióban állapot- és láthatóságváltozáskor; címkék/stáblista csere verzióellenőrzéssel és auditálással; 14 integrációs teszt | +| BSS-015 | Kapcsolódó videók szolgáltatása | done | `src/server/videos/related.ts`: manuális lista felülír mindent (csak publikált választható, önhivatkozás/duplikátum tiltva); azonos esemény öt legutóbb publikált videója (`publishedAt` desc); esemény nélkül közös címkék pontozással (több közös címke erősebb, egyezésnél `publishedAt` desc, stabil `id` tiebreak); megjelenítés minden ágon `visibleVideoCondition`-nal szűrve az SQL-ben — korlátozott videó metaadata nem szivárog; verzióellenőrzött mentés + audit; 8 integrációs teszt | +| BSS-016 | Megtekintésszámláló | done | `src/server/views/counter.ts`: anonim session cookie (`bss_view_session`) szándékosan Max-Age nélkül — böngésző bezárásáig él; a DB-ben csak a token SHA-256 kivonata, IP/felhasználói előzmény nélkül; `view_sessions` PK + ON CONFLICT DO NOTHING idempotencia párhuzamos kérésekkel szemben (teszt: Promise.all két hívásból, csak egy számol); csak publikált és a nézőnek látható videó számolható; számláló csak admin lekérdezésben (legalább tagság); 7 integrációs teszt | +| BSS-017 | Homepage, live, kiemelés és Rólunk-domain | done | `src/server/homepage/{live,highlight,about,state}.ts`: számított prioritás (aktív live > kiemelt > normál); live ütemezés oEmbed ellenőrzéssel mentéskor is, átfedés alkalmazás- ÉS DB-szinten tiltva (részleges EXCLUDE korlát: `WHERE status <> 'ended'`, így befejezett live fölé mehet másolat) — migráció: `drizzle/20260823191647_melted_mojo` (btree_gist + korlátcsere); `Indítás most` oEmbed-fallbackkel (hiba rögzül, ütemezett marad), `Lezárás most`; befejezett live nem módosítható/törölhető (csak másolatként); perces `createLiveTransitionJob` runner-feladat system audittal; kiemelés csak publikált+publikus videó; Rólunk legfeljebb hat rendezett publikus videó, érvénytelen SQL-ben kiesik; homepage: 5/6 publikus videó (hero nélkül), hat esemény, `Adás hamarosan` 24 órás sáv; 10 integrációs teszt | +| BSS-018 | Kereső-domain | done | `src/server/search/service.ts`: globális keresés videó/esemény/tag/címke csoportokra, súlyozással (spec 11.2); részletes, stabil lapozású videószűrés (`searchVideosDetailed`, címkék ÉS kapcsolattal, `count(*) over()`); láthatóság minden lekérdezésben az SQL-ben (`bss_norm` + pg_trgm, küszöb 0.3); üres/1 karakteres kifejezés nem ér adatbázist; zeneszövegben nincs keresés | +| BSS-019 | Alkalmazásváz, publikus route-ok és hibaoldalak | done | `src/routes/__root.tsx`: magyar 404 (`notFoundComponent`), hiba- és betöltési állapotok; viewer-állapot sessionből (`src/server/pages/viewer.ts` + `fetchViewerState` server fn), navbar Belépés/Kilépés gombbal és mobilmenüvel; publikus slug-feloldás régi slug redirecttel (`src/server/pages/slug-route.ts`) — piszkozat/archív/lomtár/nem látható egységesen 404; route-struktúra: `/videos/$slug`, `/events/$slug`, `/members/$slug`, `/members/archived`, `/members/contributors`, `/search`; magyar üres/betöltési/hibaállapot-komponensek (`PageStates.tsx`); 7 integrációs teszt | +| BSS-020 | Publikus videólista | done | `/videos`: thumbnail+cím kártyák; három rendezés (legutóbb feltöltött/időrendi/legnézettebb); szűrők URL-ben (szabad szöveg, címkék ÉS-kapcsolattal tag-pickerben, esemény slug szerint, recordedAt dátumtartomány, stábtag, stábszerep); oldalméret 10/25/50/100, alap 50; `parseVideoListSearch` ismeretlen értékeknél alapérték-re esik; `getVideoListPage` a BSS-018 szolgáltatást hívja, láthatóság az SQL-ben; szűrőlisták (`getVideoFilterOptions`); magyar üres/betöltési/hibaállapot; 8 integrációs teszt | +| BSS-021 | Videórészlet, player és kapcsolódó tartalom | done | `/videos/$slug`: spec 5.7 blokksorrend (player → cím → készült/feltöltve → eseménylink → leírás → vendégek → zenék → címkék aktív szűrőre linkelve → stáb pozíciónként profil-linkekkel → kapcsolódó videók a BSS-015 szolgáltatásból, SQL-szintű szűréssel); natív player posterrel és `preload="metadata"`, autoplay és letöltés gomb nélkül (`VideoDetailPlayer.tsx`); első `play` → `POST /api/videos/:id/view` (BSS-016 számláló, anonim session cookie, idempotens); médiahiba magyar üzenettel + újrapróbálás; OG/canonical meta; magyar dátumformátum (`formatDateHu`, Europe/Budapest); 7 integrációs teszt | +| BSS-022 | Publikus eseménylista és részletoldal | done | `/events`: kezdődátum desc, csak publikált; kártyán a néző számára látható videók száma overlay-ben; thumbnail fallback = legfrissebb látható videó thumbnailje (DISTINCT ON), majd placeholder; 50-es alaplapozás; `/events/$slug`: dátumintervallum, leírás, videók `recordedAt` desc nulls last, 50-es lapozás; származtatott stáblista csak látható videókból, titulus nélkül, név szerint rendezve; videó nélküli esemény is publikus; 6 integrációs teszt | +| BSS-023 | Publikus taglista és tagprofil | done | `/members`: vezetőség/stúdiósok/jelöltek/jelölt-jelöltek/aktív öregtagok blokkok lapozás nélkül; vezetőségi tag csak a Vezetőség blokkban; `/members/archived` és `/members/contributors` külön aloldalak 50-es lapozással; `/members/$slug` profil (státusz, csatlakozási félév `2023 ősz` formában, bemutatkozás) — email/mobil a sémában sincs; tevékenység év- és szerepnézettel (URL-ben marad), `recordedAt` desc nulls last, 50-es `Továbbiak betöltése`, több szerepnél tudatos ismétlés (`groupActivity` kliensbiztos modulban); csak `sync_status='ok'` profilok publikusak; jogosultsági videószűrés SQL-ben; 6 integrációs teszt | +| BSS-024 | Homepage és YouTube live felület | done | `/`: `getHomepagePage` DTO a BSS-017 state szolgáltatásból; live állapotban youtube-nocookie embed autoplay nélkül; kiemelt hero + öt, normálban hat friss videó, hero nem ismétlődik; mindhárom állapot alatt hat esemény; `Adás hamarosan` sáv; percenkénti refetch frissítés nélküli váltással (`refetchInterval: 60_000`); 2 fókuszált integrációs teszt (a prioritás-logika a BSS-017 tesztjeiben) | +| BSS-025 | Globális kereső és találati oldal | done | Navbar popover (`SearchBox.tsx`): 2 karaktertől, 250 ms debounce, csoportonként öt találat, nyíl/Enter/Esc billentyűzetkezelés, Tag/Címke megnevezés nem keveredik; `GET /api/search?q=&limit=` végpont viewer-szintű SQL-szűréssel (tiltott videó metaadata nem kerül válaszba); `/search` fülek Összes/Videók/Események/Tagok, Összes fülön típusonként max 10 találat, Videók fül a részletes lista-szűrést használja query megtartásával, címke-találat `/videos?tags=` aktív szűrőre visz; üres keresés útmutatót ad; 4 integrációs teszt | +| BSS-026 | Rólunk oldal és tanfolyam-átirányítás | done | `/about`: verziókezelt plain text szöveg kódban (`ABOUT_TEXT_VERSION`), legfeljebb hat vezetőségi videó a BSS-017 `getAboutPageVideos`-ból (archivált/lomtári/nem publikus SQL-ben kiesik); `/courses`: szerveroldali 302 a `https://tanfolyam.bsstudio.hu/` címre (`src/server.ts` entry + `isCoursesPath`), kliens navigáció ugyanoda, helyi űrlap és ál-sikerüzenet törölve; 2 integrációs teszt | | ## Fáziskapu eredménye (0. fázis, megtörtént) @@ -65,13 +74,42 @@ Következő: **3. fázis – Publikus felület** (BSS-019 – BSS-026), munka cs kulcs a config clientId+clientSecret titkából származik (új OOB titok bevezetése nélkül). - Az id_token signature-ét nem ellenőrizzük (backchannel token), de iss/aud/exp/nonce igen. -## Ismert hibák és technikai tartozás +## Fáziskapu eredménye (3. fázis) + +- [x] `pnpm check` zöld +- [x] `pnpm lint` zöld +- [x] `pnpm typecheck` zöld (0 hiba) +- [x] `TEST_DATABASE_URL=... pnpm test` zöld — 33 fájl / 276 teszt (2. fázis vége: 25/234) +- [x] `pnpm build` zöld (nitro .output) +- [x] migráció nem változott (7 migráció maradt; a 3. fázis csak route/UI/API wiring) +- [x] git diff átnézve: titkok csak gitignore-olt `.env` és `oob/` fájlokban; idegen módosítás nincs + +## Elfogadott technikai döntések a 3. fázisban + +- Az oldallogika tesztelhető szervermodulokban él (`src/server/pages/{video-list,video-detail,event-list,members,homepage,slug-route,viewer}.ts`), + a route-ok `createServerFn` wrapperrel hívják őket; a viewer-t a `getRequest()` + alapján a session cookie-ból oldjuk fel (Authentik-hívás nélkül). +- A kereső és a megtekintésszámláló `/api/*` végpontként csatlakozik a + `src/server/api/router.ts` diszpécserhez (`GET /api/search`, `POST /api/videos/:id/view`); + mindkettő opcionális `deps.db`/`deps.config` paramétert kap az integrációs tesztekhez. +- A `/courses` átirányítás kétféle úton is működik: a `src/server.ts` entry minden + `/courses` kérést 302-vel szolgál ki (JavaScript nélkül is), a kliensoldali + navigációt pedig a route `beforeLoad`-ja kezeli. +- A Rólunk-szöveg verziószámmal jelölt plain text konstans a kódban (spec 10.1: + módosítása kódváltozást igényel). +- A tevékenység-csoportosítás (`groupActivity`) és a magyar dátumformázók + kliensbiztos modulokban élnek (`src/lib/activity.ts`, `src/lib/format-date.ts`), + hogy a route-komponensek ne húzzák be a szervermodulokat. +- A homepage percenkénti `refetchInterval`-lel pollolja a state-et; külön WebSocket + vagy push nem része a V0-nak. + +## Ismert hibák és technikai tartozás (aktuális) -- A publikus prototípus oldalai még statikus placeholder tartalmat mutatnak (2–3. fázis). - `@tanstack/router-cli` (tsr) Node 24-es circular-dependency figyelmeztetése ártalmatlan. - docker-compose: HTTPS portfeltárás elhagyva (HTTP :9000). -- A belépési felület navbar-integrációja (Belépés/Kilépés gomb) a BSS-019/BSS-027 kártyákhoz tartozik; - addig közvetlen URL-lel (`/api/auth/login`) érhető el a belépés. +- A navbar globális keresője popoverben max. öt találtat mutat csoportonként; + thumbnail-előnézet a találatokban nincs (nem V0 követelmény). +- Az integrációs tesztek futtatásához továbbra is `TEST_DATABASE_URL` kell (nélküle skipelnek). ## Fáziskapu eredménye (2. fázis) @@ -108,15 +146,6 @@ Következő: **3. fázis – Publikus felület** (BSS-019 – BSS-026), munka cs - Keresési súlyok (spec 11.2): pontos 100 > előtag 80 > címke 70/55 > trigram ≤50 > eseménycím 40 > vendégek/stáb 30/25 > leírás/bemutatkozás 20. -## Ismert hibák és technikai tartozás - -- A publikus prototípus oldalai még statikus placeholder tartalmat mutatnak (3. fázis). -- `@tanstack/router-cli` (tsr) Node 24-es circular-dependency figyelmeztetése ártalmatlan. -- docker-compose: HTTPS portfeltárás elhagyva (HTTP :9000). -- A belépési felület navbar-integrációja (Belépés/Kilépés gomb) a BSS-019/BSS-027 kártyákhoz tartozik; - addig közvetlen URL-lel (`/api/auth/login`) érhető el a belépés. -- Az integrációs tesztek futtatásához továbbra is `TEST_DATABASE_URL` kell (nélküle skipelnek). - ## Fáziskapu eredménye (1. fázis) - [x] `pnpm check` zöld diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 1ee284a..f78a69f 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,76 +2,56 @@ import { useEffect, useState } from 'react' import ThemeToggle from '#/components/ThemeToggle.tsx' -import { Link } from '@tanstack/react-router' +import SearchBox from '#/components/SearchBox.tsx' +import { Link, useLocation } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { fetchViewerState } from '#/server/pages/viewer-fn.ts' + +const NAV_LINKS = [ + { to: '/videos', label: 'Videók' }, + { to: '/events', label: 'Események' }, + { to: '/members', label: 'Tagok' }, + { to: '/courses', label: 'Tanfolyamok' }, + { to: '/about', label: 'Mivel foglalkozunk?' }, +] as const export default function Navbar() { - const [pathname, setPathname] = useState(() => - typeof window !== 'undefined' ? window.location.pathname : '/', - ) + const [menuOpen, setMenuOpen] = useState(false) + const location = useLocation() + const viewerQuery = useQuery({ + queryKey: ['viewer'], + queryFn: fetchViewerState, + staleTime: 60_000, + }) + const loggedIn = viewerQuery.data?.loggedIn ?? false + // Útvonalváltásra zárjuk be a mobilmenüt. useEffect(() => { - const handleLocationChange = () => setPathname(window.location.pathname) - - // catch back/forward - window.addEventListener('popstate', handleLocationChange) - - // also patch pushState to detect client-side navigations that don't trigger popstate - const _pushState = history.pushState - ;(history as any).pushState = function (...args: any[]) { - _pushState.call(this, args[0], args[1], args[2]) - handleLocationChange() - } - - return () => { - window.removeEventListener('popstate', handleLocationChange) - // restore - ;(history as any).pushState = _pushState - } - }, []) + setMenuOpen(false) + }, [location.href]) return (
+ + {/* mobile menu */} + {menuOpen && ( + + )} ) } + +function LoginLogoutButton({ loggedIn }: { loggedIn: boolean }) { + const location = useLocation() + if (!loggedIn) { + const returnTo = `${location.pathname}${location.searchStr}` + return ( + + Belépés + + ) + } + return ( +
+ +
+ ) +} diff --git a/src/components/PageStates.tsx b/src/components/PageStates.tsx new file mode 100644 index 0000000..0b31a8f --- /dev/null +++ b/src/components/PageStates.tsx @@ -0,0 +1,81 @@ +import { Link } from '@tanstack/react-router' + +/** + * Közös magyar oldalállapotok (BSS-019): eltérő betöltési, üres és hibaállapot + * minden listára és nézetre (spec 18). + */ + +export function LoadingState({ label = 'Betöltés…' }: { label?: string }) { + return ( +
+

{label}

+
+ ) +} + +export function EmptyState({ + title, + description, +}: { + title: string + description?: string +}) { + return ( +
+

{title}

+ {description !== undefined && ( +

{description}

+ )} +
+ ) +} + +export function ErrorState({ + label = 'Hiba történt az adatok betöltése közben. Próbáld újra később.', +}: { + label?: string +}) { + return ( +
+

Hoppá!

+

+ {label} +

+
+ ) +} + +export function NotFoundContent() { + return ( +
+

404

+

+ Az oldal nem található +

+

+ A keresett tartalom nem létezik, még nem jelent meg, vagy már nem + elérhető. +

+ + Vissza a főoldalra + +
+ ) +} + +export function ForbiddenContent() { + return ( +
+

403

+

+ Hozzáférés megtagadva +

+

+ Ehhez az oldalhoz nincs jogosultságod. +

+ + Vissza a főoldalra + +
+ ) +} diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx new file mode 100644 index 0000000..0f7a933 --- /dev/null +++ b/src/components/SearchBox.tsx @@ -0,0 +1,229 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' + +interface SearchResults { + videos: Array<{ slug: string; title: string }> + events: Array<{ slug: string; title: string }> + members: Array<{ username: string; fullName: string }> + tags: Array<{ name: string }> +} + +const EMPTY_RESULTS: SearchResults = { + videos: [], + events: [], + members: [], + tags: [], +} + +/** + * Globális kereső popover (spec 11.1): két karaktertől, 250 ms késleltetéssel; + * csoportonként legfeljebb öt találat; billentyűzettel is kezelhető. + */ +export default function SearchBox() { + const navigate = useNavigate() + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [results, setResults] = useState(EMPTY_RESULTS) + const [activeIndex, setActiveIndex] = useState(-1) + const boxRef = useRef(null) + const abortRef = useRef(null) + + useEffect(() => { + function onDocClick(event: MouseEvent) { + if (boxRef.current === null) return + if ( + event.target instanceof Node && + boxRef.current.contains(event.target) + ) { + return + } + setOpen(false) + } + document.addEventListener('click', onDocClick) + return () => document.removeEventListener('click', onDocClick) + }, []) + + useEffect(() => { + const trimmed = query.trim() + if (trimmed.length < 2) { + setResults(EMPTY_RESULTS) + setActiveIndex(-1) + return + } + const timer = setTimeout(() => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + void fetch(`/api/search?q=${encodeURIComponent(trimmed)}&limit=5`, { + signal: controller.signal, + }) + .then((response) => (response.ok ? response.json() : EMPTY_RESULTS)) + .then((data: SearchResults) => { + setResults(data) + setOpen(true) + }) + .catch(() => {}) + }, 250) + return () => clearTimeout(timer) + }, [query]) + + type Hit = + | { kind: 'video'; label: string; href: string } + | { kind: 'event'; label: string; href: string } + | { kind: 'member'; label: string; href: string } + | { kind: 'tag'; label: string; href: string } + + const hits: Array = [ + ...results.videos.map((video) => ({ + kind: 'video' as const, + label: video.title, + href: `/videos/${video.slug}`, + })), + ...results.events.map((event) => ({ + kind: 'event' as const, + label: event.title, + href: `/events/${event.slug}`, + })), + ...results.members.map((member) => ({ + kind: 'member' as const, + label: `${member.fullName} (Tag)`, + href: `/members/${member.username}`, + })), + ...results.tags.map((tag) => ({ + kind: 'tag' as const, + label: `${tag.name} (Címke)`, + href: `/videos?tags=${encodeURIComponent(tag.name)}`, + })), + ] + + function openHit(hit: Hit) { + setOpen(false) + if (hit.href.startsWith('/videos?')) { + const tagName = decodeURIComponent(hit.href.replace('/videos?tags=', '')) + void navigate({ + to: '/videos', + search: { tags: [tagName] }, + }) + return + } + window.location.assign(hit.href) + } + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false) + setActiveIndex(-1) + return + } + if (!open || hits.length === 0) { + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + setActiveIndex((index) => (index + 1) % hits.length) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setActiveIndex((index) => (index <= 0 ? hits.length - 1 : index - 1)) + } else if (event.key === 'Enter') { + const hit = hits.at(activeIndex) + if (hit === undefined) { + return + } + event.preventDefault() + openHit(hit) + } + } + + const kindLabels: Record = { + video: 'Videó', + event: 'Esemény', + member: 'Tag', + tag: 'Címke', + } + + return ( +
+
+ setQuery(event.target.value)} + onFocus={() => query.trim().length >= 2 && setOpen(true)} + onKeyDown={onKeyDown} + placeholder="Keresés..." + aria-label="Keresés" + aria-expanded={open} + role="combobox" + aria-controls="global-search-results" + className="w-40 border-0 text-(--nav-search-placeholder) outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 lg:w-56" + /> + +
+ + {open && ( +
+ {hits.length === 0 ? ( +

+ Nincs találat. +

+ ) : ( +
    + {hits.map((hit, index) => ( +
  • + +
  • + ))} +
+ )} + +
+ )} +
+ ) +} diff --git a/src/components/VideoDetailPlayer.tsx b/src/components/VideoDetailPlayer.tsx new file mode 100644 index 0000000..7638fc8 --- /dev/null +++ b/src/components/VideoDetailPlayer.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +/** + * Natív MP4-lejátszó (spec 5.5): natív vezérlők, poster, preload="metadata", + * nincs autoplay és külön letöltés gomb. Az első sikeres `play` eseménynél + * egyszer megszámolja a megtekintést; médiahiba magyar üzenetet és + * újrapróbálást ad. + */ +export default function VideoDetailPlayer({ + videoId, + videoUrl, + posterUrl, + title, +}: Readonly<{ + videoId: string + videoUrl: string + posterUrl?: string | null + title: string +}>) { + const countedRef = useRef(false) + const [errorKey, setErrorKey] = useState(0) + const [failed, setFailed] = useState(false) + + useEffect(() => { + setFailed(false) + countedRef.current = false + }, [videoId, videoUrl]) + + function handlePlay() { + if (countedRef.current) { + return + } + countedRef.current = true + void fetch(`/api/videos/${videoId}/view`, { method: 'POST' }).catch( + () => {}, + ) + } + + if (failed) { + return ( +
+

+ A videó lejátszása most nem sikerült. Ellenőrizd a kapcsolatot, majd + próbáld újra. +

+ +
+ ) + } + + return ( +