Compare commits
295 Commits
falukant-3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aecd9a8245 | ||
|
|
4f3439e835 | ||
|
|
a5bec5baf7 | ||
|
|
8d23453371 | ||
|
|
2184c4a7e1 | ||
|
|
ba5e36fa55 | ||
|
|
70d1d48fbc | ||
|
|
d23026121e | ||
|
|
057b038fac | ||
|
|
0697f3d363 | ||
|
|
400d44289c | ||
|
|
bbc3354f16 | ||
|
|
d038d72cde | ||
|
|
16e54d20d0 | ||
|
|
14775eb556 | ||
|
|
ce34bae16a | ||
|
|
640cdcf671 | ||
|
|
f15924c0be | ||
|
|
0d32c5b4b3 | ||
|
|
101050ce58 | ||
|
|
b16249e7c2 | ||
|
|
8b63344bc2 | ||
|
|
b648175205 | ||
|
|
4bf1bc35ae | ||
|
|
067273d428 | ||
|
|
7ed284d74b | ||
|
|
f65d3385ec | ||
|
|
7635355e94 | ||
|
|
ec75c7ecdb | ||
|
|
786420d1d2 | ||
|
|
cff0ce1e1a | ||
|
|
8355f985cd | ||
|
|
25af538c88 | ||
|
|
d1503cd813 | ||
|
|
7d2a33b3ec | ||
|
|
752686e3e1 | ||
|
|
3870f34ef8 | ||
|
|
ae71a066c7 | ||
|
|
b52327db2e | ||
|
|
d5c089e07e | ||
|
|
0f78c624b1 | ||
|
|
e1632c41c2 | ||
|
|
323b051355 | ||
|
|
3999b17e88 | ||
|
|
8fd15614af | ||
|
|
ddefc2737b | ||
|
|
05868d8a09 | ||
|
|
b3afb988a3 | ||
|
|
3b8e0573f2 | ||
|
|
4779a6e4af | ||
|
|
39ac149430 | ||
|
|
8ec7db031b | ||
|
|
25b5b91a19 | ||
|
|
e8c6f6ffb9 | ||
|
|
62d8cd7b05 | ||
|
|
c09159d6ce | ||
|
|
8d2db95540 | ||
|
|
9519846489 | ||
|
|
f7a977df33 | ||
|
|
f1717920b6 | ||
|
|
c5ab17ad99 | ||
|
|
1839c3c57b | ||
|
|
ba63b3504f | ||
|
|
032e336b65 | ||
|
|
474e46837a | ||
|
|
e7052636ba | ||
|
|
cb2631061e | ||
|
|
d1ddfe7d31 | ||
|
|
59cad22183 | ||
|
|
57d64a7ef8 | ||
|
|
ae096eb4c3 | ||
|
|
789861999c | ||
|
|
72f4bd066d | ||
|
|
b3db65d1b8 | ||
|
|
506a9cd9c0 | ||
|
|
1ead06fd4f | ||
|
|
eecd947377 | ||
|
|
5351e3ea57 | ||
|
|
3bdb77888f | ||
|
|
c570fd6ae3 | ||
|
|
be3ed4af5d | ||
|
|
4cce044128 | ||
|
|
59875cf900 | ||
|
|
37129055e6 | ||
|
|
934e80c2ab | ||
|
|
8e20fbd24d | ||
|
|
f102069f5a | ||
|
|
afc36161ed | ||
|
|
a8b76bc21a | ||
|
|
8550bd31d9 | ||
|
|
8837494a06 | ||
|
|
0c407b81b7 | ||
|
|
71b4a02592 | ||
|
|
83e5767812 | ||
|
|
c544c2c7f9 | ||
|
|
818c8fbdf9 | ||
|
|
a6326f149d | ||
|
|
01679697b4 | ||
|
|
d4fb2a8ccc | ||
|
|
08b6437a1e | ||
|
|
baffd9d05c | ||
|
|
cbff7c130c | ||
|
|
16f3d1a320 | ||
|
|
955ea1a9ed | ||
|
|
ca614f6cc2 | ||
|
|
71748f6aa0 | ||
|
|
80b639b511 | ||
|
|
bba68da488 | ||
|
|
29c2b53f53 | ||
|
|
c3cc248a39 | ||
|
|
fb821dbf21 | ||
|
|
079250fcd7 | ||
|
|
120cb5fadd | ||
|
|
d3a554108f | ||
|
|
6471158847 | ||
|
|
1c442eb195 | ||
|
|
13f5660fee | ||
|
|
9333a8318c | ||
|
|
c1cda5fa62 | ||
|
|
88967ba9d3 | ||
|
|
92d792246c | ||
|
|
586aaec506 | ||
|
|
10690b5a6e | ||
|
|
bceef9777a | ||
|
|
4f786cdcc3 | ||
|
|
8e226615eb | ||
|
|
82734e8383 | ||
|
|
69a83c584b | ||
|
|
a8fdcd179e | ||
|
|
ace976965d | ||
|
|
7303d1ea0b | ||
|
|
4379b0b955 | ||
|
|
09af7af228 | ||
|
|
dc08da211f | ||
|
|
30e1df0dd8 | ||
|
|
95a4c977c1 | ||
|
|
6ce081196c | ||
|
|
3d5342b314 | ||
|
|
78d43e6859 | ||
|
|
41106ae306 | ||
|
|
33aa2ddd45 | ||
|
|
2be5505c55 | ||
|
|
8c0f07cc51 | ||
|
|
3018b1f2e1 | ||
|
|
a21a2314d7 | ||
|
|
a76aae3d12 | ||
|
|
7765067d1b | ||
|
|
eddbe5fa3f | ||
|
|
c907d2773d | ||
|
|
5f71e56bf9 | ||
|
|
adcbd1a95a | ||
|
|
175a61c81c | ||
|
|
4d97f24531 | ||
|
|
8d32d704b5 | ||
|
|
e5d4a5f95f | ||
|
|
d4a0f78cd0 | ||
|
|
7cd946181e | ||
|
|
cf97a3ba5e | ||
|
|
963e0c906c | ||
|
|
089743ac23 | ||
|
|
69ef120677 | ||
|
|
fe2e6a53e9 | ||
|
|
cf1b5e7f71 | ||
|
|
202002358a | ||
|
|
14eb28d37f | ||
|
|
81dbbdd6f5 | ||
|
|
9e6787fb3f | ||
|
|
2eee7bb0c1 | ||
|
|
7f57ecc35e | ||
|
|
21f6130666 | ||
|
|
594b3dac4a | ||
|
|
ef2b279df6 | ||
|
|
2ffd7a6151 | ||
|
|
045d32c245 | ||
|
|
053588ae74 | ||
|
|
749a2d6f59 | ||
|
|
95ba8f0b33 | ||
|
|
dacf6cb7f8 | ||
|
|
656c3b3d09 | ||
|
|
44ce6636c0 | ||
|
|
1413630f11 | ||
|
|
8f55f63f77 | ||
|
|
0331ffeb93 | ||
|
|
196b74bebb | ||
|
|
305e137a1a | ||
|
|
4e5ddc8027 | ||
|
|
4bb75de3f0 | ||
|
|
0572a0eb50 | ||
|
|
c13cb40c7b | ||
|
|
33787ba796 | ||
|
|
64f4468664 | ||
|
|
408b65be30 | ||
|
|
891420cb09 | ||
|
|
a657c59b2c | ||
|
|
89ec084106 | ||
|
|
a7a0daaf82 | ||
|
|
df5c2a3141 | ||
|
|
f902f5298c | ||
|
|
ddd038761b | ||
|
|
09e53244d9 | ||
|
|
714e144329 | ||
|
|
e1b3dfb00a | ||
|
|
b6a4607e60 | ||
|
|
9553cc811a | ||
|
|
59c05b3628 | ||
|
|
d3629a8a09 | ||
|
|
a17e8537fb | ||
|
|
a7f23c5885 | ||
|
|
b706191a0e | ||
|
|
ba469ef900 | ||
|
|
e852346b94 | ||
|
|
02d24eccd8 | ||
|
|
d1359ccc36 | ||
|
|
52c7f1c7ba | ||
|
|
7a2749c405 | ||
|
|
d71df901ed | ||
|
|
1af4b6c2e4 | ||
|
|
2595cb8565 | ||
|
|
45d549aa4e | ||
|
|
7f65f5e40e | ||
|
|
5ce1cc4e6a | ||
|
|
3a6d60e9a8 | ||
|
|
d5a09f359d | ||
|
|
127e95ca1c | ||
|
|
bb81126cd8 | ||
|
|
2d3d120f81 | ||
|
|
0c36c4a4e5 | ||
|
|
88f6686809 | ||
|
|
9c7b682a36 | ||
|
|
dafdbf0a84 | ||
|
|
5ac8e9b484 | ||
|
|
753c5929e1 | ||
|
|
e3f46d775a | ||
|
|
0eb3a78332 | ||
|
|
3ac9f25284 | ||
|
|
b3c9c8f37c | ||
|
|
32bc126def | ||
|
|
00a5f47cae | ||
|
|
6a1260687b | ||
|
|
7591787583 | ||
|
|
bd961a03d4 | ||
|
|
8fe816dddc | ||
|
|
e7a8dc86eb | ||
|
|
c9dc891481 | ||
|
|
89c3873db7 | ||
|
|
60352d7932 | ||
|
|
664f2af346 | ||
|
|
8212e906a3 | ||
|
|
92e17a9f43 | ||
|
|
d3727ad2f7 | ||
|
|
391e5d9992 | ||
|
|
a4bd585730 | ||
|
|
c694769f4c | ||
|
|
8b9ff9793c | ||
|
|
8ba4566d23 | ||
|
|
91420b9973 | ||
|
|
8d3e0423e7 | ||
|
|
4bafc3a61c | ||
|
|
1f43df6d41 | ||
|
|
c2a54e29f8 | ||
|
|
b1f9073f4d | ||
|
|
1b38e2412c | ||
|
|
4b9311713a | ||
|
|
77520ee46a | ||
|
|
23c07a3570 | ||
|
|
1451225978 | ||
|
|
51fd9fcd13 | ||
|
|
1fe77c0905 | ||
|
|
cd739fb52e | ||
|
|
9e845843d8 | ||
|
|
0cc280ed55 | ||
|
|
b3707d21b2 | ||
|
|
fbebd6c1c1 | ||
|
|
d7c2bda461 | ||
|
|
2bf949513b | ||
|
|
84619fb656 | ||
|
|
b600f16ecd | ||
|
|
9273066f61 | ||
|
|
7d59dbcf84 | ||
|
|
015d1ae95b | ||
|
|
e2cd6e0e5e | ||
|
|
ec113058d0 | ||
|
|
d2ac2bfdd8 | ||
|
|
d75fe18e6a | ||
|
|
479f222b54 | ||
|
|
013c536b47 | ||
|
|
3b983a0db5 | ||
|
|
5f9559ac8d | ||
|
|
f487e6d765 | ||
|
|
5e26422e9c | ||
|
|
64baebfaaa | ||
|
|
521dec24b2 | ||
|
|
36f0bd8eb9 | ||
|
|
d0a2b122b2 | ||
|
|
c80cc8ec86 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,6 +5,7 @@
|
|||||||
.depbe.sh
|
.depbe.sh
|
||||||
node_modules
|
node_modules
|
||||||
node_modules/*
|
node_modules/*
|
||||||
|
**/package-lock.json
|
||||||
backend/.env
|
backend/.env
|
||||||
backend/images
|
backend/images
|
||||||
backend/images/*
|
backend/images/*
|
||||||
@@ -17,3 +18,9 @@ frontend/dist
|
|||||||
frontend/dist/*
|
frontend/dist/*
|
||||||
frontedtree.txt
|
frontedtree.txt
|
||||||
backend/dist/
|
backend/dist/
|
||||||
|
backend/data/model-cache
|
||||||
|
build
|
||||||
|
build/*
|
||||||
|
.vscode
|
||||||
|
.vscode/*
|
||||||
|
.clang-format
|
||||||
|
|||||||
156
CHURCH_MODELS.md
Normal file
156
CHURCH_MODELS.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Church Models - Übersicht für Daemon-Entwicklung
|
||||||
|
|
||||||
|
## 1. ChurchOfficeType (falukant_type.church_office_type)
|
||||||
|
|
||||||
|
**Schema:** `falukant_type`
|
||||||
|
**Tabelle:** `church_office_type`
|
||||||
|
**Zweck:** Definiert die verschiedenen Kirchenämter-Typen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: INTEGER (PK, auto-increment)
|
||||||
|
name: STRING (z.B. "pope", "cardinal", "lay-preacher")
|
||||||
|
seatsPerRegion: INTEGER (Anzahl verfügbarer Plätze pro Region)
|
||||||
|
regionType: STRING (z.B. "country", "duchy", "city")
|
||||||
|
hierarchyLevel: INTEGER (0-8, höhere Zahl = höhere Position)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beziehungen:**
|
||||||
|
- `hasMany` ChurchOffice (als `offices`)
|
||||||
|
- `hasMany` ChurchApplication (als `applications`)
|
||||||
|
- `hasMany` ChurchOfficeRequirement (als `requirements`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ChurchOfficeRequirement (falukant_predefine.church_office_requirement)
|
||||||
|
|
||||||
|
**Schema:** `falukant_predefine`
|
||||||
|
**Tabelle:** `church_office_requirement`
|
||||||
|
**Zweck:** Definiert Voraussetzungen für Kirchenämter
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: INTEGER (PK, auto-increment)
|
||||||
|
officeTypeId: INTEGER (FK -> ChurchOfficeType.id)
|
||||||
|
prerequisiteOfficeTypeId: INTEGER (FK -> ChurchOfficeType.id, nullable)
|
||||||
|
minTitleLevel: INTEGER (nullable, optional)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beziehungen:**
|
||||||
|
- `belongsTo` ChurchOfficeType (als `officeType`)
|
||||||
|
- `belongsTo` ChurchOfficeType (als `prerequisiteOfficeType`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ChurchOffice (falukant_data.church_office)
|
||||||
|
|
||||||
|
**Schema:** `falukant_data`
|
||||||
|
**Tabelle:** `church_office`
|
||||||
|
**Zweck:** Speichert tatsächlich besetzte Kirchenämter
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: INTEGER (PK, auto-increment)
|
||||||
|
officeTypeId: INTEGER (FK -> ChurchOfficeType.id)
|
||||||
|
characterId: INTEGER (FK -> FalukantCharacter.id)
|
||||||
|
regionId: INTEGER (FK -> RegionData.id)
|
||||||
|
supervisorId: INTEGER (FK -> FalukantCharacter.id, nullable)
|
||||||
|
createdAt: DATE
|
||||||
|
updatedAt: DATE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beziehungen:**
|
||||||
|
- `belongsTo` ChurchOfficeType (als `type`)
|
||||||
|
- `belongsTo` FalukantCharacter (als `holder`)
|
||||||
|
- `belongsTo` FalukantCharacter (als `supervisor`)
|
||||||
|
- `belongsTo` RegionData (als `region`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ChurchApplication (falukant_data.church_application)
|
||||||
|
|
||||||
|
**Schema:** `falukant_data`
|
||||||
|
**Tabelle:** `church_application`
|
||||||
|
**Zweck:** Speichert Bewerbungen für Kirchenämter
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: INTEGER (PK, auto-increment)
|
||||||
|
officeTypeId: INTEGER (FK -> ChurchOfficeType.id)
|
||||||
|
characterId: INTEGER (FK -> FalukantCharacter.id)
|
||||||
|
regionId: INTEGER (FK -> RegionData.id)
|
||||||
|
supervisorId: INTEGER (FK -> FalukantCharacter.id)
|
||||||
|
status: ENUM('pending', 'approved', 'rejected')
|
||||||
|
decisionDate: DATE (nullable)
|
||||||
|
createdAt: DATE
|
||||||
|
updatedAt: DATE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beziehungen:**
|
||||||
|
- `belongsTo` ChurchOfficeType (als `officeType`)
|
||||||
|
- `belongsTo` FalukantCharacter (als `applicant`)
|
||||||
|
- `belongsTo` FalukantCharacter (als `supervisor`)
|
||||||
|
- `belongsTo` RegionData (als `region`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusätzlich benötigte Models (für Daemon)
|
||||||
|
|
||||||
|
### RegionData (falukant_data.region)
|
||||||
|
- Wird für `regionId` in ChurchOffice und ChurchApplication benötigt
|
||||||
|
- Enthält `regionType` (country, duchy, markgravate, shire, county, city)
|
||||||
|
- Enthält `parentId` für Hierarchie
|
||||||
|
|
||||||
|
### FalukantCharacter (falukant_data.character)
|
||||||
|
- Wird für `characterId` (Inhaber/Bewerber) benötigt
|
||||||
|
- Wird für `supervisorId` benötigt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Queries für Daemon
|
||||||
|
|
||||||
|
### Verfügbare Positionen finden
|
||||||
|
```sql
|
||||||
|
SELECT cot.*, COUNT(co.id) as occupied_seats
|
||||||
|
FROM falukant_type.church_office_type cot
|
||||||
|
LEFT JOIN falukant_data.church_office co
|
||||||
|
ON cot.id = co.office_type_id
|
||||||
|
AND co.region_id = ?
|
||||||
|
WHERE cot.region_type = ?
|
||||||
|
GROUP BY cot.id
|
||||||
|
HAVING COUNT(co.id) < cot.seats_per_region
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supervisor finden
|
||||||
|
```sql
|
||||||
|
SELECT co.*
|
||||||
|
FROM falukant_data.church_office co
|
||||||
|
JOIN falukant_type.church_office_type cot ON co.office_type_id = cot.id
|
||||||
|
WHERE co.region_id = ?
|
||||||
|
AND cot.hierarchy_level > (
|
||||||
|
SELECT hierarchy_level
|
||||||
|
FROM falukant_type.church_office_type
|
||||||
|
WHERE id = ?
|
||||||
|
)
|
||||||
|
ORDER BY cot.hierarchy_level ASC
|
||||||
|
LIMIT 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voraussetzungen prüfen
|
||||||
|
```sql
|
||||||
|
SELECT cor.*
|
||||||
|
FROM falukant_predefine.church_office_requirement cor
|
||||||
|
WHERE cor.office_type_id = ?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bewerbungen für Supervisor
|
||||||
|
```sql
|
||||||
|
SELECT ca.*
|
||||||
|
FROM falukant_data.church_application ca
|
||||||
|
WHERE ca.supervisor_id = ?
|
||||||
|
AND ca.status = 'pending'
|
||||||
|
```
|
||||||
78
CHURCH_OFFICES.md
Normal file
78
CHURCH_OFFICES.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Kirchenämter - Hierarchie und Verfügbarkeit
|
||||||
|
|
||||||
|
## Regionstypen
|
||||||
|
- **country** (Land): Falukant
|
||||||
|
- **duchy** (Herzogtum): Hessen
|
||||||
|
- **markgravate** (Markgrafschaft): Groß-Benbach
|
||||||
|
- **shire** (Grafschaft): Siebenbachen
|
||||||
|
- **county** (Kreis): Bad Homburg, Maintal
|
||||||
|
- **city** (Stadt): Frankfurt, Oberursel, Offenbach, Königstein
|
||||||
|
|
||||||
|
## Kirchenämter (von höchstem zu niedrigstem Rang)
|
||||||
|
|
||||||
|
| Amt | Translation Key | Hierarchie-Level | Regionstyp | Plätze pro Region | Beschreibung |
|
||||||
|
|-----|----------------|-------------------|------------|-------------------|--------------|
|
||||||
|
| **Papst** | `pope` | 8 | country | 1 | Höchstes Amt, nur einer im ganzen Land |
|
||||||
|
| **Kardinal** | `cardinal` | 7 | country | 3 | Höchste Kardinäle, mehrere pro Land möglich |
|
||||||
|
| **Erzbischof** | `archbishop` | 6 | duchy | 1 | Pro Herzogtum ein Erzbischof |
|
||||||
|
| **Bischof** | `bishop` | 5 | markgravate | 1 | Pro Markgrafschaft ein Bischof |
|
||||||
|
| **Erzdiakon** | `archdeacon` | 4 | shire | 1 | Pro Grafschaft ein Erzdiakon |
|
||||||
|
| **Dekan** | `dean` | 3 | county | 1 | Pro Kreis ein Dekan |
|
||||||
|
| **Pfarrer** | `parish-priest` | 2 | city | 1 | Pro Stadt ein Pfarrer |
|
||||||
|
| **Dorfgeistlicher** | `village-priest` | 1 | city | 1 | Pro Stadt ein Dorfgeistlicher (Einstiegsposition) |
|
||||||
|
| **Laienprediger** | `lay-preacher` | 0 | city | 3 | Pro Stadt mehrere Laienprediger (niedrigste Position) |
|
||||||
|
|
||||||
|
## Verfügbare Positionen pro Regionstyp
|
||||||
|
|
||||||
|
### country (Land: Falukant)
|
||||||
|
- **Papst**: 1 Platz
|
||||||
|
- **Kardinal**: 3 Plätze
|
||||||
|
- **Gesamt**: 4 Plätze
|
||||||
|
|
||||||
|
### duchy (Herzogtum: Hessen)
|
||||||
|
- **Erzbischof**: 1 Platz
|
||||||
|
- **Gesamt**: 1 Platz
|
||||||
|
|
||||||
|
### markgravate (Markgrafschaft: Groß-Benbach)
|
||||||
|
- **Bischof**: 1 Platz
|
||||||
|
- **Gesamt**: 1 Platz
|
||||||
|
|
||||||
|
### shire (Grafschaft: Siebenbachen)
|
||||||
|
- **Erzdiakon**: 1 Platz
|
||||||
|
- **Gesamt**: 1 Platz
|
||||||
|
|
||||||
|
### county (Kreis: Bad Homburg, Maintal)
|
||||||
|
- **Dekan**: 1 Platz pro Kreis
|
||||||
|
- **Gesamt**: 1 Platz pro Kreis
|
||||||
|
|
||||||
|
### city (Stadt: Frankfurt, Oberursel, Offenbach, Königstein)
|
||||||
|
- **Pfarrer**: 1 Platz pro Stadt
|
||||||
|
- **Dorfgeistlicher**: 1 Platz pro Stadt
|
||||||
|
- **Laienprediger**: 3 Plätze pro Stadt
|
||||||
|
- **Gesamt**: 5 Plätze pro Stadt
|
||||||
|
|
||||||
|
## Hierarchie und Beförderungsweg
|
||||||
|
|
||||||
|
1. **Laienprediger** (lay-preacher) - Einstiegsposition, keine Voraussetzung
|
||||||
|
2. **Dorfgeistlicher** (village-priest) - Voraussetzung: Laienprediger
|
||||||
|
3. **Pfarrer** (parish-priest) - Voraussetzung: Dorfgeistlicher
|
||||||
|
4. **Dekan** (dean) - Voraussetzung: Pfarrer
|
||||||
|
5. **Erzdiakon** (archdeacon) - Voraussetzung: Dekan
|
||||||
|
6. **Bischof** (bishop) - Voraussetzung: Erzdiakon
|
||||||
|
7. **Erzbischof** (archbishop) - Voraussetzung: Bischof
|
||||||
|
8. **Kardinal** (cardinal) - Voraussetzung: Erzbischof
|
||||||
|
9. **Papst** (pope) - Voraussetzung: Kardinal
|
||||||
|
|
||||||
|
## Gesamtübersicht verfügbarer Positionen
|
||||||
|
|
||||||
|
- **Papst**: 1 Position (Land)
|
||||||
|
- **Kardinal**: 3 Positionen (Land)
|
||||||
|
- **Erzbischof**: 1 Position (Herzogtum)
|
||||||
|
- **Bischof**: 1 Position (Markgrafschaft)
|
||||||
|
- **Erzdiakon**: 1 Position (Grafschaft)
|
||||||
|
- **Dekan**: 2 Positionen (2 Kreise)
|
||||||
|
- **Pfarrer**: 4 Positionen (4 Städte)
|
||||||
|
- **Dorfgeistlicher**: 4 Positionen (4 Städte)
|
||||||
|
- **Laienprediger**: 12 Positionen (4 Städte × 3)
|
||||||
|
|
||||||
|
**Gesamt**: 30 Positionen im System
|
||||||
119
CMakeLists.txt
Normal file
119
CMakeLists.txt
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(YourPartDaemon VERSION 1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
# C++ Standard and Compiler Settings
|
||||||
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
# Use best available GCC for C++23 support (OpenSUSE Tumbleweed)
|
||||||
|
# Try GCC 15 first (best C++23 support), then GCC 13, then system default
|
||||||
|
find_program(GCC15_CC gcc-15)
|
||||||
|
find_program(GCC15_CXX g++-15)
|
||||||
|
find_program(GCC13_CC gcc-13)
|
||||||
|
find_program(GCC13_CXX g++-13)
|
||||||
|
|
||||||
|
if(GCC15_CC AND GCC15_CXX)
|
||||||
|
set(CMAKE_C_COMPILER ${GCC15_CC})
|
||||||
|
set(CMAKE_CXX_COMPILER ${GCC15_CXX})
|
||||||
|
message(STATUS "Using GCC 15 for best C++23 support")
|
||||||
|
elseif(GCC13_CC AND GCC13_CXX)
|
||||||
|
set(CMAKE_C_COMPILER ${GCC13_CC})
|
||||||
|
set(CMAKE_CXX_COMPILER ${GCC13_CXX})
|
||||||
|
message(STATUS "Using GCC 13 for C++23 support")
|
||||||
|
else()
|
||||||
|
message(STATUS "Using system default compiler")
|
||||||
|
endif()
|
||||||
|
# Optimize for GCC 13 with C++23
|
||||||
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto=auto -O3 -march=native -mtune=native")
|
||||||
|
set(CMAKE_CXX_FLAGS_DEBUG "-O1 -g -DDEBUG")
|
||||||
|
set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG -march=native -mtune=native")
|
||||||
|
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -flto")
|
||||||
|
set(CMAKE_BUILD_TYPE Release)
|
||||||
|
|
||||||
|
# Include /usr/local if needed
|
||||||
|
list(APPEND CMAKE_PREFIX_PATH /usr/local)
|
||||||
|
|
||||||
|
# Find libwebsockets via pkg-config
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(LWS REQUIRED libwebsockets)
|
||||||
|
|
||||||
|
# Find other dependencies
|
||||||
|
find_package(PostgreSQL REQUIRED)
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
find_package(nlohmann_json CONFIG REQUIRED)
|
||||||
|
|
||||||
|
# PostgreSQL C++ libpqxx
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(LIBPQXX REQUIRED libpqxx)
|
||||||
|
|
||||||
|
# Project sources and headers
|
||||||
|
set(SOURCES
|
||||||
|
src/main.cpp
|
||||||
|
src/config.cpp
|
||||||
|
src/connection_pool.cpp
|
||||||
|
src/database.cpp
|
||||||
|
src/character_creation_worker.cpp
|
||||||
|
src/produce_worker.cpp
|
||||||
|
src/message_broker.cpp
|
||||||
|
src/websocket_server.cpp
|
||||||
|
src/stockagemanager.cpp
|
||||||
|
src/director_worker.cpp
|
||||||
|
src/valuerecalculationworker.cpp
|
||||||
|
src/usercharacterworker.cpp
|
||||||
|
src/houseworker.cpp
|
||||||
|
src/politics_worker.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
set(HEADERS
|
||||||
|
src/config.h
|
||||||
|
src/database.h
|
||||||
|
src/connection_pool.h
|
||||||
|
src/worker.h
|
||||||
|
src/character_creation_worker.h
|
||||||
|
src/produce_worker.h
|
||||||
|
src/message_broker.h
|
||||||
|
src/websocket_server.h
|
||||||
|
src/stockagemanager.h
|
||||||
|
src/director_worker.h
|
||||||
|
src/valuerecalculationworker.h
|
||||||
|
src/usercharacterworker.h
|
||||||
|
src/houseworker.h
|
||||||
|
src/politics_worker.h
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define executable target
|
||||||
|
add_executable(yourpart-daemon ${SOURCES} ${HEADERS}
|
||||||
|
src/utils.h src/utils.cpp
|
||||||
|
src/underground_worker.h src/underground_worker.cpp)
|
||||||
|
|
||||||
|
# Include directories
|
||||||
|
target_include_directories(yourpart-daemon PRIVATE
|
||||||
|
${PostgreSQL_INCLUDE_DIRS}
|
||||||
|
${LIBPQXX_INCLUDE_DIRS}
|
||||||
|
${LWS_INCLUDE_DIRS}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find systemd
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(SYSTEMD REQUIRED libsystemd)
|
||||||
|
|
||||||
|
# Link libraries
|
||||||
|
target_link_libraries(yourpart-daemon PRIVATE
|
||||||
|
${PostgreSQL_LIBRARIES}
|
||||||
|
Threads::Threads
|
||||||
|
z ssl crypto
|
||||||
|
${LIBPQXX_LIBRARIES}
|
||||||
|
${LWS_LIBRARIES}
|
||||||
|
nlohmann_json::nlohmann_json
|
||||||
|
${SYSTEMD_LIBRARIES}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Installation rules
|
||||||
|
install(TARGETS yourpart-daemon DESTINATION /usr/local/bin)
|
||||||
|
|
||||||
|
# Installiere Template als Referenz ZUERST (wird vom install-Skript benötigt)
|
||||||
|
install(FILES daemon.conf DESTINATION /etc/yourpart/ RENAME daemon.conf.example)
|
||||||
|
|
||||||
|
# Intelligente Konfigurationsdatei-Installation
|
||||||
|
# Verwendet ein CMake-Skript, das nur fehlende Keys hinzufügt, ohne bestehende zu überschreiben
|
||||||
|
# Das Skript liest das Template aus /etc/yourpart/daemon.conf.example oder dem Source-Verzeichnis
|
||||||
|
install(SCRIPT cmake/install-config.cmake)
|
||||||
414
CMakeLists.txt.user
Normal file
414
CMakeLists.txt.user
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE QtCreatorProject>
|
||||||
|
<!-- Written by QtCreator 17.0.0, 2025-08-16T22:07:06. -->
|
||||||
|
<qtcreator>
|
||||||
|
<data>
|
||||||
|
<variable>EnvironmentId</variable>
|
||||||
|
<value type="QByteArray">{551ef6b3-a39b-43e2-9ee3-ad56e19ff4f4}</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.ActiveTarget</variable>
|
||||||
|
<value type="qlonglong">0</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.EditorSettings</variable>
|
||||||
|
<valuemap type="QVariantMap">
|
||||||
|
<value type="bool" key="EditorConfiguration.AutoDetect">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.AutoIndent">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.CamelCaseNavigation">true</value>
|
||||||
|
<valuemap type="QVariantMap" key="EditorConfiguration.CodeStyle.0">
|
||||||
|
<value type="QString" key="language">Cpp</value>
|
||||||
|
<valuemap type="QVariantMap" key="value">
|
||||||
|
<value type="QByteArray" key="CurrentPreferences">CppGlobal</value>
|
||||||
|
</valuemap>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="EditorConfiguration.CodeStyle.1">
|
||||||
|
<value type="QString" key="language">QmlJS</value>
|
||||||
|
<valuemap type="QVariantMap" key="value">
|
||||||
|
<value type="QByteArray" key="CurrentPreferences">QmlJSGlobal</value>
|
||||||
|
</valuemap>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="EditorConfiguration.CodeStyle.Count">2</value>
|
||||||
|
<value type="QByteArray" key="EditorConfiguration.Codec">UTF-8</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.ConstrainTooltips">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.IndentSize">4</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.KeyboardTooltips">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.LineEndingBehavior">0</value>
|
||||||
|
<value type="int" key="EditorConfiguration.MarginColumn">80</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.MouseHiding">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.MouseNavigation">true</value>
|
||||||
|
<value type="int" key="EditorConfiguration.PaddingMode">1</value>
|
||||||
|
<value type="int" key="EditorConfiguration.PreferAfterWhitespaceComments">0</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.PreferSingleLineComments">false</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.ScrollWheelZooming">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.ShowMargin">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.SmartBackspaceBehavior">2</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.SmartSelectionChanging">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.SpacesForTabs">true</value>
|
||||||
|
<value type="int" key="EditorConfiguration.TabKeyBehavior">0</value>
|
||||||
|
<value type="int" key="EditorConfiguration.TabSize">8</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.UseGlobal">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.UseIndenter">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.Utf8BomBehavior">1</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.addFinalNewLine">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.cleanIndentation">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.cleanWhitespace">true</value>
|
||||||
|
<value type="QString" key="EditorConfiguration.ignoreFileTypes">*.md, *.MD, Makefile</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.inEntireDocument">false</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.skipTrailingWhitespace">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.tintMarginArea">true</value>
|
||||||
|
</valuemap>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.PluginSettings</variable>
|
||||||
|
<valuemap type="QVariantMap">
|
||||||
|
<valuemap type="QVariantMap" key="AutoTest.ActiveFrameworks">
|
||||||
|
<value type="bool" key="AutoTest.Framework.Boost">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.CTest">false</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.Catch">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.GTest">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.QtQuickTest">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.QtTest">true</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="bool" key="AutoTest.ApplyFilter">false</value>
|
||||||
|
<valuemap type="QVariantMap" key="AutoTest.CheckStates"/>
|
||||||
|
<valuelist type="QVariantList" key="AutoTest.PathFilters"/>
|
||||||
|
<value type="int" key="AutoTest.RunAfterBuild">0</value>
|
||||||
|
<value type="bool" key="AutoTest.UseGlobal">true</value>
|
||||||
|
<valuemap type="QVariantMap" key="ClangTools">
|
||||||
|
<value type="bool" key="ClangTools.AnalyzeOpenFiles">true</value>
|
||||||
|
<value type="bool" key="ClangTools.BuildBeforeAnalysis">true</value>
|
||||||
|
<value type="QString" key="ClangTools.DiagnosticConfig">Builtin.DefaultTidyAndClazy</value>
|
||||||
|
<value type="int" key="ClangTools.ParallelJobs">8</value>
|
||||||
|
<value type="bool" key="ClangTools.PreferConfigFile">true</value>
|
||||||
|
<valuelist type="QVariantList" key="ClangTools.SelectedDirs"/>
|
||||||
|
<valuelist type="QVariantList" key="ClangTools.SelectedFiles"/>
|
||||||
|
<valuelist type="QVariantList" key="ClangTools.SuppressedDiagnostics"/>
|
||||||
|
<value type="bool" key="ClangTools.UseGlobalSettings">true</value>
|
||||||
|
</valuemap>
|
||||||
|
</valuemap>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.Target.0</variable>
|
||||||
|
<valuemap type="QVariantMap">
|
||||||
|
<value type="QString" key="DeviceType">Desktop</value>
|
||||||
|
<value type="bool" key="HasPerBcDcs">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Importiertes Kit</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Importiertes Kit</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">{78ff90a3-f672-45c2-ad08-343b0923896f}</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveBuildConfiguration">0</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveDeployConfiguration">0</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveRunConfiguration">0</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.BuildConfiguration.0">
|
||||||
|
<value type="QString" key="CMake.Build.Type">Debug</value>
|
||||||
|
<value type="int" key="CMake.Configure.BaseEnvironment">2</value>
|
||||||
|
<value type="bool" key="CMake.Configure.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMake.Configure.UserEnvironmentChanges"/>
|
||||||
|
<value type="QString" key="CMake.Initial.Parameters">-DCMAKE_CXX_COMPILER:FILEPATH=%{Compiler:Executable:Cxx}
|
||||||
|
-DCMAKE_COLOR_DIAGNOSTICS:BOOL=ON
|
||||||
|
-DCMAKE_C_COMPILER:FILEPATH=%{Compiler:Executable:C}
|
||||||
|
-DCMAKE_PROJECT_INCLUDE_BEFORE:FILEPATH=%{BuildConfig:BuildDirectory:NativeFilePath}/.qtc/package-manager/auto-setup.cmake
|
||||||
|
-DCMAKE_PREFIX_PATH:PATH=%{Qt:QT_INSTALL_PREFIX}
|
||||||
|
-DCMAKE_GENERATOR:STRING=Unix Makefiles
|
||||||
|
-DCMAKE_BUILD_TYPE:STRING=Release
|
||||||
|
-DQT_QMAKE_EXECUTABLE:FILEPATH=%{Qt:qmakeExecutable}</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.BuildConfiguration.BuildDirectory">/home/torsten/Programs/yourpart-daemon/build/</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">all</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.MakeStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">1</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Build</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.1">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">clean</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.MakeStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">1</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Bereinigen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Bereinigen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Clean</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">2</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildConfiguration.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.BuildConfiguration.CustomParsers"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildConfiguration.ParseStandardOutput">false</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.BuildConfiguration.UserEnvironmentChanges"/>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Release</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.CMakeBuildConfiguration</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveDeployConfiguration">0</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveRunConfiguration">0</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">0</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.DefaultDeployConfiguration</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.1">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString"></value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.CMakePackageStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.1">
|
||||||
|
<value type="QString" key="ApplicationManagerPlugin.Deploy.InstallPackageStep.Arguments">install-package --acknowledge</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Application Manager-Paket installieren</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.InstallPackageStep</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedFiles"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedHosts"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedRemotePaths"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedSysroots"/>
|
||||||
|
<valuelist type="QVariantList" key="RemoteLinux.LastDeployedLocalTimes"/>
|
||||||
|
<valuelist type="QVariantList" key="RemoteLinux.LastDeployedRemoteTimes"/>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">2</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.Configuration</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.DeployConfigurationCount">2</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.RunConfiguration.0">
|
||||||
|
<value type="bool" key="Analyzer.Perf.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="bool" key="Analyzer.QmlProfiler.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="int" key="Analyzer.Valgrind.Callgrind.CostFormat">0</value>
|
||||||
|
<value type="bool" key="Analyzer.Valgrind.Settings.UseGlobalSettings">true</value>
|
||||||
|
<valuelist type="QVariantList" key="CustomOutputParsers"/>
|
||||||
|
<value type="int" key="PE.EnvironmentAspect.Base">2</value>
|
||||||
|
<valuelist type="QVariantList" key="PE.EnvironmentAspect.Changes"/>
|
||||||
|
<value type="bool" key="PE.EnvironmentAspect.PrintOnRun">false</value>
|
||||||
|
<value type="QString" key="PerfRecordArgsId">-e cpu-cycles --call-graph dwarf,4096 -F 250</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">yourpart-daemon</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.CMakeRunConfiguration.</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">yourpart-daemon</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseLibrarySearchPath">true</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseQmlDebuggerAuto">true</value>
|
||||||
|
<value type="QString" key="RunConfiguration.WorkingDirectory.default">/home/torsten/Programs/yourpart-daemon/build</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.RunConfigurationCount">1</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.BuildConfiguration.1">
|
||||||
|
<value type="QString" key="CMake.Build.Type">Debug</value>
|
||||||
|
<value type="int" key="CMake.Configure.BaseEnvironment">2</value>
|
||||||
|
<value type="bool" key="CMake.Configure.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMake.Configure.UserEnvironmentChanges"/>
|
||||||
|
<value type="QString" key="CMake.Initial.Parameters">-DCMAKE_CXX_COMPILER:FILEPATH=%{Compiler:Executable:Cxx}
|
||||||
|
-DCMAKE_COLOR_DIAGNOSTICS:BOOL=ON
|
||||||
|
-DCMAKE_C_COMPILER:FILEPATH=%{Compiler:Executable:C}
|
||||||
|
-DCMAKE_PROJECT_INCLUDE_BEFORE:FILEPATH=%{BuildConfig:BuildDirectory:NativeFilePath}/.qtc/package-manager/auto-setup.cmake
|
||||||
|
-DCMAKE_PREFIX_PATH:PATH=%{Qt:QT_INSTALL_PREFIX}
|
||||||
|
-DCMAKE_GENERATOR:STRING=Unix Makefiles
|
||||||
|
-DCMAKE_BUILD_TYPE:STRING=Debug
|
||||||
|
-DQT_QMAKE_EXECUTABLE:FILEPATH=%{Qt:qmakeExecutable}</value>
|
||||||
|
<value type="QString" key="CMake.Source.Directory">/mnt/share/torsten/Programs/yourpart-daemon</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.BuildConfiguration.BuildDirectory">/home/torsten/Programs/yourpart-daemon/build</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">all</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.MakeStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">1</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Build</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.1">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">clean</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.MakeStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">1</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Bereinigen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Bereinigen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Clean</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">2</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildConfiguration.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.BuildConfiguration.CustomParsers"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildConfiguration.ParseStandardOutput">false</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.BuildConfiguration.UserEnvironmentChanges"/>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Debug (importiert)</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.CMakeBuildConfiguration</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveDeployConfiguration">0</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveRunConfiguration">-1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">0</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.DefaultDeployConfiguration</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.1">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">install</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.CMakePackageStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.1">
|
||||||
|
<value type="QString" key="ApplicationManagerPlugin.Deploy.InstallPackageStep.Arguments">install-package --acknowledge</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Application Manager-Paket installieren</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.InstallPackageStep</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedFiles"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedHosts"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedRemotePaths"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedSysroots"/>
|
||||||
|
<valuelist type="QVariantList" key="RemoteLinux.LastDeployedLocalTimes"/>
|
||||||
|
<valuelist type="QVariantList" key="RemoteLinux.LastDeployedRemoteTimes"/>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">2</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.Configuration</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.DeployConfigurationCount">2</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.RunConfigurationCount">0</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.BuildConfigurationCount">2</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">0</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.DefaultDeployConfiguration</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.1">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString"></value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.CMakePackageStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.1">
|
||||||
|
<value type="QString" key="ApplicationManagerPlugin.Deploy.InstallPackageStep.Arguments">install-package --acknowledge</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Application Manager-Paket installieren</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.InstallPackageStep</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedFiles"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedHosts"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedRemotePaths"/>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.RunConfiguration.LastDeployedSysroots"/>
|
||||||
|
<valuelist type="QVariantList" key="RemoteLinux.LastDeployedLocalTimes"/>
|
||||||
|
<valuelist type="QVariantList" key="RemoteLinux.LastDeployedRemoteTimes"/>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">2</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ApplicationManagerPlugin.Deploy.Configuration</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.DeployConfigurationCount">2</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.RunConfiguration.0">
|
||||||
|
<value type="bool" key="Analyzer.Perf.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="bool" key="Analyzer.QmlProfiler.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="int" key="Analyzer.Valgrind.Callgrind.CostFormat">0</value>
|
||||||
|
<value type="bool" key="Analyzer.Valgrind.Settings.UseGlobalSettings">true</value>
|
||||||
|
<valuelist type="QVariantList" key="CustomOutputParsers"/>
|
||||||
|
<value type="int" key="PE.EnvironmentAspect.Base">2</value>
|
||||||
|
<valuelist type="QVariantList" key="PE.EnvironmentAspect.Changes"/>
|
||||||
|
<value type="bool" key="PE.EnvironmentAspect.PrintOnRun">false</value>
|
||||||
|
<value type="QString" key="PerfRecordArgsId">-e cpu-cycles --call-graph dwarf,4096 -F 250</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">yourpart-daemon</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.CMakeRunConfiguration.</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">yourpart-daemon</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseLibrarySearchPath">true</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseQmlDebuggerAuto">true</value>
|
||||||
|
<value type="QString" key="RunConfiguration.WorkingDirectory.default">/home/torsten/Programs/yourpart-daemon/build</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.RunConfigurationCount">1</value>
|
||||||
|
</valuemap>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.TargetCount</variable>
|
||||||
|
<value type="qlonglong">1</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.Updater.FileVersion</variable>
|
||||||
|
<value type="int">22</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>Version</variable>
|
||||||
|
<value type="int">22</value>
|
||||||
|
</data>
|
||||||
|
</qtcreator>
|
||||||
205
CMakeLists.txt.user.d36652f
Normal file
205
CMakeLists.txt.user.d36652f
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE QtCreatorProject>
|
||||||
|
<!-- Written by QtCreator 12.0.2, 2025-07-18T07:45:58. -->
|
||||||
|
<qtcreator>
|
||||||
|
<data>
|
||||||
|
<variable>EnvironmentId</variable>
|
||||||
|
<value type="QByteArray">{d36652ff-969b-426b-a63f-1edd325096c5}</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.ActiveTarget</variable>
|
||||||
|
<value type="qlonglong">0</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.EditorSettings</variable>
|
||||||
|
<valuemap type="QVariantMap">
|
||||||
|
<value type="bool" key="EditorConfiguration.AutoIndent">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.AutoSpacesForTabs">false</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.CamelCaseNavigation">true</value>
|
||||||
|
<valuemap type="QVariantMap" key="EditorConfiguration.CodeStyle.0">
|
||||||
|
<value type="QString" key="language">Cpp</value>
|
||||||
|
<valuemap type="QVariantMap" key="value">
|
||||||
|
<value type="QByteArray" key="CurrentPreferences">CppGlobal</value>
|
||||||
|
</valuemap>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="EditorConfiguration.CodeStyle.1">
|
||||||
|
<value type="QString" key="language">QmlJS</value>
|
||||||
|
<valuemap type="QVariantMap" key="value">
|
||||||
|
<value type="QByteArray" key="CurrentPreferences">QmlJSGlobal</value>
|
||||||
|
</valuemap>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="EditorConfiguration.CodeStyle.Count">2</value>
|
||||||
|
<value type="QByteArray" key="EditorConfiguration.Codec">UTF-8</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.ConstrainTooltips">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.IndentSize">4</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.KeyboardTooltips">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.MarginColumn">80</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.MouseHiding">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.MouseNavigation">true</value>
|
||||||
|
<value type="int" key="EditorConfiguration.PaddingMode">1</value>
|
||||||
|
<value type="int" key="EditorConfiguration.PreferAfterWhitespaceComments">0</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.PreferSingleLineComments">false</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.ScrollWheelZooming">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.ShowMargin">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.SmartBackspaceBehavior">0</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.SmartSelectionChanging">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.SpacesForTabs">true</value>
|
||||||
|
<value type="int" key="EditorConfiguration.TabKeyBehavior">0</value>
|
||||||
|
<value type="int" key="EditorConfiguration.TabSize">8</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.UseGlobal">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.UseIndenter">false</value>
|
||||||
|
<value type="int" key="EditorConfiguration.Utf8BomBehavior">1</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.addFinalNewLine">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.cleanIndentation">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.cleanWhitespace">true</value>
|
||||||
|
<value type="QString" key="EditorConfiguration.ignoreFileTypes">*.md, *.MD, Makefile</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.inEntireDocument">false</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.skipTrailingWhitespace">true</value>
|
||||||
|
<value type="bool" key="EditorConfiguration.tintMarginArea">true</value>
|
||||||
|
</valuemap>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.PluginSettings</variable>
|
||||||
|
<valuemap type="QVariantMap">
|
||||||
|
<valuemap type="QVariantMap" key="AutoTest.ActiveFrameworks">
|
||||||
|
<value type="bool" key="AutoTest.Framework.Boost">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.CTest">false</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.Catch">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.GTest">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.QtQuickTest">true</value>
|
||||||
|
<value type="bool" key="AutoTest.Framework.QtTest">true</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="AutoTest.CheckStates"/>
|
||||||
|
<value type="int" key="AutoTest.RunAfterBuild">0</value>
|
||||||
|
<value type="bool" key="AutoTest.UseGlobal">true</value>
|
||||||
|
<valuemap type="QVariantMap" key="ClangTools">
|
||||||
|
<value type="bool" key="ClangTools.AnalyzeOpenFiles">true</value>
|
||||||
|
<value type="bool" key="ClangTools.BuildBeforeAnalysis">true</value>
|
||||||
|
<value type="QString" key="ClangTools.DiagnosticConfig">Builtin.DefaultTidyAndClazy</value>
|
||||||
|
<value type="int" key="ClangTools.ParallelJobs">8</value>
|
||||||
|
<value type="bool" key="ClangTools.PreferConfigFile">true</value>
|
||||||
|
<valuelist type="QVariantList" key="ClangTools.SelectedDirs"/>
|
||||||
|
<valuelist type="QVariantList" key="ClangTools.SelectedFiles"/>
|
||||||
|
<valuelist type="QVariantList" key="ClangTools.SuppressedDiagnostics"/>
|
||||||
|
<value type="bool" key="ClangTools.UseGlobalSettings">true</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="CppEditor.QuickFix">
|
||||||
|
<value type="bool" key="UseGlobalSettings">true</value>
|
||||||
|
</valuemap>
|
||||||
|
</valuemap>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.Target.0</variable>
|
||||||
|
<valuemap type="QVariantMap">
|
||||||
|
<value type="QString" key="DeviceType">Desktop</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Importiertes Kit</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Importiertes Kit</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">{3c6cfc13-714d-4db1-bd45-b9794643cc67}</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveBuildConfiguration">0</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveDeployConfiguration">0</value>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.ActiveRunConfiguration">0</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.BuildConfiguration.0">
|
||||||
|
<value type="QString" key="CMake.Build.Type">Debug</value>
|
||||||
|
<value type="int" key="CMake.Configure.BaseEnvironment">2</value>
|
||||||
|
<value type="bool" key="CMake.Configure.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMake.Configure.UserEnvironmentChanges"/>
|
||||||
|
<value type="QString" key="CMake.Initial.Parameters">-DCMAKE_GENERATOR:STRING=Unix Makefiles
|
||||||
|
-DCMAKE_BUILD_TYPE:STRING=Build
|
||||||
|
-DCMAKE_PROJECT_INCLUDE_BEFORE:FILEPATH=%{BuildConfig:BuildDirectory:NativeFilePath}/.qtc/package-manager/auto-setup.cmake
|
||||||
|
-DQT_QMAKE_EXECUTABLE:FILEPATH=%{Qt:qmakeExecutable}
|
||||||
|
-DCMAKE_PREFIX_PATH:PATH=%{Qt:QT_INSTALL_PREFIX}
|
||||||
|
-DCMAKE_C_COMPILER:FILEPATH=%{Compiler:Executable:C}
|
||||||
|
-DCMAKE_CXX_COMPILER:FILEPATH=%{Compiler:Executable:Cxx}</value>
|
||||||
|
<value type="QString" key="CMake.Source.Directory">/home/torsten/Programs/yourpart-daemon</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.BuildConfiguration.BuildDirectory">/home/torsten/Programs/yourpart-daemon/build</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">all</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.MakeStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">1</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Build</value>
|
||||||
|
</valuemap>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.1">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildStepList.Step.0">
|
||||||
|
<value type="QString" key="CMakeProjectManager.MakeStep.BuildPreset"></value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.BuildTargets">
|
||||||
|
<value type="QString">clean</value>
|
||||||
|
</valuelist>
|
||||||
|
<value type="bool" key="CMakeProjectManager.MakeStep.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="CMakeProjectManager.MakeStep.UserEnvironmentChanges"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildStep.Enabled">true</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.MakeStep</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">1</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Bereinigen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Bereinigen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Clean</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">2</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildConfiguration.ClearSystemEnvironment">false</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.BuildConfiguration.CustomParsers"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.BuildConfiguration.ParseStandardOutput">false</value>
|
||||||
|
<valuelist type="QVariantList" key="ProjectExplorer.BuildConfiguration.UserEnvironmentChanges"/>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Erstellen</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.CMakeBuildConfiguration</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.BuildConfigurationCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.DeployConfiguration.0">
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.BuildConfiguration.BuildStepList.0">
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.BuildStepList.StepsCount">0</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DefaultDisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">Deployment</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.BuildSteps.Deploy</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="int" key="ProjectExplorer.BuildConfiguration.BuildStepListCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.DeployConfiguration.CustomData"/>
|
||||||
|
<value type="bool" key="ProjectExplorer.DeployConfiguration.CustomDataEnabled">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">ProjectExplorer.DefaultDeployConfiguration</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.DeployConfigurationCount">1</value>
|
||||||
|
<valuemap type="QVariantMap" key="ProjectExplorer.Target.RunConfiguration.0">
|
||||||
|
<value type="bool" key="Analyzer.Perf.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="bool" key="Analyzer.QmlProfiler.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="int" key="Analyzer.Valgrind.Callgrind.CostFormat">0</value>
|
||||||
|
<value type="bool" key="Analyzer.Valgrind.Settings.UseGlobalSettings">true</value>
|
||||||
|
<value type="QString" key="Analyzer.Valgrind.ValgrindExecutable">/usr/bin/valgrind</value>
|
||||||
|
<valuelist type="QVariantList" key="CustomOutputParsers"/>
|
||||||
|
<value type="int" key="PE.EnvironmentAspect.Base">2</value>
|
||||||
|
<valuelist type="QVariantList" key="PE.EnvironmentAspect.Changes"/>
|
||||||
|
<value type="bool" key="PE.EnvironmentAspect.PrintOnRun">false</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.DisplayName">yourpart-daemon</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.ProjectConfiguration.Id">CMakeProjectManager.CMakeRunConfiguration.yourpart-daemon</value>
|
||||||
|
<value type="QString" key="ProjectExplorer.RunConfiguration.BuildKey">yourpart-daemon</value>
|
||||||
|
<value type="bool" key="ProjectExplorer.RunConfiguration.Customized">false</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseCppDebuggerAuto">true</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseLibrarySearchPath">true</value>
|
||||||
|
<value type="bool" key="RunConfiguration.UseQmlDebuggerAuto">true</value>
|
||||||
|
<value type="QString" key="RunConfiguration.WorkingDirectory.default">/home/torsten/Programs/yourpart-daemon/build</value>
|
||||||
|
</valuemap>
|
||||||
|
<value type="qlonglong" key="ProjectExplorer.Target.RunConfigurationCount">1</value>
|
||||||
|
</valuemap>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.TargetCount</variable>
|
||||||
|
<value type="qlonglong">1</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>ProjectExplorer.Project.Updater.FileVersion</variable>
|
||||||
|
<value type="int">22</value>
|
||||||
|
</data>
|
||||||
|
<data>
|
||||||
|
<variable>Version</variable>
|
||||||
|
<value type="int">22</value>
|
||||||
|
</data>
|
||||||
|
</qtcreator>
|
||||||
168
SSL-SETUP.md
Normal file
168
SSL-SETUP.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# SSL/TLS Setup für YourPart Daemon
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt, wie Sie SSL/TLS-Zertifikate für den YourPart Daemon einrichten können.
|
||||||
|
|
||||||
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
|
### 1. Self-Signed Certificate (Entwicklung/Testing)
|
||||||
|
```bash
|
||||||
|
./setup-ssl.sh
|
||||||
|
# Wählen Sie Option 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Let's Encrypt Certificate (Produktion)
|
||||||
|
```bash
|
||||||
|
./setup-ssl.sh
|
||||||
|
# Wählen Sie Option 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apache2-Zertifikate verwenden (empfohlen für Ubuntu)
|
||||||
|
```bash
|
||||||
|
./setup-ssl.sh
|
||||||
|
# Wählen Sie Option 4
|
||||||
|
# Verwendet bereits vorhandene Apache2-Zertifikate
|
||||||
|
# ⚠️ Warnung bei Snakeoil-Zertifikaten (nur für localhost)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. DNS-01 Challenge (für komplexe Setups)
|
||||||
|
```bash
|
||||||
|
./setup-ssl-dns.sh
|
||||||
|
# Für Cloudflare, Route53, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
|
### Für Apache2-Zertifikate:
|
||||||
|
- Apache2 installiert oder Zertifikate in Standard-Pfaden
|
||||||
|
- Unterstützte Pfade (priorisiert nach Qualität):
|
||||||
|
- `/etc/letsencrypt/live/your-part.de/fullchain.pem` (Let's Encrypt - empfohlen)
|
||||||
|
- `/etc/letsencrypt/live/$(hostname)/fullchain.pem` (Let's Encrypt)
|
||||||
|
- `/etc/apache2/ssl/apache.crt` (Custom Apache2)
|
||||||
|
- `/etc/ssl/certs/ssl-cert-snakeoil.pem` (Ubuntu Standard - nur localhost)
|
||||||
|
|
||||||
|
### Für Let's Encrypt (HTTP-01 Challenge):
|
||||||
|
- Port 80 muss verfügbar sein
|
||||||
|
- Domain `your-part.de` muss auf den Server zeigen
|
||||||
|
- Kein anderer Service auf Port 80
|
||||||
|
|
||||||
|
### Für DNS-01 Challenge:
|
||||||
|
- DNS-Provider Account (Cloudflare, Route53, etc.)
|
||||||
|
- API-Credentials für DNS-Management
|
||||||
|
|
||||||
|
## 🔧 Konfiguration
|
||||||
|
|
||||||
|
Nach der Zertifikats-Erstellung:
|
||||||
|
|
||||||
|
1. **SSL in der Konfiguration aktivieren:**
|
||||||
|
```ini
|
||||||
|
# /etc/yourpart/daemon.conf
|
||||||
|
WEBSOCKET_SSL_ENABLED=true
|
||||||
|
WEBSOCKET_SSL_CERT_PATH=/etc/yourpart/server.crt
|
||||||
|
WEBSOCKET_SSL_KEY_PATH=/etc/yourpart/server.key
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Daemon neu starten:**
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart yourpart-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verbindung testen:**
|
||||||
|
```bash
|
||||||
|
# WebSocket Secure
|
||||||
|
wss://your-part.de:4551
|
||||||
|
|
||||||
|
# Oder ohne SSL
|
||||||
|
ws://your-part.de:4551
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Automatische Erneuerung
|
||||||
|
|
||||||
|
### Let's Encrypt-Zertifikate:
|
||||||
|
- **Cron Job:** Täglich um 2:30 Uhr
|
||||||
|
- **Script:** `/etc/yourpart/renew-ssl.sh`
|
||||||
|
- **Log:** `/var/log/yourpart/ssl-renewal.log`
|
||||||
|
|
||||||
|
### Apache2-Zertifikate:
|
||||||
|
- **Ubuntu Snakeoil:** Automatisch von Apache2 verwaltet
|
||||||
|
- **Let's Encrypt:** Automatische Erneuerung wenn erkannt
|
||||||
|
- **Custom:** Manuelle Verwaltung erforderlich
|
||||||
|
|
||||||
|
## 📁 Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/yourpart/
|
||||||
|
├── server.crt # Zertifikat (Symlink zu Let's Encrypt)
|
||||||
|
├── server.key # Private Key (Symlink zu Let's Encrypt)
|
||||||
|
├── renew-ssl.sh # Auto-Renewal Script
|
||||||
|
└── cloudflare.ini # Cloudflare Credentials (falls verwendet)
|
||||||
|
|
||||||
|
/etc/letsencrypt/live/your-part.de/
|
||||||
|
├── fullchain.pem # Vollständige Zertifikatskette
|
||||||
|
├── privkey.pem # Private Key
|
||||||
|
├── cert.pem # Zertifikat
|
||||||
|
└── chain.pem # Intermediate Certificate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
|
### Zertifikat wird nicht akzeptiert
|
||||||
|
```bash
|
||||||
|
# Prüfe Zertifikats-Gültigkeit
|
||||||
|
openssl x509 -in /etc/yourpart/server.crt -text -noout
|
||||||
|
|
||||||
|
# Prüfe Berechtigungen
|
||||||
|
ls -la /etc/yourpart/server.*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Let's Encrypt Challenge fehlgeschlagen
|
||||||
|
```bash
|
||||||
|
# Prüfe Port 80
|
||||||
|
sudo netstat -tlnp | grep :80
|
||||||
|
|
||||||
|
# Prüfe DNS
|
||||||
|
nslookup your-part.de
|
||||||
|
|
||||||
|
# Prüfe Firewall
|
||||||
|
sudo ufw status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Renewal funktioniert nicht
|
||||||
|
```bash
|
||||||
|
# Prüfe Cron Jobs
|
||||||
|
sudo crontab -l
|
||||||
|
|
||||||
|
# Teste Renewal Script
|
||||||
|
sudo /etc/yourpart/renew-ssl.sh
|
||||||
|
|
||||||
|
# Prüfe Logs
|
||||||
|
tail -f /var/log/yourpart/ssl-renewal.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Sicherheit
|
||||||
|
|
||||||
|
### Berechtigungen
|
||||||
|
- **Zertifikat:** `644` (readable by all, writable by owner)
|
||||||
|
- **Private Key:** `600` (readable/writable by owner only)
|
||||||
|
- **Owner:** `yourpart:yourpart`
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
```bash
|
||||||
|
# Öffne Port 80 für Let's Encrypt Challenge
|
||||||
|
sudo ufw allow 80/tcp
|
||||||
|
|
||||||
|
# Öffne Port 4551 für WebSocket
|
||||||
|
sudo ufw allow 4551/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Weitere Informationen
|
||||||
|
|
||||||
|
- [Let's Encrypt Dokumentation](https://letsencrypt.org/docs/)
|
||||||
|
- [Certbot Dokumentation](https://certbot.eff.org/docs/)
|
||||||
|
- [libwebsockets SSL](https://libwebsockets.org/lws-api-doc-master/html/group__ssl.html)
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. Prüfen Sie die Logs: `sudo journalctl -u yourpart-daemon -f`
|
||||||
|
2. Testen Sie die Zertifikate: `openssl s_client -connect your-part.de:4551`
|
||||||
|
3. Prüfen Sie die Firewall: `sudo ufw status`
|
||||||
184
backend/analyze-indexes.js
Executable file
184
backend/analyze-indexes.js
Executable file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script zur Analyse und Empfehlung von Indizes
|
||||||
|
*
|
||||||
|
* Analysiert:
|
||||||
|
* - Tabellen mit vielen Sequential Scans
|
||||||
|
* - Fehlende Composite Indizes für häufige JOINs
|
||||||
|
* - Ungenutzte Indizes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Index-Analyse und Empfehlungen\n');
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// 1. Tabellen mit vielen Sequential Scans
|
||||||
|
await analyzeSequentialScans();
|
||||||
|
|
||||||
|
// 2. Prüfe häufige JOIN-Patterns
|
||||||
|
await analyzeJoinPatterns();
|
||||||
|
|
||||||
|
// 3. Ungenutzte Indizes
|
||||||
|
await analyzeUnusedIndexes();
|
||||||
|
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('✅ Analyse abgeschlossen\n');
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeSequentialScans() {
|
||||||
|
console.log('📊 1. Tabellen mit vielen Sequential Scans\n');
|
||||||
|
|
||||||
|
const [tables] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
seq_scan,
|
||||||
|
seq_tup_read,
|
||||||
|
idx_scan,
|
||||||
|
seq_tup_read / NULLIF(seq_scan, 0) as avg_rows_per_scan,
|
||||||
|
CASE
|
||||||
|
WHEN seq_scan + idx_scan > 0
|
||||||
|
THEN round((seq_scan::numeric / (seq_scan + idx_scan)) * 100, 2)
|
||||||
|
ELSE 0
|
||||||
|
END as seq_scan_percent
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND seq_scan > 1000
|
||||||
|
ORDER BY seq_tup_read DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tables.length > 0) {
|
||||||
|
console.log(' ⚠️ Tabellen mit vielen Sequential Scans:');
|
||||||
|
tables.forEach(t => {
|
||||||
|
console.log(`\n ${t.table_name}:`);
|
||||||
|
console.log(` Sequential Scans: ${parseInt(t.seq_scan).toLocaleString()}`);
|
||||||
|
console.log(` Zeilen gelesen: ${parseInt(t.seq_tup_read).toLocaleString()}`);
|
||||||
|
console.log(` Index Scans: ${parseInt(t.idx_scan).toLocaleString()}`);
|
||||||
|
console.log(` Seq Scan Anteil: ${t.seq_scan_percent}%`);
|
||||||
|
console.log(` Ø Zeilen pro Scan: ${parseInt(t.avg_rows_per_scan).toLocaleString()}`);
|
||||||
|
|
||||||
|
if (t.seq_scan_percent > 50) {
|
||||||
|
console.log(` ⚠️ KRITISCH: Mehr als 50% Sequential Scans!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeJoinPatterns() {
|
||||||
|
console.log('🔗 2. Analyse häufiger JOIN-Patterns\n');
|
||||||
|
|
||||||
|
// Prüfe welche Indizes auf knowledge existieren
|
||||||
|
const [knowledgeIndexes] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
indexname,
|
||||||
|
indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'falukant_data'
|
||||||
|
AND tablename = 'knowledge'
|
||||||
|
ORDER BY indexname;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' Indizes auf falukant_data.knowledge:');
|
||||||
|
if (knowledgeIndexes.length > 0) {
|
||||||
|
knowledgeIndexes.forEach(idx => {
|
||||||
|
console.log(` - ${idx.indexname}: ${idx.indexdef}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' Keine Indizes gefunden');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Empfehlung: Composite Index auf (character_id, product_id)
|
||||||
|
const [knowledgeUsage] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
idx_scan,
|
||||||
|
idx_tup_read,
|
||||||
|
idx_tup_fetch
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE schemaname = 'falukant_data'
|
||||||
|
AND relname = 'knowledge'
|
||||||
|
AND indexrelname = 'idx_knowledge_character_id';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (knowledgeUsage.length > 0) {
|
||||||
|
const usage = knowledgeUsage[0];
|
||||||
|
console.log(' Aktuelle Nutzung von idx_knowledge_character_id:');
|
||||||
|
console.log(` Scans: ${parseInt(usage.idx_scan).toLocaleString()}`);
|
||||||
|
console.log(` Zeilen gelesen: ${parseInt(usage.idx_tup_read).toLocaleString()}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(' 💡 Empfehlung:');
|
||||||
|
console.log(' CREATE INDEX IF NOT EXISTS idx_knowledge_character_product');
|
||||||
|
console.log(' ON falukant_data.knowledge(character_id, product_id);');
|
||||||
|
console.log(' → Wird häufig für JOINs mit character_id UND product_id verwendet\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe character Indizes
|
||||||
|
const [characterIndexes] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
indexname,
|
||||||
|
indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'falukant_data'
|
||||||
|
AND tablename = 'character'
|
||||||
|
ORDER BY indexname;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' Indizes auf falukant_data.character:');
|
||||||
|
if (characterIndexes.length > 0) {
|
||||||
|
characterIndexes.forEach(idx => {
|
||||||
|
console.log(` - ${idx.indexname}: ${idx.indexdef}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeUnusedIndexes() {
|
||||||
|
console.log('🗑️ 3. Ungenutzte Indizes\n');
|
||||||
|
|
||||||
|
const [unused] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || indexrelname as index_name,
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
|
||||||
|
idx_scan as scans,
|
||||||
|
pg_relation_size(indexrelid) as size_bytes
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND idx_scan = 0
|
||||||
|
AND pg_relation_size(indexrelid) > 1024 * 1024 -- Größer als 1MB
|
||||||
|
ORDER BY pg_relation_size(indexrelid) DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (unused.length > 0) {
|
||||||
|
console.log(' ⚠️ Ungenutzte Indizes (> 1MB):');
|
||||||
|
unused.forEach(idx => {
|
||||||
|
console.log(` ${idx.index_name} auf ${idx.table_name}`);
|
||||||
|
console.log(` Größe: ${idx.index_size}, Scans: ${idx.scans}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
console.log(' 💡 Überlege, ob diese Indizes gelöscht werden können:');
|
||||||
|
console.log(' DROP INDEX IF EXISTS <index_name>;');
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log(' ✅ Keine großen ungenutzten Indizes gefunden\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -12,6 +12,7 @@ import socialnetworkRouter from './routers/socialnetworkRouter.js';
|
|||||||
import forumRouter from './routers/forumRouter.js';
|
import forumRouter from './routers/forumRouter.js';
|
||||||
import falukantRouter from './routers/falukantRouter.js';
|
import falukantRouter from './routers/falukantRouter.js';
|
||||||
import friendshipRouter from './routers/friendshipRouter.js';
|
import friendshipRouter from './routers/friendshipRouter.js';
|
||||||
|
import modelsProxyRouter from './routers/modelsProxyRouter.js';
|
||||||
import blogRouter from './routers/blogRouter.js';
|
import blogRouter from './routers/blogRouter.js';
|
||||||
import match3Router from './routers/match3Router.js';
|
import match3Router from './routers/match3Router.js';
|
||||||
import taxiRouter from './routers/taxiRouter.js';
|
import taxiRouter from './routers/taxiRouter.js';
|
||||||
@@ -19,6 +20,9 @@ import taxiMapRouter from './routers/taxiMapRouter.js';
|
|||||||
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
||||||
import termineRouter from './routers/termineRouter.js';
|
import termineRouter from './routers/termineRouter.js';
|
||||||
import vocabRouter from './routers/vocabRouter.js';
|
import vocabRouter from './routers/vocabRouter.js';
|
||||||
|
import dashboardRouter from './routers/dashboardRouter.js';
|
||||||
|
import newsRouter from './routers/newsRouter.js';
|
||||||
|
import calendarRouter from './routers/calendarRouter.js';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import './jobs/sessionCleanup.js';
|
import './jobs/sessionCleanup.js';
|
||||||
|
|
||||||
@@ -74,11 +78,22 @@ app.use('/api/vocab', vocabRouter);
|
|||||||
app.use('/api/forum', forumRouter);
|
app.use('/api/forum', forumRouter);
|
||||||
app.use('/api/falukant', falukantRouter);
|
app.use('/api/falukant', falukantRouter);
|
||||||
app.use('/api/friendships', friendshipRouter);
|
app.use('/api/friendships', friendshipRouter);
|
||||||
|
app.use('/api/models', modelsProxyRouter);
|
||||||
app.use('/api/blog', blogRouter);
|
app.use('/api/blog', blogRouter);
|
||||||
app.use('/api/termine', termineRouter);
|
app.use('/api/termine', termineRouter);
|
||||||
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
|
app.use('/api/news', newsRouter);
|
||||||
|
app.use('/api/calendar', calendarRouter);
|
||||||
|
|
||||||
// Serve frontend SPA for non-API routes to support history mode clean URLs
|
// Serve frontend SPA for non-API routes to support history mode clean URLs
|
||||||
|
// /models/* nicht statisch ausliefern – nur über /api/models (Proxy mit Komprimierung)
|
||||||
const frontendDir = path.join(__dirname, '../frontend');
|
const frontendDir = path.join(__dirname, '../frontend');
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith('/models/')) {
|
||||||
|
return res.status(404).send('Use /api/models/ for 3D models (optimized).');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use(express.static(path.join(frontendDir, 'dist')));
|
app.use(express.static(path.join(frontendDir, 'dist')));
|
||||||
app.get(/^\/(?!api\/).*/, (req, res) => {
|
app.get(/^\/(?!api\/).*/, (req, res) => {
|
||||||
res.sendFile(path.join(frontendDir, 'dist', 'index.html'));
|
res.sendFile(path.join(frontendDir, 'dist', 'index.html'));
|
||||||
|
|||||||
86
backend/check-connections.js
Normal file
86
backend/check-connections.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script zum Prüfen und Bereinigen von PostgreSQL-Verbindungen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Prüfe PostgreSQL-Verbindungen...\n');
|
||||||
|
|
||||||
|
// Prüfe aktive Verbindungen
|
||||||
|
const [connections] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
count(*) as total,
|
||||||
|
count(*) FILTER (WHERE state = 'active') as active,
|
||||||
|
count(*) FILTER (WHERE state = 'idle') as idle,
|
||||||
|
count(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction,
|
||||||
|
count(*) FILTER (WHERE usename = current_user) as my_connections
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database();
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📊 Verbindungsstatistik:');
|
||||||
|
console.log(` Gesamt: ${connections[0].total}`);
|
||||||
|
console.log(` Aktiv: ${connections[0].active}`);
|
||||||
|
console.log(` Idle: ${connections[0].idle}`);
|
||||||
|
console.log(` Idle in Transaction: ${connections[0].idle_in_transaction}`);
|
||||||
|
console.log(` Meine Verbindungen: ${connections[0].my_connections}\n`);
|
||||||
|
|
||||||
|
// Prüfe max_connections Limit
|
||||||
|
const [maxConn] = await sequelize.query(`
|
||||||
|
SELECT setting::int as max_connections
|
||||||
|
FROM pg_settings
|
||||||
|
WHERE name = 'max_connections';
|
||||||
|
`);
|
||||||
|
console.log(`📈 Max Connections Limit: ${maxConn[0].max_connections}`);
|
||||||
|
console.log(`📉 Verfügbare Connections: ${maxConn[0].max_connections - connections[0].total}\n`);
|
||||||
|
|
||||||
|
// Zeige alte idle Verbindungen
|
||||||
|
const [oldConnections] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
usename,
|
||||||
|
application_name,
|
||||||
|
state,
|
||||||
|
state_change,
|
||||||
|
now() - state_change as idle_duration,
|
||||||
|
query
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
AND state = 'idle'
|
||||||
|
AND state_change < now() - interval '1 minute'
|
||||||
|
ORDER BY state_change ASC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (oldConnections.length > 0) {
|
||||||
|
console.log(`⚠️ Gefunden ${oldConnections.length} alte idle Verbindungen (> 1 Minute):`);
|
||||||
|
oldConnections.forEach(conn => {
|
||||||
|
console.log(` PID: ${conn.pid}, User: ${conn.usename}, Idle seit: ${conn.idle_duration}`);
|
||||||
|
});
|
||||||
|
console.log('\n💡 Tipp: Du kannst alte Verbindungen beenden mit:');
|
||||||
|
console.log(' SELECT pg_terminate_backend(pid) FROM pg_stat_activity');
|
||||||
|
console.log(' WHERE datname = current_database() AND state = \'idle\' AND state_change < now() - interval \'5 minutes\';\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob wir nahe am Limit sind
|
||||||
|
const usagePercent = (connections[0].total / maxConn[0].max_connections) * 100;
|
||||||
|
if (usagePercent > 80) {
|
||||||
|
console.log(`⚠️ WARNUNG: ${usagePercent.toFixed(1)}% der verfügbaren Verbindungen werden verwendet!`);
|
||||||
|
console.log(' Es könnte sein, dass nicht genug Verbindungen verfügbar sind.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
142
backend/check-knowledge-pkey.js
Executable file
142
backend/check-knowledge-pkey.js
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script zur Analyse des knowledge_pkey Problems
|
||||||
|
*
|
||||||
|
* Prüft warum knowledge_pkey nicht verwendet wird
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Analyse knowledge_pkey Problem\n');
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// Prüfe ob knowledge einen Primary Key hat
|
||||||
|
const [pkInfo] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
a.attname as column_name,
|
||||||
|
t.conname as constraint_name,
|
||||||
|
t.contype as constraint_type
|
||||||
|
FROM pg_constraint t
|
||||||
|
JOIN pg_class c ON c.oid = t.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(t.conkey)
|
||||||
|
WHERE n.nspname = 'falukant_data'
|
||||||
|
AND c.relname = 'knowledge'
|
||||||
|
AND t.contype = 'p';
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📋 Primary Key Information:');
|
||||||
|
if (pkInfo.length > 0) {
|
||||||
|
pkInfo.forEach(pk => {
|
||||||
|
console.log(` Constraint: ${pk.constraint_name}`);
|
||||||
|
console.log(` Spalte: ${pk.column_name}`);
|
||||||
|
console.log(` Typ: ${pk.constraint_type === 'p' ? 'PRIMARY KEY' : pk.constraint_type}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' ⚠️ Kein Primary Key gefunden!');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Prüfe alle Indizes auf knowledge
|
||||||
|
const [allIndexes] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
indexname,
|
||||||
|
indexdef,
|
||||||
|
idx_scan,
|
||||||
|
idx_tup_read,
|
||||||
|
idx_tup_fetch
|
||||||
|
FROM pg_indexes
|
||||||
|
LEFT JOIN pg_stat_user_indexes
|
||||||
|
ON pg_stat_user_indexes.indexrelname = pg_indexes.indexname
|
||||||
|
AND pg_stat_user_indexes.schemaname = pg_indexes.schemaname
|
||||||
|
WHERE pg_indexes.schemaname = 'falukant_data'
|
||||||
|
AND pg_indexes.tablename = 'knowledge'
|
||||||
|
ORDER BY indexname;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📊 Alle Indizes auf knowledge:');
|
||||||
|
allIndexes.forEach(idx => {
|
||||||
|
console.log(`\n ${idx.indexname}:`);
|
||||||
|
console.log(` Definition: ${idx.indexdef}`);
|
||||||
|
console.log(` Scans: ${idx.idx_scan ? parseInt(idx.idx_scan).toLocaleString() : 'N/A'}`);
|
||||||
|
console.log(` Zeilen gelesen: ${idx.idx_tup_read ? parseInt(idx.idx_tup_read).toLocaleString() : 'N/A'}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Prüfe Tabellenstruktur
|
||||||
|
const [tableStructure] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable,
|
||||||
|
column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_data'
|
||||||
|
AND table_name = 'knowledge'
|
||||||
|
ORDER BY ordinal_position;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📋 Tabellenstruktur:');
|
||||||
|
tableStructure.forEach(col => {
|
||||||
|
console.log(` ${col.column_name}: ${col.data_type} ${col.is_nullable === 'NO' ? 'NOT NULL' : 'NULL'}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Erklärung: Warum knowledge_pkey ungenutzt ist
|
||||||
|
const pkUnused = allIndexes.find(i => i.indexname === 'knowledge_pkey' && (i.idx_scan == null || parseInt(i.idx_scan) === 0));
|
||||||
|
if (pkUnused) {
|
||||||
|
console.log('💡 Warum knowledge_pkey (0 Scans) ungenutzt ist:');
|
||||||
|
console.log(' Alle Zugriffe filtern nach (character_id, product_id), nie nach id.');
|
||||||
|
console.log(' Der PK-Index wird nur für Eindeutigkeit/Referenzen genutzt, nicht für Lookups.');
|
||||||
|
console.log(' idx_knowledge_character_product deckt die tatsächlichen Queries ab.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Queries mit id (Primary Key) gemacht werden
|
||||||
|
let idUsage = [];
|
||||||
|
try {
|
||||||
|
const [rows] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
query,
|
||||||
|
calls,
|
||||||
|
total_exec_time,
|
||||||
|
mean_exec_time
|
||||||
|
FROM pg_stat_statements
|
||||||
|
WHERE query LIKE '%knowledge%'
|
||||||
|
AND (query LIKE '%knowledge.id%' OR query LIKE '%knowledge%id%')
|
||||||
|
ORDER BY calls DESC
|
||||||
|
LIMIT 5;
|
||||||
|
`);
|
||||||
|
idUsage = rows;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ℹ️ pg_stat_statements nicht verfügbar – keine Query-Statistik.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idUsage.length > 0) {
|
||||||
|
console.log('🔍 Queries die knowledge.id verwenden:');
|
||||||
|
idUsage.forEach(q => {
|
||||||
|
console.log(` Aufrufe: ${parseInt(q.calls).toLocaleString()}`);
|
||||||
|
console.log(` Query: ${q.query.substring(0, 150)}...`);
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('pg_stat_statements')) {
|
||||||
|
console.log(' ⚠️ pg_stat_statements ist nicht aktiviert oder nicht verfügbar\n');
|
||||||
|
} else {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
}
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
55
backend/cleanup-connections.js
Normal file
55
backend/cleanup-connections.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script zum Bereinigen von alten/idle PostgreSQL-Verbindungen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🧹 Bereinige alte PostgreSQL-Verbindungen...\n');
|
||||||
|
|
||||||
|
// Beende idle Verbindungen, die älter als 5 Minuten sind (außer unserer eigenen)
|
||||||
|
const [result] = await sequelize.query(`
|
||||||
|
SELECT pg_terminate_backend(pid) as terminated
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
AND pid <> pg_backend_pid()
|
||||||
|
AND state = 'idle'
|
||||||
|
AND state_change < now() - interval '5 minutes';
|
||||||
|
`);
|
||||||
|
|
||||||
|
const terminated = result.filter(r => r.terminated).length;
|
||||||
|
console.log(`✅ ${terminated} alte idle Verbindungen wurden beendet\n`);
|
||||||
|
|
||||||
|
// Zeige verbleibende Verbindungen
|
||||||
|
const [connections] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
count(*) as total,
|
||||||
|
count(*) FILTER (WHERE state = 'active') as active,
|
||||||
|
count(*) FILTER (WHERE state = 'idle') as idle
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database();
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📊 Verbleibende Verbindungen:');
|
||||||
|
console.log(` Gesamt: ${connections[0].total}`);
|
||||||
|
console.log(` Aktiv: ${connections[0].active}`);
|
||||||
|
console.log(` Idle: ${connections[0].idle}\n`);
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
if (error.message.includes('SUPERUSER')) {
|
||||||
|
console.error('\n💡 Tipp: Du benötigst Superuser-Rechte oder musst warten, bis Verbindungen freigegeben werden.');
|
||||||
|
console.error(' Versuche es in ein paar Minuten erneut.');
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
203
backend/controllers/calendarController.js
Normal file
203
backend/controllers/calendarController.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import calendarService from '../services/calendarService.js';
|
||||||
|
|
||||||
|
function getHashedUserId(req) {
|
||||||
|
return req.headers?.userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/events
|
||||||
|
* Get all events for the authenticated user
|
||||||
|
* Query params: startDate, endDate (optional)
|
||||||
|
*/
|
||||||
|
async getEvents(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
const events = await calendarService.getEvents(hashedUserId, { startDate, endDate });
|
||||||
|
res.json(events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getEvents:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/events/:id
|
||||||
|
* Get a single event by ID
|
||||||
|
*/
|
||||||
|
async getEvent(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await calendarService.getEvent(hashedUserId, req.params.id);
|
||||||
|
res.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getEvent:', error);
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/calendar/events
|
||||||
|
* Create a new event
|
||||||
|
*/
|
||||||
|
async createEvent(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventData = req.body;
|
||||||
|
if (!eventData.title || !eventData.startDate) {
|
||||||
|
return res.status(400).json({ error: 'Title and startDate are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await calendarService.createEvent(hashedUserId, eventData);
|
||||||
|
res.status(201).json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar createEvent:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/calendar/events/:id
|
||||||
|
* Update an existing event
|
||||||
|
*/
|
||||||
|
async updateEvent(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventData = req.body;
|
||||||
|
if (!eventData.title || !eventData.startDate) {
|
||||||
|
return res.status(400).json({ error: 'Title and startDate are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await calendarService.updateEvent(hashedUserId, req.params.id, eventData);
|
||||||
|
res.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar updateEvent:', error);
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/calendar/events/:id
|
||||||
|
* Delete an event
|
||||||
|
*/
|
||||||
|
async deleteEvent(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await calendarService.deleteEvent(hashedUserId, req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar deleteEvent:', error);
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/birthdays
|
||||||
|
* Get friends' birthdays for a given year
|
||||||
|
* Query params: year (required)
|
||||||
|
*/
|
||||||
|
async getFriendsBirthdays(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||||||
|
const birthdays = await calendarService.getFriendsBirthdays(hashedUserId, year);
|
||||||
|
res.json(birthdays);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getFriendsBirthdays:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/widget/birthdays
|
||||||
|
* Get upcoming birthdays for widget display
|
||||||
|
*/
|
||||||
|
async getWidgetBirthdays(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const birthdays = await calendarService.getUpcomingBirthdays(hashedUserId, limit);
|
||||||
|
res.json(birthdays);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getWidgetBirthdays:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/widget/upcoming
|
||||||
|
* Get upcoming events for widget display
|
||||||
|
*/
|
||||||
|
async getWidgetUpcoming(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const events = await calendarService.getUpcomingEvents(hashedUserId, limit);
|
||||||
|
res.json(events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getWidgetUpcoming:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/calendar/widget/mini
|
||||||
|
* Get mini calendar data for widget display
|
||||||
|
*/
|
||||||
|
async getWidgetMiniCalendar(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await calendarService.getMiniCalendarData(hashedUserId);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Calendar getWidgetMiniCalendar:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
50
backend/controllers/dashboardController.js
Normal file
50
backend/controllers/dashboardController.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import dashboardService from '../services/dashboardService.js';
|
||||||
|
|
||||||
|
function getHashedUserId(req) {
|
||||||
|
return req.headers?.userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/** Liste der möglichen Widget-Typen (öffentlich, keine Auth nötig wenn gewünscht – aktuell mit Auth). */
|
||||||
|
async getAvailableWidgets(req, res) {
|
||||||
|
try {
|
||||||
|
const list = await dashboardService.getAvailableWidgets();
|
||||||
|
res.json(list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard getAvailableWidgets:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConfig(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const config = await dashboardService.getConfig(hashedUserId);
|
||||||
|
res.json(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard getConfig:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setConfig(req, res) {
|
||||||
|
const hashedUserId = getHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const config = req.body;
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid config' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await dashboardService.setConfig(hashedUserId, config);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard setConfig:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -56,6 +56,10 @@ class FalukantController {
|
|||||||
if (!page) page = 1;
|
if (!page) page = 1;
|
||||||
return this.service.moneyHistory(userId, page, filter);
|
return this.service.moneyHistory(userId, page, filter);
|
||||||
});
|
});
|
||||||
|
this.moneyHistoryGraph = this._wrapWithUser((userId, req) => {
|
||||||
|
const { range } = req.body || {};
|
||||||
|
return this.service.moneyHistoryGraph(userId, range || '24h');
|
||||||
|
});
|
||||||
this.getStorage = this._wrapWithUser((userId, req) => this.service.getStorage(userId, req.params.branchId));
|
this.getStorage = this._wrapWithUser((userId, req) => this.service.getStorage(userId, req.params.branchId));
|
||||||
this.buyStorage = this._wrapWithUser((userId, req) => {
|
this.buyStorage = this._wrapWithUser((userId, req) => {
|
||||||
const { branchId, amount, stockTypeId } = req.body;
|
const { branchId, amount, stockTypeId } = req.body;
|
||||||
@@ -93,17 +97,35 @@ class FalukantController {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
|
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
|
||||||
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
|
|
||||||
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
|
|
||||||
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
|
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
|
||||||
|
this.cancelWooing = this._wrapWithUser(async (userId) => {
|
||||||
|
try {
|
||||||
|
return await this.service.cancelWooing(userId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.name === 'PreconditionError' && e.message === 'cancelTooSoon') {
|
||||||
|
throw { status: 412, message: 'cancelTooSoon', retryAt: e.meta?.retryAt };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, { successStatus: 202 });
|
||||||
this.getGifts = this._wrapWithUser((userId) => {
|
this.getGifts = this._wrapWithUser((userId) => {
|
||||||
console.log('🔍 getGifts called with userId:', userId);
|
console.log('🔍 getGifts called with userId:', userId);
|
||||||
return this.service.getGifts(userId);
|
return this.service.getGifts(userId);
|
||||||
});
|
});
|
||||||
this.getChildren = this._wrapWithUser((userId) => this.service.getChildren(userId));
|
this.getChildren = this._wrapWithUser((userId) => this.service.getChildren(userId));
|
||||||
this.sendGift = this._wrapWithUser((userId, req) => this.service.sendGift(userId, req.body.giftId));
|
this.sendGift = this._wrapWithUser(async (userId, req) => {
|
||||||
|
try {
|
||||||
|
return await this.service.sendGift(userId, req.body.giftId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.name === 'PreconditionError' && e.message === 'tooOften') {
|
||||||
|
throw { status: 412, message: 'tooOften', retryAt: e.meta?.retryAt };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId));
|
this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId));
|
||||||
|
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
|
||||||
this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId));
|
this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId));
|
||||||
this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId));
|
this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId));
|
||||||
this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId));
|
this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId));
|
||||||
@@ -118,17 +140,22 @@ class FalukantController {
|
|||||||
}, { successStatus: 201 });
|
}, { successStatus: 201 });
|
||||||
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
|
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
|
||||||
|
|
||||||
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
|
|
||||||
this.executeReputationAction = this._wrapWithUser((userId, req) => {
|
|
||||||
const { actionTypeId } = req.body;
|
|
||||||
return this.service.executeReputationAction(userId, actionTypeId);
|
|
||||||
}, { successStatus: 201 });
|
|
||||||
|
|
||||||
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
|
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
|
||||||
this.baptise = this._wrapWithUser((userId, req) => {
|
this.baptise = this._wrapWithUser((userId, req) => {
|
||||||
const { characterId: childId, firstName } = req.body;
|
const { characterId: childId, firstName } = req.body;
|
||||||
return this.service.baptise(userId, childId, firstName);
|
return this.service.baptise(userId, childId, firstName);
|
||||||
});
|
});
|
||||||
|
this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId));
|
||||||
|
this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId));
|
||||||
|
this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId));
|
||||||
|
this.applyForChurchPosition = this._wrapWithUser((userId, req) => {
|
||||||
|
const { officeTypeId, regionId } = req.body;
|
||||||
|
return this.service.applyForChurchPosition(userId, officeTypeId, regionId);
|
||||||
|
});
|
||||||
|
this.decideOnChurchApplication = this._wrapWithUser((userId, req) => {
|
||||||
|
const { applicationId, decision } = req.body;
|
||||||
|
return this.service.decideOnChurchApplication(userId, applicationId, decision);
|
||||||
|
});
|
||||||
|
|
||||||
this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId));
|
this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId));
|
||||||
this.sendToSchool = this._wrapWithUser((userId, req) => {
|
this.sendToSchool = this._wrapWithUser((userId, req) => {
|
||||||
@@ -144,7 +171,16 @@ class FalukantController {
|
|||||||
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId));
|
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId));
|
||||||
|
|
||||||
this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId));
|
this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId));
|
||||||
this.healthActivity = this._wrapWithUser((userId, req) => this.service.healthActivity(userId, req.body.measureTr));
|
this.healthActivity = this._wrapWithUser(async (userId, req) => {
|
||||||
|
try {
|
||||||
|
return await this.service.healthActivity(userId, req.body.measureTr);
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.name === 'PreconditionError' && e.message === 'tooClose') {
|
||||||
|
throw { status: 412, message: 'tooClose', retryAt: e.meta?.retryAt };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId));
|
this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId));
|
||||||
this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId));
|
this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId));
|
||||||
@@ -162,6 +198,13 @@ class FalukantController {
|
|||||||
}
|
}
|
||||||
return this.service.getProductPriceInRegion(userId, productId, regionId);
|
return this.service.getProductPriceInRegion(userId, productId, regionId);
|
||||||
});
|
});
|
||||||
|
this.getAllProductPricesInRegion = this._wrapWithUser((userId, req) => {
|
||||||
|
const regionId = parseInt(req.query.regionId, 10);
|
||||||
|
if (Number.isNaN(regionId)) {
|
||||||
|
throw new Error('regionId is required');
|
||||||
|
}
|
||||||
|
return this.service.getAllProductPricesInRegion(userId, regionId);
|
||||||
|
});
|
||||||
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
|
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
|
||||||
const productId = parseInt(req.query.productId, 10);
|
const productId = parseInt(req.query.productId, 10);
|
||||||
const currentPrice = parseFloat(req.query.currentPrice);
|
const currentPrice = parseFloat(req.query.currentPrice);
|
||||||
@@ -171,6 +214,16 @@ class FalukantController {
|
|||||||
}
|
}
|
||||||
return this.service.getProductPricesInCities(userId, productId, currentPrice, currentRegionId);
|
return this.service.getProductPricesInCities(userId, productId, currentPrice, currentRegionId);
|
||||||
});
|
});
|
||||||
|
this.getProductPricesInCitiesBatch = this._wrapWithUser((userId, req) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const items = Array.isArray(body.items) ? body.items : [];
|
||||||
|
const currentRegionId = body.currentRegionId != null ? parseInt(body.currentRegionId, 10) : null;
|
||||||
|
const valid = items.map(i => ({
|
||||||
|
productId: parseInt(i.productId, 10),
|
||||||
|
currentPrice: parseFloat(i.currentPrice)
|
||||||
|
})).filter(i => !Number.isNaN(i.productId) && !Number.isNaN(i.currentPrice));
|
||||||
|
return this.service.getProductPricesInCitiesBatch(userId, valid, Number.isNaN(currentRegionId) ? null : currentRegionId);
|
||||||
|
});
|
||||||
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
|
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
|
||||||
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
|
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
|
||||||
|
|
||||||
@@ -178,6 +231,7 @@ class FalukantController {
|
|||||||
this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId));
|
this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId));
|
||||||
this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size));
|
this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size));
|
||||||
this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 });
|
this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 });
|
||||||
|
this.getDashboardWidget = this._wrapWithUser((userId) => this.service.getDashboardWidget(userId));
|
||||||
this.getUndergroundTargets = this._wrapWithUser((userId) => this.service.getPoliticalOfficeHolders(userId));
|
this.getUndergroundTargets = this._wrapWithUser((userId) => this.service.getPoliticalOfficeHolders(userId));
|
||||||
|
|
||||||
this.searchUsers = this._wrapWithUser((userId, req) => {
|
this.searchUsers = this._wrapWithUser((userId, req) => {
|
||||||
@@ -252,7 +306,13 @@ class FalukantController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Controller error:', error);
|
console.error('Controller error:', error);
|
||||||
const status = error.status && typeof error.status === 'number' ? error.status : 500;
|
const status = error.status && typeof error.status === 'number' ? error.status : 500;
|
||||||
res.status(status).json({ error: error.message || 'Internal error' });
|
// Wenn error ein Objekt mit status ist, alle Felder außer status übernehmen
|
||||||
|
if (error && typeof error === 'object' && error.status && typeof error.status === 'number') {
|
||||||
|
const { status: errorStatus, ...errorData } = error;
|
||||||
|
res.status(errorStatus).json({ error: error.message || errorData.message || 'Internal error', ...errorData });
|
||||||
|
} else {
|
||||||
|
res.status(status).json({ error: error.message || 'Internal error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ const menuStructure = {
|
|||||||
visible: ["all"],
|
visible: ["all"],
|
||||||
path: "/socialnetwork/gallery"
|
path: "/socialnetwork/gallery"
|
||||||
},
|
},
|
||||||
vocabtrainer: {
|
|
||||||
visible: ["all"],
|
|
||||||
path: "/socialnetwork/vocab",
|
|
||||||
children: {}
|
|
||||||
},
|
|
||||||
blockedUsers: {
|
blockedUsers: {
|
||||||
visible: ["all"],
|
visible: ["all"],
|
||||||
path: "/socialnetwork/blocked"
|
path: "/socialnetwork/blocked"
|
||||||
@@ -183,6 +178,30 @@ const menuStructure = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
personal: {
|
||||||
|
visible: ["all"],
|
||||||
|
icon: "profile16.png",
|
||||||
|
children: {
|
||||||
|
sprachenlernen: {
|
||||||
|
visible: ["all"],
|
||||||
|
children: {
|
||||||
|
vocabtrainer: {
|
||||||
|
visible: ["all"],
|
||||||
|
path: "/socialnetwork/vocab",
|
||||||
|
showVocabLanguages: 1 // Flag für dynamische Sprachen-Liste
|
||||||
|
},
|
||||||
|
sprachkurse: {
|
||||||
|
visible: ["all"],
|
||||||
|
path: "/socialnetwork/vocab/courses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
visible: ["all"],
|
||||||
|
path: "/personal/calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
visible: ["all"],
|
visible: ["all"],
|
||||||
icon: "settings16.png",
|
icon: "settings16.png",
|
||||||
@@ -377,22 +396,9 @@ class NavigationController {
|
|||||||
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
||||||
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
|
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
|
||||||
|
|
||||||
// Dynamisches Submenü: Treffpunkt → Vokabeltrainer → (Neue Sprache + abonnierte/angelegte)
|
// Vokabeltrainer: Sprachen werden im Frontend dynamisch geladen (wie Forum)
|
||||||
// Wichtig: "Neue Sprache" soll IMMER sichtbar sein – auch wenn die DB-Abfrage (noch) fehlschlägt.
|
// Keine children mehr, da das Menü nur 2 Ebenen unterstützt
|
||||||
if (filteredMenu?.socialnetwork?.children?.vocabtrainer) {
|
// Das Frontend lädt die Sprachen separat und zeigt sie als submenu2 an
|
||||||
const children = {
|
|
||||||
newLanguage: { path: '/socialnetwork/vocab/new' },
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const langs = await this.vocabService.listLanguagesForMenu(user.id);
|
|
||||||
for (const l of langs) {
|
|
||||||
children[`lang_${l.id}`] = { path: `/socialnetwork/vocab/${l.id}`, label: l.name };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[menu] Konnte Vokabeltrainer-Sprachen nicht laden:', e?.message || e);
|
|
||||||
}
|
|
||||||
filteredMenu.socialnetwork.children.vocabtrainer.children = children;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json(filteredMenu);
|
res.status(200).json(filteredMenu);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
21
backend/controllers/newsController.js
Normal file
21
backend/controllers/newsController.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import newsService from '../services/newsService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/news?counter=0&language=de&category=top
|
||||||
|
* counter = wievieltes News-Widget aufgerufen wird (0, 1, 2, …), damit keine doppelten Artikel.
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
async getNews(req, res) {
|
||||||
|
const counter = Math.max(0, parseInt(req.query.counter, 10) || 0);
|
||||||
|
const language = (req.query.language || 'de').slice(0, 10);
|
||||||
|
const category = (req.query.category || 'top').slice(0, 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results, nextPage } = await newsService.getNews({ counter, language, category });
|
||||||
|
res.json({ results, nextPage });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('News getNews:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'News konnten nicht geladen werden.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ class VocabController {
|
|||||||
this.service = new VocabService();
|
this.service = new VocabService();
|
||||||
|
|
||||||
this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId));
|
this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId));
|
||||||
|
this.listAllLanguages = this._wrapWithUser(() => this.service.listAllLanguages());
|
||||||
this.createLanguage = this._wrapWithUser((userId, req) => this.service.createLanguage(userId, req.body), { successStatus: 201 });
|
this.createLanguage = this._wrapWithUser((userId, req) => this.service.createLanguage(userId, req.body), { successStatus: 201 });
|
||||||
this.subscribe = this._wrapWithUser((userId, req) => this.service.subscribeByShareCode(userId, req.body), { successStatus: 201 });
|
this.subscribe = this._wrapWithUser((userId, req) => this.service.subscribeByShareCode(userId, req.body), { successStatus: 201 });
|
||||||
this.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(userId, req.params.languageId));
|
this.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(userId, req.params.languageId));
|
||||||
@@ -21,6 +22,39 @@ class VocabController {
|
|||||||
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
|
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
|
||||||
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
|
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
|
||||||
this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
|
this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
|
||||||
|
|
||||||
|
// Courses
|
||||||
|
this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 });
|
||||||
|
this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query));
|
||||||
|
this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId));
|
||||||
|
this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode));
|
||||||
|
this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body));
|
||||||
|
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
|
||||||
|
|
||||||
|
// Lessons
|
||||||
|
this.getLesson = this._wrapWithUser((userId, req) => this.service.getLesson(userId, req.params.lessonId));
|
||||||
|
this.addLessonToCourse = this._wrapWithUser((userId, req) => this.service.addLessonToCourse(userId, req.params.courseId, req.body), { successStatus: 201 });
|
||||||
|
this.updateLesson = this._wrapWithUser((userId, req) => this.service.updateLesson(userId, req.params.lessonId, req.body));
|
||||||
|
this.deleteLesson = this._wrapWithUser((userId, req) => this.service.deleteLesson(userId, req.params.lessonId));
|
||||||
|
|
||||||
|
// Enrollment
|
||||||
|
this.enrollInCourse = this._wrapWithUser((userId, req) => this.service.enrollInCourse(userId, req.params.courseId), { successStatus: 201 });
|
||||||
|
this.unenrollFromCourse = this._wrapWithUser((userId, req) => this.service.unenrollFromCourse(userId, req.params.courseId));
|
||||||
|
this.getMyCourses = this._wrapWithUser((userId) => this.service.getMyCourses(userId));
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId));
|
||||||
|
this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body));
|
||||||
|
|
||||||
|
// Grammar Exercises
|
||||||
|
this.getExerciseTypes = this._wrapWithUser((userId) => this.service.getExerciseTypes());
|
||||||
|
this.createGrammarExercise = this._wrapWithUser((userId, req) => this.service.createGrammarExercise(userId, req.params.lessonId, req.body), { successStatus: 201 });
|
||||||
|
this.getGrammarExercisesForLesson = this._wrapWithUser((userId, req) => this.service.getGrammarExercisesForLesson(userId, req.params.lessonId));
|
||||||
|
this.getGrammarExercise = this._wrapWithUser((userId, req) => this.service.getGrammarExercise(userId, req.params.exerciseId));
|
||||||
|
this.checkGrammarExerciseAnswer = this._wrapWithUser((userId, req) => this.service.checkGrammarExerciseAnswer(userId, req.params.exerciseId, req.body.answer));
|
||||||
|
this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId));
|
||||||
|
this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body));
|
||||||
|
this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
||||||
|
|||||||
159
backend/create-performance-indexes.js
Executable file
159
backend/create-performance-indexes.js
Executable file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen von Performance-Indizes
|
||||||
|
*
|
||||||
|
* Erstellt Indizes basierend auf der Analyse häufiger Queries:
|
||||||
|
* - inventory: stock_id
|
||||||
|
* - stock: branch_id
|
||||||
|
* - production: branch_id
|
||||||
|
* - director: employer_user_id
|
||||||
|
* - knowledge: (character_id, product_id) composite
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🔧 Erstelle Performance-Indizes\n');
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
const indexes = [
|
||||||
|
{
|
||||||
|
name: 'idx_knowledge_character_product',
|
||||||
|
table: 'falukant_data.knowledge',
|
||||||
|
columns: '(character_id, product_id)',
|
||||||
|
description: 'Composite Index für JOINs mit character_id UND product_id',
|
||||||
|
critical: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idx_inventory_stock_id',
|
||||||
|
table: 'falukant_data.inventory',
|
||||||
|
columns: '(stock_id)',
|
||||||
|
description: 'Index für WHERE inventory.stock_id = ...',
|
||||||
|
critical: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idx_stock_branch_id',
|
||||||
|
table: 'falukant_data.stock',
|
||||||
|
columns: '(branch_id)',
|
||||||
|
description: 'Index für WHERE stock.branch_id = ...',
|
||||||
|
critical: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idx_production_branch_id',
|
||||||
|
table: 'falukant_data.production',
|
||||||
|
columns: '(branch_id)',
|
||||||
|
description: 'Index für WHERE production.branch_id = ...',
|
||||||
|
critical: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idx_director_employer_user_id',
|
||||||
|
table: 'falukant_data.director',
|
||||||
|
columns: '(employer_user_id)',
|
||||||
|
description: 'Index für WHERE director.employer_user_id = ...',
|
||||||
|
critical: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idx_production_start_timestamp',
|
||||||
|
table: 'falukant_data.production',
|
||||||
|
columns: '(start_timestamp)',
|
||||||
|
description: 'Index für WHERE production.start_timestamp <= ...',
|
||||||
|
critical: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'idx_director_last_salary_payout',
|
||||||
|
table: 'falukant_data.director',
|
||||||
|
columns: '(last_salary_payout)',
|
||||||
|
description: 'Index für WHERE director.last_salary_payout < ...',
|
||||||
|
critical: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(`📋 ${indexes.length} Indizes werden erstellt:\n`);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < indexes.length; i++) {
|
||||||
|
const idx = indexes[i];
|
||||||
|
const criticalMark = idx.critical ? ' ⚠️ KRITISCH' : '';
|
||||||
|
|
||||||
|
console.log(`[${i + 1}/${indexes.length}] ${idx.name}${criticalMark}`);
|
||||||
|
console.log(` Tabelle: ${idx.table}`);
|
||||||
|
console.log(` Spalten: ${idx.columns}`);
|
||||||
|
console.log(` Beschreibung: ${idx.description}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prüfe ob Index bereits existiert
|
||||||
|
const [existing] = await sequelize.query(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE schemaname || '.' || tablename = '${idx.table}'
|
||||||
|
AND indexname = '${idx.name}'
|
||||||
|
) as exists;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (existing[0].exists) {
|
||||||
|
console.log(` ⏭️ Index existiert bereits, überspringe\n`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Index
|
||||||
|
const startTime = Date.now();
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS ${idx.name}
|
||||||
|
ON ${idx.table} USING btree ${idx.columns};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
console.log(` ✅ Erstellt in ${duration}s\n`);
|
||||||
|
created++;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ Fehler: ${error.message}\n`);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`✅ Zusammenfassung:`);
|
||||||
|
console.log(` Erstellt: ${created}`);
|
||||||
|
console.log(` Übersprungen: ${skipped}`);
|
||||||
|
console.log(` Fehler: ${errors}\n`);
|
||||||
|
|
||||||
|
// ANALYZE ausführen, damit PostgreSQL die neuen Indizes berücksichtigt
|
||||||
|
const tablesToAnalyze = [
|
||||||
|
'falukant_data.knowledge',
|
||||||
|
'falukant_data.inventory',
|
||||||
|
'falukant_data.stock',
|
||||||
|
'falukant_data.production',
|
||||||
|
'falukant_data.director'
|
||||||
|
];
|
||||||
|
if (created > 0) {
|
||||||
|
console.log('📊 Führe ANALYZE auf betroffenen Tabellen aus...\n');
|
||||||
|
for (const table of tablesToAnalyze) {
|
||||||
|
try {
|
||||||
|
await sequelize.query(`ANALYZE ${table};`);
|
||||||
|
console.log(` ✅ ANALYZE ${table};`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ⚠️ ${table}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -25,11 +25,13 @@ function createServer() {
|
|||||||
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
||||||
});
|
});
|
||||||
wss = new WebSocketServer({ server: httpsServer });
|
wss = new WebSocketServer({ server: httpsServer });
|
||||||
|
// Direkte Verbindung: lausche auf allen Interfaces (0.0.0.0)
|
||||||
httpsServer.listen(PORT, '0.0.0.0', () => {
|
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
|
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
wss = new WebSocketServer({ port: PORT });
|
// Direkte Verbindung: lausche auf allen Interfaces (0.0.0.0)
|
||||||
|
wss = new WebSocketServer({ port: PORT, host: '0.0.0.0' });
|
||||||
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
|
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
479
backend/diagnose-db-performance.js
Executable file
479
backend/diagnose-db-performance.js
Executable file
@@ -0,0 +1,479 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umfassendes Diagnose-Script für Datenbank-Performance
|
||||||
|
*
|
||||||
|
* Untersucht:
|
||||||
|
* - Verbindungsstatistiken
|
||||||
|
* - Langsame Queries
|
||||||
|
* - Tabellengrößen und Bloat
|
||||||
|
* - Indizes (fehlende/ungenutzte)
|
||||||
|
* - Vacuum/Analyze Status
|
||||||
|
* - Locking/Blocking
|
||||||
|
* - Query-Statistiken
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Datenbank-Performance-Diagnose\n');
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// 1. Verbindungsstatistiken
|
||||||
|
await checkConnections();
|
||||||
|
|
||||||
|
// 2. Langsame Queries (wenn pg_stat_statements aktiviert ist)
|
||||||
|
await checkSlowQueries();
|
||||||
|
|
||||||
|
// 3. Tabellengrößen und Bloat
|
||||||
|
await checkTableSizes();
|
||||||
|
|
||||||
|
// 4. Indizes prüfen
|
||||||
|
await checkIndexes();
|
||||||
|
|
||||||
|
// 5. Vacuum/Analyze Status
|
||||||
|
await checkVacuumStatus();
|
||||||
|
|
||||||
|
// 6. Locking/Blocking
|
||||||
|
await checkLocks();
|
||||||
|
|
||||||
|
// 7. Query-Statistiken (wenn pg_stat_statements aktiviert ist)
|
||||||
|
await checkQueryStats();
|
||||||
|
|
||||||
|
// 8. Connection Pool Status
|
||||||
|
await checkConnectionPool();
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('✅ Diagnose abgeschlossen\n');
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkConnections() {
|
||||||
|
console.log('📊 1. Verbindungsstatistiken\n');
|
||||||
|
|
||||||
|
const [connections] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
count(*) as total,
|
||||||
|
count(*) FILTER (WHERE state = 'active') as active,
|
||||||
|
count(*) FILTER (WHERE state = 'idle') as idle,
|
||||||
|
count(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction,
|
||||||
|
count(*) FILTER (WHERE wait_event_type IS NOT NULL) as waiting
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database();
|
||||||
|
`);
|
||||||
|
|
||||||
|
const conn = connections[0];
|
||||||
|
console.log(` Gesamt: ${conn.total}`);
|
||||||
|
console.log(` Aktiv: ${conn.active}`);
|
||||||
|
console.log(` Idle: ${conn.idle}`);
|
||||||
|
console.log(` Idle in Transaction: ${conn.idle_in_transaction}`);
|
||||||
|
console.log(` Wartend: ${conn.waiting}\n`);
|
||||||
|
|
||||||
|
const [maxConn] = await sequelize.query(`
|
||||||
|
SELECT setting::int as max_connections
|
||||||
|
FROM pg_settings
|
||||||
|
WHERE name = 'max_connections';
|
||||||
|
`);
|
||||||
|
|
||||||
|
const usagePercent = (conn.total / maxConn[0].max_connections) * 100;
|
||||||
|
console.log(` Max Connections: ${maxConn[0].max_connections}`);
|
||||||
|
console.log(` Auslastung: ${usagePercent.toFixed(1)}%\n`);
|
||||||
|
|
||||||
|
if (usagePercent > 80) {
|
||||||
|
console.log(' ⚠️ WARNUNG: Hohe Verbindungsauslastung!\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeige lange laufende Queries
|
||||||
|
const [longRunning] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
usename,
|
||||||
|
application_name,
|
||||||
|
state,
|
||||||
|
now() - query_start as duration,
|
||||||
|
wait_event_type,
|
||||||
|
wait_event,
|
||||||
|
left(query, 100) as query_preview
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
AND state != 'idle'
|
||||||
|
AND now() - query_start > interval '5 seconds'
|
||||||
|
ORDER BY query_start ASC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (longRunning.length > 0) {
|
||||||
|
console.log(' ⚠️ Lange laufende Queries (> 5 Sekunden):');
|
||||||
|
longRunning.forEach(q => {
|
||||||
|
const duration = Math.round(q.duration.total_seconds);
|
||||||
|
console.log(` PID ${q.pid}: ${duration}s - ${q.query_preview}...`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSlowQueries() {
|
||||||
|
console.log('🐌 2. Langsame Queries (pg_stat_statements)\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prüfe ob pg_stat_statements aktiviert ist
|
||||||
|
const [extension] = await sequelize.query(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'
|
||||||
|
) as exists;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!extension[0].exists) {
|
||||||
|
console.log(' ℹ️ pg_stat_statements ist nicht aktiviert.');
|
||||||
|
console.log(' 💡 Aktivieren mit: CREATE EXTENSION IF NOT EXISTS pg_stat_statements;\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [slowQueries] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
left(query, 100) as query_preview,
|
||||||
|
calls,
|
||||||
|
total_exec_time,
|
||||||
|
mean_exec_time,
|
||||||
|
max_exec_time,
|
||||||
|
(total_exec_time / sum(total_exec_time) OVER ()) * 100 as percent_total
|
||||||
|
FROM pg_stat_statements
|
||||||
|
WHERE mean_exec_time > 100 -- Queries mit > 100ms Durchschnitt
|
||||||
|
ORDER BY total_exec_time DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (slowQueries.length > 0) {
|
||||||
|
console.log(' Top 10 langsamste Queries (nach Gesamtzeit):');
|
||||||
|
slowQueries.forEach((q, i) => {
|
||||||
|
console.log(` ${i + 1}. ${q.query_preview}...`);
|
||||||
|
console.log(` Aufrufe: ${q.calls}, Durchschnitt: ${q.mean_exec_time.toFixed(2)}ms, Max: ${q.max_exec_time.toFixed(2)}ms`);
|
||||||
|
console.log(` Gesamtzeit: ${q.total_exec_time.toFixed(2)}ms (${q.percent_total.toFixed(1)}%)\n`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' ✅ Keine sehr langsamen Queries gefunden (> 100ms Durchschnitt)\n');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠️ Fehler beim Abrufen der Query-Statistiken: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkTableSizes() {
|
||||||
|
console.log('📦 3. Tabellengrößen und Bloat\n');
|
||||||
|
|
||||||
|
const [tableSizes] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || relname as full_table_name,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) as total_size,
|
||||||
|
pg_size_pretty(pg_relation_size(schemaname||'.'||relname)) as table_size,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname) - pg_relation_size(schemaname||'.'||relname)) as indexes_size,
|
||||||
|
n_live_tup as row_count,
|
||||||
|
n_dead_tup as dead_rows,
|
||||||
|
CASE
|
||||||
|
WHEN n_live_tup > 0 THEN round((n_dead_tup::numeric / n_live_tup::numeric) * 100, 2)
|
||||||
|
ELSE 0
|
||||||
|
END as dead_row_percent,
|
||||||
|
last_vacuum,
|
||||||
|
last_autovacuum,
|
||||||
|
last_analyze,
|
||||||
|
last_autoanalyze
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
ORDER BY pg_total_relation_size(schemaname||'.'||relname) DESC
|
||||||
|
LIMIT 20;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tableSizes.length > 0) {
|
||||||
|
console.log(' Top 20 größte Tabellen:');
|
||||||
|
tableSizes.forEach((t, i) => {
|
||||||
|
console.log(` ${i + 1}. ${t.full_table_name}`);
|
||||||
|
console.log(` Größe: ${t.total_size} (Tabelle: ${t.table_size}, Indizes: ${t.indexes_size})`);
|
||||||
|
console.log(` Zeilen: ${parseInt(t.row_count).toLocaleString()}, Tote Zeilen: ${parseInt(t.dead_rows).toLocaleString()} (${t.dead_row_percent}%)`);
|
||||||
|
|
||||||
|
if (parseFloat(t.dead_row_percent) > 20) {
|
||||||
|
console.log(` ⚠️ Hoher Bloat-Anteil! Vacuum empfohlen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.last_vacuum || t.last_autovacuum) {
|
||||||
|
const lastVacuum = t.last_vacuum || t.last_autovacuum;
|
||||||
|
const daysSinceVacuum = Math.floor((new Date() - new Date(lastVacuum)) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysSinceVacuum > 7) {
|
||||||
|
console.log(` ⚠️ Letztes Vacuum: ${daysSinceVacuum} Tage her`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkIndexes() {
|
||||||
|
console.log('🔍 4. Indizes-Analyse\n');
|
||||||
|
|
||||||
|
// Fehlende Indizes (basierend auf pg_stat_user_tables)
|
||||||
|
const [missingIndexes] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
seq_scan,
|
||||||
|
seq_tup_read,
|
||||||
|
idx_scan,
|
||||||
|
seq_tup_read / NULLIF(seq_scan, 0) as avg_seq_read
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND seq_scan > 1000
|
||||||
|
AND seq_tup_read / NULLIF(seq_scan, 0) > 1000
|
||||||
|
ORDER BY seq_tup_read DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (missingIndexes.length > 0) {
|
||||||
|
console.log(' ⚠️ Tabellen mit vielen Sequential Scans (möglicherweise fehlende Indizes):');
|
||||||
|
missingIndexes.forEach(t => {
|
||||||
|
console.log(` ${t.table_name}: ${t.seq_scan} seq scans, ${parseInt(t.seq_tup_read).toLocaleString()} Zeilen gelesen`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungenutzte Indizes
|
||||||
|
const [unusedIndexes] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || indexrelname as index_name,
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
|
||||||
|
idx_scan as scans
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND idx_scan = 0
|
||||||
|
AND pg_relation_size(indexrelid) > 1024 * 1024 -- Größer als 1MB
|
||||||
|
ORDER BY pg_relation_size(indexrelid) DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (unusedIndexes.length > 0) {
|
||||||
|
console.log(' ⚠️ Ungenutzte Indizes (> 1MB, nie verwendet):');
|
||||||
|
unusedIndexes.forEach(idx => {
|
||||||
|
console.log(` ${idx.index_name} auf ${idx.table_name}: ${idx.index_size} (0 Scans)`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index Bloat
|
||||||
|
const [indexBloat] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || indexrelname as index_name,
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
|
||||||
|
idx_scan as scans,
|
||||||
|
idx_tup_read as tuples_read,
|
||||||
|
idx_tup_fetch as tuples_fetched
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND pg_relation_size(indexrelid) > 10 * 1024 * 1024 -- Größer als 10MB
|
||||||
|
ORDER BY pg_relation_size(indexrelid) DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (indexBloat.length > 0) {
|
||||||
|
console.log(' Top 10 größte Indizes:');
|
||||||
|
indexBloat.forEach(idx => {
|
||||||
|
console.log(` ${idx.index_name} auf ${idx.table_name}: ${idx.index_size} (${idx.scans} Scans)`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkVacuumStatus() {
|
||||||
|
console.log('🧹 5. Vacuum/Analyze Status\n');
|
||||||
|
|
||||||
|
const [vacuumStats] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
last_vacuum,
|
||||||
|
last_autovacuum,
|
||||||
|
last_analyze,
|
||||||
|
last_autoanalyze,
|
||||||
|
n_dead_tup,
|
||||||
|
n_live_tup,
|
||||||
|
CASE
|
||||||
|
WHEN n_live_tup > 0 THEN round((n_dead_tup::numeric / n_live_tup::numeric) * 100, 2)
|
||||||
|
ELSE 0
|
||||||
|
END as dead_percent
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND (
|
||||||
|
(last_vacuum IS NULL AND last_autovacuum IS NULL)
|
||||||
|
OR (last_vacuum < now() - interval '7 days' AND last_autovacuum < now() - interval '7 days')
|
||||||
|
OR n_dead_tup > 10000
|
||||||
|
)
|
||||||
|
ORDER BY n_dead_tup DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (vacuumStats.length > 0) {
|
||||||
|
console.log(' ⚠️ Tabellen, die Vacuum benötigen könnten:');
|
||||||
|
vacuumStats.forEach(t => {
|
||||||
|
const lastVacuum = t.last_vacuum || t.last_autovacuum || 'Nie';
|
||||||
|
const daysSince = lastVacuum !== 'Nie'
|
||||||
|
? Math.floor((new Date() - new Date(lastVacuum)) / (1000 * 60 * 60 * 24))
|
||||||
|
: '∞';
|
||||||
|
console.log(` ${t.table_name}:`);
|
||||||
|
console.log(` Tote Zeilen: ${parseInt(t.n_dead_tup).toLocaleString()} (${t.dead_percent}%)`);
|
||||||
|
console.log(` Letztes Vacuum: ${lastVacuum} (${daysSince} Tage)`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log(' ✅ Alle Tabellen sind aktuell gevacuumt\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkLocks() {
|
||||||
|
console.log('🔒 6. Locking/Blocking\n');
|
||||||
|
|
||||||
|
const [locks] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
blocked_locks.pid AS blocked_pid,
|
||||||
|
blocked_activity.usename AS blocked_user,
|
||||||
|
blocking_locks.pid AS blocking_pid,
|
||||||
|
blocking_activity.usename AS blocking_user,
|
||||||
|
blocked_activity.query AS blocked_statement,
|
||||||
|
blocking_activity.query AS blocking_statement,
|
||||||
|
blocked_activity.application_name AS blocked_app,
|
||||||
|
blocking_activity.application_name AS blocking_app
|
||||||
|
FROM pg_catalog.pg_locks blocked_locks
|
||||||
|
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
|
||||||
|
JOIN pg_catalog.pg_locks blocking_locks
|
||||||
|
ON blocking_locks.locktype = blocked_locks.locktype
|
||||||
|
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
|
||||||
|
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
|
||||||
|
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
|
||||||
|
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
|
||||||
|
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
|
||||||
|
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
|
||||||
|
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
|
||||||
|
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
|
||||||
|
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
|
||||||
|
AND blocking_locks.pid != blocked_locks.pid
|
||||||
|
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
|
||||||
|
WHERE NOT blocked_locks.granted;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (locks.length > 0) {
|
||||||
|
console.log(' ⚠️ Blockierte Queries gefunden:');
|
||||||
|
locks.forEach(lock => {
|
||||||
|
console.log(` Blockiert: PID ${lock.blocked_pid} (${lock.blocked_user})`);
|
||||||
|
console.log(` Blockiert von: PID ${lock.blocking_pid} (${lock.blocking_user})`);
|
||||||
|
console.log(` Blockierte Query: ${lock.blocked_statement.substring(0, 100)}...`);
|
||||||
|
console.log(` Blockierende Query: ${lock.blocking_statement.substring(0, 100)}...\n`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' ✅ Keine blockierten Queries gefunden\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeige alle aktiven Locks
|
||||||
|
const [allLocks] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
locktype,
|
||||||
|
relation::regclass as relation,
|
||||||
|
mode,
|
||||||
|
granted,
|
||||||
|
pid
|
||||||
|
FROM pg_locks
|
||||||
|
WHERE relation IS NOT NULL
|
||||||
|
AND NOT granted
|
||||||
|
LIMIT 10;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (allLocks.length > 0) {
|
||||||
|
console.log(' ⚠️ Wartende Locks:');
|
||||||
|
allLocks.forEach(lock => {
|
||||||
|
console.log(` ${lock.locktype} auf ${lock.relation}: ${lock.mode} (PID ${lock.pid})`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkQueryStats() {
|
||||||
|
console.log('📈 7. Query-Statistiken\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [extension] = await sequelize.query(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'
|
||||||
|
) as exists;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!extension[0].exists) {
|
||||||
|
console.log(' ℹ️ pg_stat_statements ist nicht aktiviert.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [topQueries] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
left(query, 80) as query_preview,
|
||||||
|
calls,
|
||||||
|
total_exec_time,
|
||||||
|
mean_exec_time,
|
||||||
|
(100 * total_exec_time / sum(total_exec_time) OVER ()) as percent_total
|
||||||
|
FROM pg_stat_statements
|
||||||
|
WHERE query NOT LIKE '%pg_stat_statements%'
|
||||||
|
ORDER BY calls DESC
|
||||||
|
LIMIT 5;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (topQueries.length > 0) {
|
||||||
|
console.log(' Top 5 häufigste Queries:');
|
||||||
|
topQueries.forEach((q, i) => {
|
||||||
|
console.log(` ${i + 1}. ${q.query_preview}...`);
|
||||||
|
console.log(` Aufrufe: ${parseInt(q.calls).toLocaleString()}, Durchschnitt: ${q.mean_exec_time.toFixed(2)}ms`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠️ Fehler: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkConnectionPool() {
|
||||||
|
console.log('🏊 8. Connection Pool Status\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Hole Pool-Konfiguration aus Sequelize Config
|
||||||
|
const config = sequelize.config;
|
||||||
|
const poolConfig = config.pool || {};
|
||||||
|
|
||||||
|
console.log(` Pool-Konfiguration:`);
|
||||||
|
console.log(` Max: ${poolConfig.max || 'N/A'}`);
|
||||||
|
console.log(` Min: ${poolConfig.min || 'N/A'}`);
|
||||||
|
console.log(` Acquire Timeout: ${poolConfig.acquire || 'N/A'}ms`);
|
||||||
|
console.log(` Idle Timeout: ${poolConfig.idle || 'N/A'}ms`);
|
||||||
|
console.log(` Evict Interval: ${poolConfig.evict || 'N/A'}ms\n`);
|
||||||
|
|
||||||
|
// Versuche Pool-Status zu bekommen
|
||||||
|
const pool = sequelize.connectionManager.pool;
|
||||||
|
if (pool) {
|
||||||
|
const poolSize = pool.size || 0;
|
||||||
|
const poolUsed = pool.used || 0;
|
||||||
|
const poolPending = pool.pending || 0;
|
||||||
|
|
||||||
|
console.log(` Pool-Status:`);
|
||||||
|
console.log(` Größe: ${poolSize}`);
|
||||||
|
console.log(` Verwendet: ${poolUsed}`);
|
||||||
|
console.log(` Wartend: ${poolPending}\n`);
|
||||||
|
} else {
|
||||||
|
console.log(` ℹ️ Pool-Objekt nicht verfügbar\n`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠️ Fehler beim Abrufen der Pool-Informationen: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Rollback: Remove indexes for director proposals and character queries
|
||||||
|
-- Created: 2026-01-12
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_character_region_user_created;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_character_region_user;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_character_user_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_director_proposal_employer_character;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_director_character_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_director_employer_user_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_knowledge_character_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_relationship_character1_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_child_relation_father_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_child_relation_mother_id;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Migration: Add indexes for director proposals and character queries
|
||||||
|
-- Created: 2026-01-12
|
||||||
|
|
||||||
|
-- Index für schnelle Suche nach NPCs in einer Region (mit Altersbeschränkung)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_character_region_user_created
|
||||||
|
ON falukant_data.character (region_id, user_id, created_at)
|
||||||
|
WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
-- Index für schnelle Suche nach NPCs ohne Altersbeschränkung
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_character_region_user
|
||||||
|
ON falukant_data.character (region_id, user_id)
|
||||||
|
WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
-- Index für Character-Suche nach user_id (wichtig für getFamily, getDirectorForBranch)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_character_user_id
|
||||||
|
ON falukant_data.character (user_id);
|
||||||
|
|
||||||
|
-- Index für Director-Proposals
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_director_proposal_employer_character
|
||||||
|
ON falukant_data.director_proposal (employer_user_id, director_character_id);
|
||||||
|
|
||||||
|
-- Index für aktive Direktoren
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_director_character_id
|
||||||
|
ON falukant_data.director (director_character_id);
|
||||||
|
|
||||||
|
-- Index für Director-Suche nach employer_user_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_director_employer_user_id
|
||||||
|
ON falukant_data.director (employer_user_id);
|
||||||
|
|
||||||
|
-- Index für Knowledge-Berechnung
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_character_id
|
||||||
|
ON falukant_data.knowledge (character_id);
|
||||||
|
|
||||||
|
-- Index für Relationships (getFamily)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relationship_character1_id
|
||||||
|
ON falukant_data.relationship (character1_id);
|
||||||
|
|
||||||
|
-- Index für ChildRelations (getFamily)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_child_relation_father_id
|
||||||
|
ON falukant_data.child_relation (father_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_child_relation_mother_id
|
||||||
|
ON falukant_data.child_relation (mother_id);
|
||||||
132
backend/migrations/20260115000000-add-vocab-courses.cjs
Normal file
132
backend/migrations/20260115000000-add-vocab-courses.cjs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
// Kurs-Tabelle
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
difficulty_level INTEGER DEFAULT 1,
|
||||||
|
is_public BOOLEAN DEFAULT false,
|
||||||
|
share_code TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_owner_fk
|
||||||
|
FOREIGN KEY (owner_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Lektionen innerhalb eines Kurses
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
chapter_id INTEGER NOT NULL,
|
||||||
|
lesson_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_lesson_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_lesson_chapter_fk
|
||||||
|
FOREIGN KEY (chapter_id)
|
||||||
|
REFERENCES community.vocab_chapter(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Einschreibungen in Kurse
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_enrollment_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_enrollment_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fortschritt pro User und Lektion
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
score INTEGER DEFAULT 0,
|
||||||
|
last_accessed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_course_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Indizes
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_owner_idx
|
||||||
|
ON community.vocab_course(owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_language_idx
|
||||||
|
ON community.vocab_course(language_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_public_idx
|
||||||
|
ON community.vocab_course(is_public);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx
|
||||||
|
ON community.vocab_course_lesson(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx
|
||||||
|
ON community.vocab_course_lesson(chapter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx
|
||||||
|
ON community.vocab_course_enrollment(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx
|
||||||
|
ON community.vocab_course_enrollment(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx
|
||||||
|
ON community.vocab_course_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx
|
||||||
|
ON community.vocab_course_progress(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx
|
||||||
|
ON community.vocab_course_progress(lesson_id);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP TABLE IF EXISTS community.vocab_course_progress CASCADE;
|
||||||
|
DROP TABLE IF EXISTS community.vocab_course_enrollment CASCADE;
|
||||||
|
DROP TABLE IF EXISTS community.vocab_course_lesson CASCADE;
|
||||||
|
DROP TABLE IF EXISTS community.vocab_course CASCADE;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
// Grammatik-Übungstypen (z.B. "gap_fill", "multiple_choice", "sentence_building", "transformation")
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Grammatik-Übungen (verknüpft mit Lektionen)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
exercise_type_id INTEGER NOT NULL,
|
||||||
|
exercise_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
instruction TEXT,
|
||||||
|
question_data JSONB NOT NULL,
|
||||||
|
answer_data JSONB NOT NULL,
|
||||||
|
explanation TEXT,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_grammar_exercise_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_type_fk
|
||||||
|
FOREIGN KEY (exercise_type_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise_type(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Fortschritt für Grammatik-Übungen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
exercise_id INTEGER NOT NULL,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
correct_attempts INTEGER DEFAULT 0,
|
||||||
|
last_attempt_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_exercise_fk
|
||||||
|
FOREIGN KEY (exercise_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Indizes
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx
|
||||||
|
ON community.vocab_grammar_exercise(lesson_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx
|
||||||
|
ON community.vocab_grammar_exercise(exercise_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(exercise_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Standard-Übungstypen einfügen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||||
|
('gap_fill', 'Lückentext-Übung'),
|
||||||
|
('multiple_choice', 'Multiple-Choice-Fragen'),
|
||||||
|
('sentence_building', 'Satzbau-Übung'),
|
||||||
|
('transformation', 'Satzumformung'),
|
||||||
|
('conjugation', 'Konjugations-Übung'),
|
||||||
|
('declension', 'Deklinations-Übung')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP TABLE IF EXISTS community.vocab_grammar_exercise_progress CASCADE;
|
||||||
|
DROP TABLE IF EXISTS community.vocab_grammar_exercise CASCADE;
|
||||||
|
DROP TABLE IF EXISTS community.vocab_grammar_exercise_type CASCADE;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
47
backend/migrations/20260115000002-add-course-structure.cjs
Normal file
47
backend/migrations/20260115000002-add-course-structure.cjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
// chapter_id optional machen (nicht alle Lektionen brauchen ein Kapitel)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
ALTER COLUMN chapter_id DROP NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Kurs-Wochen/Module hinzufügen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
ADD COLUMN IF NOT EXISTS week_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS day_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab',
|
||||||
|
ADD COLUMN IF NOT EXISTS audio_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS cultural_notes TEXT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Indizes für Wochen/Tage
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx
|
||||||
|
ON community.vocab_course_lesson(course_id, week_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx
|
||||||
|
ON community.vocab_course_lesson(lesson_type);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Kommentar hinzufügen für lesson_type
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
|
||||||
|
'Type: vocab, grammar, conversation, culture, review';
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
DROP COLUMN IF EXISTS week_number,
|
||||||
|
DROP COLUMN IF EXISTS day_number,
|
||||||
|
DROP COLUMN IF EXISTS lesson_type,
|
||||||
|
DROP COLUMN IF EXISTS audio_url,
|
||||||
|
DROP COLUMN IF EXISTS cultural_notes;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
// Lernziele für Lektionen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
ADD COLUMN IF NOT EXISTS target_minutes INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80,
|
||||||
|
ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Kommentare hinzufügen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
|
||||||
|
'Zielzeit in Minuten für diese Lektion';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
|
||||||
|
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
|
||||||
|
'Muss diese Lektion wiederholt werden, wenn Ziel nicht erreicht?';
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
DROP COLUMN IF EXISTS target_minutes,
|
||||||
|
DROP COLUMN IF EXISTS target_score_percent,
|
||||||
|
DROP COLUMN IF EXISTS requires_review;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import ChatUser from './chat/user.js';
|
|||||||
import Room from './chat/room.js';
|
import Room from './chat/room.js';
|
||||||
import User from './community/user.js';
|
import User from './community/user.js';
|
||||||
import UserParam from './community/user_param.js';
|
import UserParam from './community/user_param.js';
|
||||||
|
import UserDashboard from './community/user_dashboard.js';
|
||||||
import UserParamType from './type/user_param.js';
|
import UserParamType from './type/user_param.js';
|
||||||
import UserRightType from './type/user_right.js';
|
import UserRightType from './type/user_right.js';
|
||||||
import UserRight from './community/user_right.js';
|
import UserRight from './community/user_right.js';
|
||||||
@@ -93,6 +94,10 @@ import PoliticalOfficeRequirement from './falukant/predefine/political_office_pr
|
|||||||
import PoliticalOfficePrerequisite from './falukant/predefine/political_office_prerequisite.js';
|
import PoliticalOfficePrerequisite from './falukant/predefine/political_office_prerequisite.js';
|
||||||
import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
|
import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
|
||||||
import ElectionHistory from './falukant/log/election_history.js';
|
import ElectionHistory from './falukant/log/election_history.js';
|
||||||
|
import ChurchOfficeType from './falukant/type/church_office_type.js';
|
||||||
|
import ChurchOfficeRequirement from './falukant/predefine/church_office_requirement.js';
|
||||||
|
import ChurchOffice from './falukant/data/church_office.js';
|
||||||
|
import ChurchApplication from './falukant/data/church_application.js';
|
||||||
import Underground from './falukant/data/underground.js';
|
import Underground from './falukant/data/underground.js';
|
||||||
import UndergroundType from './falukant/type/underground.js';
|
import UndergroundType from './falukant/type/underground.js';
|
||||||
import VehicleType from './falukant/type/vehicle.js';
|
import VehicleType from './falukant/type/vehicle.js';
|
||||||
@@ -102,8 +107,17 @@ import RegionDistance from './falukant/data/region_distance.js';
|
|||||||
import WeatherType from './falukant/type/weather.js';
|
import WeatherType from './falukant/type/weather.js';
|
||||||
import Weather from './falukant/data/weather.js';
|
import Weather from './falukant/data/weather.js';
|
||||||
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
|
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
|
||||||
|
import ProductPriceHistory from './falukant/log/product_price_history.js';
|
||||||
import Blog from './community/blog.js';
|
import Blog from './community/blog.js';
|
||||||
import BlogPost from './community/blog_post.js';
|
import BlogPost from './community/blog_post.js';
|
||||||
|
import VocabCourse from './community/vocab_course.js';
|
||||||
|
import VocabCourseLesson from './community/vocab_course_lesson.js';
|
||||||
|
import VocabCourseEnrollment from './community/vocab_course_enrollment.js';
|
||||||
|
import VocabCourseProgress from './community/vocab_course_progress.js';
|
||||||
|
import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js';
|
||||||
|
import VocabGrammarExercise from './community/vocab_grammar_exercise.js';
|
||||||
|
import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js';
|
||||||
|
import CalendarEvent from './community/calendar_event.js';
|
||||||
import Campaign from './match3/campaign.js';
|
import Campaign from './match3/campaign.js';
|
||||||
import Match3Level from './match3/level.js';
|
import Match3Level from './match3/level.js';
|
||||||
import Objective from './match3/objective.js';
|
import Objective from './match3/objective.js';
|
||||||
@@ -155,6 +169,9 @@ export default function setupAssociations() {
|
|||||||
User.hasMany(UserParam, { foreignKey: 'userId', as: 'user_params' });
|
User.hasMany(UserParam, { foreignKey: 'userId', as: 'user_params' });
|
||||||
UserParam.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
UserParam.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
|
User.hasOne(UserDashboard, { foreignKey: 'userId', as: 'dashboard' });
|
||||||
|
UserDashboard.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
|
||||||
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' });
|
||||||
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' });
|
||||||
|
|
||||||
@@ -405,6 +422,13 @@ export default function setupAssociations() {
|
|||||||
DaySell.belongsTo(FalukantUser, { foreignKey: 'sellerId', as: 'user' });
|
DaySell.belongsTo(FalukantUser, { foreignKey: 'sellerId', as: 'user' });
|
||||||
FalukantUser.hasMany(DaySell, { foreignKey: 'sellerId', as: 'daySells' });
|
FalukantUser.hasMany(DaySell, { foreignKey: 'sellerId', as: 'daySells' });
|
||||||
|
|
||||||
|
// Produkt-Preishistorie (Zeitreihe für Preiskurven)
|
||||||
|
ProductPriceHistory.belongsTo(ProductType, { foreignKey: 'productId', as: 'productType' });
|
||||||
|
ProductType.hasMany(ProductPriceHistory, { foreignKey: 'productId', as: 'priceHistory' });
|
||||||
|
|
||||||
|
ProductPriceHistory.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' });
|
||||||
|
RegionData.hasMany(ProductPriceHistory, { foreignKey: 'regionId', as: 'productPriceHistory' });
|
||||||
|
|
||||||
Notification.belongsTo(FalukantUser, { foreignKey: 'userId', as: 'user' });
|
Notification.belongsTo(FalukantUser, { foreignKey: 'userId', as: 'user' });
|
||||||
FalukantUser.hasMany(Notification, { foreignKey: 'userId', as: 'notifications' });
|
FalukantUser.hasMany(Notification, { foreignKey: 'userId', as: 'notifications' });
|
||||||
|
|
||||||
@@ -558,14 +582,14 @@ export default function setupAssociations() {
|
|||||||
|
|
||||||
Party.belongsToMany(TitleOfNobility, {
|
Party.belongsToMany(TitleOfNobility, {
|
||||||
through: PartyInvitedNobility,
|
through: PartyInvitedNobility,
|
||||||
foreignKey: 'party_id',
|
foreignKey: 'partyId',
|
||||||
otherKey: 'title_of_nobility_id',
|
otherKey: 'titleOfNobilityId',
|
||||||
as: 'invitedNobilities',
|
as: 'invitedNobilities',
|
||||||
});
|
});
|
||||||
TitleOfNobility.belongsToMany(Party, {
|
TitleOfNobility.belongsToMany(Party, {
|
||||||
through: PartyInvitedNobility,
|
through: PartyInvitedNobility,
|
||||||
foreignKey: 'title_of_nobility_id',
|
foreignKey: 'titleOfNobilityId',
|
||||||
otherKey: 'party_id',
|
otherKey: 'partyId',
|
||||||
as: 'partiesInvitedTo',
|
as: 'partiesInvitedTo',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -859,6 +883,96 @@ export default function setupAssociations() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// — Church Offices —
|
||||||
|
|
||||||
|
// Requirements for church office
|
||||||
|
ChurchOfficeRequirement.belongsTo(ChurchOfficeType, {
|
||||||
|
foreignKey: 'officeTypeId',
|
||||||
|
as: 'officeType'
|
||||||
|
});
|
||||||
|
ChurchOfficeType.hasMany(ChurchOfficeRequirement, {
|
||||||
|
foreignKey: 'officeTypeId',
|
||||||
|
as: 'requirements'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prerequisite office type
|
||||||
|
ChurchOfficeRequirement.belongsTo(ChurchOfficeType, {
|
||||||
|
foreignKey: 'prerequisiteOfficeTypeId',
|
||||||
|
as: 'prerequisiteOfficeType'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actual church office holdings
|
||||||
|
ChurchOffice.belongsTo(ChurchOfficeType, {
|
||||||
|
foreignKey: 'officeTypeId',
|
||||||
|
as: 'type'
|
||||||
|
});
|
||||||
|
ChurchOfficeType.hasMany(ChurchOffice, {
|
||||||
|
foreignKey: 'officeTypeId',
|
||||||
|
as: 'offices'
|
||||||
|
});
|
||||||
|
|
||||||
|
ChurchOffice.belongsTo(FalukantCharacter, {
|
||||||
|
foreignKey: 'characterId',
|
||||||
|
as: 'holder'
|
||||||
|
});
|
||||||
|
FalukantCharacter.hasOne(ChurchOffice, {
|
||||||
|
foreignKey: 'characterId',
|
||||||
|
as: 'heldChurchOffice'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Supervisor relationship
|
||||||
|
ChurchOffice.belongsTo(FalukantCharacter, {
|
||||||
|
foreignKey: 'supervisorId',
|
||||||
|
as: 'supervisor'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Region relationship
|
||||||
|
ChurchOffice.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'regionId',
|
||||||
|
as: 'region'
|
||||||
|
});
|
||||||
|
RegionData.hasMany(ChurchOffice, {
|
||||||
|
foreignKey: 'regionId',
|
||||||
|
as: 'churchOffices'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Applications for church office
|
||||||
|
ChurchApplication.belongsTo(ChurchOfficeType, {
|
||||||
|
foreignKey: 'officeTypeId',
|
||||||
|
as: 'officeType'
|
||||||
|
});
|
||||||
|
ChurchOfficeType.hasMany(ChurchApplication, {
|
||||||
|
foreignKey: 'officeTypeId',
|
||||||
|
as: 'applications'
|
||||||
|
});
|
||||||
|
|
||||||
|
ChurchApplication.belongsTo(FalukantCharacter, {
|
||||||
|
foreignKey: 'characterId',
|
||||||
|
as: 'applicant'
|
||||||
|
});
|
||||||
|
FalukantCharacter.hasMany(ChurchApplication, {
|
||||||
|
foreignKey: 'characterId',
|
||||||
|
as: 'churchApplications'
|
||||||
|
});
|
||||||
|
|
||||||
|
ChurchApplication.belongsTo(FalukantCharacter, {
|
||||||
|
foreignKey: 'supervisorId',
|
||||||
|
as: 'supervisor'
|
||||||
|
});
|
||||||
|
FalukantCharacter.hasMany(ChurchApplication, {
|
||||||
|
foreignKey: 'supervisorId',
|
||||||
|
as: 'supervisedApplications'
|
||||||
|
});
|
||||||
|
|
||||||
|
ChurchApplication.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'regionId',
|
||||||
|
as: 'region'
|
||||||
|
});
|
||||||
|
RegionData.hasMany(ChurchApplication, {
|
||||||
|
foreignKey: 'regionId',
|
||||||
|
as: 'churchApplications'
|
||||||
|
});
|
||||||
|
|
||||||
Underground.belongsTo(UndergroundType, {
|
Underground.belongsTo(UndergroundType, {
|
||||||
foreignKey: 'undergroundTypeId',
|
foreignKey: 'undergroundTypeId',
|
||||||
as: 'undergroundType'
|
as: 'undergroundType'
|
||||||
@@ -941,5 +1055,41 @@ export default function setupAssociations() {
|
|||||||
|
|
||||||
TaxiHighscore.belongsTo(TaxiMap, { foreignKey: 'mapId', as: 'map' });
|
TaxiHighscore.belongsTo(TaxiMap, { foreignKey: 'mapId', as: 'map' });
|
||||||
TaxiMap.hasMany(TaxiHighscore, { foreignKey: 'mapId', as: 'highscores' });
|
TaxiMap.hasMany(TaxiHighscore, { foreignKey: 'mapId', as: 'highscores' });
|
||||||
|
|
||||||
|
// Vocab Course associations
|
||||||
|
VocabCourse.belongsTo(User, { foreignKey: 'ownerUserId', as: 'owner' });
|
||||||
|
User.hasMany(VocabCourse, { foreignKey: 'ownerUserId', as: 'ownedCourses' });
|
||||||
|
|
||||||
|
VocabCourseLesson.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' });
|
||||||
|
VocabCourse.hasMany(VocabCourseLesson, { foreignKey: 'courseId', as: 'lessons' });
|
||||||
|
|
||||||
|
VocabCourseEnrollment.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
User.hasMany(VocabCourseEnrollment, { foreignKey: 'userId', as: 'courseEnrollments' });
|
||||||
|
VocabCourseEnrollment.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' });
|
||||||
|
VocabCourse.hasMany(VocabCourseEnrollment, { foreignKey: 'courseId', as: 'enrollments' });
|
||||||
|
|
||||||
|
VocabCourseProgress.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
User.hasMany(VocabCourseProgress, { foreignKey: 'userId', as: 'courseProgress' });
|
||||||
|
VocabCourseProgress.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' });
|
||||||
|
VocabCourse.hasMany(VocabCourseProgress, { foreignKey: 'courseId', as: 'progress' });
|
||||||
|
VocabCourseProgress.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
|
||||||
|
VocabCourseLesson.hasMany(VocabCourseProgress, { foreignKey: 'lessonId', as: 'progress' });
|
||||||
|
|
||||||
|
// Grammar Exercise associations
|
||||||
|
VocabGrammarExercise.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
|
||||||
|
VocabCourseLesson.hasMany(VocabGrammarExercise, { foreignKey: 'lessonId', as: 'grammarExercises' });
|
||||||
|
VocabGrammarExercise.belongsTo(VocabGrammarExerciseType, { foreignKey: 'exerciseTypeId', as: 'exerciseType' });
|
||||||
|
VocabGrammarExerciseType.hasMany(VocabGrammarExercise, { foreignKey: 'exerciseTypeId', as: 'exercises' });
|
||||||
|
VocabGrammarExercise.belongsTo(User, { foreignKey: 'createdByUserId', as: 'creator' });
|
||||||
|
User.hasMany(VocabGrammarExercise, { foreignKey: 'createdByUserId', as: 'createdGrammarExercises' });
|
||||||
|
|
||||||
|
VocabGrammarExerciseProgress.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
User.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'userId', as: 'grammarExerciseProgress' });
|
||||||
|
VocabGrammarExerciseProgress.belongsTo(VocabGrammarExercise, { foreignKey: 'exerciseId', as: 'exercise' });
|
||||||
|
VocabGrammarExercise.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'exerciseId', as: 'progress' });
|
||||||
|
|
||||||
|
// Calendar associations
|
||||||
|
CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||||
|
User.hasMany(CalendarEvent, { foreignKey: 'userId', as: 'calendarEvents' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
86
backend/models/community/calendar_event.js
Normal file
86
backend/models/community/calendar_event.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class CalendarEvent extends Model { }
|
||||||
|
|
||||||
|
CalendarEvent.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'user',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
categoryId: {
|
||||||
|
type: DataTypes.STRING(50),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'personal',
|
||||||
|
comment: 'Category key: personal, work, family, health, birthday, holiday, reminder, other'
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'End date for multi-day events, null means same as startDate'
|
||||||
|
},
|
||||||
|
startTime: {
|
||||||
|
type: DataTypes.TIME,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Start time, null for all-day events'
|
||||||
|
},
|
||||||
|
endTime: {
|
||||||
|
type: DataTypes.TIME,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'End time, null for all-day events'
|
||||||
|
},
|
||||||
|
allDay: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'CalendarEvent',
|
||||||
|
tableName: 'calendar_event',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['user_id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id', 'start_date']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['user_id', 'start_date', 'end_date']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CalendarEvent;
|
||||||
24
backend/models/community/user_dashboard.js
Normal file
24
backend/models/community/user_dashboard.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import User from './user.js';
|
||||||
|
|
||||||
|
const UserDashboard = sequelize.define('user_dashboard', {
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
primaryKey: true,
|
||||||
|
references: { model: User, key: 'id' }
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: { widgets: [] }
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'user_dashboard',
|
||||||
|
schema: 'community',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export default UserDashboard;
|
||||||
75
backend/models/community/vocab_course.js
Normal file
75
backend/models/community/vocab_course.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabCourse extends Model {}
|
||||||
|
|
||||||
|
VocabCourse.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
ownerUserId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'owner_user_id'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
languageId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'language_id'
|
||||||
|
},
|
||||||
|
nativeLanguageId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'native_language_id',
|
||||||
|
comment: 'Muttersprache des Lerners (z.B. Deutsch, Englisch). NULL bedeutet "für alle Sprachen".'
|
||||||
|
},
|
||||||
|
difficultyLevel: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 1,
|
||||||
|
field: 'difficulty_level'
|
||||||
|
},
|
||||||
|
isPublic: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
field: 'is_public'
|
||||||
|
},
|
||||||
|
shareCode: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
field: 'share_code'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'updated_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabCourse',
|
||||||
|
tableName: 'vocab_course',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabCourse;
|
||||||
37
backend/models/community/vocab_course_enrollment.js
Normal file
37
backend/models/community/vocab_course_enrollment.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabCourseEnrollment extends Model {}
|
||||||
|
|
||||||
|
VocabCourseEnrollment.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'user_id'
|
||||||
|
},
|
||||||
|
courseId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'course_id'
|
||||||
|
},
|
||||||
|
enrolledAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'enrolled_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabCourseEnrollment',
|
||||||
|
tableName: 'vocab_course_enrollment',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabCourseEnrollment;
|
||||||
93
backend/models/community/vocab_course_lesson.js
Normal file
93
backend/models/community/vocab_course_lesson.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabCourseLesson extends Model {}
|
||||||
|
|
||||||
|
VocabCourseLesson.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
courseId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'course_id'
|
||||||
|
},
|
||||||
|
chapterId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'chapter_id'
|
||||||
|
},
|
||||||
|
lessonNumber: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'lesson_number'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
weekNumber: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'week_number'
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'day_number'
|
||||||
|
},
|
||||||
|
lessonType: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'vocab',
|
||||||
|
field: 'lesson_type'
|
||||||
|
},
|
||||||
|
audioUrl: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'audio_url'
|
||||||
|
},
|
||||||
|
culturalNotes: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'cultural_notes'
|
||||||
|
},
|
||||||
|
targetMinutes: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'target_minutes'
|
||||||
|
},
|
||||||
|
targetScorePercent: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 80,
|
||||||
|
field: 'target_score_percent'
|
||||||
|
},
|
||||||
|
requiresReview: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
field: 'requires_review'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabCourseLesson',
|
||||||
|
tableName: 'vocab_course_lesson',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabCourseLesson;
|
||||||
56
backend/models/community/vocab_course_progress.js
Normal file
56
backend/models/community/vocab_course_progress.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabCourseProgress extends Model {}
|
||||||
|
|
||||||
|
VocabCourseProgress.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'user_id'
|
||||||
|
},
|
||||||
|
courseId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'course_id'
|
||||||
|
},
|
||||||
|
lessonId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'lesson_id'
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
lastAccessedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'last_accessed_at'
|
||||||
|
},
|
||||||
|
completedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'completed_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabCourseProgress',
|
||||||
|
tableName: 'vocab_course_progress',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabCourseProgress;
|
||||||
69
backend/models/community/vocab_grammar_exercise.js
Normal file
69
backend/models/community/vocab_grammar_exercise.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabGrammarExercise extends Model {}
|
||||||
|
|
||||||
|
VocabGrammarExercise.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
lessonId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'lesson_id'
|
||||||
|
},
|
||||||
|
exerciseTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'exercise_type_id'
|
||||||
|
},
|
||||||
|
exerciseNumber: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'exercise_number'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
instruction: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
questionData: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'question_data'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'answer_data'
|
||||||
|
},
|
||||||
|
explanation: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
createdByUserId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'created_by_user_id'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabGrammarExercise',
|
||||||
|
tableName: 'vocab_grammar_exercise',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabGrammarExercise;
|
||||||
57
backend/models/community/vocab_grammar_exercise_progress.js
Normal file
57
backend/models/community/vocab_grammar_exercise_progress.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabGrammarExerciseProgress extends Model {}
|
||||||
|
|
||||||
|
VocabGrammarExerciseProgress.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'user_id'
|
||||||
|
},
|
||||||
|
exerciseId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'exercise_id'
|
||||||
|
},
|
||||||
|
attempts: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
correctAttempts: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
field: 'correct_attempts'
|
||||||
|
},
|
||||||
|
lastAttemptAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'last_attempt_at'
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
completedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'completed_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabGrammarExerciseProgress',
|
||||||
|
tableName: 'vocab_grammar_exercise_progress',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabGrammarExerciseProgress;
|
||||||
36
backend/models/community/vocab_grammar_exercise_type.js
Normal file
36
backend/models/community/vocab_grammar_exercise_type.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VocabGrammarExerciseType extends Model {}
|
||||||
|
|
||||||
|
VocabGrammarExerciseType.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
field: 'created_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VocabGrammarExerciseType',
|
||||||
|
tableName: 'vocab_grammar_exercise_type',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VocabGrammarExerciseType;
|
||||||
47
backend/models/falukant/data/church_application.js
Normal file
47
backend/models/falukant/data/church_application.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class ChurchApplication extends Model {}
|
||||||
|
|
||||||
|
ChurchApplication.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
officeTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
characterId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
regionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
supervisorId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'ID des Vorgesetzten, der über die Bewerbung entscheidet (null für Einstiegspositionen ohne Supervisor)'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('pending', 'approved', 'rejected'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'pending'
|
||||||
|
},
|
||||||
|
decisionDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ChurchApplication',
|
||||||
|
tableName: 'church_application',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChurchApplication;
|
||||||
38
backend/models/falukant/data/church_office.js
Normal file
38
backend/models/falukant/data/church_office.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class ChurchOffice extends Model {}
|
||||||
|
|
||||||
|
ChurchOffice.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
officeTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
characterId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
regionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
supervisorId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'ID des Vorgesetzten (höhere Position in der Hierarchie)'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ChurchOffice',
|
||||||
|
tableName: 'church_office',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChurchOffice;
|
||||||
@@ -22,7 +22,12 @@ Production.init({
|
|||||||
startTimestamp: {
|
startTimestamp: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: sequelize.literal('CURRENT_TIMESTAMP')}
|
defaultValue: sequelize.literal('CURRENT_TIMESTAMP')},
|
||||||
|
sleep: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
comment: 'Produktion ist zurückgestellt'}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'Production',
|
modelName: 'Production',
|
||||||
|
|||||||
@@ -10,11 +10,20 @@ RegionData.init({
|
|||||||
allowNull: false},
|
allowNull: false},
|
||||||
regionTypeId: {
|
regionTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: RegionType,
|
||||||
|
key: 'id',
|
||||||
|
schema: 'falukant_type'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
parentId: {
|
parentId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'region',
|
||||||
|
key: 'id',
|
||||||
|
schema: 'falukant_data'}
|
||||||
},
|
},
|
||||||
map: {
|
map: {
|
||||||
type: DataTypes.JSONB,
|
type: DataTypes.JSONB,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ class FalukantStock extends Model { }
|
|||||||
FalukantStock.init({
|
FalukantStock.init({
|
||||||
branchId: {
|
branchId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
stockTypeId: {
|
stockTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|||||||
44
backend/models/falukant/log/product_price_history.js
Normal file
44
backend/models/falukant/log/product_price_history.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preishistorie pro Produkt und Region (Zeitreihe für Preis-Graphen).
|
||||||
|
* Aktuell wird diese Tabelle noch nicht befüllt; sie dient nur als Grundlage.
|
||||||
|
*/
|
||||||
|
class ProductPriceHistory extends Model { }
|
||||||
|
|
||||||
|
ProductPriceHistory.init({
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
regionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: DataTypes.DECIMAL(12, 2),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
recordedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: sequelize.literal('CURRENT_TIMESTAMP')
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ProductPriceHistory',
|
||||||
|
tableName: 'product_price_history',
|
||||||
|
schema: 'falukant_log',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
name: 'product_price_history_product_region_recorded_idx',
|
||||||
|
fields: ['product_id', 'region_id', 'recorded_at']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProductPriceHistory;
|
||||||
|
|
||||||
49
backend/models/falukant/log/relationship_change_log.js
Normal file
49
backend/models/falukant/log/relationship_change_log.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log aller Änderungen an relationship und marriage_proposals.
|
||||||
|
* Einträge werden ausschließlich durch DB-Trigger geschrieben und nicht gelöscht.
|
||||||
|
* Hilft zu analysieren, warum z.B. Werbungen um einen Partner verschwinden.
|
||||||
|
*/
|
||||||
|
class RelationshipChangeLog extends Model {}
|
||||||
|
|
||||||
|
RelationshipChangeLog.init(
|
||||||
|
{
|
||||||
|
changedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
tableName: {
|
||||||
|
type: DataTypes.STRING(64),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
type: DataTypes.STRING(16),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
recordId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
payloadOld: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
payloadNew: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'RelationshipChangeLog',
|
||||||
|
tableName: 'relationship_change_log',
|
||||||
|
schema: 'falukant_log',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RelationshipChangeLog;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class ChurchOfficeRequirement extends Model {}
|
||||||
|
|
||||||
|
ChurchOfficeRequirement.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
officeTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
prerequisiteOfficeTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Erforderliche niedrigere Position in der Hierarchie'
|
||||||
|
},
|
||||||
|
minTitleLevel: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Mindest-Titel-Level (optional)'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ChurchOfficeRequirement',
|
||||||
|
tableName: 'church_office_requirement',
|
||||||
|
schema: 'falukant_predefine',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChurchOfficeRequirement;
|
||||||
@@ -10,12 +10,14 @@ PromotionalGiftCharacterTrait.init(
|
|||||||
giftId: {
|
giftId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'gift_id',
|
field: 'gift_id',
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
},
|
},
|
||||||
traitId: {
|
traitId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'trait_id',
|
field: 'trait_id',
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
},
|
},
|
||||||
suitability: {
|
suitability: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ PromotionalGiftMood.init(
|
|||||||
giftId: {
|
giftId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'gift_id',
|
field: 'gift_id',
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
},
|
},
|
||||||
moodId: {
|
moodId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'mood_id',
|
field: 'mood_id',
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
primaryKey: true
|
||||||
},
|
},
|
||||||
suitability: {
|
suitability: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|||||||
38
backend/models/falukant/type/church_office_type.js
Normal file
38
backend/models/falukant/type/church_office_type.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class ChurchOfficeType extends Model {}
|
||||||
|
|
||||||
|
ChurchOfficeType.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
seatsPerRegion: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
regionType: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
hierarchyLevel: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Höhere Zahl = höhere Position in der Hierarchie'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ChurchOfficeType',
|
||||||
|
tableName: 'church_office_type',
|
||||||
|
schema: 'falukant_type',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChurchOfficeType;
|
||||||
@@ -15,7 +15,8 @@ ProductType.init({
|
|||||||
allowNull: false},
|
allowNull: false},
|
||||||
sellCost: {
|
sellCost: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false}
|
allowNull: false
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'ProductType',
|
modelName: 'ProductType',
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import SettingsType from './type/settings.js';
|
|||||||
import UserParamValue from './type/user_param_value.js';
|
import UserParamValue from './type/user_param_value.js';
|
||||||
import UserParamType from './type/user_param.js';
|
import UserParamType from './type/user_param.js';
|
||||||
import UserRightType from './type/user_right.js';
|
import UserRightType from './type/user_right.js';
|
||||||
|
import WidgetType from './type/widget_type.js';
|
||||||
import User from './community/user.js';
|
import User from './community/user.js';
|
||||||
import UserParam from './community/user_param.js';
|
import UserParam from './community/user_param.js';
|
||||||
|
import UserDashboard from './community/user_dashboard.js';
|
||||||
import Login from './logs/login.js';
|
import Login from './logs/login.js';
|
||||||
import UserRight from './community/user_right.js';
|
import UserRight from './community/user_right.js';
|
||||||
import InterestType from './type/interest.js';
|
import InterestType from './type/interest.js';
|
||||||
@@ -87,6 +89,7 @@ import Learning from './falukant/data/learning.js';
|
|||||||
import Credit from './falukant/data/credit.js';
|
import Credit from './falukant/data/credit.js';
|
||||||
import DebtorsPrism from './falukant/data/debtors_prism.js';
|
import DebtorsPrism from './falukant/data/debtors_prism.js';
|
||||||
import HealthActivity from './falukant/log/health_activity.js';
|
import HealthActivity from './falukant/log/health_activity.js';
|
||||||
|
import ProductPriceHistory from './falukant/log/product_price_history.js';
|
||||||
|
|
||||||
// — Match3 Minigame —
|
// — Match3 Minigame —
|
||||||
import Match3Campaign from './match3/campaign.js';
|
import Match3Campaign from './match3/campaign.js';
|
||||||
@@ -113,6 +116,13 @@ import Vote from './falukant/data/vote.js';
|
|||||||
import ElectionResult from './falukant/data/election_result.js';
|
import ElectionResult from './falukant/data/election_result.js';
|
||||||
import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
|
import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
|
||||||
import ElectionHistory from './falukant/log/election_history.js';
|
import ElectionHistory from './falukant/log/election_history.js';
|
||||||
|
import RelationshipChangeLog from './falukant/log/relationship_change_log.js';
|
||||||
|
|
||||||
|
// — Kirchliche Ämter (Church) —
|
||||||
|
import ChurchOfficeType from './falukant/type/church_office_type.js';
|
||||||
|
import ChurchOfficeRequirement from './falukant/predefine/church_office_requirement.js';
|
||||||
|
import ChurchOffice from './falukant/data/church_office.js';
|
||||||
|
import ChurchApplication from './falukant/data/church_application.js';
|
||||||
import UndergroundType from './falukant/type/underground.js';
|
import UndergroundType from './falukant/type/underground.js';
|
||||||
import Underground from './falukant/data/underground.js';
|
import Underground from './falukant/data/underground.js';
|
||||||
import VehicleType from './falukant/type/vehicle.js';
|
import VehicleType from './falukant/type/vehicle.js';
|
||||||
@@ -129,13 +139,25 @@ import ChatRight from './chat/rights.js';
|
|||||||
import ChatUserRight from './chat/user_rights.js';
|
import ChatUserRight from './chat/user_rights.js';
|
||||||
import RoomType from './chat/room_type.js';
|
import RoomType from './chat/room_type.js';
|
||||||
|
|
||||||
|
// — Vocab Courses —
|
||||||
|
import VocabCourse from './community/vocab_course.js';
|
||||||
|
import VocabCourseLesson from './community/vocab_course_lesson.js';
|
||||||
|
import VocabCourseEnrollment from './community/vocab_course_enrollment.js';
|
||||||
|
import VocabCourseProgress from './community/vocab_course_progress.js';
|
||||||
|
import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js';
|
||||||
|
import VocabGrammarExercise from './community/vocab_grammar_exercise.js';
|
||||||
|
import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js';
|
||||||
|
import CalendarEvent from './community/calendar_event.js';
|
||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
SettingsType,
|
SettingsType,
|
||||||
UserParamValue,
|
UserParamValue,
|
||||||
UserParamType,
|
UserParamType,
|
||||||
UserRightType,
|
UserRightType,
|
||||||
|
WidgetType,
|
||||||
User,
|
User,
|
||||||
UserParam,
|
UserParam,
|
||||||
|
UserDashboard,
|
||||||
Login,
|
Login,
|
||||||
UserRight,
|
UserRight,
|
||||||
InterestType,
|
InterestType,
|
||||||
@@ -218,6 +240,7 @@ const models = {
|
|||||||
Credit,
|
Credit,
|
||||||
DebtorsPrism,
|
DebtorsPrism,
|
||||||
HealthActivity,
|
HealthActivity,
|
||||||
|
ProductPriceHistory,
|
||||||
RegionDistance,
|
RegionDistance,
|
||||||
VehicleType,
|
VehicleType,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
@@ -233,6 +256,11 @@ const models = {
|
|||||||
ElectionResult,
|
ElectionResult,
|
||||||
PoliticalOfficeHistory,
|
PoliticalOfficeHistory,
|
||||||
ElectionHistory,
|
ElectionHistory,
|
||||||
|
RelationshipChangeLog,
|
||||||
|
ChurchOfficeType,
|
||||||
|
ChurchOfficeRequirement,
|
||||||
|
ChurchOffice,
|
||||||
|
ChurchApplication,
|
||||||
UndergroundType,
|
UndergroundType,
|
||||||
Underground,
|
Underground,
|
||||||
WeatherType,
|
WeatherType,
|
||||||
@@ -263,6 +291,18 @@ const models = {
|
|||||||
TaxiMapTileStreet,
|
TaxiMapTileStreet,
|
||||||
TaxiMapTileHouse,
|
TaxiMapTileHouse,
|
||||||
TaxiHighscore,
|
TaxiHighscore,
|
||||||
|
|
||||||
|
// Vocab Courses
|
||||||
|
VocabCourse,
|
||||||
|
VocabCourseLesson,
|
||||||
|
VocabCourseEnrollment,
|
||||||
|
VocabCourseProgress,
|
||||||
|
VocabGrammarExerciseType,
|
||||||
|
VocabGrammarExercise,
|
||||||
|
VocabGrammarExerciseProgress,
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
CalendarEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default models;
|
export default models;
|
||||||
|
|||||||
@@ -350,15 +350,16 @@ export async function createTriggers() {
|
|||||||
SELECT * FROM random_fill
|
SELECT * FROM random_fill
|
||||||
),
|
),
|
||||||
|
|
||||||
-- 8) Neue Ämter anlegen und sofort zurückliefern
|
-- 8) Neue Ämter anlegen – created_at = Wahldatum (Amtsbeginn), nicht NOW()
|
||||||
|
-- damit termEnds = Amtsbeginn + termLength korrekt berechnet werden kann
|
||||||
created_offices AS (
|
created_offices AS (
|
||||||
INSERT INTO falukant_data.political_office
|
INSERT INTO falukant_data.political_office
|
||||||
(office_type_id, character_id, created_at, updated_at, region_id)
|
(office_type_id, character_id, created_at, updated_at, region_id)
|
||||||
SELECT
|
SELECT
|
||||||
tp.tp_office_type_id,
|
tp.tp_office_type_id,
|
||||||
fw.character_id,
|
fw.character_id,
|
||||||
NOW() AS created_at,
|
tp.tp_election_date AS created_at,
|
||||||
NOW() AS updated_at,
|
tp.tp_election_date AS updated_at,
|
||||||
tp.tp_region_id
|
tp.tp_region_id
|
||||||
FROM final_winners fw
|
FROM final_winners fw
|
||||||
JOIN to_process tp
|
JOIN to_process tp
|
||||||
|
|||||||
39
backend/models/type/widget_type.js
Normal file
39
backend/models/type/widget_type.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
|
||||||
|
const WidgetType = sequelize.define('widget_type', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Anzeigename des Widgets (z. B. "Termine")'
|
||||||
|
},
|
||||||
|
endpoint: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'API-Pfad (z. B. "/api/termine")'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Optionale Beschreibung'
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
field: 'order_id',
|
||||||
|
comment: 'Sortierreihenfolge'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'widget_type',
|
||||||
|
schema: 'type',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WidgetType;
|
||||||
4322
backend/package-lock.json
generated
4322
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,9 @@
|
|||||||
"dev": "NODE_ENV=development node server.js",
|
"dev": "NODE_ENV=development node server.js",
|
||||||
"start-daemon": "node daemonServer.js",
|
"start-daemon": "node daemonServer.js",
|
||||||
"sync-db": "node sync-database.js",
|
"sync-db": "node sync-database.js",
|
||||||
|
"sync-tables": "node sync-tables-only.js",
|
||||||
|
"check-connections": "node check-connections.js",
|
||||||
|
"cleanup-connections": "node cleanup-connections.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -37,7 +40,8 @@
|
|||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0",
|
||||||
|
"@gltf-transform/cli": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sequelize-cli": "^6.6.2"
|
"sequelize-cli": "^6.6.2"
|
||||||
|
|||||||
20
backend/routers/calendarRouter.js
Normal file
20
backend/routers/calendarRouter.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authMiddleware.js';
|
||||||
|
import calendarController from '../controllers/calendarController.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.get('/events', authenticate, calendarController.getEvents);
|
||||||
|
router.get('/events/:id', authenticate, calendarController.getEvent);
|
||||||
|
router.post('/events', authenticate, calendarController.createEvent);
|
||||||
|
router.put('/events/:id', authenticate, calendarController.updateEvent);
|
||||||
|
router.delete('/events/:id', authenticate, calendarController.deleteEvent);
|
||||||
|
router.get('/birthdays', authenticate, calendarController.getFriendsBirthdays);
|
||||||
|
|
||||||
|
// Widget endpoints
|
||||||
|
router.get('/widget/birthdays', authenticate, calendarController.getWidgetBirthdays);
|
||||||
|
router.get('/widget/upcoming', authenticate, calendarController.getWidgetUpcoming);
|
||||||
|
router.get('/widget/mini', authenticate, calendarController.getWidgetMiniCalendar);
|
||||||
|
|
||||||
|
export default router;
|
||||||
11
backend/routers/dashboardRouter.js
Normal file
11
backend/routers/dashboardRouter.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authMiddleware.js';
|
||||||
|
import dashboardController from '../controllers/dashboardController.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/widgets', authenticate, dashboardController.getAvailableWidgets.bind(dashboardController));
|
||||||
|
router.get('/config', authenticate, dashboardController.getConfig.bind(dashboardController));
|
||||||
|
router.put('/config', authenticate, dashboardController.setConfig.bind(dashboardController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -11,6 +11,7 @@ router.get('/character/affect', falukantController.getCharacterAffect);
|
|||||||
router.get('/name/randomfirstname/:gender', falukantController.randomFirstName);
|
router.get('/name/randomfirstname/:gender', falukantController.randomFirstName);
|
||||||
router.get('/name/randomlastname', falukantController.randomLastName);
|
router.get('/name/randomlastname', falukantController.randomLastName);
|
||||||
router.get('/info', falukantController.getInfo);
|
router.get('/info', falukantController.getInfo);
|
||||||
|
router.get('/dashboard-widget', falukantController.getDashboardWidget);
|
||||||
router.get('/branches/types', falukantController.getBranchTypes);
|
router.get('/branches/types', falukantController.getBranchTypes);
|
||||||
router.get('/branches/:branch', falukantController.getBranch);
|
router.get('/branches/:branch', falukantController.getBranch);
|
||||||
router.get('/branches', falukantController.getBranches);
|
router.get('/branches', falukantController.getBranches);
|
||||||
@@ -28,6 +29,7 @@ router.get('/inventory/?:branchId', falukantController.getInventory);
|
|||||||
router.post('/sell/all', falukantController.sellAllProducts);
|
router.post('/sell/all', falukantController.sellAllProducts);
|
||||||
router.post('/sell', falukantController.sellProduct);
|
router.post('/sell', falukantController.sellProduct);
|
||||||
router.post('/moneyhistory', falukantController.moneyHistory);
|
router.post('/moneyhistory', falukantController.moneyHistory);
|
||||||
|
router.post('/moneyhistory/graph', falukantController.moneyHistoryGraph);
|
||||||
router.get('/storage/:branchId', falukantController.getStorage);
|
router.get('/storage/:branchId', falukantController.getStorage);
|
||||||
router.post('/storage', falukantController.buyStorage);
|
router.post('/storage', falukantController.buyStorage);
|
||||||
router.delete('/storage', falukantController.sellStorage);
|
router.delete('/storage', falukantController.sellStorage);
|
||||||
@@ -38,14 +40,14 @@ router.get('/director/:branchId', falukantController.getDirectorForBranch);
|
|||||||
router.get('/directors', falukantController.getAllDirectors);
|
router.get('/directors', falukantController.getAllDirectors);
|
||||||
router.post('/directors', falukantController.updateDirector);
|
router.post('/directors', falukantController.updateDirector);
|
||||||
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
||||||
|
router.post('/family/cancel-wooing', falukantController.cancelWooing);
|
||||||
router.post('/family/set-heir', falukantController.setHeir);
|
router.post('/family/set-heir', falukantController.setHeir);
|
||||||
router.get('/heirs/potential', falukantController.getPotentialHeirs);
|
|
||||||
router.post('/heirs/select', falukantController.selectHeir);
|
|
||||||
router.get('/family/gifts', falukantController.getGifts);
|
router.get('/family/gifts', falukantController.getGifts);
|
||||||
router.get('/family/children', falukantController.getChildren);
|
router.get('/family/children', falukantController.getChildren);
|
||||||
router.post('/family/gift', falukantController.sendGift);
|
router.post('/family/gift', falukantController.sendGift);
|
||||||
router.get('/family', falukantController.getFamily);
|
router.get('/family', falukantController.getFamily);
|
||||||
router.get('/nobility/titels', falukantController.getTitlesOfNobility);
|
router.get('/nobility/titels', falukantController.getTitlesOfNobility);
|
||||||
|
router.get('/reputation/actions', falukantController.getReputationActions);
|
||||||
router.get('/houses/types', falukantController.getHouseTypes);
|
router.get('/houses/types', falukantController.getHouseTypes);
|
||||||
router.get('/houses/buyable', falukantController.getBuyableHouses);
|
router.get('/houses/buyable', falukantController.getBuyableHouses);
|
||||||
router.get('/houses', falukantController.getUserHouse);
|
router.get('/houses', falukantController.getUserHouse);
|
||||||
@@ -55,10 +57,13 @@ router.post('/houses', falukantController.buyUserHouse);
|
|||||||
router.get('/party/types', falukantController.getPartyTypes);
|
router.get('/party/types', falukantController.getPartyTypes);
|
||||||
router.post('/party', falukantController.createParty);
|
router.post('/party', falukantController.createParty);
|
||||||
router.get('/party', falukantController.getParties);
|
router.get('/party', falukantController.getParties);
|
||||||
router.get('/reputation/actions', falukantController.getReputationActions);
|
|
||||||
router.post('/reputation/actions', falukantController.executeReputationAction);
|
|
||||||
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
||||||
router.post('/church/baptise', falukantController.baptise);
|
router.post('/church/baptise', falukantController.baptise);
|
||||||
|
router.get('/church/overview', falukantController.getChurchOverview);
|
||||||
|
router.get('/church/positions/available', falukantController.getAvailableChurchPositions);
|
||||||
|
router.get('/church/applications/supervised', falukantController.getSupervisedApplications);
|
||||||
|
router.post('/church/positions/apply', falukantController.applyForChurchPosition);
|
||||||
|
router.post('/church/applications/decide', falukantController.decideOnChurchApplication);
|
||||||
router.get('/education', falukantController.getEducation);
|
router.get('/education', falukantController.getEducation);
|
||||||
router.post('/education', falukantController.sendToSchool);
|
router.post('/education', falukantController.sendToSchool);
|
||||||
router.get('/bank/overview', falukantController.getBankOverview);
|
router.get('/bank/overview', falukantController.getBankOverview);
|
||||||
@@ -76,7 +81,9 @@ router.get('/politics/open', falukantController.getOpenPolitics);
|
|||||||
router.post('/politics/open', falukantController.applyForElections);
|
router.post('/politics/open', falukantController.applyForElections);
|
||||||
router.get('/cities', falukantController.getRegions);
|
router.get('/cities', falukantController.getRegions);
|
||||||
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
||||||
|
router.get('/products/prices-in-region', falukantController.getAllProductPricesInRegion);
|
||||||
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
||||||
|
router.post('/products/prices-in-cities-batch', falukantController.getProductPricesInCitiesBatch);
|
||||||
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
||||||
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||||
router.post('/vehicles', falukantController.buyVehicles);
|
router.post('/vehicles', falukantController.buyVehicles);
|
||||||
|
|||||||
28
backend/routers/modelsProxyRouter.js
Normal file
28
backend/routers/modelsProxyRouter.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import { getOptimizedModelPath } from '../services/modelsProxyService.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/models/3d/falukant/characters/:filename
|
||||||
|
* Liefert die Draco-optimierte GLB-Datei (aus Cache oder nach Optimierung).
|
||||||
|
*/
|
||||||
|
router.get('/3d/falukant/characters/:filename', async (req, res) => {
|
||||||
|
const { filename } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachePath = await getOptimizedModelPath(filename);
|
||||||
|
res.setHeader('Content-Type', 'model/gltf-binary');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
res.sendFile(cachePath);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message?.includes('Invalid model filename') || e.message?.includes('not found')) {
|
||||||
|
return res.status(404).send(e.message);
|
||||||
|
}
|
||||||
|
console.error('[models-proxy]', e.message);
|
||||||
|
res.status(500).send('Model optimization failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
9
backend/routers/newsRouter.js
Normal file
9
backend/routers/newsRouter.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/authMiddleware.js';
|
||||||
|
import newsController from '../controllers/newsController.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', authenticate, newsController.getNews.bind(newsController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -8,6 +8,7 @@ const vocabController = new VocabController();
|
|||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
router.get('/languages', vocabController.listLanguages);
|
router.get('/languages', vocabController.listLanguages);
|
||||||
|
router.get('/languages/all', vocabController.listAllLanguages);
|
||||||
router.post('/languages', vocabController.createLanguage);
|
router.post('/languages', vocabController.createLanguage);
|
||||||
router.post('/subscribe', vocabController.subscribe);
|
router.post('/subscribe', vocabController.subscribe);
|
||||||
router.get('/languages/:languageId', vocabController.getLanguage);
|
router.get('/languages/:languageId', vocabController.getLanguage);
|
||||||
@@ -22,6 +23,39 @@ router.get('/chapters/:chapterId', vocabController.getChapter);
|
|||||||
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||||
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
|
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
|
||||||
|
|
||||||
|
// Courses
|
||||||
|
router.post('/courses', vocabController.createCourse);
|
||||||
|
router.get('/courses', vocabController.getCourses);
|
||||||
|
router.get('/courses/my', vocabController.getMyCourses);
|
||||||
|
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
|
||||||
|
router.get('/courses/:courseId', vocabController.getCourse);
|
||||||
|
router.put('/courses/:courseId', vocabController.updateCourse);
|
||||||
|
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
||||||
|
|
||||||
|
// Lessons
|
||||||
|
router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse);
|
||||||
|
router.put('/lessons/:lessonId', vocabController.updateLesson);
|
||||||
|
router.delete('/lessons/:lessonId', vocabController.deleteLesson);
|
||||||
|
|
||||||
|
// Enrollment
|
||||||
|
router.post('/courses/:courseId/enroll', vocabController.enrollInCourse);
|
||||||
|
router.delete('/courses/:courseId/enroll', vocabController.unenrollFromCourse);
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
router.get('/courses/:courseId/progress', vocabController.getCourseProgress);
|
||||||
|
router.get('/lessons/:lessonId', vocabController.getLesson);
|
||||||
|
router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
|
||||||
|
|
||||||
|
// Grammar Exercises
|
||||||
|
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
|
||||||
|
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
|
||||||
|
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
|
||||||
|
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);
|
||||||
|
router.get('/grammar-exercises/:exerciseId', vocabController.getGrammarExercise);
|
||||||
|
router.post('/grammar-exercises/:exerciseId/check', vocabController.checkGrammarExerciseAnswer);
|
||||||
|
router.put('/grammar-exercises/:exerciseId', vocabController.updateGrammarExercise);
|
||||||
|
router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExercise);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
116
backend/scripts/add-bisaya-week1-lessons.js
Normal file
116
backend/scripts/add-bisaya-week1-lessons.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Hinzufügen der Lektionen 9 und 10 (Woche 1 - Wiederholung, Woche 1 - Vokabeltest)
|
||||||
|
* zu bestehenden Bisaya-Kursen, falls diese noch fehlen.
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/add-bisaya-week1-lessons.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
|
||||||
|
const LESSONS_TO_ADD = [
|
||||||
|
{
|
||||||
|
lessonNumber: 9,
|
||||||
|
weekNumber: 1,
|
||||||
|
dayNumber: 5,
|
||||||
|
lessonType: 'review',
|
||||||
|
title: 'Woche 1 - Wiederholung',
|
||||||
|
description: 'Wiederhole alle Inhalte der ersten Woche',
|
||||||
|
culturalNotes: 'Wiederholung ist der Schlüssel zum Erfolg!',
|
||||||
|
targetMinutes: 30,
|
||||||
|
targetScorePercent: 80,
|
||||||
|
requiresReview: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lessonNumber: 10,
|
||||||
|
weekNumber: 1,
|
||||||
|
dayNumber: 5,
|
||||||
|
lessonType: 'vocab',
|
||||||
|
title: 'Woche 1 - Vokabeltest',
|
||||||
|
description: 'Teste dein Wissen aus Woche 1',
|
||||||
|
culturalNotes: null,
|
||||||
|
targetMinutes: 15,
|
||||||
|
targetScorePercent: 80,
|
||||||
|
requiresReview: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function addBisayaWeek1Lessons() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT id, title FROM community.vocab_course WHERE language_id = :languageId`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: bisayaLanguage.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurs(e)\n`);
|
||||||
|
|
||||||
|
let totalAdded = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
for (const lessonData of LESSONS_TO_ADD) {
|
||||||
|
const existing = await VocabCourseLesson.findOne({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
lessonNumber: lessonData.lessonNumber
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(` ⏭️ Lektion ${lessonData.lessonNumber}: "${lessonData.title}" - bereits vorhanden`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await VocabCourseLesson.create({
|
||||||
|
courseId: course.id,
|
||||||
|
chapterId: null,
|
||||||
|
lessonNumber: lessonData.lessonNumber,
|
||||||
|
title: lessonData.title,
|
||||||
|
description: lessonData.description,
|
||||||
|
weekNumber: lessonData.weekNumber,
|
||||||
|
dayNumber: lessonData.dayNumber,
|
||||||
|
lessonType: lessonData.lessonType,
|
||||||
|
culturalNotes: lessonData.culturalNotes,
|
||||||
|
targetMinutes: lessonData.targetMinutes,
|
||||||
|
targetScorePercent: lessonData.targetScorePercent,
|
||||||
|
requiresReview: lessonData.requiresReview
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✅ Lektion ${lessonData.lessonNumber}: "${lessonData.title}" hinzugefügt`);
|
||||||
|
totalAdded++;
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Fertig! ${totalAdded} Lektion(en) hinzugefügt.`);
|
||||||
|
console.log('💡 Führe danach create-bisaya-course-content.js aus, um die Übungen zu erstellen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
addBisayaWeek1Lessons()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
141
backend/scripts/add-grammar-exercises-to-existing-courses.js
Normal file
141
backend/scripts/add-grammar-exercises-to-existing-courses.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Hinzufügen von Grammatik-Übungen zu bestehenden Kursen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/add-grammar-exercises-to-existing-courses.js
|
||||||
|
*
|
||||||
|
* Fügt Beispiel-Grammatik-Übungen zu allen Grammar-Lektionen hinzu, die noch keine Übungen haben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'system'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'admin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
console.error('❌ System-Benutzer nicht gefunden. Bitte erstelle einen System-Benutzer.');
|
||||||
|
throw new Error('System user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Beispiel-Grammatik-Übungen für eine Grammar-Lektion
|
||||||
|
function createExampleGrammarExercises(lessonId, lessonTitle, ownerUserId) {
|
||||||
|
const exercises = [];
|
||||||
|
|
||||||
|
// Beispiel-Übung 1: Gap Fill (Lückentext)
|
||||||
|
exercises.push({
|
||||||
|
lessonId: lessonId,
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
exerciseNumber: 1,
|
||||||
|
title: `${lessonTitle} - Übung 1`,
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Wörtern.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Hallo! Wie geht es {gap}? Mir geht es {gap}, danke!',
|
||||||
|
gaps: 2
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['dir', 'gut']
|
||||||
|
}),
|
||||||
|
explanation: 'Die richtigen Antworten sind "dir" und "gut".',
|
||||||
|
createdByUserId: ownerUserId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beispiel-Übung 2: Multiple Choice
|
||||||
|
exercises.push({
|
||||||
|
lessonId: lessonId,
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: 2,
|
||||||
|
title: `${lessonTitle} - Übung 2`,
|
||||||
|
instruction: 'Wähle die richtige Antwort aus.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Guten Tag"?',
|
||||||
|
options: ['Guten Tag', 'Gute Nacht', 'Auf Wiedersehen', 'Tschüss']
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
}),
|
||||||
|
explanation: 'Die richtige Antwort ist "Guten Tag".',
|
||||||
|
createdByUserId: ownerUserId
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGrammarExercisesToExistingCourses() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
console.log(`Verwende System-Benutzer: ${systemUser.username} (ID: ${systemUser.id})\n`);
|
||||||
|
|
||||||
|
// Finde alle Grammar-Lektionen ohne Übungen
|
||||||
|
const grammarLessons = await sequelize.query(
|
||||||
|
`SELECT l.id, l.title, l.course_id, c.owner_user_id
|
||||||
|
FROM community.vocab_course_lesson l
|
||||||
|
JOIN community.vocab_course c ON c.id = l.course_id
|
||||||
|
WHERE l.lesson_type = 'grammar'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM community.vocab_grammar_exercise e
|
||||||
|
WHERE e.lesson_id = l.id
|
||||||
|
)
|
||||||
|
ORDER BY l.course_id, l.lesson_number`,
|
||||||
|
{
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${grammarLessons.length} Grammar-Lektionen ohne Übungen\n`);
|
||||||
|
|
||||||
|
if (grammarLessons.length === 0) {
|
||||||
|
console.log('✅ Alle Grammar-Lektionen haben bereits Übungen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
for (const lesson of grammarLessons) {
|
||||||
|
const exercises = createExampleGrammarExercises(lesson.id, lesson.title, lesson.owner_user_id);
|
||||||
|
|
||||||
|
for (const exercise of exercises) {
|
||||||
|
await VocabGrammarExercise.create(exercise);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${exercises.length} Übungen zu "${lesson.title}" hinzugefügt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${addedCount} Grammatik-Übungen zu ${grammarLessons.length} Lektionen hinzugefügt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addGrammarExercisesToExistingCourses()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
829
backend/scripts/create-bisaya-course-content.js
Normal file
829
backend/scripts/create-bisaya-course-content.js
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen von sprachspezifischem Content für Bisaya-Kurse
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/create-bisaya-course-content.js
|
||||||
|
*
|
||||||
|
* Erstellt Grammatik-Übungen für alle Lektionen in Bisaya-Kursen basierend auf dem Thema der Lektion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
|
||||||
|
// Bisaya-spezifische Übungen basierend auf Lektionsthemen
|
||||||
|
const BISAYA_EXERCISES = {
|
||||||
|
// Lektion 1: Begrüßungen & Höflichkeit
|
||||||
|
'Begrüßungen & Höflichkeit': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wie geht es dir?" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Begrüßung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wie geht es dir?" auf Bisaya?',
|
||||||
|
options: ['Kumusta ka?', 'Maayo', 'Salamat', 'Palihug']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Kumusta ka?" ist die Standard-Begrüßung auf Bisaya, ähnlich wie "Wie geht es dir?"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Begrüßungen vervollständigen',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Kumusta ka? Maayo {gap} (ich). Salamat.',
|
||||||
|
gaps: 1
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['ko']
|
||||||
|
},
|
||||||
|
explanation: '"Maayo ko" bedeutet "Mir geht es gut". "ko" ist "ich" auf Bisaya.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Was bedeutet "Salamat"?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Salamat"?',
|
||||||
|
options: ['Danke', 'Bitte', 'Entschuldigung', 'Auf Wiedersehen']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Salamat" bedeutet "Danke" auf Bisaya.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Lektion 3: Familienwörter
|
||||||
|
'Familienwörter': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Mutter" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Mutter" auf Bisaya?',
|
||||||
|
options: ['Nanay', 'Tatay', 'Kuya', 'Ate']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Nanay" bedeutet "Mutter" auf Bisaya. "Mama" wird auch verwendet.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Vater" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Vater" auf Bisaya?',
|
||||||
|
options: ['Tatay', 'Nanay', 'Kuya', 'Ate']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Tatay" bedeutet "Vater" auf Bisaya. "Papa" wird auch verwendet.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "älterer Bruder" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "älterer Bruder" auf Bisaya?',
|
||||||
|
options: ['Kuya', 'Ate', 'Nanay', 'Tatay']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Kuya" bedeutet "älterer Bruder" auf Bisaya. Wird auch für respektvolle Anrede von älteren Männern verwendet.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "ältere Schwester" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "ältere Schwester" auf Bisaya?',
|
||||||
|
options: ['Ate', 'Kuya', 'Nanay', 'Tatay']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Ate" bedeutet "ältere Schwester" auf Bisaya. Wird auch für respektvolle Anrede von älteren Frauen verwendet.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Großmutter" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Großmutter" auf Bisaya?',
|
||||||
|
options: ['Lola', 'Lolo', 'Nanay', 'Tatay']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Lola" bedeutet "Großmutter" auf Bisaya.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Großvater" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Großvater" auf Bisaya?',
|
||||||
|
options: ['Lolo', 'Lola', 'Nanay', 'Tatay']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Lolo" bedeutet "Großvater" auf Bisaya.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Familienwörter vervollständigen',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Familienwörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} (Mutter) | {gap} (Vater) | {gap} (älterer Bruder) | {gap} (ältere Schwester) | {gap} (Großmutter) | {gap} (Großvater)',
|
||||||
|
gaps: 6
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Lola', 'Lolo']
|
||||||
|
},
|
||||||
|
explanation: 'Nanay = Mutter, Tatay = Vater, Kuya = älterer Bruder, Ate = ältere Schwester, Lola = Großmutter, Lolo = Großvater.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Familienwörter übersetzen',
|
||||||
|
instruction: 'Übersetze das Familienwort ins Bisaya.',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Mutter',
|
||||||
|
sourceLanguage: 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Nanay',
|
||||||
|
alternatives: ['Mama', 'Nanay', 'Inahan']
|
||||||
|
},
|
||||||
|
explanation: '"Nanay" oder "Mama" bedeutet "Mutter" auf Bisaya.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Lektion 15: Zeitformen - Grundlagen
|
||||||
|
'Zeitformen - Grundlagen': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Vergangenheit verstehen',
|
||||||
|
instruction: 'Was bedeutet "Ni-kaon ko"?',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Ni-kaon ko',
|
||||||
|
sourceLanguage: 'Bisaya',
|
||||||
|
targetLanguage: 'Deutsch'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Ich habe gegessen',
|
||||||
|
alternatives: ['I ate', 'I have eaten']
|
||||||
|
},
|
||||||
|
explanation: 'Das Präfix "Ni-" zeigt die Vergangenheit an. "Ni-kaon ko" bedeutet "Ich habe gegessen".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Zukunft verstehen',
|
||||||
|
instruction: 'Was bedeutet "Mo-kaon ko"?',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Mo-kaon ko',
|
||||||
|
sourceLanguage: 'Bisaya',
|
||||||
|
targetLanguage: 'Deutsch'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Ich werde essen',
|
||||||
|
alternatives: ['I will eat', 'I am going to eat']
|
||||||
|
},
|
||||||
|
explanation: 'Das Präfix "Mo-" zeigt die Zukunft an. "Mo-kaon ko" bedeutet "Ich werde essen".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Zeitformen erkennen',
|
||||||
|
instruction: 'Setze die richtigen Präfixe ein.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap}-kaon ko (Vergangenheit) | {gap}-kaon ko (Zukunft)',
|
||||||
|
gaps: 2
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Ni', 'Mo']
|
||||||
|
},
|
||||||
|
explanation: 'Ni- für Vergangenheit, Mo- für Zukunft.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Lektion 25: Höflichkeitsformen
|
||||||
|
'Höflichkeitsformen': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Bitte langsam"?',
|
||||||
|
instruction: 'Wähle die höfliche Form aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Bitte langsam" auf Bisaya?',
|
||||||
|
options: ['Hinay-hinay lang', 'Palihug', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Hinay-hinay lang" bedeutet "Bitte langsam" und ist sehr höflich.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Höfliche Sätze vervollständigen',
|
||||||
|
instruction: 'Fülle die Lücken mit höflichen Wörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} lang (Bitte langsam), wala ko kasabot. {gap} ka mubalik? (Bitte wiederholen)',
|
||||||
|
gaps: 2
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Hinay-hinay', 'Palihug']
|
||||||
|
},
|
||||||
|
explanation: '"Hinay-hinay lang" = "Bitte langsam", "Palihug" = "Bitte".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Was bedeutet "Palihug"?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Palihug"?',
|
||||||
|
options: ['Bitte', 'Danke', 'Entschuldigung', 'Gern geschehen']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Palihug" bedeutet "Bitte" auf Bisaya und wird für höfliche Bitten verwendet.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Lektion: Überlebenssätze
|
||||||
|
'Überlebenssätze': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Ich verstehe nicht"?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?',
|
||||||
|
options: ['Wala ko kasabot', 'Palihug', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht" - sehr wichtig für Anfänger!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Kannst du das wiederholen?"?',
|
||||||
|
instruction: 'Wähle die richtige Bitte aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Kannst du das wiederholen?" auf Bisaya?',
|
||||||
|
options: ['Palihug ka mubalik?', 'Salamat', 'Maayo', 'Kumusta ka?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Palihug ka mubalik?" bedeutet "Bitte kannst du wiederholen?" - essentiell für das Lernen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Überlebenssätze vervollständigen',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} ko kasabot (Ich verstehe nicht). {gap} ka mubalik? (Bitte wiederholen) {gap} lang (Bitte langsam).',
|
||||||
|
gaps: 3
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Wala', 'Palihug', 'Hinay-hinay']
|
||||||
|
},
|
||||||
|
explanation: '"Wala ko kasabot" = "Ich verstehe nicht", "Palihug ka mubalik?" = "Bitte wiederholen", "Hinay-hinay lang" = "Bitte langsam".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wo ist...?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wo ist die Toilette?" auf Bisaya?',
|
||||||
|
options: ['Asa ang CR?', 'Kumusta ka?', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Asa ang CR?" bedeutet "Wo ist die Toilette?" - "Asa" = "Wo", "CR" = "Comfort Room" (Toilette).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wie viel kostet das?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wie viel kostet das?" auf Bisaya?',
|
||||||
|
options: ['Tagpila ni?', 'Asa ni?', 'Unsa ni?', 'Kinsa ni?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila ni?" bedeutet "Wie viel kostet das?" - sehr nützlich beim Einkaufen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Wichtige Fragen bilden',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Fragewörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} ang CR? (Wo ist die Toilette?) | {gap} ni? (Wie viel kostet das?) | {gap} ni? (Was ist das?)',
|
||||||
|
gaps: 3
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Asa', 'Tagpila', 'Unsa']
|
||||||
|
},
|
||||||
|
explanation: '"Asa" = "Wo", "Tagpila" = "Wie viel", "Unsa" = "Was".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Entschuldigung"?',
|
||||||
|
instruction: 'Wähle die richtige Entschuldigung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Entschuldigung" auf Bisaya?',
|
||||||
|
options: ['Pasensya', 'Salamat', 'Palihug', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Pasensya" bedeutet "Entschuldigung" oder "Entschuldige bitte" - wichtig für höfliche Kommunikation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Überlebenssätze übersetzen',
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Ich spreche kein Bisaya',
|
||||||
|
sourceLanguage: 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Dili ko mag-Bisaya',
|
||||||
|
alternatives: ['Wala ko mag-Bisaya', 'Dili ko makasabot Bisaya']
|
||||||
|
},
|
||||||
|
explanation: '"Dili ko mag-Bisaya" bedeutet "Ich spreche kein Bisaya" - nützlich, um zu erklären, dass du noch lernst.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Auch für "Überlebenssätze - Teil 1" und "Überlebenssätze - Teil 2"
|
||||||
|
'Überlebenssätze - Teil 1': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Ich verstehe nicht"?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?',
|
||||||
|
options: ['Wala ko kasabot', 'Palihug', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht" - sehr wichtig für Anfänger!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Kannst du das wiederholen?"?',
|
||||||
|
instruction: 'Wähle die richtige Bitte aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Kannst du das wiederholen?" auf Bisaya?',
|
||||||
|
options: ['Palihug ka mubalik?', 'Salamat', 'Maayo', 'Kumusta ka?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Palihug ka mubalik?" bedeutet "Bitte kannst du wiederholen?" - essentiell für das Lernen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wo ist...?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wo ist die Toilette?" auf Bisaya?',
|
||||||
|
options: ['Asa ang CR?', 'Kumusta ka?', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Asa ang CR?" bedeutet "Wo ist die Toilette?" - "Asa" = "Wo", "CR" = "Comfort Room" (Toilette).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Überlebenssätze vervollständigen',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} ko kasabot (Ich verstehe nicht). {gap} ka mubalik? (Bitte wiederholen)',
|
||||||
|
gaps: 2
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Wala', 'Palihug']
|
||||||
|
},
|
||||||
|
explanation: '"Wala ko kasabot" = "Ich verstehe nicht", "Palihug ka mubalik?" = "Bitte wiederholen".'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
'Überlebenssätze - Teil 2': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wie viel kostet das?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wie viel kostet das?" auf Bisaya?',
|
||||||
|
options: ['Tagpila ni?', 'Asa ni?', 'Unsa ni?', 'Kinsa ni?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila ni?" bedeutet "Wie viel kostet das?" - sehr nützlich beim Einkaufen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Entschuldigung"?',
|
||||||
|
instruction: 'Wähle die richtige Entschuldigung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Entschuldigung" auf Bisaya?',
|
||||||
|
options: ['Pasensya', 'Salamat', 'Palihug', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Pasensya" bedeutet "Entschuldigung" oder "Entschuldige bitte" - wichtig für höfliche Kommunikation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Wichtige Fragen bilden',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Fragewörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} ni? (Wie viel kostet das?) | {gap} ni? (Was ist das?) | {gap} lang (Bitte langsam)',
|
||||||
|
gaps: 3
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Tagpila', 'Unsa', 'Hinay-hinay']
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila" = "Wie viel", "Unsa" = "Was", "Hinay-hinay lang" = "Bitte langsam".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Überlebenssätze übersetzen',
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Ich spreche kein Bisaya',
|
||||||
|
sourceLanguage: 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Dili ko mag-Bisaya',
|
||||||
|
alternatives: ['Wala ko mag-Bisaya', 'Dili ko makasabot Bisaya']
|
||||||
|
},
|
||||||
|
explanation: '"Dili ko mag-Bisaya" bedeutet "Ich spreche kein Bisaya" - nützlich, um zu erklären, dass du noch lernst.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Woche 1 - Wiederholung (Lektion 9)
|
||||||
|
'Woche 1 - Wiederholung': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wiederholung: Wie sagt man "Wie geht es dir?"?',
|
||||||
|
instruction: 'Wähle die richtige Begrüßung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wie geht es dir?" auf Bisaya?',
|
||||||
|
options: ['Kumusta ka?', 'Maayo', 'Salamat', 'Palihug']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Kumusta ka?" ist die Standard-Begrüßung auf Bisaya.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wiederholung: Wie sagt man "Mutter" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Mutter" auf Bisaya?',
|
||||||
|
options: ['Nanay', 'Tatay', 'Kuya', 'Ate']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Nanay" bedeutet "Mutter" auf Bisaya.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wiederholung: Was bedeutet "Palangga taka"?',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Palangga taka"?',
|
||||||
|
options: ['Ich hab dich lieb', 'Danke', 'Guten Tag', 'Auf Wiedersehen']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Palangga taka" bedeutet "Ich hab dich lieb" - wärmer als "I love you" im Familienkontext.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wiederholung: Was fragt man mit "Nikaon ka?"?',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Nikaon ka?"?',
|
||||||
|
options: ['Hast du schon gegessen?', 'Wie geht es dir?', 'Danke', 'Bitte']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Nikaon ka?" bedeutet "Hast du schon gegessen?" - typisch fürsorglich auf den Philippinen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?',
|
||||||
|
options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Woche 1 - Vokabeltest (Lektion 10)
|
||||||
|
'Woche 1 - Vokabeltest': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vokabeltest: Kumusta',
|
||||||
|
instruction: 'Was bedeutet "Kumusta"?',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Kumusta"?',
|
||||||
|
options: ['Wie geht es dir?', 'Danke', 'Bitte', 'Auf Wiedersehen']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Kumusta" kommt von spanisch "¿Cómo está?" - "Wie geht es dir?"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vokabeltest: Lola',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Lola"?',
|
||||||
|
options: ['Großmutter', 'Großvater', 'Mutter', 'Vater']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Lola" = Großmutter, "Lolo" = Großvater.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vokabeltest: Salamat',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Salamat"?',
|
||||||
|
options: ['Danke', 'Bitte', 'Entschuldigung', 'Gern geschehen']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Salamat" bedeutet "Danke".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vokabeltest: Lami',
|
||||||
|
instruction: 'Was bedeutet "Lami"?',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Lami"?',
|
||||||
|
options: ['Lecker', 'Viel', 'Gut', 'Schnell']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Lami" bedeutet "lecker" oder "schmackhaft" - wichtig beim Essen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vokabeltest: Mingaw ko nimo',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Mingaw ko nimo"?',
|
||||||
|
options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich']
|
||||||
|
},
|
||||||
|
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||||
|
explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'system'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'admin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
console.error('❌ System-Benutzer nicht gefunden.');
|
||||||
|
throw new Error('System user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExercisesForLesson(lessonTitle) {
|
||||||
|
// Suche nach exaktem Titel
|
||||||
|
if (BISAYA_EXERCISES[lessonTitle]) {
|
||||||
|
return BISAYA_EXERCISES[lessonTitle];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Suche nach Teilstring
|
||||||
|
for (const [key, exercises] of Object.entries(BISAYA_EXERCISES)) {
|
||||||
|
if (lessonTitle.includes(key) || key.includes(lessonTitle)) {
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Übungen für unbekannte Lektionen (statt Dummy-Übungen)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBisayaCourseContent() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
console.log(`Verwende System-Benutzer: ${systemUser.username} (ID: ${systemUser.id})\n`);
|
||||||
|
|
||||||
|
// Finde alle Bisaya-Kurse
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT id, title, owner_user_id AS "ownerUserId" FROM community.vocab_course WHERE language_id = :languageId`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: bisayaLanguage.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalExercisesAdded = 0;
|
||||||
|
let totalLessonsProcessed = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: { courseId: course.id },
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} Lektionen gefunden\n`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
const exercises = getExercisesForLesson(lesson.title);
|
||||||
|
if (exercises.length === 0) {
|
||||||
|
const existingCount = await VocabGrammarExercise.count({ where: { lessonId: lesson.id } });
|
||||||
|
if (existingCount > 0) {
|
||||||
|
console.log(` ⏭️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - bereits ${existingCount} Übung(en) vorhanden`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - keine Übungen definiert`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei Woche-1-Wiederholung/Vokabeltest: Alte Platzhalter entfernen und ersetzen
|
||||||
|
const replacePlaceholders = ['Woche 1 - Wiederholung', 'Woche 1 - Vokabeltest'].includes(lesson.title);
|
||||||
|
const existingCount = await VocabGrammarExercise.count({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCount > 0 && !replacePlaceholders) {
|
||||||
|
console.log(` ⏭️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - bereits ${existingCount} Übung(en) vorhanden`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacePlaceholders && existingCount > 0) {
|
||||||
|
const deleted = await VocabGrammarExercise.destroy({ where: { lessonId: lesson.id } });
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deleted} Platzhalter entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Übungen
|
||||||
|
let exerciseNumber = 1;
|
||||||
|
for (const exerciseData of exercises) {
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: exerciseData.exerciseTypeId,
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: exerciseData.title,
|
||||||
|
instruction: exerciseData.instruction,
|
||||||
|
questionData: JSON.stringify(exerciseData.questionData),
|
||||||
|
answerData: JSON.stringify(exerciseData.answerData),
|
||||||
|
explanation: exerciseData.explanation,
|
||||||
|
createdByUserId: course.ownerUserId || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesAdded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${exercises.length} Übung(en) erstellt`);
|
||||||
|
totalLessonsProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessonsProcessed} Lektionen bearbeitet`);
|
||||||
|
console.log(` ${totalExercisesAdded} Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createBisayaCourseContent()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
309
backend/scripts/create-bisaya-course.js
Executable file
309
backend/scripts/create-bisaya-course.js
Executable file
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen eines vollständigen 4-Wochen Bisaya-Kurses
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/create-bisaya-course.js <languageId> <ownerHashedId>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const LESSONS = [
|
||||||
|
// WOCHE 1: Grundlagen & Aussprache
|
||||||
|
{ week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit',
|
||||||
|
desc: 'Lerne die wichtigsten Begrüßungen und Höflichkeitsformeln',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Philippiner schätzen Höflichkeit sehr. Lächeln ist wichtig!' },
|
||||||
|
|
||||||
|
{ week: 1, day: 1, num: 2, type: 'vocab', title: 'Überlebenssätze - Teil 1',
|
||||||
|
desc: 'Die 10 wichtigsten Sätze für den Alltag',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: 'Diese Sätze helfen dir sofort im Alltag weiter.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 2, num: 3, type: 'vocab', title: 'Familienwörter',
|
||||||
|
desc: 'Mama, Papa, Kuya, Ate, Lola, Lolo und mehr',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: 'Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll!' },
|
||||||
|
|
||||||
|
{ week: 1, day: 2, num: 4, type: 'conversation', title: 'Familien-Gespräche',
|
||||||
|
desc: 'Einfache Gespräche mit Familienmitgliedern',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Familienkonversationen sind herzlicher als formelle Gespräche.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 3, num: 5, type: 'conversation', title: 'Gefühle & Zuneigung',
|
||||||
|
desc: 'Mingaw ko nimo, Palangga taka und mehr',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Palangga taka ist wärmer als "I love you" im Familienkontext.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 3, num: 6, type: 'vocab', title: 'Überlebenssätze - Teil 2',
|
||||||
|
desc: 'Weitere wichtige Alltagssätze',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 1, day: 4, num: 7, type: 'conversation', title: 'Essen & Fürsorge',
|
||||||
|
desc: 'Nikaon ka? Kaon ta! Lami!',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Essen = Liebe! "Nikaon na ka?" ist sehr fürsorglich.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 4, num: 8, type: 'vocab', title: 'Essen & Trinken',
|
||||||
|
desc: 'Wichtige Wörter rund ums Essen',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 1, day: 5, num: 9, type: 'review', title: 'Woche 1 - Wiederholung',
|
||||||
|
desc: 'Wiederhole alle Inhalte der ersten Woche',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: 'Wiederholung ist der Schlüssel zum Erfolg!' },
|
||||||
|
|
||||||
|
{ week: 1, day: 5, num: 10, type: 'vocab', title: 'Woche 1 - Vokabeltest',
|
||||||
|
desc: 'Teste dein Wissen aus Woche 1',
|
||||||
|
targetMin: 15, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
// WOCHE 2: Alltag & Familie
|
||||||
|
{ week: 2, day: 1, num: 11, type: 'conversation', title: 'Alltagsgespräche - Teil 1',
|
||||||
|
desc: 'Wie war dein Tag? Was machst du?',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Alltagsgespräche sind wichtig für echte Kommunikation.' },
|
||||||
|
|
||||||
|
{ week: 2, day: 1, num: 12, type: 'vocab', title: 'Haus & Familie',
|
||||||
|
desc: 'Balay, Kwarto, Kusina, Pamilya',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 2, num: 13, type: 'conversation', title: 'Alltagsgespräche - Teil 2',
|
||||||
|
desc: 'Wohin gehst du? Was machst du heute?',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 2, num: 14, type: 'vocab', title: 'Ort & Richtung',
|
||||||
|
desc: 'Asa, dinhi, didto, padulong',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 3, num: 15, type: 'grammar', title: 'Zeitformen - Grundlagen',
|
||||||
|
desc: 'Ni-kaon ko, Mo-kaon ko - Vergangenheit und Zukunft',
|
||||||
|
targetMin: 25, targetScore: 75, review: true,
|
||||||
|
cultural: 'Cebuano hat keine komplexen Zeiten wie Deutsch. Zeit wird mit Präfixen ausgedrückt.' },
|
||||||
|
|
||||||
|
{ week: 2, day: 3, num: 16, type: 'vocab', title: 'Zeit & Datum',
|
||||||
|
desc: 'Karon, ugma, gahapon, karon adlaw',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 4, num: 17, type: 'conversation', title: 'Einkaufen & Preise',
|
||||||
|
desc: 'Tagpila ni? Pwede barato?',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Handeln ist in den Philippinen üblich und erwartet.' },
|
||||||
|
|
||||||
|
{ week: 2, day: 4, num: 18, type: 'vocab', title: 'Zahlen & Preise',
|
||||||
|
desc: '1-100, Preise, Mengen',
|
||||||
|
targetMin: 25, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 5, num: 19, type: 'review', title: 'Woche 2 - Wiederholung',
|
||||||
|
desc: 'Wiederhole alle Inhalte der zweiten Woche',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 5, num: 20, type: 'vocab', title: 'Woche 2 - Vokabeltest',
|
||||||
|
desc: 'Teste dein Wissen aus Woche 2',
|
||||||
|
targetMin: 15, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
// WOCHE 3: Vertiefung
|
||||||
|
{ week: 3, day: 1, num: 21, type: 'conversation', title: 'Gefühle & Emotionen',
|
||||||
|
desc: 'Nalipay, nasubo, nahadlok, naguol',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Emotionen auszudrücken ist wichtig für echte Verbindung.' },
|
||||||
|
|
||||||
|
{ week: 3, day: 1, num: 22, type: 'vocab', title: 'Gefühle & Emotionen',
|
||||||
|
desc: 'Wörter für verschiedene Gefühle',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 2, num: 23, type: 'conversation', title: 'Gesundheit & Wohlbefinden',
|
||||||
|
desc: 'Sakit, maayo, tambal, doktor',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 2, num: 24, type: 'vocab', title: 'Körper & Gesundheit',
|
||||||
|
desc: 'Wörter rund um den Körper und Gesundheit',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 3, num: 25, type: 'grammar', title: 'Höflichkeitsformen',
|
||||||
|
desc: 'Palihug, Pwede, Tabang',
|
||||||
|
targetMin: 20, targetScore: 75, review: true,
|
||||||
|
cultural: 'Höflichkeit ist extrem wichtig in der philippinischen Kultur.' },
|
||||||
|
|
||||||
|
{ week: 3, day: 3, num: 26, type: 'conversation', title: 'Bitten & Fragen',
|
||||||
|
desc: 'Wie man höflich fragt und bittet',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 4, num: 27, type: 'conversation', title: 'Kinder & Familie',
|
||||||
|
desc: 'Gespräche mit und über Kinder',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Kinder sind sehr wichtig in philippinischen Familien.' },
|
||||||
|
|
||||||
|
{ week: 3, day: 4, num: 28, type: 'vocab', title: 'Kinder & Spiel',
|
||||||
|
desc: 'Wörter für Kinder und Spielsachen',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 5, num: 29, type: 'review', title: 'Woche 3 - Wiederholung',
|
||||||
|
desc: 'Wiederhole alle Inhalte der dritten Woche',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 5, num: 30, type: 'vocab', title: 'Woche 3 - Vokabeltest',
|
||||||
|
desc: 'Teste dein Wissen aus Woche 3',
|
||||||
|
targetMin: 15, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
// WOCHE 4: Freies Sprechen
|
||||||
|
{ week: 4, day: 1, num: 31, type: 'conversation', title: 'Freies Gespräch - Thema 1',
|
||||||
|
desc: 'Übe freies Sprechen zu verschiedenen Themen',
|
||||||
|
targetMin: 20, targetScore: 75, review: false,
|
||||||
|
cultural: 'Fehler sind okay! Philippiner schätzen das Bemühen.' },
|
||||||
|
|
||||||
|
{ week: 4, day: 1, num: 32, type: 'vocab', title: 'Wiederholung - Woche 1 & 2',
|
||||||
|
desc: 'Wiederhole wichtige Vokabeln aus den ersten beiden Wochen',
|
||||||
|
targetMin: 25, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 2, num: 33, type: 'conversation', title: 'Freies Gespräch - Thema 2',
|
||||||
|
desc: 'Weitere Übung im freien Sprechen',
|
||||||
|
targetMin: 20, targetScore: 75, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 2, num: 34, type: 'vocab', title: 'Wiederholung - Woche 3',
|
||||||
|
desc: 'Wiederhole wichtige Vokabeln aus Woche 3',
|
||||||
|
targetMin: 25, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 3, num: 35, type: 'conversation', title: 'Komplexere Gespräche',
|
||||||
|
desc: 'Längere Gespräche zu verschiedenen Themen',
|
||||||
|
targetMin: 25, targetScore: 75, review: false,
|
||||||
|
cultural: 'Je mehr du sprichst, desto besser wirst du!' },
|
||||||
|
|
||||||
|
{ week: 4, day: 3, num: 36, type: 'review', title: 'Gesamtwiederholung',
|
||||||
|
desc: 'Wiederhole alle wichtigen Inhalte des Kurses',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 4, num: 37, type: 'conversation', title: 'Praktische Übung',
|
||||||
|
desc: 'Simuliere echte Gesprächssituationen',
|
||||||
|
targetMin: 25, targetScore: 75, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 4, num: 38, type: 'vocab', title: 'Abschlusstest - Vokabeln',
|
||||||
|
desc: 'Finaler Vokabeltest über den gesamten Kurs',
|
||||||
|
targetMin: 20, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 5, num: 39, type: 'review', title: 'Abschlussprüfung',
|
||||||
|
desc: 'Finale Prüfung über alle Kursinhalte',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: 'Gratulation zum Abschluss des Kurses!' },
|
||||||
|
|
||||||
|
{ week: 4, day: 5, num: 40, type: 'culture', title: 'Kulturelle Tipps & Tricks',
|
||||||
|
desc: 'Wichtige kulturelle Hinweise für den Alltag',
|
||||||
|
targetMin: 15, targetScore: 0, review: false,
|
||||||
|
cultural: 'Kulturelles Verständnis ist genauso wichtig wie die Sprache selbst.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function createBisayaCourse(languageId, ownerHashedId) {
|
||||||
|
try {
|
||||||
|
// Finde User
|
||||||
|
const user = await User.findOne({ where: { hashedId: ownerHashedId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User mit hashedId ${ownerHashedId} nicht gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe, ob Sprache existiert
|
||||||
|
const [lang] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE id = :langId`,
|
||||||
|
{ replacements: { langId: languageId }, type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
if (!lang) {
|
||||||
|
throw new Error(`Sprache mit ID ${languageId} nicht gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Kurs
|
||||||
|
const shareCode = crypto.randomBytes(8).toString('hex');
|
||||||
|
const course = await VocabCourse.create({
|
||||||
|
ownerUserId: user.id,
|
||||||
|
title: 'Bisaya für Familien - Schnellstart in 4 Wochen',
|
||||||
|
description: 'Lerne Bisaya (Cebuano) schnell und praktisch für den Familienalltag. Fokus auf Sprechen & Hören mit strukturiertem 4-Wochen-Plan.',
|
||||||
|
languageId: Number(languageId),
|
||||||
|
difficultyLevel: 1,
|
||||||
|
isPublic: true,
|
||||||
|
shareCode
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Kurs erstellt: ${course.id} - "${course.title}"`);
|
||||||
|
console.log(` Share-Code: ${shareCode}`);
|
||||||
|
|
||||||
|
// Erstelle Lektionen
|
||||||
|
for (const lessonData of LESSONS) {
|
||||||
|
const lesson = await VocabCourseLesson.create({
|
||||||
|
courseId: course.id,
|
||||||
|
chapterId: null, // Wird später mit Vokabeln verknüpft
|
||||||
|
lessonNumber: lessonData.num,
|
||||||
|
title: lessonData.title,
|
||||||
|
description: lessonData.desc,
|
||||||
|
weekNumber: lessonData.week,
|
||||||
|
dayNumber: lessonData.day,
|
||||||
|
lessonType: lessonData.type,
|
||||||
|
culturalNotes: lessonData.cultural,
|
||||||
|
targetMinutes: lessonData.targetMin,
|
||||||
|
targetScorePercent: lessonData.targetScore,
|
||||||
|
requiresReview: lessonData.review
|
||||||
|
});
|
||||||
|
console.log(` ✅ Lektion ${lessonData.num}: ${lessonData.title} (Woche ${lessonData.week}, Tag ${lessonData.day})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Kurs erfolgreich erstellt mit ${LESSONS.length} Lektionen!`);
|
||||||
|
console.log(`\n📊 Kurs-Statistik:`);
|
||||||
|
console.log(` - Gesamte Lektionen: ${LESSONS.length}`);
|
||||||
|
console.log(` - Vokabel-Lektionen: ${LESSONS.filter(l => l.type === 'vocab').length}`);
|
||||||
|
console.log(` - Konversations-Lektionen: ${LESSONS.filter(l => l.type === 'conversation').length}`);
|
||||||
|
console.log(` - Grammatik-Lektionen: ${LESSONS.filter(l => l.type === 'grammar').length}`);
|
||||||
|
console.log(` - Wiederholungs-Lektionen: ${LESSONS.filter(l => l.type === 'review').length}`);
|
||||||
|
console.log(` - Durchschnittliche Zeit pro Tag: ~${Math.round(LESSONS.reduce((sum, l) => sum + l.targetMin, 0) / (4 * 5))} Minuten`);
|
||||||
|
console.log(`\n💡 Nächste Schritte:`);
|
||||||
|
console.log(` 1. Füge Vokabeln zu den Vokabel-Lektionen hinzu`);
|
||||||
|
console.log(` 2. Erstelle Grammatik-Übungen für die Grammatik-Lektionen`);
|
||||||
|
console.log(` 3. Teile den Kurs mit anderen (Share-Code: ${shareCode})`);
|
||||||
|
|
||||||
|
return course;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Erstellen des Kurses:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI-Aufruf
|
||||||
|
const languageId = process.argv[2];
|
||||||
|
const ownerHashedId = process.argv[3];
|
||||||
|
|
||||||
|
if (!languageId || !ownerHashedId) {
|
||||||
|
console.error('Verwendung: node create-bisaya-course.js <languageId> <ownerHashedId>');
|
||||||
|
console.error('Beispiel: node create-bisaya-course.js 1 abc123def456');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
createBisayaCourse(languageId, ownerHashedId)
|
||||||
|
.then(() => {
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
557
backend/scripts/create-language-courses.js
Executable file
557
backend/scripts/create-language-courses.js
Executable file
@@ -0,0 +1,557 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen von öffentlichen Sprachkursen für verschiedene Sprachen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/create-language-courses.js
|
||||||
|
*
|
||||||
|
* Erstellt öffentliche Kurse für alle Kombinationen von:
|
||||||
|
* - Zielsprachen: Bisaya, Französisch, Spanisch, Latein, Italienisch, Portugiesisch, Tagalog
|
||||||
|
* - Muttersprachen: Deutsch, Englisch, Spanisch, Französisch, Italienisch, Portugiesisch
|
||||||
|
*
|
||||||
|
* Die Kurse werden automatisch einem System-Benutzer zugeordnet und sind öffentlich zugänglich.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
// Kursstruktur für alle Sprachen (4 Wochen, 40 Lektionen)
|
||||||
|
const LESSON_TEMPLATE = [
|
||||||
|
// WOCHE 1: Grundlagen & Aussprache
|
||||||
|
{ week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit',
|
||||||
|
desc: 'Lerne die wichtigsten Begrüßungen und Höflichkeitsformeln',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Höflichkeit ist wichtig. Lächeln hilft!' },
|
||||||
|
|
||||||
|
{ week: 1, day: 1, num: 2, type: 'vocab', title: 'Überlebenssätze - Teil 1',
|
||||||
|
desc: 'Die 10 wichtigsten Sätze für den Alltag',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: 'Diese Sätze helfen dir sofort im Alltag weiter.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 2, num: 3, type: 'vocab', title: 'Familienwörter',
|
||||||
|
desc: 'Mama, Papa, Geschwister, Großeltern und mehr',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: 'Familienwörter sind wichtig für echte Gespräche.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 2, num: 4, type: 'conversation', title: 'Familien-Gespräche',
|
||||||
|
desc: 'Einfache Gespräche mit Familienmitgliedern',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Familienkonversationen sind herzlicher als formelle Gespräche.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 3, num: 5, type: 'conversation', title: 'Gefühle & Zuneigung',
|
||||||
|
desc: 'Wie man Gefühle ausdrückt',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Gefühle auszudrücken ist wichtig für echte Verbindung.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 3, num: 6, type: 'vocab', title: 'Überlebenssätze - Teil 2',
|
||||||
|
desc: 'Weitere wichtige Alltagssätze',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 1, day: 4, num: 7, type: 'conversation', title: 'Essen & Fürsorge',
|
||||||
|
desc: 'Gespräche rund ums Essen',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Essen verbindet Menschen überall auf der Welt.' },
|
||||||
|
|
||||||
|
{ week: 1, day: 4, num: 8, type: 'vocab', title: 'Essen & Trinken',
|
||||||
|
desc: 'Wichtige Wörter rund ums Essen',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 1, day: 5, num: 9, type: 'review', title: 'Woche 1 - Wiederholung',
|
||||||
|
desc: 'Wiederhole alle Inhalte der ersten Woche',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: 'Wiederholung ist der Schlüssel zum Erfolg!' },
|
||||||
|
|
||||||
|
{ week: 1, day: 5, num: 10, type: 'vocab', title: 'Woche 1 - Vokabeltest',
|
||||||
|
desc: 'Teste dein Wissen aus Woche 1',
|
||||||
|
targetMin: 15, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
// WOCHE 2: Alltag & Familie
|
||||||
|
{ week: 2, day: 1, num: 11, type: 'conversation', title: 'Alltagsgespräche - Teil 1',
|
||||||
|
desc: 'Wie war dein Tag? Was machst du?',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Alltagsgespräche sind wichtig für echte Kommunikation.' },
|
||||||
|
|
||||||
|
{ week: 2, day: 1, num: 12, type: 'vocab', title: 'Haus & Familie',
|
||||||
|
desc: 'Wörter für Haus, Zimmer, Familie',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 2, num: 13, type: 'conversation', title: 'Alltagsgespräche - Teil 2',
|
||||||
|
desc: 'Wohin gehst du? Was machst du heute?',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 2, num: 14, type: 'vocab', title: 'Ort & Richtung',
|
||||||
|
desc: 'Wo, hier, dort, gehen zu',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 3, num: 15, type: 'grammar', title: 'Zeitformen - Grundlagen',
|
||||||
|
desc: 'Vergangenheit, Gegenwart, Zukunft',
|
||||||
|
targetMin: 25, targetScore: 75, review: true,
|
||||||
|
cultural: 'Zeitformen sind wichtig für präzise Kommunikation.' },
|
||||||
|
|
||||||
|
{ week: 2, day: 3, num: 16, type: 'vocab', title: 'Zeit & Datum',
|
||||||
|
desc: 'Jetzt, morgen, gestern, heute',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 4, num: 17, type: 'conversation', title: 'Einkaufen & Preise',
|
||||||
|
desc: 'Wie viel kostet das? Kann es billiger sein?',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Einkaufen ist eine wichtige Alltagssituation.' },
|
||||||
|
|
||||||
|
{ week: 2, day: 4, num: 18, type: 'vocab', title: 'Zahlen & Preise',
|
||||||
|
desc: 'Zahlen 1-100, Preise, Mengen',
|
||||||
|
targetMin: 25, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 5, num: 19, type: 'review', title: 'Woche 2 - Wiederholung',
|
||||||
|
desc: 'Wiederhole alle Inhalte der zweiten Woche',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 2, day: 5, num: 20, type: 'vocab', title: 'Woche 2 - Vokabeltest',
|
||||||
|
desc: 'Teste dein Wissen aus Woche 2',
|
||||||
|
targetMin: 15, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
// WOCHE 3: Vertiefung
|
||||||
|
{ week: 3, day: 1, num: 21, type: 'conversation', title: 'Gefühle & Emotionen',
|
||||||
|
desc: 'Wie man verschiedene Gefühle ausdrückt',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Emotionen auszudrücken ist wichtig für echte Verbindung.' },
|
||||||
|
|
||||||
|
{ week: 3, day: 1, num: 22, type: 'vocab', title: 'Gefühle & Emotionen',
|
||||||
|
desc: 'Wörter für verschiedene Gefühle',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 2, num: 23, type: 'conversation', title: 'Gesundheit & Wohlbefinden',
|
||||||
|
desc: 'Gespräche über Gesundheit',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 2, num: 24, type: 'vocab', title: 'Körper & Gesundheit',
|
||||||
|
desc: 'Wörter rund um den Körper und Gesundheit',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 3, num: 25, type: 'grammar', title: 'Höflichkeitsformen',
|
||||||
|
desc: 'Wie man höflich spricht',
|
||||||
|
targetMin: 20, targetScore: 75, review: true,
|
||||||
|
cultural: 'Höflichkeit ist extrem wichtig in jeder Kultur.' },
|
||||||
|
|
||||||
|
{ week: 3, day: 3, num: 26, type: 'conversation', title: 'Bitten & Fragen',
|
||||||
|
desc: 'Wie man höflich fragt und bittet',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 4, num: 27, type: 'conversation', title: 'Kinder & Familie',
|
||||||
|
desc: 'Gespräche mit und über Kinder',
|
||||||
|
targetMin: 15, targetScore: 80, review: false,
|
||||||
|
cultural: 'Kinder sind sehr wichtig in Familien.' },
|
||||||
|
|
||||||
|
{ week: 3, day: 4, num: 28, type: 'vocab', title: 'Kinder & Spiel',
|
||||||
|
desc: 'Wörter für Kinder und Spielsachen',
|
||||||
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 5, num: 29, type: 'review', title: 'Woche 3 - Wiederholung',
|
||||||
|
desc: 'Wiederhole alle Inhalte der dritten Woche',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 3, day: 5, num: 30, type: 'vocab', title: 'Woche 3 - Vokabeltest',
|
||||||
|
desc: 'Teste dein Wissen aus Woche 3',
|
||||||
|
targetMin: 15, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
// WOCHE 4: Freies Sprechen
|
||||||
|
{ week: 4, day: 1, num: 31, type: 'conversation', title: 'Freies Gespräch - Thema 1',
|
||||||
|
desc: 'Übe freies Sprechen zu verschiedenen Themen',
|
||||||
|
targetMin: 20, targetScore: 75, review: false,
|
||||||
|
cultural: 'Fehler sind okay! Muttersprachler schätzen das Bemühen.' },
|
||||||
|
|
||||||
|
{ week: 4, day: 1, num: 32, type: 'vocab', title: 'Wiederholung - Woche 1 & 2',
|
||||||
|
desc: 'Wiederhole wichtige Vokabeln aus den ersten beiden Wochen',
|
||||||
|
targetMin: 25, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 2, num: 33, type: 'conversation', title: 'Freies Gespräch - Thema 2',
|
||||||
|
desc: 'Weitere Übung im freien Sprechen',
|
||||||
|
targetMin: 20, targetScore: 75, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 2, num: 34, type: 'vocab', title: 'Wiederholung - Woche 3',
|
||||||
|
desc: 'Wiederhole wichtige Vokabeln aus Woche 3',
|
||||||
|
targetMin: 25, targetScore: 85, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 3, num: 35, type: 'conversation', title: 'Komplexere Gespräche',
|
||||||
|
desc: 'Längere Gespräche zu verschiedenen Themen',
|
||||||
|
targetMin: 25, targetScore: 75, review: false,
|
||||||
|
cultural: 'Je mehr du sprichst, desto besser wirst du!' },
|
||||||
|
|
||||||
|
{ week: 4, day: 3, num: 36, type: 'review', title: 'Gesamtwiederholung',
|
||||||
|
desc: 'Wiederhole alle wichtigen Inhalte des Kurses',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 4, num: 37, type: 'conversation', title: 'Praktische Übung',
|
||||||
|
desc: 'Simuliere echte Gesprächssituationen',
|
||||||
|
targetMin: 25, targetScore: 75, review: false,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 4, num: 38, type: 'vocab', title: 'Abschlusstest - Vokabeln',
|
||||||
|
desc: 'Finaler Vokabeltest über den gesamten Kurs',
|
||||||
|
targetMin: 20, targetScore: 80, review: true,
|
||||||
|
cultural: null },
|
||||||
|
|
||||||
|
{ week: 4, day: 5, num: 39, type: 'review', title: 'Abschlussprüfung',
|
||||||
|
desc: 'Finale Prüfung über alle Kursinhalte',
|
||||||
|
targetMin: 30, targetScore: 80, review: false,
|
||||||
|
cultural: 'Gratulation zum Abschluss des Kurses!' },
|
||||||
|
|
||||||
|
{ week: 4, day: 5, num: 40, type: 'culture', title: 'Kulturelle Tipps & Tricks',
|
||||||
|
desc: 'Wichtige kulturelle Hinweise für den Alltag',
|
||||||
|
targetMin: 15, targetScore: 0, review: false,
|
||||||
|
cultural: 'Kulturelles Verständnis ist genauso wichtig wie die Sprache selbst.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Zielsprachen (die zu lernenden Sprachen)
|
||||||
|
const TARGET_LANGUAGES = [
|
||||||
|
'Bisaya',
|
||||||
|
'Französisch',
|
||||||
|
'Spanisch',
|
||||||
|
'Latein',
|
||||||
|
'Italienisch',
|
||||||
|
'Portugiesisch',
|
||||||
|
'Tagalog'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Muttersprachen (für die Kurse erstellt werden)
|
||||||
|
const NATIVE_LANGUAGES = [
|
||||||
|
'Deutsch',
|
||||||
|
'Englisch',
|
||||||
|
'Spanisch',
|
||||||
|
'Französisch',
|
||||||
|
'Italienisch',
|
||||||
|
'Portugiesisch'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generiere Kurskonfigurationen für alle Kombinationen
|
||||||
|
function generateCourseConfigs() {
|
||||||
|
const configs = [];
|
||||||
|
|
||||||
|
for (const targetLang of TARGET_LANGUAGES) {
|
||||||
|
for (const nativeLang of NATIVE_LANGUAGES) {
|
||||||
|
// Überspringe, wenn Zielsprache = Muttersprache
|
||||||
|
if (targetLang === nativeLang) continue;
|
||||||
|
|
||||||
|
const title = `${targetLang} für ${nativeLang}sprachige - Schnellstart in 4 Wochen`;
|
||||||
|
let description = `Lerne ${targetLang} schnell und praktisch für den Alltag. `;
|
||||||
|
|
||||||
|
if (targetLang === 'Latein') {
|
||||||
|
description = `Lerne ${targetLang} systematisch mit Fokus auf Grammatik und Vokabular. `;
|
||||||
|
} else if (targetLang === 'Bisaya') {
|
||||||
|
description = `Lerne ${targetLang} (Cebuano) schnell und praktisch für den Familienalltag. `;
|
||||||
|
}
|
||||||
|
|
||||||
|
description += `Fokus auf Sprechen & Hören mit strukturiertem 4-Wochen-Plan.`;
|
||||||
|
|
||||||
|
configs.push({
|
||||||
|
targetLanguageName: targetLang,
|
||||||
|
nativeLanguageName: nativeLang,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
difficultyLevel: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGE_COURSES = generateCourseConfigs();
|
||||||
|
|
||||||
|
async function findOrCreateLanguage(languageName, ownerUserId) {
|
||||||
|
// Suche zuerst nach vorhandener Sprache
|
||||||
|
const [existing] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = :name LIMIT 1`,
|
||||||
|
{
|
||||||
|
replacements: { name: languageName },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log(` ✅ Sprache "${languageName}" bereits vorhanden (ID: ${existing.id})`);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle neue Sprache
|
||||||
|
const shareCode = crypto.randomBytes(8).toString('hex');
|
||||||
|
const [created] = await sequelize.query(
|
||||||
|
`INSERT INTO community.vocab_language (owner_user_id, name, share_code)
|
||||||
|
VALUES (:ownerUserId, :name, :shareCode)
|
||||||
|
RETURNING id`,
|
||||||
|
{
|
||||||
|
replacements: { ownerUserId, name: languageName, shareCode },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(` ✅ Sprache "${languageName}" erstellt (ID: ${created.id})`);
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCourseForLanguage(targetLanguageId, nativeLanguageId, languageConfig, ownerUserId) {
|
||||||
|
const shareCode = crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
|
const course = await VocabCourse.create({
|
||||||
|
ownerUserId,
|
||||||
|
title: languageConfig.title,
|
||||||
|
description: languageConfig.description,
|
||||||
|
languageId: Number(targetLanguageId),
|
||||||
|
nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null,
|
||||||
|
difficultyLevel: languageConfig.difficultyLevel || 1,
|
||||||
|
isPublic: true,
|
||||||
|
shareCode
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✅ Kurs erstellt: "${course.title}" (ID: ${course.id}, Share-Code: ${shareCode})`);
|
||||||
|
|
||||||
|
// Erstelle Lektionen
|
||||||
|
const createdLessons = [];
|
||||||
|
for (const lessonData of LESSON_TEMPLATE) {
|
||||||
|
const lesson = await VocabCourseLesson.create({
|
||||||
|
courseId: course.id,
|
||||||
|
chapterId: null,
|
||||||
|
lessonNumber: lessonData.num,
|
||||||
|
title: lessonData.title,
|
||||||
|
description: lessonData.desc,
|
||||||
|
weekNumber: lessonData.week,
|
||||||
|
dayNumber: lessonData.day,
|
||||||
|
lessonType: lessonData.type,
|
||||||
|
culturalNotes: lessonData.cultural,
|
||||||
|
targetMinutes: lessonData.targetMin,
|
||||||
|
targetScorePercent: lessonData.targetScore,
|
||||||
|
requiresReview: lessonData.review
|
||||||
|
});
|
||||||
|
createdLessons.push({ lesson, lessonData });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ ${LESSON_TEMPLATE.length} Lektionen erstellt`);
|
||||||
|
|
||||||
|
// Erstelle Beispiel-Grammatik-Übungen für Grammar-Lektionen
|
||||||
|
let grammarExerciseCount = 0;
|
||||||
|
for (const { lesson, lessonData } of createdLessons) {
|
||||||
|
if (lessonData.type === 'grammar') {
|
||||||
|
// Erstelle 2-3 Beispiel-Übungen für jede Grammar-Lektion
|
||||||
|
const exercises = createExampleGrammarExercises(lesson.id, lessonData, ownerUserId);
|
||||||
|
for (const exercise of exercises) {
|
||||||
|
await VocabGrammarExercise.create(exercise);
|
||||||
|
grammarExerciseCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grammarExerciseCount > 0) {
|
||||||
|
console.log(` ✅ ${grammarExerciseCount} Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Beispiel-Grammatik-Übungen für eine Grammar-Lektion
|
||||||
|
function createExampleGrammarExercises(lessonId, lessonData, ownerUserId) {
|
||||||
|
const exercises = [];
|
||||||
|
|
||||||
|
// Beispiel-Übung 1: Gap Fill (Lückentext)
|
||||||
|
exercises.push({
|
||||||
|
lessonId: lessonId,
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
exerciseNumber: 1,
|
||||||
|
title: `${lessonData.title} - Übung 1`,
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Wörtern.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Hallo! Wie geht es {gap}? Mir geht es {gap}, danke!',
|
||||||
|
gaps: 2
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['dir', 'gut']
|
||||||
|
}),
|
||||||
|
explanation: 'Die richtigen Antworten sind "dir" und "gut".',
|
||||||
|
createdByUserId: ownerUserId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beispiel-Übung 2: Multiple Choice
|
||||||
|
exercises.push({
|
||||||
|
lessonId: lessonId,
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: 2,
|
||||||
|
title: `${lessonData.title} - Übung 2`,
|
||||||
|
instruction: 'Wähle die richtige Antwort aus.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Guten Tag" auf ' + lessonData.title.split(' - ')[0] + '?',
|
||||||
|
options: ['Option A', 'Option B', 'Option C', 'Option D']
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
}),
|
||||||
|
explanation: 'Die richtige Antwort ist Option A.',
|
||||||
|
createdByUserId: ownerUserId
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
// Versuche zuerst einen System-Benutzer zu finden (z.B. mit username "system" oder "admin")
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'system'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
// Versuche Admin-Benutzer
|
||||||
|
systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'admin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
// Erstelle einen System-Benutzer
|
||||||
|
console.log(' ℹ️ Erstelle System-Benutzer für öffentliche Kurse...');
|
||||||
|
const saltRounds = 10;
|
||||||
|
const randomPassword = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hashedPassword = await bcrypt.hash(randomPassword, saltRounds);
|
||||||
|
|
||||||
|
systemUser = await User.create({
|
||||||
|
username: 'system',
|
||||||
|
email: 'system@your-part.de',
|
||||||
|
password: hashedPassword,
|
||||||
|
active: true,
|
||||||
|
registrationDate: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// hashedId wird automatisch vom Hook gesetzt, aber warte kurz darauf
|
||||||
|
await systemUser.reload();
|
||||||
|
console.log(` ✅ System-Benutzer erstellt (ID: ${systemUser.id}, hashedId: ${systemUser.hashedId})`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ System-Benutzer gefunden (ID: ${systemUser.id}, Username: ${systemUser.username})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAllLanguageCourses() {
|
||||||
|
try {
|
||||||
|
// Finde oder erstelle System-Benutzer
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
|
||||||
|
console.log(`\n🚀 Erstelle öffentliche Sprachkurse (Besitzer: System-Benutzer ID ${systemUser.id})\n`);
|
||||||
|
|
||||||
|
const createdCourses = [];
|
||||||
|
|
||||||
|
// Stelle sicher, dass alle benötigten Sprachen existieren
|
||||||
|
console.log(`\n🌍 Stelle sicher, dass alle Sprachen existieren...`);
|
||||||
|
const allLanguages = [...new Set([...TARGET_LANGUAGES, ...NATIVE_LANGUAGES])];
|
||||||
|
const languageMap = new Map();
|
||||||
|
|
||||||
|
for (const langName of allLanguages) {
|
||||||
|
const langId = await findOrCreateLanguage(langName, systemUser.id);
|
||||||
|
languageMap.set(langName, langId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const langConfig of LANGUAGE_COURSES) {
|
||||||
|
console.log(`\n📚 Verarbeite: ${langConfig.targetLanguageName} für ${langConfig.nativeLanguageName}sprachige`);
|
||||||
|
|
||||||
|
const targetLanguageId = languageMap.get(langConfig.targetLanguageName);
|
||||||
|
const nativeLanguageId = languageMap.get(langConfig.nativeLanguageName);
|
||||||
|
|
||||||
|
// Prüfe, ob Kurs bereits existiert (unabhängig vom Besitzer, wenn öffentlich)
|
||||||
|
const existingCourse = await VocabCourse.findOne({
|
||||||
|
where: {
|
||||||
|
languageId: targetLanguageId,
|
||||||
|
nativeLanguageId: nativeLanguageId,
|
||||||
|
isPublic: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCourse) {
|
||||||
|
console.log(` ⚠️ Kurs "${langConfig.title}" existiert bereits (ID: ${existingCourse.id})`);
|
||||||
|
createdCourses.push({
|
||||||
|
...langConfig,
|
||||||
|
courseId: existingCourse.id,
|
||||||
|
targetLanguageId,
|
||||||
|
nativeLanguageId,
|
||||||
|
skipped: true
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Kurs
|
||||||
|
const course = await createCourseForLanguage(targetLanguageId, nativeLanguageId, langConfig, systemUser.id);
|
||||||
|
createdCourses.push({
|
||||||
|
...langConfig,
|
||||||
|
courseId: course.id,
|
||||||
|
targetLanguageId,
|
||||||
|
nativeLanguageId,
|
||||||
|
shareCode: course.shareCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\n🎉 Zusammenfassung:\n`);
|
||||||
|
console.log(` Gesamt: ${LANGUAGE_COURSES.length} Sprachen`);
|
||||||
|
console.log(` Erstellt: ${createdCourses.filter(c => !c.skipped).length} Kurse`);
|
||||||
|
console.log(` Übersprungen: ${createdCourses.filter(c => c.skipped).length} Kurse`);
|
||||||
|
|
||||||
|
console.log(`\n📋 Erstellte Kurse:\n`);
|
||||||
|
for (const course of createdCourses) {
|
||||||
|
if (course.skipped) {
|
||||||
|
console.log(` ⚠️ ${course.languageName}: Bereits vorhanden (ID: ${course.courseId})`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ ${course.languageName}: ${course.title}`);
|
||||||
|
console.log(` Share-Code: ${course.shareCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n💡 Nächste Schritte:`);
|
||||||
|
console.log(` 1. Füge Vokabeln zu den Vokabel-Lektionen hinzu`);
|
||||||
|
console.log(` 2. Erstelle Grammatik-Übungen für die Grammatik-Lektionen`);
|
||||||
|
console.log(` 3. Teile die Kurse mit anderen (Share-Codes oben)`);
|
||||||
|
|
||||||
|
return createdCourses;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Erstellen der Kurse:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI-Aufruf
|
||||||
|
// Keine Parameter mehr nötig - verwendet automatisch System-Benutzer
|
||||||
|
createAllLanguageCourses()
|
||||||
|
.then(() => {
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
88
backend/scripts/delete-all-family-words-exercises.js
Executable file
88
backend/scripts/delete-all-family-words-exercises.js
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Löschen ALLER "Familienwörter"-Übungen aus Bisaya-Kursen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/delete-all-family-words-exercises.js
|
||||||
|
*
|
||||||
|
* Löscht alle Grammatik-Übungen von "Familienwörter"-Lektionen, um Platz für neue zu schaffen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
|
||||||
|
async function deleteAllFamilyWordsExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
// Finde alle Bisaya-Kurse
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT id, title FROM community.vocab_course WHERE language_id = :languageId`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: bisayaLanguage.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
let totalLessons = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
// Finde "Familienwörter"-Lektionen
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
title: 'Familienwörter'
|
||||||
|
},
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} "Familienwörter"-Lektionen gefunden`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
// Lösche ALLE bestehenden Übungen
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} Übung(en) gelöscht`);
|
||||||
|
totalDeleted += deletedCount;
|
||||||
|
totalLessons++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessons} Lektionen bearbeitet`);
|
||||||
|
console.log(` ${totalDeleted} Übungen gelöscht`);
|
||||||
|
console.log(`\n💡 Hinweis: Führe jetzt das update-family-words-exercises.js Script aus, um neue Übungen zu erstellen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAllFamilyWordsExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
89
backend/scripts/fix-begruessungen-gap-fill.js
Executable file
89
backend/scripts/fix-begruessungen-gap-fill.js
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Korrigieren der Gap-Fill-Übung in "Begrüßungen & Höflichkeit"
|
||||||
|
* Fügt Muttersprache-Hinweise hinzu, damit Vokabeln korrekt extrahiert werden können
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
|
||||||
|
async function fixBegruessungenGapFill() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
// Finde alle "Begrüßungen & Höflichkeit" Lektionen
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: { title: 'Begrüßungen & Höflichkeit' },
|
||||||
|
include: [{ model: VocabCourse, as: 'course' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${lessons.length} "Begrüßungen & Höflichkeit"-Lektionen\n`);
|
||||||
|
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
console.log(`📚 Kurs: ${lesson.course.title} (Kurs-ID: ${lesson.course.id}, Lektion-ID: ${lesson.id})`);
|
||||||
|
|
||||||
|
// Finde Gap-Fill-Übung mit "ko" als Antwort
|
||||||
|
const exercises = await VocabGrammarExercise.findAll({
|
||||||
|
where: {
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: 1 // gap_fill
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const exercise of exercises) {
|
||||||
|
const qData = typeof exercise.questionData === 'string'
|
||||||
|
? JSON.parse(exercise.questionData)
|
||||||
|
: exercise.questionData;
|
||||||
|
const aData = typeof exercise.answerData === 'string'
|
||||||
|
? JSON.parse(exercise.answerData)
|
||||||
|
: exercise.answerData;
|
||||||
|
|
||||||
|
// Prüfe ob es die problematische Übung ist (enthält "ko" als Antwort ohne Muttersprache-Hinweis)
|
||||||
|
if (aData.answers && aData.answers.includes('ko')) {
|
||||||
|
const text = qData.text || '';
|
||||||
|
|
||||||
|
// Prüfe ob Muttersprache-Hinweise fehlen
|
||||||
|
if (!text.includes('(ich)') && !text.includes('(I)')) {
|
||||||
|
console.log(` 🔧 Korrigiere Übung "${exercise.title}" (ID: ${exercise.id})`);
|
||||||
|
|
||||||
|
// Korrigiere den Text
|
||||||
|
const correctedText = text.replace(
|
||||||
|
'Maayo {gap}.',
|
||||||
|
'Maayo {gap} (ich).'
|
||||||
|
);
|
||||||
|
|
||||||
|
qData.text = correctedText;
|
||||||
|
|
||||||
|
await exercise.update({
|
||||||
|
questionData: qData
|
||||||
|
});
|
||||||
|
|
||||||
|
totalUpdated++;
|
||||||
|
console.log(` ✅ Aktualisiert: "${correctedText}"`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✓ Übung "${exercise.title}" bereits korrekt`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${lessons.length} Lektionen verarbeitet`);
|
||||||
|
console.log(` ${totalUpdated} Übungen aktualisiert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixBegruessungenGapFill()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
552
backend/scripts/update-family-conversations-exercises.js
Executable file
552
backend/scripts/update-family-conversations-exercises.js
Executable file
@@ -0,0 +1,552 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen von Übungen für die "Familien-Gespräche" Lektion
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/update-family-conversations-exercises.js
|
||||||
|
*
|
||||||
|
* Erstellt Gesprächsübungen für die "Familien-Gespräche" Lektion in allen Bisaya-Kursen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
// Familiengespräche auf Bisaya mit verschiedenen Muttersprachen
|
||||||
|
const FAMILY_CONVERSATIONS = {
|
||||||
|
// Deutsch -> Bisaya
|
||||||
|
'Deutsch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ka, Nanay?',
|
||||||
|
native: 'Wie geht es dir, Mama?',
|
||||||
|
explanation: '"Kumusta ka" ist "Wie geht es dir?" und "Nanay" ist "Mama"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ko, Salamat. Ikaw?',
|
||||||
|
native: 'Mir geht es gut, danke. Und dir?',
|
||||||
|
explanation: '"Maayo ko" bedeutet "Mir geht es gut", "Ikaw" ist "du" (formal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asa si Tatay?',
|
||||||
|
native: 'Wo ist Papa?',
|
||||||
|
explanation: '"Asa" bedeutet "wo", "si" ist ein Marker für Personen, "Tatay" ist "Papa"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Naa siya sa balay.',
|
||||||
|
native: 'Er ist zu Hause.',
|
||||||
|
explanation: '"Naa" bedeutet "ist/sein", "siya" ist "er/sie", "sa balay" ist "zu Hause"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta na ang Kuya?',
|
||||||
|
native: 'Wie geht es dem älteren Bruder?',
|
||||||
|
explanation: '"Kumusta na" ist "Wie geht es", "ang" ist ein Artikel, "Kuya" ist "älterer Bruder"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ra siya.',
|
||||||
|
native: 'Es geht ihm gut.',
|
||||||
|
explanation: '"Maayo ra" bedeutet "gut/gut geht es", "siya" ist "ihm"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko, Nanay.',
|
||||||
|
native: 'Ich bin hungrig, Mama.',
|
||||||
|
explanation: '"Gutom" bedeutet "hungrig", "na" zeigt einen Zustand, "ko" ist "ich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Hulata lang, hapit na ang pagkaon.',
|
||||||
|
native: 'Warte nur, das Essen ist fast fertig.',
|
||||||
|
explanation: '"Hulata" ist "warte", "lang" ist "nur", "hapit na" ist "fast", "pagkaon" ist "Essen"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Englisch -> Bisaya
|
||||||
|
'Englisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ka, Nanay?',
|
||||||
|
native: 'How are you, Mom?',
|
||||||
|
explanation: '"Kumusta ka" means "How are you?" and "Nanay" means "Mom"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ko, Salamat. Ikaw?',
|
||||||
|
native: 'I\'m fine, thank you. And you?',
|
||||||
|
explanation: '"Maayo ko" means "I\'m fine", "Ikaw" is "you" (formal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asa si Tatay?',
|
||||||
|
native: 'Where is Dad?',
|
||||||
|
explanation: '"Asa" means "where", "si" is a person marker, "Tatay" means "Dad"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Naa siya sa balay.',
|
||||||
|
native: 'He is at home.',
|
||||||
|
explanation: '"Naa" means "is/be", "siya" is "he/she", "sa balay" means "at home"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta na ang Kuya?',
|
||||||
|
native: 'How is the older brother?',
|
||||||
|
explanation: '"Kumusta na" means "How is", "ang" is an article, "Kuya" means "older brother"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ra siya.',
|
||||||
|
native: 'He is fine.',
|
||||||
|
explanation: '"Maayo ra" means "fine/well", "siya" means "he"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko, Nanay.',
|
||||||
|
native: 'I\'m hungry, Mom.',
|
||||||
|
explanation: '"Gutom" means "hungry", "na" shows a state, "ko" is "I"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Hulata lang, hapit na ang pagkaon.',
|
||||||
|
native: 'Just wait, the food is almost ready.',
|
||||||
|
explanation: '"Hulata" means "wait", "lang" means "just", "hapit na" means "almost", "pagkaon" means "food"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Spanisch -> Bisaya
|
||||||
|
'Spanisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ka, Nanay?',
|
||||||
|
native: '¿Cómo estás, Mamá?',
|
||||||
|
explanation: '"Kumusta ka" significa "¿Cómo estás?" y "Nanay" significa "Mamá"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ko, Salamat. Ikaw?',
|
||||||
|
native: 'Estoy bien, gracias. ¿Y tú?',
|
||||||
|
explanation: '"Maayo ko" significa "Estoy bien", "Ikaw" es "tú" (formal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asa si Tatay?',
|
||||||
|
native: '¿Dónde está Papá?',
|
||||||
|
explanation: '"Asa" significa "dónde", "si" es un marcador de persona, "Tatay" significa "Papá"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Naa siya sa balay.',
|
||||||
|
native: 'Él está en casa.',
|
||||||
|
explanation: '"Naa" significa "está/ser", "siya" es "él/ella", "sa balay" significa "en casa"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta na ang Kuya?',
|
||||||
|
native: '¿Cómo está el hermano mayor?',
|
||||||
|
explanation: '"Kumusta na" significa "¿Cómo está?", "ang" es un artículo, "Kuya" significa "hermano mayor"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ra siya.',
|
||||||
|
native: 'Él está bien.',
|
||||||
|
explanation: '"Maayo ra" significa "bien", "siya" significa "él"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko, Nanay.',
|
||||||
|
native: 'Tengo hambre, Mamá.',
|
||||||
|
explanation: '"Gutom" significa "hambriento", "na" muestra un estado, "ko" es "yo"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Hulata lang, hapit na ang pagkaon.',
|
||||||
|
native: 'Solo espera, la comida está casi lista.',
|
||||||
|
explanation: '"Hulata" significa "espera", "lang" significa "solo", "hapit na" significa "casi", "pagkaon" significa "comida"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Französisch -> Bisaya
|
||||||
|
'Französisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ka, Nanay?',
|
||||||
|
native: 'Comment vas-tu, Maman?',
|
||||||
|
explanation: '"Kumusta ka" signifie "Comment vas-tu?" et "Nanay" signifie "Maman"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ko, Salamat. Ikaw?',
|
||||||
|
native: 'Je vais bien, merci. Et toi?',
|
||||||
|
explanation: '"Maayo ko" signifie "Je vais bien", "Ikaw" est "tu" (formel)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asa si Tatay?',
|
||||||
|
native: 'Où est Papa?',
|
||||||
|
explanation: '"Asa" signifie "où", "si" est un marqueur de personne, "Tatay" signifie "Papa"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Naa siya sa balay.',
|
||||||
|
native: 'Il est à la maison.',
|
||||||
|
explanation: '"Naa" signifie "est/être", "siya" est "il/elle", "sa balay" signifie "à la maison"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta na ang Kuya?',
|
||||||
|
native: 'Comment va le grand frère?',
|
||||||
|
explanation: '"Kumusta na" signifie "Comment va", "ang" est un article, "Kuya" signifie "grand frère"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ra siya.',
|
||||||
|
native: 'Il va bien.',
|
||||||
|
explanation: '"Maayo ra" signifie "bien", "siya" signifie "il"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko, Nanay.',
|
||||||
|
native: 'J\'ai faim, Maman.',
|
||||||
|
explanation: '"Gutom" signifie "faim", "na" montre un état, "ko" est "je"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Hulata lang, hapit na ang pagkaon.',
|
||||||
|
native: 'Attends juste, la nourriture est presque prête.',
|
||||||
|
explanation: '"Hulata" signifie "attends", "lang" signifie "juste", "hapit na" signifie "presque", "pagkaon" signifie "nourriture"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Italienisch -> Bisaya
|
||||||
|
'Italienisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ka, Nanay?',
|
||||||
|
native: 'Come stai, Mamma?',
|
||||||
|
explanation: '"Kumusta ka" significa "Come stai?" e "Nanay" significa "Mamma"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ko, Salamat. Ikaw?',
|
||||||
|
native: 'Sto bene, grazie. E tu?',
|
||||||
|
explanation: '"Maayo ko" significa "Sto bene", "Ikaw" è "tu" (formale)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asa si Tatay?',
|
||||||
|
native: 'Dove è Papà?',
|
||||||
|
explanation: '"Asa" significa "dove", "si" è un marcatore di persona, "Tatay" significa "Papà"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Naa siya sa balay.',
|
||||||
|
native: 'È a casa.',
|
||||||
|
explanation: '"Naa" significa "è/essere", "siya" è "lui/lei", "sa balay" significa "a casa"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta na ang Kuya?',
|
||||||
|
native: 'Come sta il fratello maggiore?',
|
||||||
|
explanation: '"Kumusta na" significa "Come sta", "ang" è un articolo, "Kuya" significa "fratello maggiore"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ra siya.',
|
||||||
|
native: 'Sta bene.',
|
||||||
|
explanation: '"Maayo ra" significa "bene", "siya" significa "lui"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko, Nanay.',
|
||||||
|
native: 'Ho fame, Mamma.',
|
||||||
|
explanation: '"Gutom" significa "fame", "na" mostra uno stato, "ko" è "io"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Hulata lang, hapit na ang pagkaon.',
|
||||||
|
native: 'Aspetta solo, il cibo è quasi pronto.',
|
||||||
|
explanation: '"Hulata" significa "aspetta", "lang" significa "solo", "hapit na" significa "quasi", "pagkaon" significa "cibo"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Portugiesisch -> Bisaya
|
||||||
|
'Portugiesisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ka, Nanay?',
|
||||||
|
native: 'Como você está, Mãe?',
|
||||||
|
explanation: '"Kumusta ka" significa "Como você está?" e "Nanay" significa "Mãe"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ko, Salamat. Ikaw?',
|
||||||
|
native: 'Estou bem, obrigado. E você?',
|
||||||
|
explanation: '"Maayo ko" significa "Estou bem", "Ikaw" é "você" (formal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asa si Tatay?',
|
||||||
|
native: 'Onde está o Papai?',
|
||||||
|
explanation: '"Asa" significa "onde", "si" é um marcador de pessoa, "Tatay" significa "Papai"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Naa siya sa balay.',
|
||||||
|
native: 'Ele está em casa.',
|
||||||
|
explanation: '"Naa" significa "está/ser", "siya" é "ele/ela", "sa balay" significa "em casa"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta na ang Kuya?',
|
||||||
|
native: 'Como está o irmão mais velho?',
|
||||||
|
explanation: '"Kumusta na" significa "Como está", "ang" é um artigo, "Kuya" significa "irmão mais velho"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo ra siya.',
|
||||||
|
native: 'Ele está bem.',
|
||||||
|
explanation: '"Maayo ra" significa "bem", "siya" significa "ele"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko, Nanay.',
|
||||||
|
native: 'Estou com fome, Mãe.',
|
||||||
|
explanation: '"Gutom" significa "fome", "na" mostra um estado, "ko" é "eu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Hulata lang, hapit na ang pagkaon.',
|
||||||
|
native: 'Apenas espere, a comida está quase pronta.',
|
||||||
|
explanation: '"Hulata" significa "espere", "lang" significa "apenas", "hapit na" significa "quase", "pagkaon" significa "comida"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
// Versuche zuerst einen System-Benutzer zu finden (z.B. mit username "system" oder "admin")
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: { [sequelize.Sequelize.Op.in]: ['system', 'admin', 'System', 'Admin'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
// Erstelle einen System-Benutzer
|
||||||
|
const password = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const hashedId = crypto.createHash('sha256').update(`system-${Date.now()}`).digest('hex');
|
||||||
|
|
||||||
|
systemUser = await User.create({
|
||||||
|
username: 'system',
|
||||||
|
password: hashedPassword,
|
||||||
|
hashedId: hashedId,
|
||||||
|
email: 'system@your-part.de'
|
||||||
|
});
|
||||||
|
console.log('✅ System-Benutzer erstellt:', systemUser.hashedId);
|
||||||
|
} else {
|
||||||
|
console.log('✅ System-Benutzer gefunden:', systemUser.hashedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFamilyConversationExercises(nativeLanguageName) {
|
||||||
|
const exercises = [];
|
||||||
|
const conversations = FAMILY_CONVERSATIONS[nativeLanguageName]?.conversations || [];
|
||||||
|
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
console.warn(`⚠️ Keine Gespräche für Muttersprache "${nativeLanguageName}" gefunden. Verwende Deutsch als Fallback.`);
|
||||||
|
return createFamilyConversationExercises('Deutsch');
|
||||||
|
}
|
||||||
|
|
||||||
|
let exerciseNum = 1;
|
||||||
|
|
||||||
|
// Multiple Choice: Übersetze Bisaya-Satz in Muttersprache (alle Gespräche)
|
||||||
|
conversations.forEach((conv, idx) => {
|
||||||
|
// Erstelle für jedes Gespräch eine Multiple Choice Übung
|
||||||
|
const wrongOptions = conversations
|
||||||
|
.filter((c, i) => i !== idx)
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(c => c.native);
|
||||||
|
|
||||||
|
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
|
||||||
|
const correctIndex = options.indexOf(conv.native);
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: `Familien-Gespräch ${idx + 1} - Übersetzung`,
|
||||||
|
instruction: 'Übersetze den Bisaya-Satz ins ' + nativeLanguageName,
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Wie sagt man "${conv.bisaya}" auf ${nativeLanguageName}?`,
|
||||||
|
options: options
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: correctIndex
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multiple Choice: Rückwärts-Übersetzung (Was bedeutet dieser Satz?)
|
||||||
|
conversations.forEach((conv, idx) => {
|
||||||
|
if (idx < 6) { // Erste 6 als Rückwärts-Übersetzung
|
||||||
|
const wrongOptions = conversations
|
||||||
|
.filter((c, i) => i !== idx)
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(c => c.native);
|
||||||
|
|
||||||
|
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
|
||||||
|
const correctIndex = options.indexOf(conv.native);
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: `Familien-Gespräch ${idx + 1} - Was bedeutet dieser Satz?`,
|
||||||
|
instruction: 'Was bedeutet dieser Bisaya-Satz?',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Was bedeutet "${conv.bisaya}"?`,
|
||||||
|
options: options
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: correctIndex
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap Fill: Vervollständige Familiengespräche (mehrere Varianten)
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: 'Familien-Gespräch 1 - Vervollständigen',
|
||||||
|
instruction: 'Vervollständige das Gespräch mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Person A: Kumusta ka, {gap}? (Mama)\nPerson B: {gap} ko, Salamat. Ikaw? (Mir geht es gut)',
|
||||||
|
gaps: 2
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Nanay', 'Maayo']
|
||||||
|
}),
|
||||||
|
explanation: '"Nanay" ist "Mama" und "Maayo ko" bedeutet "Mir geht es gut"'
|
||||||
|
});
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: 'Familien-Gespräch 2 - Vervollständigen',
|
||||||
|
instruction: 'Vervollständige das Gespräch mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Person A: {gap} si Tatay? (Wo ist)\nPerson B: {gap} siya sa balay. (Er ist)',
|
||||||
|
gaps: 2
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Asa', 'Naa']
|
||||||
|
}),
|
||||||
|
explanation: '"Asa" bedeutet "wo" und "Naa" bedeutet "ist/sein"'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transformation: Übersetze Muttersprache-Satz nach Bisaya (mehrere Varianten)
|
||||||
|
conversations.slice(0, 4).forEach((conv, idx) => {
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 3, // transformation
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: `Familien-Gespräch ${idx + 1} - Übersetzung nach Bisaya`,
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'transformation',
|
||||||
|
text: conv.native
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'transformation',
|
||||||
|
correctAnswer: conv.bisaya
|
||||||
|
}),
|
||||||
|
explanation: `"${conv.bisaya}" bedeutet "${conv.native}" auf Bisaya. ${conv.explanation}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFamilyConversationExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✅ Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
|
||||||
|
// Finde Bisaya-Sprache mit SQL
|
||||||
|
const [bisayaLangResult] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLangResult) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bisayaLanguageId = bisayaLangResult.id;
|
||||||
|
|
||||||
|
// Hole alle Bisaya-Kurse mit native language info
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT
|
||||||
|
c.id,
|
||||||
|
c.title,
|
||||||
|
c.native_language_id,
|
||||||
|
nl.name as native_language_name
|
||||||
|
FROM community.vocab_course c
|
||||||
|
LEFT JOIN community.vocab_language nl ON c.native_language_id = nl.id
|
||||||
|
WHERE c.language_id = :bisayaLanguageId`,
|
||||||
|
{
|
||||||
|
replacements: { bisayaLanguageId },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📚 Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalExercisesCreated = 0;
|
||||||
|
let totalLessonsProcessed = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📖 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
// Finde native language name
|
||||||
|
const nativeLanguageName = course.native_language_name || 'Deutsch';
|
||||||
|
console.log(` Muttersprache: ${nativeLanguageName}`);
|
||||||
|
|
||||||
|
// Finde "Familien-Gespräche" Lektion
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
title: 'Familien-Gespräche'
|
||||||
|
},
|
||||||
|
attributes: ['id', 'title', 'lessonNumber']
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} "Familien-Gespräche"-Lektion(en) gefunden`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
// Lösche vorhandene Übungen
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} alte Übung(en) gelöscht`);
|
||||||
|
|
||||||
|
// Erstelle neue Übungen
|
||||||
|
const exercises = createFamilyConversationExercises(nativeLanguageName);
|
||||||
|
|
||||||
|
if (exercises.length > 0) {
|
||||||
|
const exercisesToCreate = exercises.map(ex => ({
|
||||||
|
...ex,
|
||||||
|
lessonId: lesson.id,
|
||||||
|
createdByUserId: systemUser.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
await VocabGrammarExercise.bulkCreate(exercisesToCreate);
|
||||||
|
totalExercisesCreated += exercisesToCreate.length;
|
||||||
|
console.log(` ✅ ${exercisesToCreate.length} neue Übung(en) erstellt`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Keine Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalLessonsProcessed++;
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessonsProcessed} "Familien-Gespräche"-Lektion(en) verarbeitet`);
|
||||||
|
console.log(` ${totalExercisesCreated} Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFamilyConversationExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
293
backend/scripts/update-family-words-exercises.js
Executable file
293
backend/scripts/update-family-words-exercises.js
Executable file
@@ -0,0 +1,293 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Aktualisieren der "Familienwörter"-Übungen in Bisaya-Kursen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/update-family-words-exercises.js
|
||||||
|
*
|
||||||
|
* Ersetzt bestehende Dummy-Übungen durch spezifische Familienwörter-Übungen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
|
||||||
|
// Familienwörter-Übersetzungen in verschiedene Muttersprachen
|
||||||
|
const FAMILY_WORDS = {
|
||||||
|
Mutter: {
|
||||||
|
de: 'Mutter',
|
||||||
|
en: 'Mother',
|
||||||
|
es: 'Madre',
|
||||||
|
fr: 'Mère',
|
||||||
|
it: 'Madre',
|
||||||
|
pt: 'Mãe'
|
||||||
|
},
|
||||||
|
Vater: {
|
||||||
|
de: 'Vater',
|
||||||
|
en: 'Father',
|
||||||
|
es: 'Padre',
|
||||||
|
fr: 'Père',
|
||||||
|
it: 'Padre',
|
||||||
|
pt: 'Pai'
|
||||||
|
},
|
||||||
|
'älterer Bruder': {
|
||||||
|
de: 'älterer Bruder',
|
||||||
|
en: 'older brother',
|
||||||
|
es: 'hermano mayor',
|
||||||
|
fr: 'frère aîné',
|
||||||
|
it: 'fratello maggiore',
|
||||||
|
pt: 'irmão mais velho'
|
||||||
|
},
|
||||||
|
'ältere Schwester': {
|
||||||
|
de: 'ältere Schwester',
|
||||||
|
en: 'older sister',
|
||||||
|
es: 'hermana mayor',
|
||||||
|
fr: 'sœur aînée',
|
||||||
|
it: 'sorella maggiore',
|
||||||
|
pt: 'irmã mais velha'
|
||||||
|
},
|
||||||
|
Großmutter: {
|
||||||
|
de: 'Großmutter',
|
||||||
|
en: 'Grandmother',
|
||||||
|
es: 'Abuela',
|
||||||
|
fr: 'Grand-mère',
|
||||||
|
it: 'Nonna',
|
||||||
|
pt: 'Avó'
|
||||||
|
},
|
||||||
|
Großvater: {
|
||||||
|
de: 'Großvater',
|
||||||
|
en: 'Grandfather',
|
||||||
|
es: 'Abuelo',
|
||||||
|
fr: 'Grand-père',
|
||||||
|
it: 'Nonno',
|
||||||
|
pt: 'Avô'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bisaya-Übersetzungen
|
||||||
|
const BISAYA_TRANSLATIONS = {
|
||||||
|
'Mutter': 'Nanay',
|
||||||
|
'Vater': 'Tatay',
|
||||||
|
'älterer Bruder': 'Kuya',
|
||||||
|
'ältere Schwester': 'Ate',
|
||||||
|
'Großmutter': 'Lola',
|
||||||
|
'Großvater': 'Lolo'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sprach-Codes für Mapping
|
||||||
|
const LANGUAGE_CODES = {
|
||||||
|
'Deutsch': 'de',
|
||||||
|
'English': 'en',
|
||||||
|
'Español': 'es',
|
||||||
|
'Français': 'fr',
|
||||||
|
'Italiano': 'it',
|
||||||
|
'Português': 'pt',
|
||||||
|
'Spanish': 'es',
|
||||||
|
'French': 'fr',
|
||||||
|
'Italian': 'it',
|
||||||
|
'Portuguese': 'pt'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erstelle Übungen basierend auf Muttersprache
|
||||||
|
function createFamilyWordsExercises(nativeLanguageName) {
|
||||||
|
const langCode = LANGUAGE_CODES[nativeLanguageName] || 'de'; // Fallback zu Deutsch
|
||||||
|
|
||||||
|
const exercises = [];
|
||||||
|
|
||||||
|
// Multiple Choice Übungen für jedes Familienwort
|
||||||
|
const familyWords = Object.keys(FAMILY_WORDS);
|
||||||
|
const bisayaWords = ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Lola', 'Lolo'];
|
||||||
|
|
||||||
|
familyWords.forEach((key, index) => {
|
||||||
|
const nativeWord = FAMILY_WORDS[key][langCode];
|
||||||
|
const bisayaWord = BISAYA_TRANSLATIONS[key];
|
||||||
|
|
||||||
|
// Erstelle Multiple Choice mit falschen Antworten
|
||||||
|
const wrongAnswers = bisayaWords.filter(w => w !== bisayaWord);
|
||||||
|
const shuffledWrong = wrongAnswers.sort(() => Math.random() - 0.5).slice(0, 3);
|
||||||
|
const options = [bisayaWord, ...shuffledWrong].sort(() => Math.random() - 0.5);
|
||||||
|
const correctIndex = options.indexOf(bisayaWord);
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: `Wie sagt man "${nativeWord}" auf Bisaya?`,
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Wie sagt man "${nativeWord}" auf Bisaya?`,
|
||||||
|
options: options
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: correctIndex
|
||||||
|
},
|
||||||
|
explanation: `"${bisayaWord}" bedeutet "${nativeWord}" auf Bisaya.`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap Fill Übung
|
||||||
|
const nativeWords = familyWords.map(key => FAMILY_WORDS[key][langCode]);
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Familienwörter vervollständigen',
|
||||||
|
instruction: `Fülle die Lücken mit den richtigen Bisaya-Familienwörtern.`,
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: familyWords.map((key, i) => `{gap} (${nativeWords[i]})`).join(' | '),
|
||||||
|
gaps: familyWords.length
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: bisayaWords
|
||||||
|
},
|
||||||
|
explanation: bisayaWords.map((bw, i) => `${bw} = ${nativeWords[i]}`).join(', ') + '.'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transformation Übung
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Familienwörter übersetzen',
|
||||||
|
instruction: `Übersetze das Familienwort ins Bisaya.`,
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: nativeWords[0], // Erste Muttersprache als Beispiel
|
||||||
|
sourceLanguage: nativeLanguageName || 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: bisayaWords[0],
|
||||||
|
alternatives: [bisayaWords[0]] // Nur die korrekte Antwort
|
||||||
|
},
|
||||||
|
explanation: `"${bisayaWords[0]}" bedeutet "${nativeWords[0]}" auf Bisaya.`
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'system'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'admin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
console.error('❌ System-Benutzer nicht gefunden.');
|
||||||
|
throw new Error('System user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFamilyWordsExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
console.log(`Verwende System-Benutzer: ${systemUser.username} (ID: ${systemUser.id})\n`);
|
||||||
|
|
||||||
|
// Finde alle Bisaya-Kurse
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT c.id, c.title, c.owner_user_id, c.native_language_id, nl.name as native_language_name
|
||||||
|
FROM community.vocab_course c
|
||||||
|
LEFT JOIN community.vocab_language nl ON c.native_language_id = nl.id
|
||||||
|
WHERE c.language_id = :languageId`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: bisayaLanguage.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalExercisesUpdated = 0;
|
||||||
|
let totalLessonsUpdated = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
const nativeLangName = course.native_language_name || 'Deutsch'; // Fallback zu Deutsch
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id}, Muttersprache: ${nativeLangName})`);
|
||||||
|
|
||||||
|
// Erstelle Übungen für diese Muttersprache
|
||||||
|
const exercises = createFamilyWordsExercises(nativeLangName);
|
||||||
|
|
||||||
|
// Finde "Familienwörter"-Lektionen
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
title: 'Familienwörter'
|
||||||
|
},
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} "Familienwörter"-Lektionen gefunden\n`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
// Lösche bestehende Übungen (inkl. Dummy-Übungen)
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} alte Übung(en) gelöscht`);
|
||||||
|
|
||||||
|
// Erstelle neue Übungen
|
||||||
|
let exerciseNumber = 1;
|
||||||
|
for (const exerciseData of exercises) {
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: exerciseData.exerciseTypeId,
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: exerciseData.title,
|
||||||
|
instruction: exerciseData.instruction,
|
||||||
|
questionData: JSON.stringify(exerciseData.questionData),
|
||||||
|
answerData: JSON.stringify(exerciseData.answerData),
|
||||||
|
explanation: exerciseData.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${exercises.length} neue Übung(en) erstellt (${nativeLangName} → Bisaya)`);
|
||||||
|
totalLessonsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessonsUpdated} Lektionen aktualisiert`);
|
||||||
|
console.log(` ${totalExercisesUpdated} neue Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFamilyWordsExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
552
backend/scripts/update-feelings-affection-exercises.js
Executable file
552
backend/scripts/update-feelings-affection-exercises.js
Executable file
@@ -0,0 +1,552 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen von Übungen für die "Gefühle & Zuneigung" Lektion
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/update-feelings-affection-exercises.js
|
||||||
|
*
|
||||||
|
* Erstellt Gesprächsübungen für die "Gefühle & Zuneigung" Lektion in allen Bisaya-Kursen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
// Gefühle & Zuneigung auf Bisaya mit verschiedenen Muttersprachen
|
||||||
|
const FEELINGS_AFFECTION = {
|
||||||
|
// Deutsch -> Bisaya
|
||||||
|
'Deutsch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Gihigugma ko ikaw.',
|
||||||
|
native: 'Ich liebe dich.',
|
||||||
|
explanation: '"Gihigugma" bedeutet "lieben", "ko" ist "ich", "ikaw" ist "dich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nahigugma ko nimo.',
|
||||||
|
native: 'Ich liebe dich. (alternativ)',
|
||||||
|
explanation: '"Nahigugma" ist eine andere Form von "lieben", "nimo" ist "dich" (informell)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Ganahan ko nimo.',
|
||||||
|
native: 'Ich mag dich.',
|
||||||
|
explanation: '"Ganahan" bedeutet "mögen/gefallen", "ko" ist "ich", "nimo" ist "dich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga nakita ka.',
|
||||||
|
native: 'Ich bin glücklich, dich zu sehen.',
|
||||||
|
explanation: '"Nalipay" bedeutet "glücklich", "ko" ist "ich", "nga nakita ka" ist "dich zu sehen"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gimingaw ko nimo.',
|
||||||
|
native: 'Ich vermisse dich.',
|
||||||
|
explanation: '"Gimingaw" bedeutet "vermissen", "ko" ist "ich", "nimo" ist "dich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko.',
|
||||||
|
native: 'Ich bin glücklich.',
|
||||||
|
explanation: '"Nalipay" bedeutet "glücklich", "ko" ist "ich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nasubo ko.',
|
||||||
|
native: 'Ich bin traurig.',
|
||||||
|
explanation: '"Nasubo" bedeutet "traurig", "ko" ist "ich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga naa ka dinhi.',
|
||||||
|
native: 'Ich bin glücklich, dass du hier bist.',
|
||||||
|
explanation: '"Nalipay" ist "glücklich", "nga naa ka dinhi" bedeutet "dass du hier bist"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Englisch -> Bisaya
|
||||||
|
'Englisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Gihigugma ko ikaw.',
|
||||||
|
native: 'I love you.',
|
||||||
|
explanation: '"Gihigugma" means "love", "ko" is "I", "ikaw" is "you"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nahigugma ko nimo.',
|
||||||
|
native: 'I love you. (alternative)',
|
||||||
|
explanation: '"Nahigugma" is another form of "love", "nimo" is "you" (informal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Ganahan ko nimo.',
|
||||||
|
native: 'I like you.',
|
||||||
|
explanation: '"Ganahan" means "like", "ko" is "I", "nimo" is "you"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga nakita ka.',
|
||||||
|
native: 'I am happy to see you.',
|
||||||
|
explanation: '"Nalipay" means "happy", "ko" is "I", "nga nakita ka" is "to see you"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gimingaw ko nimo.',
|
||||||
|
native: 'I miss you.',
|
||||||
|
explanation: '"Gimingaw" means "miss", "ko" is "I", "nimo" is "you"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko.',
|
||||||
|
native: 'I am happy.',
|
||||||
|
explanation: '"Nalipay" means "happy", "ko" is "I"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nasubo ko.',
|
||||||
|
native: 'I am sad.',
|
||||||
|
explanation: '"Nasubo" means "sad", "ko" is "I"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga naa ka dinhi.',
|
||||||
|
native: 'I am happy that you are here.',
|
||||||
|
explanation: '"Nalipay" is "happy", "nga naa ka dinhi" means "that you are here"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Spanisch -> Bisaya
|
||||||
|
'Spanisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Gihigugma ko ikaw.',
|
||||||
|
native: 'Te amo.',
|
||||||
|
explanation: '"Gihigugma" significa "amar", "ko" es "yo", "ikaw" es "tú"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nahigugma ko nimo.',
|
||||||
|
native: 'Te amo. (alternativa)',
|
||||||
|
explanation: '"Nahigugma" es otra forma de "amar", "nimo" es "tú" (informal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Ganahan ko nimo.',
|
||||||
|
native: 'Me gustas.',
|
||||||
|
explanation: '"Ganahan" significa "gustar", "ko" es "yo", "nimo" es "tú"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga nakita ka.',
|
||||||
|
native: 'Estoy feliz de verte.',
|
||||||
|
explanation: '"Nalipay" significa "feliz", "ko" es "yo", "nga nakita ka" es "verte"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gimingaw ko nimo.',
|
||||||
|
native: 'Te extraño.',
|
||||||
|
explanation: '"Gimingaw" significa "extrañar", "ko" es "yo", "nimo" es "tú"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko.',
|
||||||
|
native: 'Estoy feliz.',
|
||||||
|
explanation: '"Nalipay" significa "feliz", "ko" es "yo"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nasubo ko.',
|
||||||
|
native: 'Estoy triste.',
|
||||||
|
explanation: '"Nasubo" significa "triste", "ko" es "yo"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga naa ka dinhi.',
|
||||||
|
native: 'Estoy feliz de que estés aquí.',
|
||||||
|
explanation: '"Nalipay" es "feliz", "nga naa ka dinhi" significa "que estés aquí"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Französisch -> Bisaya
|
||||||
|
'Französisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Gihigugma ko ikaw.',
|
||||||
|
native: 'Je t\'aime.',
|
||||||
|
explanation: '"Gihigugma" signifie "aimer", "ko" est "je", "ikaw" est "tu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nahigugma ko nimo.',
|
||||||
|
native: 'Je t\'aime. (alternative)',
|
||||||
|
explanation: '"Nahigugma" est une autre forme de "aimer", "nimo" est "tu" (informel)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Ganahan ko nimo.',
|
||||||
|
native: 'Je t\'aime bien.',
|
||||||
|
explanation: '"Ganahan" signifie "aimer bien", "ko" est "je", "nimo" est "tu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga nakita ka.',
|
||||||
|
native: 'Je suis heureux de te voir.',
|
||||||
|
explanation: '"Nalipay" signifie "heureux", "ko" est "je", "nga nakita ka" est "te voir"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gimingaw ko nimo.',
|
||||||
|
native: 'Tu me manques.',
|
||||||
|
explanation: '"Gimingaw" signifie "manquer", "ko" est "je", "nimo" est "tu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko.',
|
||||||
|
native: 'Je suis heureux.',
|
||||||
|
explanation: '"Nalipay" signifie "heureux", "ko" est "je"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nasubo ko.',
|
||||||
|
native: 'Je suis triste.',
|
||||||
|
explanation: '"Nasubo" signifie "triste", "ko" est "je"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga naa ka dinhi.',
|
||||||
|
native: 'Je suis heureux que tu sois ici.',
|
||||||
|
explanation: '"Nalipay" est "heureux", "nga naa ka dinhi" signifie "que tu sois ici"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Italienisch -> Bisaya
|
||||||
|
'Italienisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Gihigugma ko ikaw.',
|
||||||
|
native: 'Ti amo.',
|
||||||
|
explanation: '"Gihigugma" significa "amare", "ko" è "io", "ikaw" è "tu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nahigugma ko nimo.',
|
||||||
|
native: 'Ti amo. (alternativa)',
|
||||||
|
explanation: '"Nahigugma" è un\'altra forma di "amare", "nimo" è "tu" (informale)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Ganahan ko nimo.',
|
||||||
|
native: 'Mi piaci.',
|
||||||
|
explanation: '"Ganahan" significa "piacere", "ko" è "io", "nimo" è "tu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga nakita ka.',
|
||||||
|
native: 'Sono felice di vederti.',
|
||||||
|
explanation: '"Nalipay" significa "felice", "ko" è "io", "nga nakita ka" è "vederti"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gimingaw ko nimo.',
|
||||||
|
native: 'Mi manchi.',
|
||||||
|
explanation: '"Gimingaw" significa "mancare", "ko" è "io", "nimo" è "tu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko.',
|
||||||
|
native: 'Sono felice.',
|
||||||
|
explanation: '"Nalipay" significa "felice", "ko" è "io"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nasubo ko.',
|
||||||
|
native: 'Sono triste.',
|
||||||
|
explanation: '"Nasubo" significa "triste", "ko" è "io"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga naa ka dinhi.',
|
||||||
|
native: 'Sono felice che tu sia qui.',
|
||||||
|
explanation: '"Nalipay" è "felice", "nga naa ka dinhi" significa "che tu sia qui"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Portugiesisch -> Bisaya
|
||||||
|
'Portugiesisch': {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
bisaya: 'Gihigugma ko ikaw.',
|
||||||
|
native: 'Eu te amo.',
|
||||||
|
explanation: '"Gihigugma" significa "amar", "ko" é "eu", "ikaw" é "você"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nahigugma ko nimo.',
|
||||||
|
native: 'Eu te amo. (alternativa)',
|
||||||
|
explanation: '"Nahigugma" é outra forma de "amar", "nimo" é "você" (informal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Ganahan ko nimo.',
|
||||||
|
native: 'Eu gosto de você.',
|
||||||
|
explanation: '"Ganahan" significa "gostar", "ko" é "eu", "nimo" é "você"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga nakita ka.',
|
||||||
|
native: 'Estou feliz em te ver.',
|
||||||
|
explanation: '"Nalipay" significa "feliz", "ko" é "eu", "nga nakita ka" é "te ver"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gimingaw ko nimo.',
|
||||||
|
native: 'Eu sinto sua falta.',
|
||||||
|
explanation: '"Gimingaw" significa "sentir falta", "ko" é "eu", "nimo" é "você"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko.',
|
||||||
|
native: 'Estou feliz.',
|
||||||
|
explanation: '"Nalipay" significa "feliz", "ko" é "eu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nasubo ko.',
|
||||||
|
native: 'Estou triste.',
|
||||||
|
explanation: '"Nasubo" significa "triste", "ko" é "eu"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Nalipay ko nga naa ka dinhi.',
|
||||||
|
native: 'Estou feliz que você esteja aqui.',
|
||||||
|
explanation: '"Nalipay" é "feliz", "nga naa ka dinhi" significa "que você esteja aqui"'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
// Versuche zuerst einen System-Benutzer zu finden (z.B. mit username "system" oder "admin")
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: { [sequelize.Sequelize.Op.in]: ['system', 'admin', 'System', 'Admin'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
// Erstelle einen System-Benutzer
|
||||||
|
const password = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const hashedId = crypto.createHash('sha256').update(`system-${Date.now()}`).digest('hex');
|
||||||
|
|
||||||
|
systemUser = await User.create({
|
||||||
|
username: 'system',
|
||||||
|
password: hashedPassword,
|
||||||
|
hashedId: hashedId,
|
||||||
|
email: 'system@your-part.de'
|
||||||
|
});
|
||||||
|
console.log('✅ System-Benutzer erstellt:', systemUser.hashedId);
|
||||||
|
} else {
|
||||||
|
console.log('✅ System-Benutzer gefunden:', systemUser.hashedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeelingsAffectionExercises(nativeLanguageName) {
|
||||||
|
const exercises = [];
|
||||||
|
const conversations = FEELINGS_AFFECTION[nativeLanguageName]?.conversations || [];
|
||||||
|
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
console.warn(`⚠️ Keine Gespräche für Muttersprache "${nativeLanguageName}" gefunden. Verwende Deutsch als Fallback.`);
|
||||||
|
return createFeelingsAffectionExercises('Deutsch');
|
||||||
|
}
|
||||||
|
|
||||||
|
let exerciseNum = 1;
|
||||||
|
|
||||||
|
// Multiple Choice: Übersetze Bisaya-Satz in Muttersprache (alle Gespräche)
|
||||||
|
conversations.forEach((conv, idx) => {
|
||||||
|
// Erstelle für jedes Gespräch eine Multiple Choice Übung
|
||||||
|
const wrongOptions = conversations
|
||||||
|
.filter((c, i) => i !== idx)
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(c => c.native);
|
||||||
|
|
||||||
|
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
|
||||||
|
const correctIndex = options.indexOf(conv.native);
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: `Gefühle & Zuneigung ${idx + 1} - Übersetzung`,
|
||||||
|
instruction: 'Übersetze den Bisaya-Satz ins ' + nativeLanguageName,
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Wie sagt man "${conv.bisaya}" auf ${nativeLanguageName}?`,
|
||||||
|
options: options
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: correctIndex
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multiple Choice: Rückwärts-Übersetzung (Was bedeutet dieser Satz?)
|
||||||
|
conversations.forEach((conv, idx) => {
|
||||||
|
if (idx < 6) { // Erste 6 als Rückwärts-Übersetzung
|
||||||
|
const wrongOptions = conversations
|
||||||
|
.filter((c, i) => i !== idx)
|
||||||
|
.sort(() => Math.random() - 0.5)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(c => c.native);
|
||||||
|
|
||||||
|
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
|
||||||
|
const correctIndex = options.indexOf(conv.native);
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: `Gefühle & Zuneigung ${idx + 1} - Was bedeutet dieser Satz?`,
|
||||||
|
instruction: 'Was bedeutet dieser Bisaya-Satz?',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Was bedeutet "${conv.bisaya}"?`,
|
||||||
|
options: options
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: correctIndex
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gap Fill: Vervollständige Gefühlsausdrücke (mehrere Varianten)
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: 'Gefühle & Zuneigung 1 - Vervollständigen',
|
||||||
|
instruction: 'Vervollständige den Satz mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Person A: {gap} ko ikaw. (Ich liebe dich)\nPerson B: {gap} ko pud. (Ich liebe dich auch)',
|
||||||
|
gaps: 2
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Gihigugma', 'Gihigugma']
|
||||||
|
}),
|
||||||
|
explanation: '"Gihigugma" bedeutet "lieben" und wird wiederholt, um "auch" auszudrücken'
|
||||||
|
});
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: 'Gefühle & Zuneigung 2 - Vervollständigen',
|
||||||
|
instruction: 'Vervollständige den Satz mit den richtigen Bisaya-Wörtern.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Person A: {gap} ko nga nakita ka. (Ich bin glücklich)\nPerson B: {gap} ko pud. (Ich auch)',
|
||||||
|
gaps: 2
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Nalipay', 'Nalipay']
|
||||||
|
}),
|
||||||
|
explanation: '"Nalipay" bedeutet "glücklich sein"'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transformation: Übersetze Muttersprache-Satz nach Bisaya (mehrere Varianten)
|
||||||
|
conversations.slice(0, 4).forEach((conv, idx) => {
|
||||||
|
exercises.push({
|
||||||
|
exerciseTypeId: 3, // transformation
|
||||||
|
exerciseNumber: exerciseNum++,
|
||||||
|
title: `Gefühle & Zuneigung ${idx + 1} - Übersetzung nach Bisaya`,
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'transformation',
|
||||||
|
text: conv.native
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'transformation',
|
||||||
|
correctAnswer: conv.bisaya
|
||||||
|
}),
|
||||||
|
explanation: `"${conv.bisaya}" bedeutet "${conv.native}" auf Bisaya. ${conv.explanation}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFeelingsAffectionExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✅ Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
|
||||||
|
// Finde Bisaya-Sprache mit SQL
|
||||||
|
const [bisayaLangResult] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLangResult) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bisayaLanguageId = bisayaLangResult.id;
|
||||||
|
|
||||||
|
// Hole alle Bisaya-Kurse mit native language info
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT
|
||||||
|
c.id,
|
||||||
|
c.title,
|
||||||
|
c.native_language_id,
|
||||||
|
nl.name as native_language_name
|
||||||
|
FROM community.vocab_course c
|
||||||
|
LEFT JOIN community.vocab_language nl ON c.native_language_id = nl.id
|
||||||
|
WHERE c.language_id = :bisayaLanguageId`,
|
||||||
|
{
|
||||||
|
replacements: { bisayaLanguageId },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📚 Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalExercisesCreated = 0;
|
||||||
|
let totalLessonsProcessed = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📖 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
// Finde native language name
|
||||||
|
const nativeLanguageName = course.native_language_name || 'Deutsch';
|
||||||
|
console.log(` Muttersprache: ${nativeLanguageName}`);
|
||||||
|
|
||||||
|
// Finde "Gefühle & Zuneigung" Lektion
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
title: 'Gefühle & Zuneigung'
|
||||||
|
},
|
||||||
|
attributes: ['id', 'title', 'lessonNumber']
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} "Gefühle & Zuneigung"-Lektion(en) gefunden`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
// Lösche vorhandene Übungen
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} alte Übung(en) gelöscht`);
|
||||||
|
|
||||||
|
// Erstelle neue Übungen
|
||||||
|
const exercises = createFeelingsAffectionExercises(nativeLanguageName);
|
||||||
|
|
||||||
|
if (exercises.length > 0) {
|
||||||
|
const exercisesToCreate = exercises.map(ex => ({
|
||||||
|
...ex,
|
||||||
|
lessonId: lesson.id,
|
||||||
|
createdByUserId: systemUser.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
await VocabGrammarExercise.bulkCreate(exercisesToCreate);
|
||||||
|
totalExercisesCreated += exercisesToCreate.length;
|
||||||
|
console.log(` ✅ ${exercisesToCreate.length} neue Übung(en) erstellt`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Keine Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalLessonsProcessed++;
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessonsProcessed} "Gefühle & Zuneigung"-Lektion(en) verarbeitet`);
|
||||||
|
console.log(` ${totalExercisesCreated} Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFeelingsAffectionExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
730
backend/scripts/update-food-care-exercises.js
Executable file
730
backend/scripts/update-food-care-exercises.js
Executable file
@@ -0,0 +1,730 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Erstellen von Übungen für die "Essen & Fürsorge" und "Essen & Trinken" Lektionen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/update-food-care-exercises.js
|
||||||
|
*
|
||||||
|
* Erstellt Gesprächsübungen für die "Essen & Fürsorge" und "Essen & Trinken" Lektionen in allen Bisaya-Kursen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import VocabCourse from '../models/community/vocab_course.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
// Essen & Fürsorge / Essen & Trinken auf Bisaya mit verschiedenen Muttersprachen
|
||||||
|
const FOOD_CARE_CONVERSATIONS = {
|
||||||
|
// Deutsch -> Bisaya
|
||||||
|
'Deutsch': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko.',
|
||||||
|
native: 'Ich habe Hunger.',
|
||||||
|
explanation: '"Gutom" bedeutet "Hunger", "na" ist "schon", "ko" ist "ich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gihikap ko.',
|
||||||
|
native: 'Ich habe Durst.',
|
||||||
|
explanation: '"Gihikap" bedeutet "Durst haben", "ko" ist "ich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gusto ka mokaon?',
|
||||||
|
native: 'Möchtest du essen?',
|
||||||
|
explanation: '"Gusto" bedeutet "möchten", "ka" ist "du", "mokaon" ist "essen"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Oo, gusto ko.',
|
||||||
|
native: 'Ja, ich möchte.',
|
||||||
|
explanation: '"Oo" ist "Ja", "gusto ko" ist "ich möchte"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Unsa ang gusto nimo?',
|
||||||
|
native: 'Was möchtest du?',
|
||||||
|
explanation: '"Unsa" ist "Was", "ang" ist Artikel, "gusto nimo" ist "du möchtest"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gusto ko ug kan-on.',
|
||||||
|
native: 'Ich möchte Reis.',
|
||||||
|
explanation: '"Gusto ko" ist "ich möchte", "ug" ist "und/ein", "kan-on" ist "Reis"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Palihug, hatagi ko ug tubig.',
|
||||||
|
native: 'Bitte gib mir Wasser.',
|
||||||
|
explanation: '"Palihug" ist "Bitte", "hatagi" ist "geben", "ko" ist "mir", "ug tubig" ist "Wasser"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Salamat sa pagkaon.',
|
||||||
|
native: 'Danke für das Essen.',
|
||||||
|
explanation: '"Salamat" ist "Danke", "sa pagkaon" ist "für das Essen"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Lami kaayo!',
|
||||||
|
native: 'Sehr lecker!',
|
||||||
|
explanation: '"Lami" bedeutet "lecker", "kaayo" ist "sehr"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Busog na ko.',
|
||||||
|
native: 'Ich bin satt.',
|
||||||
|
explanation: '"Busog" bedeutet "satt", "na" ist "schon", "ko" ist "ich"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ang pagkaon?',
|
||||||
|
native: 'Wie schmeckt das Essen?',
|
||||||
|
explanation: '"Kumusta" ist "Wie", "ang pagkaon" ist "das Essen"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo kaayo ang pagkaon.',
|
||||||
|
native: 'Das Essen ist sehr gut.',
|
||||||
|
explanation: '"Maayo" ist "gut", "kaayo" ist "sehr", "ang pagkaon" ist "das Essen"'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{
|
||||||
|
bisaya: 'Kan-on',
|
||||||
|
native: 'Reis',
|
||||||
|
explanation: '"Kan-on" ist das grundlegende Wort für "Reis"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Tubig',
|
||||||
|
native: 'Wasser',
|
||||||
|
explanation: '"Tubig" bedeutet "Wasser"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Pan',
|
||||||
|
native: 'Brot',
|
||||||
|
explanation: '"Pan" bedeutet "Brot"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Isda',
|
||||||
|
native: 'Fisch',
|
||||||
|
explanation: '"Isda" bedeutet "Fisch"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Manok',
|
||||||
|
native: 'Huhn',
|
||||||
|
explanation: '"Manok" bedeutet "Huhn"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Baboy',
|
||||||
|
native: 'Schwein',
|
||||||
|
explanation: '"Baboy" bedeutet "Schwein"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gulay',
|
||||||
|
native: 'Gemüse',
|
||||||
|
explanation: '"Gulay" bedeutet "Gemüse"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Prutas',
|
||||||
|
native: 'Obst',
|
||||||
|
explanation: '"Prutas" bedeutet "Obst"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gatas',
|
||||||
|
native: 'Milch',
|
||||||
|
explanation: '"Gatas" bedeutet "Milch"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kape',
|
||||||
|
native: 'Kaffee',
|
||||||
|
explanation: '"Kape" bedeutet "Kaffee"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Tsa',
|
||||||
|
native: 'Tee',
|
||||||
|
explanation: '"Tsa" bedeutet "Tee"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asin',
|
||||||
|
native: 'Salz',
|
||||||
|
explanation: '"Asin" bedeutet "Salz"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asukar',
|
||||||
|
native: 'Zucker',
|
||||||
|
explanation: '"Asukar" bedeutet "Zucker"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Tinapay',
|
||||||
|
native: 'Brot (alternativ)',
|
||||||
|
explanation: '"Tinapay" ist eine alternative Bezeichnung für "Brot"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Bugas',
|
||||||
|
native: 'Reis (roh)',
|
||||||
|
explanation: '"Bugas" ist ungekochter Reis, "kan-on" ist gekochter Reis'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Englisch -> Bisaya
|
||||||
|
'Englisch': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{
|
||||||
|
bisaya: 'Gutom na ko.',
|
||||||
|
native: 'I am hungry.',
|
||||||
|
explanation: '"Gutom" means "hungry", "na" is "already", "ko" is "I"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gihikap ko.',
|
||||||
|
native: 'I am thirsty.',
|
||||||
|
explanation: '"Gihikap" means "thirsty", "ko" is "I"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gusto ka mokaon?',
|
||||||
|
native: 'Do you want to eat?',
|
||||||
|
explanation: '"Gusto" means "want", "ka" is "you", "mokaon" is "to eat"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Oo, gusto ko.',
|
||||||
|
native: 'Yes, I want.',
|
||||||
|
explanation: '"Oo" is "Yes", "gusto ko" is "I want"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Unsa ang gusto nimo?',
|
||||||
|
native: 'What do you want?',
|
||||||
|
explanation: '"Unsa" is "What", "ang" is article, "gusto nimo" is "you want"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gusto ko ug kan-on.',
|
||||||
|
native: 'I want rice.',
|
||||||
|
explanation: '"Gusto ko" is "I want", "ug" is "a/some", "kan-on" is "rice"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Palihug, hatagi ko ug tubig.',
|
||||||
|
native: 'Please give me water.',
|
||||||
|
explanation: '"Palihug" is "Please", "hatagi" is "give", "ko" is "me", "ug tubig" is "water"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Salamat sa pagkaon.',
|
||||||
|
native: 'Thank you for the food.',
|
||||||
|
explanation: '"Salamat" is "Thank you", "sa pagkaon" is "for the food"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Lami kaayo!',
|
||||||
|
native: 'Very delicious!',
|
||||||
|
explanation: '"Lami" means "delicious", "kaayo" is "very"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Busog na ko.',
|
||||||
|
native: 'I am full.',
|
||||||
|
explanation: '"Busog" means "full", "na" is "already", "ko" is "I"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kumusta ang pagkaon?',
|
||||||
|
native: 'How is the food?',
|
||||||
|
explanation: '"Kumusta" is "How", "ang pagkaon" is "the food"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Maayo kaayo ang pagkaon.',
|
||||||
|
native: 'The food is very good.',
|
||||||
|
explanation: '"Maayo" is "good", "kaayo" is "very", "ang pagkaon" is "the food"'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{
|
||||||
|
bisaya: 'Kan-on',
|
||||||
|
native: 'Rice',
|
||||||
|
explanation: '"Kan-on" is the basic word for "rice"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Tubig',
|
||||||
|
native: 'Water',
|
||||||
|
explanation: '"Tubig" means "water"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Pan',
|
||||||
|
native: 'Bread',
|
||||||
|
explanation: '"Pan" means "bread"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Isda',
|
||||||
|
native: 'Fish',
|
||||||
|
explanation: '"Isda" means "fish"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Manok',
|
||||||
|
native: 'Chicken',
|
||||||
|
explanation: '"Manok" means "chicken"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Baboy',
|
||||||
|
native: 'Pig',
|
||||||
|
explanation: '"Baboy" means "pig"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gulay',
|
||||||
|
native: 'Vegetables',
|
||||||
|
explanation: '"Gulay" means "vegetables"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Prutas',
|
||||||
|
native: 'Fruit',
|
||||||
|
explanation: '"Prutas" means "fruit"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Gatas',
|
||||||
|
native: 'Milk',
|
||||||
|
explanation: '"Gatas" means "milk"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Kape',
|
||||||
|
native: 'Coffee',
|
||||||
|
explanation: '"Kape" means "coffee"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Tsa',
|
||||||
|
native: 'Tea',
|
||||||
|
explanation: '"Tsa" means "tea"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asin',
|
||||||
|
native: 'Salt',
|
||||||
|
explanation: '"Asin" means "salt"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Asukar',
|
||||||
|
native: 'Sugar',
|
||||||
|
explanation: '"Asukar" means "sugar"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Tinapay',
|
||||||
|
native: 'Bread (alternative)',
|
||||||
|
explanation: '"Tinapay" is an alternative term for "bread"'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bisaya: 'Bugas',
|
||||||
|
native: 'Rice (uncooked)',
|
||||||
|
explanation: '"Bugas" is uncooked rice, "kan-on" is cooked rice'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erweitere für weitere Sprachen (Spanisch, Französisch, Italienisch, Portugiesisch, Tagalog)
|
||||||
|
const ADDITIONAL_LANGUAGES = {
|
||||||
|
'Spanisch': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{ bisaya: 'Gutom na ko.', native: 'Tengo hambre.', explanation: '"Gutom" significa "hambre", "na" es "ya", "ko" es "yo"' },
|
||||||
|
{ bisaya: 'Gihikap ko.', native: 'Tengo sed.', explanation: '"Gihikap" significa "sed", "ko" es "yo"' },
|
||||||
|
{ bisaya: 'Gusto ka mokaon?', native: '¿Quieres comer?', explanation: '"Gusto" significa "querer", "ka" es "tú", "mokaon" es "comer"' },
|
||||||
|
{ bisaya: 'Oo, gusto ko.', native: 'Sí, quiero.', explanation: '"Oo" es "Sí", "gusto ko" es "quiero"' },
|
||||||
|
{ bisaya: 'Unsa ang gusto nimo?', native: '¿Qué quieres?', explanation: '"Unsa" es "Qué", "ang" es artículo, "gusto nimo" es "quieres"' },
|
||||||
|
{ bisaya: 'Gusto ko ug kan-on.', native: 'Quiero arroz.', explanation: '"Gusto ko" es "quiero", "ug kan-on" es "arroz"' },
|
||||||
|
{ bisaya: 'Palihug, hatagi ko ug tubig.', native: 'Por favor, dame agua.', explanation: '"Palihug" es "Por favor", "hatagi" es "dar", "ko" es "me", "ug tubig" es "agua"' },
|
||||||
|
{ bisaya: 'Salamat sa pagkaon.', native: 'Gracias por la comida.', explanation: '"Salamat" es "Gracias", "sa pagkaon" es "por la comida"' },
|
||||||
|
{ bisaya: 'Lami kaayo!', native: '¡Muy delicioso!', explanation: '"Lami" significa "delicioso", "kaayo" es "muy"' },
|
||||||
|
{ bisaya: 'Busog na ko.', native: 'Estoy lleno.', explanation: '"Busog" significa "lleno", "na" es "ya", "ko" es "yo"' },
|
||||||
|
{ bisaya: 'Kumusta ang pagkaon?', native: '¿Cómo está la comida?', explanation: '"Kumusta" es "Cómo", "ang pagkaon" es "la comida"' },
|
||||||
|
{ bisaya: 'Maayo kaayo ang pagkaon.', native: 'La comida está muy buena.', explanation: '"Maayo" es "buena", "kaayo" es "muy", "ang pagkaon" es "la comida"' }
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{ bisaya: 'Kan-on', native: 'Arroz', explanation: '"Kan-on" es la palabra básica para "arroz"' },
|
||||||
|
{ bisaya: 'Tubig', native: 'Agua', explanation: '"Tubig" significa "agua"' },
|
||||||
|
{ bisaya: 'Pan', native: 'Pan', explanation: '"Pan" significa "pan"' },
|
||||||
|
{ bisaya: 'Isda', native: 'Pescado', explanation: '"Isda" significa "pescado"' },
|
||||||
|
{ bisaya: 'Manok', native: 'Pollo', explanation: '"Manok" significa "pollo"' },
|
||||||
|
{ bisaya: 'Baboy', native: 'Cerdo', explanation: '"Baboy" significa "cerdo"' },
|
||||||
|
{ bisaya: 'Gulay', native: 'Verduras', explanation: '"Gulay" significa "verduras"' },
|
||||||
|
{ bisaya: 'Prutas', native: 'Fruta', explanation: '"Prutas" significa "fruta"' },
|
||||||
|
{ bisaya: 'Gatas', native: 'Leche', explanation: '"Gatas" significa "leche"' },
|
||||||
|
{ bisaya: 'Kape', native: 'Café', explanation: '"Kape" significa "café"' },
|
||||||
|
{ bisaya: 'Tsa', native: 'Té', explanation: '"Tsa" significa "té"' },
|
||||||
|
{ bisaya: 'Asin', native: 'Sal', explanation: '"Asin" significa "sal"' },
|
||||||
|
{ bisaya: 'Asukar', native: 'Azúcar', explanation: '"Asukar" significa "azúcar"' },
|
||||||
|
{ bisaya: 'Tinapay', native: 'Pan (alternativo)', explanation: '"Tinapay" es un término alternativo para "pan"' },
|
||||||
|
{ bisaya: 'Bugas', native: 'Arroz (crudo)', explanation: '"Bugas" es arroz crudo, "kan-on" es arroz cocido' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Französisch': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{ bisaya: 'Gutom na ko.', native: 'J\'ai faim.', explanation: '"Gutom" signifie "faim", "na" est "déjà", "ko" est "je"' },
|
||||||
|
{ bisaya: 'Gihikap ko.', native: 'J\'ai soif.', explanation: '"Gihikap" signifie "soif", "ko" est "je"' },
|
||||||
|
{ bisaya: 'Gusto ka mokaon?', native: 'Tu veux manger?', explanation: '"Gusto" signifie "vouloir", "ka" est "tu", "mokaon" est "manger"' },
|
||||||
|
{ bisaya: 'Oo, gusto ko.', native: 'Oui, je veux.', explanation: '"Oo" est "Oui", "gusto ko" est "je veux"' },
|
||||||
|
{ bisaya: 'Unsa ang gusto nimo?', native: 'Que veux-tu?', explanation: '"Unsa" est "Que", "ang" est article, "gusto nimo" est "tu veux"' },
|
||||||
|
{ bisaya: 'Gusto ko ug kan-on.', native: 'Je veux du riz.', explanation: '"Gusto ko" est "je veux", "ug kan-on" est "riz"' },
|
||||||
|
{ bisaya: 'Palihug, hatagi ko ug tubig.', native: 'S\'il te plaît, donne-moi de l\'eau.', explanation: '"Palihug" est "S\'il te plaît", "hatagi" est "donner", "ko" est "moi", "ug tubig" est "eau"' },
|
||||||
|
{ bisaya: 'Salamat sa pagkaon.', native: 'Merci pour la nourriture.', explanation: '"Salamat" est "Merci", "sa pagkaon" est "pour la nourriture"' },
|
||||||
|
{ bisaya: 'Lami kaayo!', native: 'Très délicieux!', explanation: '"Lami" signifie "délicieux", "kaayo" est "très"' },
|
||||||
|
{ bisaya: 'Busog na ko.', native: 'Je suis rassasié.', explanation: '"Busog" signifie "rassasié", "na" est "déjà", "ko" est "je"' },
|
||||||
|
{ bisaya: 'Kumusta ang pagkaon?', native: 'Comment est la nourriture?', explanation: '"Kumusta" est "Comment", "ang pagkaon" est "la nourriture"' },
|
||||||
|
{ bisaya: 'Maayo kaayo ang pagkaon.', native: 'La nourriture est très bonne.', explanation: '"Maayo" est "bonne", "kaayo" est "très", "ang pagkaon" est "la nourriture"' }
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{ bisaya: 'Kan-on', native: 'Riz', explanation: '"Kan-on" est le mot de base pour "riz"' },
|
||||||
|
{ bisaya: 'Tubig', native: 'Eau', explanation: '"Tubig" signifie "eau"' },
|
||||||
|
{ bisaya: 'Pan', native: 'Pain', explanation: '"Pan" signifie "pain"' },
|
||||||
|
{ bisaya: 'Isda', native: 'Poisson', explanation: '"Isda" signifie "poisson"' },
|
||||||
|
{ bisaya: 'Manok', native: 'Poulet', explanation: '"Manok" signifie "poulet"' },
|
||||||
|
{ bisaya: 'Baboy', native: 'Porc', explanation: '"Baboy" signifie "porc"' },
|
||||||
|
{ bisaya: 'Gulay', native: 'Légumes', explanation: '"Gulay" signifie "légumes"' },
|
||||||
|
{ bisaya: 'Prutas', native: 'Fruit', explanation: '"Prutas" signifie "fruit"' },
|
||||||
|
{ bisaya: 'Gatas', native: 'Lait', explanation: '"Gatas" signifie "lait"' },
|
||||||
|
{ bisaya: 'Kape', native: 'Café', explanation: '"Kape" signifie "café"' },
|
||||||
|
{ bisaya: 'Tsa', native: 'Thé', explanation: '"Tsa" signifie "thé"' },
|
||||||
|
{ bisaya: 'Asin', native: 'Sel', explanation: '"Asin" signifie "sel"' },
|
||||||
|
{ bisaya: 'Asukar', native: 'Sucre', explanation: '"Asukar" signifie "sucre"' },
|
||||||
|
{ bisaya: 'Tinapay', native: 'Pain (alternatif)', explanation: '"Tinapay" est un terme alternatif pour "pain"' },
|
||||||
|
{ bisaya: 'Bugas', native: 'Riz (cru)', explanation: '"Bugas" est riz cru, "kan-on" est riz cuit' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Italienisch': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{ bisaya: 'Gutom na ko.', native: 'Ho fame.', explanation: '"Gutom" significa "fame", "na" è "già", "ko" è "io"' },
|
||||||
|
{ bisaya: 'Gihikap ko.', native: 'Ho sete.', explanation: '"Gihikap" significa "sete", "ko" è "io"' },
|
||||||
|
{ bisaya: 'Gusto ka mokaon?', native: 'Vuoi mangiare?', explanation: '"Gusto" significa "volere", "ka" è "tu", "mokaon" è "mangiare"' },
|
||||||
|
{ bisaya: 'Oo, gusto ko.', native: 'Sì, voglio.', explanation: '"Oo" è "Sì", "gusto ko" è "voglio"' },
|
||||||
|
{ bisaya: 'Unsa ang gusto nimo?', native: 'Cosa vuoi?', explanation: '"Unsa" è "Cosa", "ang" è articolo, "gusto nimo" è "vuoi"' },
|
||||||
|
{ bisaya: 'Gusto ko ug kan-on.', native: 'Voglio riso.', explanation: '"Gusto ko" è "voglio", "ug kan-on" è "riso"' },
|
||||||
|
{ bisaya: 'Palihug, hatagi ko ug tubig.', native: 'Per favore, dammi acqua.', explanation: '"Palihug" è "Per favore", "hatagi" è "dare", "ko" è "mi", "ug tubig" è "acqua"' },
|
||||||
|
{ bisaya: 'Salamat sa pagkaon.', native: 'Grazie per il cibo.', explanation: '"Salamat" è "Grazie", "sa pagkaon" è "per il cibo"' },
|
||||||
|
{ bisaya: 'Lami kaayo!', native: 'Molto delizioso!', explanation: '"Lami" significa "delizioso", "kaayo" è "molto"' },
|
||||||
|
{ bisaya: 'Busog na ko.', native: 'Sono sazio.', explanation: '"Busog" significa "sazio", "na" è "già", "ko" è "io"' },
|
||||||
|
{ bisaya: 'Kumusta ang pagkaon?', native: 'Com\'è il cibo?', explanation: '"Kumusta" è "Come", "ang pagkaon" è "il cibo"' },
|
||||||
|
{ bisaya: 'Maayo kaayo ang pagkaon.', native: 'Il cibo è molto buono.', explanation: '"Maayo" è "buono", "kaayo" è "molto", "ang pagkaon" è "il cibo"' }
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{ bisaya: 'Kan-on', native: 'Riso', explanation: '"Kan-on" è la parola base per "riso"' },
|
||||||
|
{ bisaya: 'Tubig', native: 'Acqua', explanation: '"Tubig" significa "acqua"' },
|
||||||
|
{ bisaya: 'Pan', native: 'Pane', explanation: '"Pan" significa "pane"' },
|
||||||
|
{ bisaya: 'Isda', native: 'Pesce', explanation: '"Isda" significa "pesce"' },
|
||||||
|
{ bisaya: 'Manok', native: 'Pollo', explanation: '"Manok" significa "pollo"' },
|
||||||
|
{ bisaya: 'Baboy', native: 'Maiale', explanation: '"Baboy" significa "maiale"' },
|
||||||
|
{ bisaya: 'Gulay', native: 'Verdura', explanation: '"Gulay" significa "verdura"' },
|
||||||
|
{ bisaya: 'Prutas', native: 'Frutta', explanation: '"Prutas" significa "frutta"' },
|
||||||
|
{ bisaya: 'Gatas', native: 'Latte', explanation: '"Gatas" significa "latte"' },
|
||||||
|
{ bisaya: 'Kape', native: 'Caffè', explanation: '"Kape" significa "caffè"' },
|
||||||
|
{ bisaya: 'Tsa', native: 'Tè', explanation: '"Tsa" significa "tè"' },
|
||||||
|
{ bisaya: 'Asin', native: 'Sale', explanation: '"Asin" significa "sale"' },
|
||||||
|
{ bisaya: 'Asukar', native: 'Zucchero', explanation: '"Asukar" significa "zucchero"' },
|
||||||
|
{ bisaya: 'Tinapay', native: 'Pane (alternativo)', explanation: '"Tinapay" è un termine alternativo per "pane"' },
|
||||||
|
{ bisaya: 'Bugas', native: 'Riso (crudo)', explanation: '"Bugas" è riso crudo, "kan-on" è riso cotto' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Portugiesisch': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{ bisaya: 'Gutom na ko.', native: 'Tenho fome.', explanation: '"Gutom" significa "fome", "na" é "já", "ko" é "eu"' },
|
||||||
|
{ bisaya: 'Gihikap ko.', native: 'Tenho sede.', explanation: '"Gihikap" significa "sede", "ko" é "eu"' },
|
||||||
|
{ bisaya: 'Gusto ka mokaon?', native: 'Quer comer?', explanation: '"Gusto" significa "querer", "ka" é "você", "mokaon" é "comer"' },
|
||||||
|
{ bisaya: 'Oo, gusto ko.', native: 'Sim, quero.', explanation: '"Oo" é "Sim", "gusto ko" é "quero"' },
|
||||||
|
{ bisaya: 'Unsa ang gusto nimo?', native: 'O que você quer?', explanation: '"Unsa" é "O que", "ang" é artigo, "gusto nimo" é "você quer"' },
|
||||||
|
{ bisaya: 'Gusto ko ug kan-on.', native: 'Quero arroz.', explanation: '"Gusto ko" é "quero", "ug kan-on" é "arroz"' },
|
||||||
|
{ bisaya: 'Palihug, hatagi ko ug tubig.', native: 'Por favor, me dê água.', explanation: '"Palihug" é "Por favor", "hatagi" é "dar", "ko" é "me", "ug tubig" é "água"' },
|
||||||
|
{ bisaya: 'Salamat sa pagkaon.', native: 'Obrigado pela comida.', explanation: '"Salamat" é "Obrigado", "sa pagkaon" é "pela comida"' },
|
||||||
|
{ bisaya: 'Lami kaayo!', native: 'Muito delicioso!', explanation: '"Lami" significa "delicioso", "kaayo" é "muito"' },
|
||||||
|
{ bisaya: 'Busog na ko.', native: 'Estou cheio.', explanation: '"Busog" significa "cheio", "na" é "já", "ko" é "eu"' },
|
||||||
|
{ bisaya: 'Kumusta ang pagkaon?', native: 'Como está a comida?', explanation: '"Kumusta" é "Como", "ang pagkaon" é "a comida"' },
|
||||||
|
{ bisaya: 'Maayo kaayo ang pagkaon.', native: 'A comida está muito boa.', explanation: '"Maayo" é "boa", "kaayo" é "muito", "ang pagkaon" é "a comida"' }
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{ bisaya: 'Kan-on', native: 'Arroz', explanation: '"Kan-on" é a palavra básica para "arroz"' },
|
||||||
|
{ bisaya: 'Tubig', native: 'Água', explanation: '"Tubig" significa "água"' },
|
||||||
|
{ bisaya: 'Pan', native: 'Pão', explanation: '"Pan" significa "pão"' },
|
||||||
|
{ bisaya: 'Isda', native: 'Peixe', explanation: '"Isda" significa "peixe"' },
|
||||||
|
{ bisaya: 'Manok', native: 'Frango', explanation: '"Manok" significa "frango"' },
|
||||||
|
{ bisaya: 'Baboy', native: 'Porco', explanation: '"Baboy" significa "porco"' },
|
||||||
|
{ bisaya: 'Gulay', native: 'Legumes', explanation: '"Gulay" significa "legumes"' },
|
||||||
|
{ bisaya: 'Prutas', native: 'Fruta', explanation: '"Prutas" significa "fruta"' },
|
||||||
|
{ bisaya: 'Gatas', native: 'Leite', explanation: '"Gatas" significa "leite"' },
|
||||||
|
{ bisaya: 'Kape', native: 'Café', explanation: '"Kape" significa "café"' },
|
||||||
|
{ bisaya: 'Tsa', native: 'Chá', explanation: '"Tsa" significa "chá"' },
|
||||||
|
{ bisaya: 'Asin', native: 'Sal', explanation: '"Asin" significa "sal"' },
|
||||||
|
{ bisaya: 'Asukar', native: 'Açúcar', explanation: '"Asukar" significa "açúcar"' },
|
||||||
|
{ bisaya: 'Tinapay', native: 'Pão (alternativo)', explanation: '"Tinapay" é um termo alternativo para "pão"' },
|
||||||
|
{ bisaya: 'Bugas', native: 'Arroz (cru)', explanation: '"Bugas" é arroz cru, "kan-on" é arroz cozido' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Tagalog': {
|
||||||
|
'Essen & Fürsorge': [
|
||||||
|
{ bisaya: 'Gutom na ko.', native: 'Gutom na ako.', explanation: '"Gutom" ay "gutom", "na" ay "na", "ko" ay "ako"' },
|
||||||
|
{ bisaya: 'Gihikap ko.', native: 'Nauuhaw ako.', explanation: '"Gihikap" ay "nauuhaw", "ko" ay "ako"' },
|
||||||
|
{ bisaya: 'Gusto ka mokaon?', native: 'Gusto mo bang kumain?', explanation: '"Gusto" ay "gusto", "ka" ay "mo", "mokaon" ay "kumain"' },
|
||||||
|
{ bisaya: 'Oo, gusto ko.', native: 'Oo, gusto ko.', explanation: '"Oo" ay "Oo", "gusto ko" ay "gusto ko"' },
|
||||||
|
{ bisaya: 'Unsa ang gusto nimo?', native: 'Ano ang gusto mo?', explanation: '"Unsa" ay "Ano", "ang" ay "ang", "gusto nimo" ay "gusto mo"' },
|
||||||
|
{ bisaya: 'Gusto ko ug kan-on.', native: 'Gusto ko ng kanin.', explanation: '"Gusto ko" ay "gusto ko", "ug kan-on" ay "kanin"' },
|
||||||
|
{ bisaya: 'Palihug, hatagi ko ug tubig.', native: 'Pakiusap, bigyan mo ako ng tubig.', explanation: '"Palihug" ay "Pakiusap", "hatagi" ay "bigyan", "ko" ay "ako", "ug tubig" ay "tubig"' },
|
||||||
|
{ bisaya: 'Salamat sa pagkaon.', native: 'Salamat sa pagkain.', explanation: '"Salamat" ay "Salamat", "sa pagkaon" ay "sa pagkain"' },
|
||||||
|
{ bisaya: 'Lami kaayo!', native: 'Masarap talaga!', explanation: '"Lami" ay "masarap", "kaayo" ay "talaga"' },
|
||||||
|
{ bisaya: 'Busog na ko.', native: 'Busog na ako.', explanation: '"Busog" ay "busog", "na" ay "na", "ko" ay "ako"' },
|
||||||
|
{ bisaya: 'Kumusta ang pagkaon?', native: 'Kumusta ang pagkain?', explanation: '"Kumusta" ay "Kumusta", "ang pagkaon" ay "ang pagkain"' },
|
||||||
|
{ bisaya: 'Maayo kaayo ang pagkaon.', native: 'Mabuti talaga ang pagkain.', explanation: '"Maayo" ay "mabuti", "kaayo" ay "talaga", "ang pagkaon" ay "ang pagkain"' }
|
||||||
|
],
|
||||||
|
'Essen & Trinken': [
|
||||||
|
{ bisaya: 'Kan-on', native: 'Kanin', explanation: '"Kan-on" ay ang salitang base para sa "kanin"' },
|
||||||
|
{ bisaya: 'Tubig', native: 'Tubig', explanation: '"Tubig" ay "tubig"' },
|
||||||
|
{ bisaya: 'Pan', native: 'Tinapay', explanation: '"Pan" ay "tinapay"' },
|
||||||
|
{ bisaya: 'Isda', native: 'Isda', explanation: '"Isda" ay "isda"' },
|
||||||
|
{ bisaya: 'Manok', native: 'Manok', explanation: '"Manok" ay "manok"' },
|
||||||
|
{ bisaya: 'Baboy', native: 'Baboy', explanation: '"Baboy" ay "baboy"' },
|
||||||
|
{ bisaya: 'Gulay', native: 'Gulay', explanation: '"Gulay" ay "gulay"' },
|
||||||
|
{ bisaya: 'Prutas', native: 'Prutas', explanation: '"Prutas" ay "prutas"' },
|
||||||
|
{ bisaya: 'Gatas', native: 'Gatas', explanation: '"Gatas" ay "gatas"' },
|
||||||
|
{ bisaya: 'Kape', native: 'Kape', explanation: '"Kape" ay "kape"' },
|
||||||
|
{ bisaya: 'Tsa', native: 'Tsa', explanation: '"Tsa" ay "tsa"' },
|
||||||
|
{ bisaya: 'Asin', native: 'Asin', explanation: '"Asin" ay "asin"' },
|
||||||
|
{ bisaya: 'Asukar', native: 'Asukal', explanation: '"Asukar" ay "asukal"' },
|
||||||
|
{ bisaya: 'Tinapay', native: 'Tinapay', explanation: '"Tinapay" ay "tinapay"' },
|
||||||
|
{ bisaya: 'Bugas', native: 'Bigas', explanation: '"Bugas" ay "bigas", "kan-on" ay "kanin"' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kombiniere alle Sprachen
|
||||||
|
const ALL_FOOD_CARE = { ...FOOD_CARE_CONVERSATIONS, ...ADDITIONAL_LANGUAGES };
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: { [Op.in]: ['system', 'admin', 'System', 'Admin'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
console.error('❌ System-Benutzer nicht gefunden.');
|
||||||
|
throw new Error('System user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFoodCareExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
console.log(`Verwende System-Benutzer: ${systemUser.username} (ID: ${systemUser.id})\n`);
|
||||||
|
|
||||||
|
// Finde alle Bisaya-Kurse
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bisayaLanguageId = bisayaLanguage.id;
|
||||||
|
|
||||||
|
// Hole alle Bisaya-Kurse mit native language info
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT
|
||||||
|
c.id,
|
||||||
|
c.title,
|
||||||
|
c.owner_user_id,
|
||||||
|
c.native_language_id,
|
||||||
|
nl.name as native_language_name
|
||||||
|
FROM community.vocab_course c
|
||||||
|
LEFT JOIN community.vocab_language nl ON c.native_language_id = nl.id
|
||||||
|
WHERE c.language_id = :bisayaLanguageId`,
|
||||||
|
{
|
||||||
|
replacements: { bisayaLanguageId },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalExercisesCreated = 0;
|
||||||
|
let totalLessonsUpdated = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
const nativeLangName = course.native_language_name || 'Deutsch';
|
||||||
|
console.log(` Muttersprache: ${nativeLangName}`);
|
||||||
|
|
||||||
|
// Finde "Essen & Fürsorge" und "Essen & Trinken" Lektionen
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
title: ['Essen & Fürsorge', 'Essen & Trinken']
|
||||||
|
},
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} Lektion(en) gefunden\n`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
const conversations = ALL_FOOD_CARE[nativeLangName]?.[lesson.title];
|
||||||
|
|
||||||
|
if (!conversations || conversations.length === 0) {
|
||||||
|
console.log(` ⚠️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - keine Übungen für Muttersprache "${nativeLangName}" definiert`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lösche bestehende Übungen
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} alte Übung(en) gelöscht`);
|
||||||
|
|
||||||
|
// Erstelle neue Übungen basierend auf dem Lektionstyp
|
||||||
|
let exerciseNumber = 1;
|
||||||
|
|
||||||
|
if (lesson.title === 'Essen & Fürsorge') {
|
||||||
|
// Gesprächsübungen für "Essen & Fürsorge"
|
||||||
|
for (const conv of conversations) {
|
||||||
|
// Multiple Choice: Übersetzung von Muttersprache zu Bisaya
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: `Wie sagt man "${conv.native}"?`,
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Wie sagt man "${conv.native}" auf Bisaya?`,
|
||||||
|
options: [
|
||||||
|
conv.bisaya,
|
||||||
|
conversations[(exerciseNumber - 2 + 1) % conversations.length]?.bisaya || 'Salamat',
|
||||||
|
conversations[(exerciseNumber - 2 + 2) % conversations.length]?.bisaya || 'Maayo',
|
||||||
|
conversations[(exerciseNumber - 2 + 3) % conversations.length]?.bisaya || 'Palihug'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesCreated++;
|
||||||
|
|
||||||
|
// Multiple Choice: Übersetzung von Bisaya zu Muttersprache
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: `Was bedeutet "${conv.bisaya}"?`,
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Was bedeutet "${conv.bisaya}"?`,
|
||||||
|
options: [
|
||||||
|
conv.native,
|
||||||
|
conversations[(exerciseNumber - 3 + 1) % conversations.length]?.native || 'Danke',
|
||||||
|
conversations[(exerciseNumber - 3 + 2) % conversations.length]?.native || 'Bitte',
|
||||||
|
conversations[(exerciseNumber - 3 + 3) % conversations.length]?.native || 'Gut'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformation-Übungen
|
||||||
|
const selectedConvs = conversations.slice(0, 3);
|
||||||
|
for (const conv of selectedConvs) {
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: `Übersetze: "${conv.native}"`,
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'transformation',
|
||||||
|
text: conv.native,
|
||||||
|
sourceLanguage: nativeLangName,
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'transformation',
|
||||||
|
correct: conv.bisaya,
|
||||||
|
alternatives: []
|
||||||
|
}),
|
||||||
|
explanation: conv.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesCreated++;
|
||||||
|
}
|
||||||
|
} else if (lesson.title === 'Essen & Trinken') {
|
||||||
|
// Vokabular-Übungen für "Essen & Trinken"
|
||||||
|
for (const vocab of conversations) {
|
||||||
|
// Multiple Choice: Muttersprache -> Bisaya
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: `Wie sagt man "${vocab.native}"?`,
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Wie sagt man "${vocab.native}" auf Bisaya?`,
|
||||||
|
options: [
|
||||||
|
vocab.bisaya,
|
||||||
|
conversations[(exerciseNumber - 2 + 1) % conversations.length]?.bisaya || 'Salamat',
|
||||||
|
conversations[(exerciseNumber - 2 + 2) % conversations.length]?.bisaya || 'Maayo',
|
||||||
|
conversations[(exerciseNumber - 2 + 3) % conversations.length]?.bisaya || 'Palihug'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
}),
|
||||||
|
explanation: vocab.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesCreated++;
|
||||||
|
|
||||||
|
// Multiple Choice: Bisaya -> Muttersprache
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: `Was bedeutet "${vocab.bisaya}"?`,
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: `Was bedeutet "${vocab.bisaya}"?`,
|
||||||
|
options: [
|
||||||
|
vocab.native,
|
||||||
|
conversations[(exerciseNumber - 3 + 1) % conversations.length]?.native || 'Danke',
|
||||||
|
conversations[(exerciseNumber - 3 + 2) % conversations.length]?.native || 'Bitte',
|
||||||
|
conversations[(exerciseNumber - 3 + 3) % conversations.length]?.native || 'Gut'
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
answerData: JSON.stringify({
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
}),
|
||||||
|
explanation: vocab.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ ${lessons.length} Lektion(en) aktualisiert\n`);
|
||||||
|
totalLessonsUpdated += lessons.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessonsUpdated} Lektionen aktualisiert`);
|
||||||
|
console.log(` ${totalExercisesCreated} neue Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFoodCareExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
454
backend/scripts/update-survival-sentences-exercises.js
Executable file
454
backend/scripts/update-survival-sentences-exercises.js
Executable file
@@ -0,0 +1,454 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Aktualisieren der "Überlebenssätze"-Übungen in Bisaya-Kursen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/update-survival-sentences-exercises.js
|
||||||
|
*
|
||||||
|
* Ersetzt bestehende generische Übungen durch spezifische Überlebenssätze-Übungen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
|
||||||
|
// Spezifische Übungen für Überlebenssätze
|
||||||
|
const SURVIVAL_EXERCISES = {
|
||||||
|
'Überlebenssätze - Teil 1': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Ich verstehe nicht"?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?',
|
||||||
|
options: ['Wala ko kasabot', 'Palihug', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht" - sehr wichtig für Anfänger!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Kannst du das wiederholen?"?',
|
||||||
|
instruction: 'Wähle die richtige Bitte aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Kannst du das wiederholen?" auf Bisaya?',
|
||||||
|
options: ['Palihug ka mubalik?', 'Salamat', 'Maayo', 'Kumusta ka?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Palihug ka mubalik?" bedeutet "Bitte kannst du wiederholen?" - essentiell für das Lernen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wo ist...?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wo ist die Toilette?" auf Bisaya?',
|
||||||
|
options: ['Asa ang CR?', 'Kumusta ka?', 'Salamat', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Asa ang CR?" bedeutet "Wo ist die Toilette?" - "Asa" = "Wo", "CR" = "Comfort Room" (Toilette).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vervollständige den Satz: "Ich verstehe nicht"',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?',
|
||||||
|
options: ['Wala ko kasabot', 'Dili ko kasabot', 'Wala ko makasabot', 'Dili ko makasabot']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Vervollständige den Satz: "Bitte wiederholen"',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Bitte wiederholen" auf Bisaya?',
|
||||||
|
options: ['Palihug ka mubalik?', 'Palihug balik', 'Salamat mubalik', 'Maayo mubalik']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Palihug ka mubalik?" bedeutet "Bitte kannst du wiederholen?".'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
'Überlebenssätze - Teil 2': [
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Wie viel kostet das?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Wie viel kostet das?" auf Bisaya?',
|
||||||
|
options: ['Tagpila ni?', 'Asa ni?', 'Unsa ni?', 'Kinsa ni?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila ni?" bedeutet "Wie viel kostet das?" - sehr nützlich beim Einkaufen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Entschuldigung"?',
|
||||||
|
instruction: 'Wähle die richtige Entschuldigung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Entschuldigung" auf Bisaya?',
|
||||||
|
options: ['Pasensya', 'Salamat', 'Palihug', 'Maayo']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Pasensya" bedeutet "Entschuldigung" oder "Entschuldige bitte" - wichtig für höfliche Kommunikation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Was ist das?"?',
|
||||||
|
instruction: 'Wähle die richtige Frage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Was ist das?" auf Bisaya?',
|
||||||
|
options: ['Unsa ni?', 'Asa ni?', 'Tagpila ni?', 'Kinsa ni?']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Unsa ni?" bedeutet "Was ist das?" - "Unsa" = "Was", "ni" = "das".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Bitte langsam"?',
|
||||||
|
instruction: 'Wähle die richtige Bitte aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Bitte langsam" auf Bisaya?',
|
||||||
|
options: ['Hinay-hinay lang', 'Palihug lang', 'Maayo lang', 'Salamat lang']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Hinay-hinay lang" bedeutet "Bitte langsam" - sehr wichtig, wenn jemand zu schnell spricht!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Ich spreche kein Bisaya"?',
|
||||||
|
instruction: 'Wähle die richtige Aussage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich spreche kein Bisaya" auf Bisaya?',
|
||||||
|
options: ['Dili ko mag-Bisaya', 'Wala ko mag-Bisaya', 'Maayo ko mag-Bisaya', 'Salamat ko mag-Bisaya']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Dili ko mag-Bisaya" bedeutet "Ich spreche kein Bisaya" - nützlich, um zu erklären, dass du noch lernst.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Kannst du das aufschreiben?"?',
|
||||||
|
instruction: 'Wähle die richtige Bitte aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Kannst du das aufschreiben?" auf Bisaya?',
|
||||||
|
options: ['Palihug isulat ni', 'Palihug basahon ni', 'Palihug sulaton ni', 'Palihug pakigamit ni']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Palihug isulat ni" bedeutet "Bitte schreibe das auf" - hilfreich beim Lernen neuer Wörter.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "Ich bin verloren"?',
|
||||||
|
instruction: 'Wähle die richtige Aussage aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "Ich bin verloren" auf Bisaya?',
|
||||||
|
options: ['Nawala ko', 'Naa ko', 'Maayo ko', 'Salamat ko']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Nawala ko" bedeutet "Ich bin verloren" - wichtig, wenn du Hilfe brauchst.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Wichtige Fragen bilden',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Fragewörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: '{gap} ni? (Wie viel kostet das?) | {gap} ni? (Was ist das?) | {gap} lang (Bitte langsam)',
|
||||||
|
gaps: 3
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['Tagpila', 'Unsa', 'Hinay-hinay']
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila" = "Wie viel", "Unsa" = "Was", "Hinay-hinay lang" = "Bitte langsam".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 1, // gap_fill
|
||||||
|
title: 'Überlebenssätze vervollständigen',
|
||||||
|
instruction: 'Fülle die Lücken mit den richtigen Wörtern.',
|
||||||
|
questionData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
text: 'Palihug {gap} ni (Bitte schreibe das auf) | {gap} ko (Ich bin verloren) | Dili ko {gap} (Ich spreche kein Bisaya)',
|
||||||
|
gaps: 3
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'gap_fill',
|
||||||
|
answers: ['isulat', 'Nawala', 'mag-Bisaya']
|
||||||
|
},
|
||||||
|
explanation: '"isulat" = "aufschreiben", "Nawala" = "verloren", "mag-Bisaya" = "Bisaya sprechen".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Was bedeutet "Tagpila"?',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Tagpila"?',
|
||||||
|
options: ['Wie viel', 'Was', 'Wo', 'Wer']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila" bedeutet "Wie viel" und wird verwendet, um nach Preisen zu fragen.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Was bedeutet "Pasensya"?',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Pasensya"?',
|
||||||
|
options: ['Entschuldigung', 'Danke', 'Bitte', 'Gut']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Pasensya" bedeutet "Entschuldigung" oder "Entschuldige bitte" - wichtig für höfliche Kommunikation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Was bedeutet "Hinay-hinay lang"?',
|
||||||
|
instruction: 'Wähle die richtige Bedeutung aus.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Was bedeutet "Hinay-hinay lang"?',
|
||||||
|
options: ['Bitte langsam', 'Bitte schnell', 'Bitte laut', 'Bitte leise']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Hinay-hinay lang" bedeutet "Bitte langsam" - sehr wichtig, wenn jemand zu schnell spricht!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Überlebenssätze übersetzen - Einkaufen',
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Wie viel kostet das?',
|
||||||
|
sourceLanguage: 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Tagpila ni?',
|
||||||
|
alternatives: ['Tagpila kini?', 'Pila ni?']
|
||||||
|
},
|
||||||
|
explanation: '"Tagpila ni?" bedeutet "Wie viel kostet das?" - sehr nützlich beim Einkaufen!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Überlebenssätze übersetzen - Kommunikation',
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Ich spreche kein Bisaya',
|
||||||
|
sourceLanguage: 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Dili ko mag-Bisaya',
|
||||||
|
alternatives: ['Wala ko mag-Bisaya', 'Dili ko makasabot Bisaya']
|
||||||
|
},
|
||||||
|
explanation: '"Dili ko mag-Bisaya" bedeutet "Ich spreche kein Bisaya" - nützlich, um zu erklären, dass du noch lernst.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 4, // transformation
|
||||||
|
title: 'Überlebenssätze übersetzen - Hilfe',
|
||||||
|
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||||
|
questionData: {
|
||||||
|
type: 'transformation',
|
||||||
|
text: 'Ich bin verloren',
|
||||||
|
sourceLanguage: 'Deutsch',
|
||||||
|
targetLanguage: 'Bisaya'
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'transformation',
|
||||||
|
correct: 'Nawala ko',
|
||||||
|
alternatives: ['Nawala ako', 'Nawala na ko']
|
||||||
|
},
|
||||||
|
explanation: '"Nawala ko" bedeutet "Ich bin verloren" - wichtig, wenn du Hilfe brauchst.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findOrCreateSystemUser() {
|
||||||
|
let systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'system'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
systemUser = await User.findOne({
|
||||||
|
where: {
|
||||||
|
username: 'admin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
console.error('❌ System-Benutzer nicht gefunden.');
|
||||||
|
throw new Error('System user not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSurvivalExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
const systemUser = await findOrCreateSystemUser();
|
||||||
|
console.log(`Verwende System-Benutzer: ${systemUser.username} (ID: ${systemUser.id})\n`);
|
||||||
|
|
||||||
|
// Finde alle Bisaya-Kurse
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT id, title, owner_user_id FROM community.vocab_course WHERE language_id = :languageId`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: bisayaLanguage.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurse\n`);
|
||||||
|
|
||||||
|
let totalExercisesUpdated = 0;
|
||||||
|
let totalLessonsUpdated = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
// Finde "Überlebenssätze"-Lektionen
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
title: ['Überlebenssätze - Teil 1', 'Überlebenssätze - Teil 2']
|
||||||
|
},
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ${lessons.length} "Überlebenssätze"-Lektionen gefunden\n`);
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
const exercises = SURVIVAL_EXERCISES[lesson.title];
|
||||||
|
|
||||||
|
if (!exercises || exercises.length === 0) {
|
||||||
|
console.log(` ⚠️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - keine Übungen definiert`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lösche bestehende Übungen
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} alte Übung(en) gelöscht`);
|
||||||
|
|
||||||
|
// Erstelle neue Übungen
|
||||||
|
let exerciseNumber = 1;
|
||||||
|
for (const exerciseData of exercises) {
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: exerciseData.exerciseTypeId,
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: exerciseData.title,
|
||||||
|
instruction: exerciseData.instruction,
|
||||||
|
questionData: JSON.stringify(exerciseData.questionData),
|
||||||
|
answerData: JSON.stringify(exerciseData.answerData),
|
||||||
|
explanation: exerciseData.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalExercisesUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${exercises.length} neue Übung(en) erstellt`);
|
||||||
|
totalLessonsUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalLessonsUpdated} Lektionen aktualisiert`);
|
||||||
|
console.log(` ${totalExercisesUpdated} neue Grammatik-Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSurvivalExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
129
backend/scripts/update-week1-bisaya-exercises.js
Normal file
129
backend/scripts/update-week1-bisaya-exercises.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Aktualisieren der Woche-1-Lektionen in Bisaya-Kursen
|
||||||
|
*
|
||||||
|
* Verwendung:
|
||||||
|
* node backend/scripts/update-week1-bisaya-exercises.js
|
||||||
|
*
|
||||||
|
* - Entfernt alte Platzhalter-Übungen
|
||||||
|
* - Ersetzt durch korrekte Inhalte für "Woche 1 - Wiederholung" und "Woche 1 - Vokabeltest"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
|
||||||
|
const LESSON_TITLES = ['Woche 1 - Wiederholung', 'Woche 1 - Vokabeltest'];
|
||||||
|
|
||||||
|
const BISAYA_EXERCISES = {
|
||||||
|
'Woche 1 - Wiederholung': [
|
||||||
|
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Wie geht es dir?"?', instruction: 'Wähle die richtige Begrüßung aus.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Wie geht es dir?" auf Bisaya?', options: ['Kumusta ka?', 'Maayo', 'Salamat', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Kumusta ka?" ist die Standard-Begrüßung auf Bisaya.' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Mutter" auf Bisaya?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Mutter" auf Bisaya?', options: ['Nanay', 'Tatay', 'Kuya', 'Ate'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nanay" bedeutet "Mutter" auf Bisaya.' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Wiederholung: Was bedeutet "Palangga taka"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Palangga taka"?', options: ['Ich hab dich lieb', 'Danke', 'Guten Tag', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Palangga taka" bedeutet "Ich hab dich lieb" - wärmer als "I love you" im Familienkontext.' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Wiederholung: Was fragt man mit "Nikaon ka?"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Nikaon ka?"?', options: ['Hast du schon gegessen?', 'Wie geht es dir?', 'Danke', 'Bitte'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nikaon ka?" bedeutet "Hast du schon gegessen?" - typisch fürsorglich auf den Philippinen.' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' }
|
||||||
|
],
|
||||||
|
'Woche 1 - Vokabeltest': [
|
||||||
|
{ exerciseTypeId: 2, title: 'Vokabeltest: Kumusta', instruction: 'Was bedeutet "Kumusta"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Kumusta"?', options: ['Wie geht es dir?', 'Danke', 'Bitte', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Kumusta" kommt von spanisch "¿Cómo está?" - "Wie geht es dir?"' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Vokabeltest: Lola', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lola"?', options: ['Großmutter', 'Großvater', 'Mutter', 'Vater'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lola" = Großmutter, "Lolo" = Großvater.' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Vokabeltest: Salamat', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Salamat"?', options: ['Danke', 'Bitte', 'Entschuldigung', 'Gern geschehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Salamat" bedeutet "Danke".' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Vokabeltest: Lami', instruction: 'Was bedeutet "Lami"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lami"?', options: ['Lecker', 'Viel', 'Gut', 'Schnell'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lami" bedeutet "lecker" oder "schmackhaft" - wichtig beim Essen!' },
|
||||||
|
{ exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateWeek1BisayaExercises() {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
|
||||||
|
|
||||||
|
let systemUser;
|
||||||
|
try {
|
||||||
|
systemUser = await User.findOne({ where: { username: 'system' } });
|
||||||
|
if (!systemUser) systemUser = await User.findOne({ where: { username: 'admin' } });
|
||||||
|
if (!systemUser) throw new Error('System user not found');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ System-Benutzer nicht gefunden.');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bisayaLanguage] = await sequelize.query(
|
||||||
|
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||||
|
{ type: sequelize.QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bisayaLanguage) {
|
||||||
|
console.error('❌ Bisaya-Sprache nicht gefunden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courses = await sequelize.query(
|
||||||
|
`SELECT c.id, c.title, c.owner_user_id
|
||||||
|
FROM community.vocab_course c
|
||||||
|
WHERE c.language_id = :languageId`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: bisayaLanguage.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Gefunden: ${courses.length} Bisaya-Kurs(e)\n`);
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
let totalAdded = 0;
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
console.log(`📚 Kurs: ${course.title} (ID: ${course.id})`);
|
||||||
|
|
||||||
|
for (const lessonTitle of LESSON_TITLES) {
|
||||||
|
const exercises = BISAYA_EXERCISES[lessonTitle];
|
||||||
|
if (!exercises || exercises.length === 0) continue;
|
||||||
|
|
||||||
|
const lessons = await VocabCourseLesson.findAll({
|
||||||
|
where: { courseId: course.id, title: lessonTitle },
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const lesson of lessons) {
|
||||||
|
const deletedCount = await VocabGrammarExercise.destroy({
|
||||||
|
where: { lessonId: lesson.id }
|
||||||
|
});
|
||||||
|
totalDeleted += deletedCount;
|
||||||
|
console.log(` 🗑️ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${deletedCount} alte Übung(en) entfernt`);
|
||||||
|
|
||||||
|
let exerciseNumber = 1;
|
||||||
|
for (const ex of exercises) {
|
||||||
|
await VocabGrammarExercise.create({
|
||||||
|
lessonId: lesson.id,
|
||||||
|
exerciseTypeId: ex.exerciseTypeId,
|
||||||
|
exerciseNumber: exerciseNumber++,
|
||||||
|
title: ex.title,
|
||||||
|
instruction: ex.instruction,
|
||||||
|
questionData: JSON.stringify(ex.questionData),
|
||||||
|
answerData: JSON.stringify(ex.answerData),
|
||||||
|
explanation: ex.explanation,
|
||||||
|
createdByUserId: course.owner_user_id || systemUser.id
|
||||||
|
});
|
||||||
|
totalAdded++;
|
||||||
|
}
|
||||||
|
console.log(` ✅ Lektion ${lesson.lessonNumber}: "${lesson.title}" - ${exercises.length} neue Übung(en)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Zusammenfassung:`);
|
||||||
|
console.log(` ${totalDeleted} Platzhalter-Übungen entfernt`);
|
||||||
|
console.log(` ${totalAdded} neue Übungen erstellt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWeek1BisayaExercises()
|
||||||
|
.then(() => {
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
sequelize.close();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,19 +1,60 @@
|
|||||||
import './config/loadEnv.js'; // .env deterministisch laden
|
import './config/loadEnv.js'; // .env deterministisch laden
|
||||||
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import fs from 'fs';
|
||||||
|
// Assoziationen sofort setzen, bevor app (und damit Services/Router) geladen werden.
|
||||||
|
// So nutzen alle Modelle dieselbe Instanz inkl. Associations (verhindert EagerLoadingError).
|
||||||
|
import setupAssociations from './models/associations.js';
|
||||||
|
setupAssociations();
|
||||||
|
|
||||||
import app from './app.js';
|
import app from './app.js';
|
||||||
import { setupWebSocket } from './utils/socket.js';
|
import { setupWebSocket } from './utils/socket.js';
|
||||||
import { syncDatabase } from './utils/syncDatabase.js';
|
import { syncDatabase } from './utils/syncDatabase.js';
|
||||||
|
|
||||||
const server = http.createServer(app);
|
// HTTP-Server für API (Port 2020, intern, über Apache-Proxy)
|
||||||
|
const API_PORT = Number.parseInt(process.env.PORT || '2020', 10);
|
||||||
|
const httpServer = http.createServer(app);
|
||||||
|
// Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server
|
||||||
|
// setupWebSocket(httpServer); // Entfernt: Socket.io nur über HTTPS
|
||||||
|
|
||||||
setupWebSocket(server);
|
// HTTPS-Server für Socket.io (Port 4443, direkt erreichbar)
|
||||||
|
let httpsServer = null;
|
||||||
|
const SOCKET_IO_PORT = Number.parseInt(process.env.SOCKET_IO_PORT || '4443', 10);
|
||||||
|
const USE_TLS = process.env.SOCKET_IO_TLS === '1';
|
||||||
|
const TLS_KEY_PATH = process.env.SOCKET_IO_TLS_KEY_PATH;
|
||||||
|
const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH;
|
||||||
|
const TLS_CA_PATH = process.env.SOCKET_IO_TLS_CA_PATH;
|
||||||
|
|
||||||
|
if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
|
||||||
|
try {
|
||||||
|
httpsServer = https.createServer({
|
||||||
|
key: fs.readFileSync(TLS_KEY_PATH),
|
||||||
|
cert: fs.readFileSync(TLS_CERT_PATH),
|
||||||
|
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
||||||
|
}, app);
|
||||||
|
setupWebSocket(httpsServer);
|
||||||
|
console.log(`[Socket.io] HTTPS-Server für Socket.io konfiguriert auf Port ${SOCKET_IO_PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Socket.io] Fehler beim Laden der TLS-Zertifikate:', err.message);
|
||||||
|
console.error('[Socket.io] Socket.io wird nicht verfügbar sein');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[Socket.io] TLS nicht konfiguriert - Socket.io wird nicht verfügbar sein');
|
||||||
|
}
|
||||||
|
|
||||||
syncDatabase().then(() => {
|
syncDatabase().then(() => {
|
||||||
const port = process.env.PORT || 3001;
|
// API-Server auf Port 2020 (intern, nur localhost)
|
||||||
server.listen(port, () => {
|
httpServer.listen(API_PORT, '127.0.0.1', () => {
|
||||||
console.log('Server is running on port', port);
|
console.log(`[API] HTTP-Server läuft auf localhost:${API_PORT} (intern, über Apache-Proxy)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Socket.io-Server auf Port 4443 (extern, direkt erreichbar)
|
||||||
|
if (httpsServer) {
|
||||||
|
httpsServer.listen(SOCKET_IO_PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[Socket.io] HTTPS-Server läuft auf Port ${SOCKET_IO_PORT} (direkt erreichbar)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to sync database:', err);
|
console.error('Failed to sync database:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
507
backend/services/calendarService.js
Normal file
507
backend/services/calendarService.js
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
import CalendarEvent from '../models/community/calendar_event.js';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import Friendship from '../models/community/friendship.js';
|
||||||
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
import UserParamType from '../models/type/user_param.js';
|
||||||
|
import UserParamVisibility from '../models/community/user_param_visibility.js';
|
||||||
|
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
|
||||||
|
class CalendarService {
|
||||||
|
/**
|
||||||
|
* Get all calendar events for a user
|
||||||
|
* @param {string} hashedUserId - The user's hashed ID
|
||||||
|
* @param {object} options - Optional filters (startDate, endDate)
|
||||||
|
*/
|
||||||
|
async getEvents(hashedUserId, options = {}) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { userId: user.id };
|
||||||
|
|
||||||
|
// Filter by date range if provided
|
||||||
|
if (options.startDate || options.endDate) {
|
||||||
|
where[Op.or] = [];
|
||||||
|
|
||||||
|
if (options.startDate && options.endDate) {
|
||||||
|
// Events that overlap with the requested range
|
||||||
|
where[Op.or].push({
|
||||||
|
startDate: { [Op.between]: [options.startDate, options.endDate] }
|
||||||
|
});
|
||||||
|
where[Op.or].push({
|
||||||
|
endDate: { [Op.between]: [options.startDate, options.endDate] }
|
||||||
|
});
|
||||||
|
where[Op.or].push({
|
||||||
|
[Op.and]: [
|
||||||
|
{ startDate: { [Op.lte]: options.startDate } },
|
||||||
|
{ endDate: { [Op.gte]: options.endDate } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else if (options.startDate) {
|
||||||
|
where[Op.or].push({ startDate: { [Op.gte]: options.startDate } });
|
||||||
|
where[Op.or].push({ endDate: { [Op.gte]: options.startDate } });
|
||||||
|
} else if (options.endDate) {
|
||||||
|
where[Op.or].push({ startDate: { [Op.lte]: options.endDate } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await CalendarEvent.findAll({
|
||||||
|
where,
|
||||||
|
order: [['startDate', 'ASC'], ['startTime', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
return events.map(e => this.formatEvent(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single event by ID
|
||||||
|
*/
|
||||||
|
async getEvent(hashedUserId, eventId) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await CalendarEvent.findOne({
|
||||||
|
where: { id: eventId, userId: user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new calendar event
|
||||||
|
*/
|
||||||
|
async createEvent(hashedUserId, eventData) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await CalendarEvent.create({
|
||||||
|
userId: user.id,
|
||||||
|
title: eventData.title,
|
||||||
|
description: eventData.description || null,
|
||||||
|
categoryId: eventData.categoryId || 'personal',
|
||||||
|
startDate: eventData.startDate,
|
||||||
|
endDate: eventData.endDate || eventData.startDate,
|
||||||
|
startTime: eventData.allDay ? null : eventData.startTime,
|
||||||
|
endTime: eventData.allDay ? null : eventData.endTime,
|
||||||
|
allDay: eventData.allDay || false
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.formatEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing calendar event
|
||||||
|
*/
|
||||||
|
async updateEvent(hashedUserId, eventId, eventData) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await CalendarEvent.findOne({
|
||||||
|
where: { id: eventId, userId: user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await event.update({
|
||||||
|
title: eventData.title,
|
||||||
|
description: eventData.description || null,
|
||||||
|
categoryId: eventData.categoryId || 'personal',
|
||||||
|
startDate: eventData.startDate,
|
||||||
|
endDate: eventData.endDate || eventData.startDate,
|
||||||
|
startTime: eventData.allDay ? null : eventData.startTime,
|
||||||
|
endTime: eventData.allDay ? null : eventData.endTime,
|
||||||
|
allDay: eventData.allDay || false
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.formatEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a calendar event
|
||||||
|
*/
|
||||||
|
async deleteEvent(hashedUserId, eventId) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await CalendarEvent.findOne({
|
||||||
|
where: { id: eventId, userId: user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await event.destroy();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get friends' birthdays that are visible to the user
|
||||||
|
* @param {string} hashedUserId - The user's hashed ID
|
||||||
|
* @param {number} year - The year to get birthdays for
|
||||||
|
*/
|
||||||
|
async getFriendsBirthdays(hashedUserId, year) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's age for visibility check
|
||||||
|
const userAge = await this.getUserAge(user.id);
|
||||||
|
|
||||||
|
// Get all accepted friendships
|
||||||
|
const friendships = await Friendship.findAll({
|
||||||
|
where: {
|
||||||
|
accepted: true,
|
||||||
|
withdrawn: false,
|
||||||
|
denied: false,
|
||||||
|
[Op.or]: [
|
||||||
|
{ user1Id: user.id },
|
||||||
|
{ user2Id: user.id }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const birthdays = [];
|
||||||
|
|
||||||
|
for (const friendship of friendships) {
|
||||||
|
// Get the friend's user ID
|
||||||
|
const friendId = friendship.user1Id === user.id ? friendship.user2Id : friendship.user1Id;
|
||||||
|
|
||||||
|
// Get the friend's birthdate param with visibility
|
||||||
|
const birthdateParam = await UserParam.findOne({
|
||||||
|
where: { userId: friendId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'birthdate' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: UserParamVisibility,
|
||||||
|
as: 'param_visibilities',
|
||||||
|
include: [{
|
||||||
|
model: UserParamVisibilityType,
|
||||||
|
as: 'visibility_type'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!birthdateParam || !birthdateParam.value) continue;
|
||||||
|
|
||||||
|
// Check visibility
|
||||||
|
const visibility = birthdateParam.param_visibilities?.[0]?.visibility_type?.description || 'Invisible';
|
||||||
|
if (!this.isBirthdayVisibleToFriend(visibility, userAge)) continue;
|
||||||
|
|
||||||
|
// Get friend's username
|
||||||
|
const friend = await User.findOne({
|
||||||
|
where: { id: friendId },
|
||||||
|
attributes: ['username', 'hashedId']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!friend) continue;
|
||||||
|
|
||||||
|
// Parse birthdate and create birthday event for the requested year
|
||||||
|
const birthdate = new Date(birthdateParam.value);
|
||||||
|
if (isNaN(birthdate.getTime())) continue;
|
||||||
|
|
||||||
|
const birthdayDate = `${year}-${String(birthdate.getMonth() + 1).padStart(2, '0')}-${String(birthdate.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
birthdays.push({
|
||||||
|
id: `birthday-${friend.hashedId}-${year}`,
|
||||||
|
title: friend.username,
|
||||||
|
categoryId: 'birthday',
|
||||||
|
startDate: birthdayDate,
|
||||||
|
endDate: birthdayDate,
|
||||||
|
allDay: true,
|
||||||
|
isBirthday: true,
|
||||||
|
friendHashedId: friend.hashedId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return birthdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if birthdate is visible to a friend
|
||||||
|
*/
|
||||||
|
isBirthdayVisibleToFriend(visibility, requestingUserAge) {
|
||||||
|
// Visible to friends if visibility is 'All', 'Friends', or 'FriendsAndAdults' (if adult)
|
||||||
|
return visibility === 'All' ||
|
||||||
|
visibility === 'Friends' ||
|
||||||
|
(visibility === 'FriendsAndAdults' && requestingUserAge >= 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's age from birthdate
|
||||||
|
*/
|
||||||
|
async getUserAge(userId) {
|
||||||
|
const birthdateParam = await UserParam.findOne({
|
||||||
|
where: { userId },
|
||||||
|
include: [{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'birthdate' }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!birthdateParam || !birthdateParam.value) return 0;
|
||||||
|
|
||||||
|
const birthdate = new Date(birthdateParam.value);
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - birthdate.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - birthdate.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthdate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming birthdays for widget (sorted by next occurrence)
|
||||||
|
*/
|
||||||
|
async getUpcomingBirthdays(hashedUserId, limit = 10) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAge = await this.getUserAge(user.id);
|
||||||
|
const today = new Date();
|
||||||
|
const currentYear = today.getFullYear();
|
||||||
|
|
||||||
|
// Get all accepted friendships
|
||||||
|
const friendships = await Friendship.findAll({
|
||||||
|
where: {
|
||||||
|
accepted: true,
|
||||||
|
withdrawn: false,
|
||||||
|
denied: false,
|
||||||
|
[Op.or]: [
|
||||||
|
{ user1Id: user.id },
|
||||||
|
{ user2Id: user.id }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const birthdays = [];
|
||||||
|
|
||||||
|
for (const friendship of friendships) {
|
||||||
|
const friendId = friendship.user1Id === user.id ? friendship.user2Id : friendship.user1Id;
|
||||||
|
|
||||||
|
const birthdateParam = await UserParam.findOne({
|
||||||
|
where: { userId: friendId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'birthdate' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: UserParamVisibility,
|
||||||
|
as: 'param_visibilities',
|
||||||
|
include: [{
|
||||||
|
model: UserParamVisibilityType,
|
||||||
|
as: 'visibility_type'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!birthdateParam || !birthdateParam.value) continue;
|
||||||
|
|
||||||
|
const visibility = birthdateParam.param_visibilities?.[0]?.visibility_type?.description || 'Invisible';
|
||||||
|
if (!this.isBirthdayVisibleToFriend(visibility, userAge)) continue;
|
||||||
|
|
||||||
|
const friend = await User.findOne({
|
||||||
|
where: { id: friendId },
|
||||||
|
attributes: ['username', 'hashedId']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!friend) continue;
|
||||||
|
|
||||||
|
const birthdate = new Date(birthdateParam.value);
|
||||||
|
if (isNaN(birthdate.getTime())) continue;
|
||||||
|
|
||||||
|
// Calculate next birthday
|
||||||
|
let nextBirthday = new Date(currentYear, birthdate.getMonth(), birthdate.getDate());
|
||||||
|
if (nextBirthday < today) {
|
||||||
|
nextBirthday = new Date(currentYear + 1, birthdate.getMonth(), birthdate.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate days until birthday
|
||||||
|
const diffTime = nextBirthday.getTime() - today.getTime();
|
||||||
|
const daysUntil = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Calculate age they will turn
|
||||||
|
const turningAge = nextBirthday.getFullYear() - birthdate.getFullYear();
|
||||||
|
|
||||||
|
birthdays.push({
|
||||||
|
username: friend.username,
|
||||||
|
hashedId: friend.hashedId,
|
||||||
|
date: `${String(birthdate.getMonth() + 1).padStart(2, '0')}-${String(birthdate.getDate()).padStart(2, '0')}`,
|
||||||
|
nextDate: nextBirthday.toISOString().split('T')[0],
|
||||||
|
daysUntil,
|
||||||
|
turningAge
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by days until birthday
|
||||||
|
birthdays.sort((a, b) => a.daysUntil - b.daysUntil);
|
||||||
|
|
||||||
|
return birthdays.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming events for widget
|
||||||
|
*/
|
||||||
|
async getUpcomingEvents(hashedUserId, limit = 10) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const events = await CalendarEvent.findAll({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
[Op.or]: [
|
||||||
|
{ startDate: { [Op.gte]: todayStr } },
|
||||||
|
{ endDate: { [Op.gte]: todayStr } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
order: [['startDate', 'ASC'], ['startTime', 'ASC']],
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
|
||||||
|
return events.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
titel: e.title,
|
||||||
|
datum: e.startDate,
|
||||||
|
beschreibung: e.description || null,
|
||||||
|
categoryId: e.categoryId,
|
||||||
|
allDay: e.allDay,
|
||||||
|
startTime: e.startTime ? e.startTime.substring(0, 5) : null,
|
||||||
|
endDate: e.endDate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mini calendar data for widget
|
||||||
|
*/
|
||||||
|
async getMiniCalendarData(hashedUserId) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = today.getMonth();
|
||||||
|
|
||||||
|
// Get first and last day of month
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
const startStr = firstDay.toISOString().split('T')[0];
|
||||||
|
const endStr = lastDay.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get user events for this month
|
||||||
|
const events = await CalendarEvent.findAll({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
[Op.or]: [
|
||||||
|
{ startDate: { [Op.between]: [startStr, endStr] } },
|
||||||
|
{ endDate: { [Op.between]: [startStr, endStr] } },
|
||||||
|
{
|
||||||
|
[Op.and]: [
|
||||||
|
{ startDate: { [Op.lte]: startStr } },
|
||||||
|
{ endDate: { [Op.gte]: endStr } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get birthdays for this month
|
||||||
|
const birthdays = await this.getFriendsBirthdays(hashedUserId, year);
|
||||||
|
const monthBirthdays = birthdays.filter(b => {
|
||||||
|
const bMonth = parseInt(b.startDate.split('-')[1]);
|
||||||
|
return bMonth === month + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build days with events
|
||||||
|
const daysWithEvents = {};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const start = new Date(event.startDate);
|
||||||
|
const end = event.endDate ? new Date(event.endDate) : start;
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end && d <= lastDay; d.setDate(d.getDate() + 1)) {
|
||||||
|
if (d >= firstDay) {
|
||||||
|
const dayNum = d.getDate();
|
||||||
|
if (!daysWithEvents[dayNum]) {
|
||||||
|
daysWithEvents[dayNum] = { events: 0, birthdays: 0 };
|
||||||
|
}
|
||||||
|
daysWithEvents[dayNum].events++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const birthday of monthBirthdays) {
|
||||||
|
const dayNum = parseInt(birthday.startDate.split('-')[2]);
|
||||||
|
if (!daysWithEvents[dayNum]) {
|
||||||
|
daysWithEvents[dayNum] = { events: 0, birthdays: 0 };
|
||||||
|
}
|
||||||
|
daysWithEvents[dayNum].birthdays++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
month: month + 1,
|
||||||
|
today: today.getDate(),
|
||||||
|
firstDayOfWeek: firstDay.getDay() === 0 ? 7 : firstDay.getDay(), // Monday = 1
|
||||||
|
daysInMonth: lastDay.getDate(),
|
||||||
|
daysWithEvents
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format event for API response
|
||||||
|
*/
|
||||||
|
formatEvent(event) {
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
categoryId: event.categoryId,
|
||||||
|
startDate: event.startDate,
|
||||||
|
endDate: event.endDate,
|
||||||
|
startTime: event.startTime ? event.startTime.substring(0, 5) : null, // HH:MM format
|
||||||
|
endTime: event.endTime ? event.endTime.substring(0, 5) : null,
|
||||||
|
allDay: event.allDay,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
updatedAt: event.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CalendarService();
|
||||||
59
backend/services/dashboardService.js
Normal file
59
backend/services/dashboardService.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import BaseService from './BaseService.js';
|
||||||
|
import UserDashboard from '../models/community/user_dashboard.js';
|
||||||
|
import WidgetType from '../models/type/widget_type.js';
|
||||||
|
|
||||||
|
class DashboardService extends BaseService {
|
||||||
|
/**
|
||||||
|
* Liste aller möglichen (verfügbaren) Widget-Typen.
|
||||||
|
* @returns {Promise<Array<{ id: number, label: string, endpoint: string, description: string|null, orderId: number }>>}
|
||||||
|
*/
|
||||||
|
async getAvailableWidgets() {
|
||||||
|
const rows = await WidgetType.findAll({
|
||||||
|
order: [['orderId', 'ASC'], ['id', 'ASC']],
|
||||||
|
attributes: ['id', 'label', 'endpoint', 'description', 'orderId']
|
||||||
|
});
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
endpoint: r.endpoint,
|
||||||
|
description: r.description ?? null,
|
||||||
|
orderId: r.orderId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} hashedUserId
|
||||||
|
* @returns {Promise<{ widgets: Array<{ id: string, title: string, endpoint: string }> }>}
|
||||||
|
*/
|
||||||
|
async getConfig(hashedUserId) {
|
||||||
|
const user = await this.getUserByHashedId(hashedUserId);
|
||||||
|
const row = await UserDashboard.findOne({ where: { userId: user.id } });
|
||||||
|
const config = row?.config ?? { widgets: [] };
|
||||||
|
if (!Array.isArray(config.widgets)) config.widgets = [];
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} hashedUserId
|
||||||
|
* @param {{ widgets: Array<{ id: string, title: string, endpoint: string }> }} config
|
||||||
|
*/
|
||||||
|
async setConfig(hashedUserId, config) {
|
||||||
|
const user = await this.getUserByHashedId(hashedUserId);
|
||||||
|
const widgets = Array.isArray(config?.widgets) ? config.widgets : [];
|
||||||
|
const sanitized = widgets.map(w => ({
|
||||||
|
id: String(w?.id ?? ''),
|
||||||
|
title: String(w?.title ?? ''),
|
||||||
|
endpoint: String(w?.endpoint ?? '')
|
||||||
|
})).filter(w => w.id && (w.title || w.endpoint));
|
||||||
|
const payload = { widgets: sanitized };
|
||||||
|
const existing = await UserDashboard.findOne({ where: { userId: user.id } });
|
||||||
|
if (existing) {
|
||||||
|
await existing.update({ config: payload });
|
||||||
|
} else {
|
||||||
|
await UserDashboard.create({ userId: user.id, config: payload });
|
||||||
|
}
|
||||||
|
return { widgets: sanitized };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new DashboardService();
|
||||||
File diff suppressed because it is too large
Load Diff
125
backend/services/modelsProxyService.js
Normal file
125
backend/services/modelsProxyService.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Model-Proxy-Service: Lädt GLB-Dateien, komprimiert sie mit gltf-transform (Draco + Textur-Optimierung)
|
||||||
|
* und legt sie im Datei-Cache ab. Weitere Requests werden aus dem Cache bedient.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BACKEND_DIR = path.join(__dirname, '..');
|
||||||
|
const PROJECT_ROOT = path.join(BACKEND_DIR, '..');
|
||||||
|
const MODELS_REL = path.join('models', '3d', 'falukant', 'characters');
|
||||||
|
const DIST_MODELS = path.join(PROJECT_ROOT, 'frontend', 'dist', MODELS_REL);
|
||||||
|
const PUBLIC_MODELS = path.join(PROJECT_ROOT, 'frontend', 'public', MODELS_REL);
|
||||||
|
const CACHE_DIR = path.join(BACKEND_DIR, 'data', 'model-cache');
|
||||||
|
const CLI_PATH = path.join(BACKEND_DIR, 'node_modules', '.bin', 'gltf-transform');
|
||||||
|
|
||||||
|
/** Einmal ermitteltes Quellverzeichnis (frontend/dist oder frontend/public). */
|
||||||
|
let _sourceDir = null;
|
||||||
|
|
||||||
|
/** Production: frontend/dist; Local: frontend/public. Einmal pro Prozess festgelegt, damit
|
||||||
|
* isCacheValid() stets gegen dieselbe Quelle prüft (kein Wechsel zwischen dist/public). */
|
||||||
|
function getSourceDir() {
|
||||||
|
if (_sourceDir !== null) return _sourceDir;
|
||||||
|
_sourceDir = fs.existsSync(DIST_MODELS) ? DIST_MODELS : PUBLIC_MODELS;
|
||||||
|
return _sourceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erlaubte Dateinamen (nur [a-z0-9_.-]+.glb) */
|
||||||
|
const FILENAME_RE = /^[a-z0-9_.-]+\.glb$/i;
|
||||||
|
|
||||||
|
/** Laufende Optimierungen pro Dateiname → Promise<string> (Cache-Pfad) */
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass der Cache-Ordner existiert.
|
||||||
|
*/
|
||||||
|
function ensureCacheDir() {
|
||||||
|
if (!fs.existsSync(CACHE_DIR)) {
|
||||||
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob die Cache-Datei gültig ist (existiert und ist nicht älter als die Quelle).
|
||||||
|
* @param {string} sourcePath
|
||||||
|
* @param {string} cachePath
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isCacheValid(sourcePath, cachePath) {
|
||||||
|
if (!fs.existsSync(cachePath)) return false;
|
||||||
|
if (!fs.existsSync(sourcePath)) return false;
|
||||||
|
const sourceStat = fs.statSync(sourcePath);
|
||||||
|
const cacheStat = fs.statSync(cachePath);
|
||||||
|
return cacheStat.mtimeMs >= sourceStat.mtimeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt gltf-transform optimize aus (Draco + texture-size 1024).
|
||||||
|
* @param {string} inputPath
|
||||||
|
* @param {string} outputPath
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function runOptimize(inputPath, outputPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
'node',
|
||||||
|
[CLI_PATH, 'optimize', inputPath, outputPath, '--compress', 'draco', '--texture-size', '1024'],
|
||||||
|
{ cwd: BACKEND_DIR, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||||
|
);
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr?.on('data', (d) => { stderr += d.toString(); });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`gltf-transform exit ${code}: ${stderr}`));
|
||||||
|
});
|
||||||
|
child.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Pfad zur optimierten (gecachten) GLB-Datei.
|
||||||
|
* Erstellt die optimierte Datei per gltf-transform, falls nicht (gültig) gecacht.
|
||||||
|
*
|
||||||
|
* @param {string} filename - z.B. "male_child.glb"
|
||||||
|
* @returns {Promise<string>} Absoluter Pfad zur optimierten Datei (Cache)
|
||||||
|
* @throws {Error} Bei ungültigem Dateinamen oder fehlender Quelldatei
|
||||||
|
*/
|
||||||
|
export async function getOptimizedModelPath(filename) {
|
||||||
|
if (!FILENAME_RE.test(filename)) {
|
||||||
|
throw new Error(`Invalid model filename: ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceDir = getSourceDir();
|
||||||
|
const sourcePath = path.join(sourceDir, filename);
|
||||||
|
const cacheFilename = filename.replace(/\.glb$/, '_opt.glb');
|
||||||
|
const cachePath = path.join(CACHE_DIR, cacheFilename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Source model not found: ${filename} (looked in ${sourceDir})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCacheDir();
|
||||||
|
|
||||||
|
if (isCacheValid(sourcePath, cachePath)) {
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let promise = pending.get(filename);
|
||||||
|
if (!promise) {
|
||||||
|
promise = (async () => {
|
||||||
|
try {
|
||||||
|
await runOptimize(sourcePath, cachePath);
|
||||||
|
return cachePath;
|
||||||
|
} finally {
|
||||||
|
pending.delete(filename);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
pending.set(filename, promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
123
backend/services/newsService.js
Normal file
123
backend/services/newsService.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Proxy für newsdata.io API.
|
||||||
|
* Endpoint: https://newsdata.io/api/1/news?apikey=...&language=...&category=...
|
||||||
|
* Pagination: counter = wievieltes Widget dieser Art (0 = erste Seite, 1 = zweite, …), damit News nicht doppelt gezeigt werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NEWS_BASE = 'https://newsdata.io/api/1/news';
|
||||||
|
|
||||||
|
// Cache für News-Ergebnisse (pro Sprache/Kategorie)
|
||||||
|
const newsCache = new Map();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten Cache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {number} options.counter - 0 = erste Seite, 1 = zweite, … (für Pagination/nextPage)
|
||||||
|
* @param {string} [options.language] - z. B. de, en
|
||||||
|
* @param {string} [options.category] - z. B. top, technology
|
||||||
|
* @returns {Promise<{ results: Array, nextPage: string|null }>}
|
||||||
|
*/
|
||||||
|
async function fetchNewsPage({ language = 'de', category = 'top', nextPageToken = null }) {
|
||||||
|
const apiKey = process.env.NEWSDATA_IO_API_KEY;
|
||||||
|
if (!apiKey || !apiKey.trim()) {
|
||||||
|
throw new Error('NEWSDATA_IO_API_KEY is not set in .env');
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('apikey', apiKey.trim());
|
||||||
|
params.set('language', String(language));
|
||||||
|
params.set('category', String(category));
|
||||||
|
if (nextPageToken) params.set('page', nextPageToken);
|
||||||
|
|
||||||
|
const url = `${NEWS_BASE}?${params.toString()}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`newsdata.io: ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
results: data.results ?? [],
|
||||||
|
nextPage: data.nextPage ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt gecachte Artikel oder lädt sie von der API
|
||||||
|
*/
|
||||||
|
async function getCachedNews({ language = 'de', category = 'top', minArticles = 10 }) {
|
||||||
|
const cacheKey = `${language}:${category}`;
|
||||||
|
const cached = newsCache.get(cacheKey);
|
||||||
|
|
||||||
|
// Cache gültig?
|
||||||
|
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||||||
|
// Wenn wir mehr Artikel brauchen, erweitern
|
||||||
|
if (cached.articles.length >= minArticles) {
|
||||||
|
return cached.articles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Daten laden
|
||||||
|
const collected = cached?.articles || [];
|
||||||
|
let nextPageToken = cached?.nextPage || null;
|
||||||
|
|
||||||
|
while (collected.length < minArticles) {
|
||||||
|
try {
|
||||||
|
const page = await fetchNewsPage({
|
||||||
|
language,
|
||||||
|
category,
|
||||||
|
nextPageToken: nextPageToken || undefined
|
||||||
|
});
|
||||||
|
const items = page.results ?? [];
|
||||||
|
|
||||||
|
// Duplikate vermeiden (nach title)
|
||||||
|
const existingTitles = new Set(collected.map(a => a.title));
|
||||||
|
for (const item of items) {
|
||||||
|
if (!existingTitles.has(item.title)) {
|
||||||
|
collected.push(item);
|
||||||
|
existingTitles.add(item.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPageToken = page.nextPage ?? null;
|
||||||
|
if (items.length === 0 || !nextPageToken) break;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('News fetch error:', error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache aktualisieren
|
||||||
|
newsCache.set(cacheKey, {
|
||||||
|
articles: collected,
|
||||||
|
nextPage: nextPageToken,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den N-ten Artikel (counter = 0, 1, 2, …) für das N-te News-Widget.
|
||||||
|
* Nutzt Cache, damit mehrere Widgets unterschiedliche Artikel bekommen.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …)
|
||||||
|
* @param {string} [options.language]
|
||||||
|
* @param {string} [options.category]
|
||||||
|
* @returns {Promise<{ results: Array, nextPage: string|null }>}
|
||||||
|
*/
|
||||||
|
async function getNews({ counter = 0, language = 'de', category = 'top' }) {
|
||||||
|
const neededIndex = Math.max(0, counter);
|
||||||
|
|
||||||
|
// Mindestens so viele Artikel laden wie benötigt
|
||||||
|
const articles = await getCachedNews({
|
||||||
|
language,
|
||||||
|
category,
|
||||||
|
minArticles: neededIndex + 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const single = articles[neededIndex] ? [articles[neededIndex]] : [];
|
||||||
|
return { results: single, nextPage: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getNews };
|
||||||
File diff suppressed because it is too large
Load Diff
24
backend/sql/add-native-language-to-courses.sql
Normal file
24
backend/sql/add-native-language-to-courses.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Füge native_language_id zu vocab_course hinzu
|
||||||
|
-- ============================================
|
||||||
|
-- Dieses Feld speichert die Muttersprache des Lerners
|
||||||
|
-- z.B. "Bisaya für Deutschsprachige" -> language_id = Bisaya, native_language_id = Deutsch
|
||||||
|
|
||||||
|
-- Spalte hinzufügen
|
||||||
|
ALTER TABLE community.vocab_course
|
||||||
|
ADD COLUMN IF NOT EXISTS native_language_id INTEGER;
|
||||||
|
|
||||||
|
-- Foreign Key Constraint hinzufügen
|
||||||
|
ALTER TABLE community.vocab_course
|
||||||
|
ADD CONSTRAINT vocab_course_native_language_fk
|
||||||
|
FOREIGN KEY (native_language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Index für bessere Performance
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_native_language_idx
|
||||||
|
ON community.vocab_course(native_language_id);
|
||||||
|
|
||||||
|
-- Kommentar hinzufügen
|
||||||
|
COMMENT ON COLUMN community.vocab_course.native_language_id IS
|
||||||
|
'Muttersprache des Lerners (z.B. Deutsch, Englisch). NULL bedeutet "für alle Sprachen".';
|
||||||
16
backend/sql/add-speaking-exercise-types.sql
Normal file
16
backend/sql/add-speaking-exercise-types.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Neue Übungstypen für Sprachproduktion hinzufügen
|
||||||
|
-- ============================================
|
||||||
|
-- Führe diese Queries direkt auf dem Server aus
|
||||||
|
|
||||||
|
-- Neue Übungstypen hinzufügen
|
||||||
|
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||||
|
('reading_aloud', 'Laut vorlesen - Übung zur Verbesserung der Aussprache'),
|
||||||
|
('speaking_from_memory', 'Aus dem Kopf sprechen - Übung zur aktiven Sprachproduktion')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Hinweis:
|
||||||
|
-- - reading_aloud: Text wird angezeigt, User liest vor, Speech Recognition prüft
|
||||||
|
-- - speaking_from_memory: Prompt wird angezeigt, User spricht frei, manuelle/automatische Bewertung
|
||||||
|
-- ============================================
|
||||||
242
backend/sql/create-vocab-courses.sql
Normal file
242
backend/sql/create-vocab-courses.sql
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Vocab Courses - Vollständige SQL-Installation
|
||||||
|
-- ============================================
|
||||||
|
-- Führe diese Queries direkt auf dem Server aus
|
||||||
|
-- Reihenfolge beachten!
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Kurs-Tabellen erstellen
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Kurs-Tabelle
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
difficulty_level INTEGER DEFAULT 1,
|
||||||
|
is_public BOOLEAN DEFAULT false,
|
||||||
|
share_code TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_owner_fk
|
||||||
|
FOREIGN KEY (owner_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lektionen innerhalb eines Kurses
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
chapter_id INTEGER,
|
||||||
|
lesson_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
week_number INTEGER,
|
||||||
|
day_number INTEGER,
|
||||||
|
lesson_type TEXT DEFAULT 'vocab',
|
||||||
|
audio_url TEXT,
|
||||||
|
cultural_notes TEXT,
|
||||||
|
target_minutes INTEGER,
|
||||||
|
target_score_percent INTEGER DEFAULT 80,
|
||||||
|
requires_review BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_lesson_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_lesson_chapter_fk
|
||||||
|
FOREIGN KEY (chapter_id)
|
||||||
|
REFERENCES community.vocab_chapter(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Einschreibungen in Kurse
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_enrollment_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_enrollment_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fortschritt pro User und Lektion
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
score INTEGER DEFAULT 0,
|
||||||
|
last_accessed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_course_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Grammatik-Übungstabellen erstellen
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Grammatik-Übungstypen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grammatik-Übungen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
exercise_type_id INTEGER NOT NULL,
|
||||||
|
exercise_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
instruction TEXT,
|
||||||
|
question_data JSONB NOT NULL,
|
||||||
|
answer_data JSONB NOT NULL,
|
||||||
|
explanation TEXT,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_grammar_exercise_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_type_fk
|
||||||
|
FOREIGN KEY (exercise_type_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise_type(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fortschritt für Grammatik-Übungen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
exercise_id INTEGER NOT NULL,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
correct_attempts INTEGER DEFAULT 0,
|
||||||
|
last_attempt_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_exercise_fk
|
||||||
|
FOREIGN KEY (exercise_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Indizes erstellen
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Kurs-Indizes
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_owner_idx
|
||||||
|
ON community.vocab_course(owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_language_idx
|
||||||
|
ON community.vocab_course(language_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_public_idx
|
||||||
|
ON community.vocab_course(is_public);
|
||||||
|
|
||||||
|
-- Lektion-Indizes
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx
|
||||||
|
ON community.vocab_course_lesson(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx
|
||||||
|
ON community.vocab_course_lesson(chapter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx
|
||||||
|
ON community.vocab_course_lesson(course_id, week_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx
|
||||||
|
ON community.vocab_course_lesson(lesson_type);
|
||||||
|
|
||||||
|
-- Einschreibungs-Indizes
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx
|
||||||
|
ON community.vocab_course_enrollment(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx
|
||||||
|
ON community.vocab_course_enrollment(course_id);
|
||||||
|
|
||||||
|
-- Fortschritts-Indizes
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx
|
||||||
|
ON community.vocab_course_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx
|
||||||
|
ON community.vocab_course_progress(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx
|
||||||
|
ON community.vocab_course_progress(lesson_id);
|
||||||
|
|
||||||
|
-- Grammatik-Übungs-Indizes
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx
|
||||||
|
ON community.vocab_grammar_exercise(lesson_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx
|
||||||
|
ON community.vocab_grammar_exercise(exercise_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(exercise_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. Standard-Daten einfügen
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Standard-Übungstypen für Grammatik
|
||||||
|
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||||
|
('gap_fill', 'Lückentext-Übung'),
|
||||||
|
('multiple_choice', 'Multiple-Choice-Fragen'),
|
||||||
|
('sentence_building', 'Satzbau-Übung'),
|
||||||
|
('transformation', 'Satzumformung'),
|
||||||
|
('conjugation', 'Konjugations-Übung'),
|
||||||
|
('declension', 'Deklinations-Übung')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. Kommentare hinzufügen (optional)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
|
||||||
|
'Type: vocab, grammar, conversation, culture, review';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
|
||||||
|
'Zielzeit in Minuten für diese Lektion';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
|
||||||
|
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
|
||||||
|
'Muss diese Lektion wiederholt werden, wenn Ziel nicht erreicht?';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Fertig!
|
||||||
|
-- ============================================
|
||||||
|
-- Alle Tabellen, Indizes und Standard-Daten wurden erstellt.
|
||||||
|
-- Du kannst jetzt Kurse erstellen und verwenden.
|
||||||
131
backend/sql/update-vocab-courses-existing.sql
Normal file
131
backend/sql/update-vocab-courses-existing.sql
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Vocab Courses - Update für bestehende Installation
|
||||||
|
-- ============================================
|
||||||
|
-- Führe diese Queries aus, wenn die Tabellen bereits existieren
|
||||||
|
-- (z.B. wenn nur die Basis-Tabellen erstellt wurden)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. chapter_id optional machen
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
ALTER COLUMN chapter_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Neue Spalten zu vocab_course_lesson hinzufügen
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE community.vocab_course_lesson
|
||||||
|
ADD COLUMN IF NOT EXISTS week_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS day_number INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab',
|
||||||
|
ADD COLUMN IF NOT EXISTS audio_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS cultural_notes TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS target_minutes INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80,
|
||||||
|
ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Neue Indizes hinzufügen
|
||||||
|
-- ============================================
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx
|
||||||
|
ON community.vocab_course_lesson(course_id, week_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx
|
||||||
|
ON community.vocab_course_lesson(lesson_type);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. Grammatik-Übungstabellen erstellen (falls noch nicht vorhanden)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Grammatik-Übungstypen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grammatik-Übungen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
exercise_type_id INTEGER NOT NULL,
|
||||||
|
exercise_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
instruction TEXT,
|
||||||
|
question_data JSONB NOT NULL,
|
||||||
|
answer_data JSONB NOT NULL,
|
||||||
|
explanation TEXT,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_grammar_exercise_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_type_fk
|
||||||
|
FOREIGN KEY (exercise_type_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise_type(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fortschritt für Grammatik-Übungen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
exercise_id INTEGER NOT NULL,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
correct_attempts INTEGER DEFAULT 0,
|
||||||
|
last_attempt_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_exercise_fk
|
||||||
|
FOREIGN KEY (exercise_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indizes für Grammatik-Übungen
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx
|
||||||
|
ON community.vocab_grammar_exercise(lesson_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx
|
||||||
|
ON community.vocab_grammar_exercise(exercise_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(exercise_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. Standard-Daten einfügen
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||||
|
('gap_fill', 'Lückentext-Übung'),
|
||||||
|
('multiple_choice', 'Multiple-Choice-Fragen'),
|
||||||
|
('sentence_building', 'Satzbau-Übung'),
|
||||||
|
('transformation', 'Satzumformung'),
|
||||||
|
('conjugation', 'Konjugations-Übung'),
|
||||||
|
('declension', 'Deklinations-Übung')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 6. Kommentare hinzufügen
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
|
||||||
|
'Type: vocab, grammar, conversation, culture, review';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
|
||||||
|
'Zielzeit in Minuten für diese Lektion';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
|
||||||
|
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
|
||||||
|
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
|
||||||
|
'Muss diese Lektion wiederholt werden, wenn Ziel nicht erreicht?';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Fertig!
|
||||||
|
-- ============================================
|
||||||
43
backend/sync-tables-only.js
Normal file
43
backend/sync-tables-only.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einfaches Script zum Erstellen/Aktualisieren von Tabellen
|
||||||
|
* Ohne Cleanup und Initialisierung
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { initializeDatabase, syncModelsAlways, sequelize } from './utils/sequelize.js';
|
||||||
|
import setupAssociations from './models/associations.js';
|
||||||
|
import models from './models/index.js';
|
||||||
|
|
||||||
|
console.log('🗄️ Starte Tabellen-Synchronisation (nur Schema-Updates)...');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
// 1. Datenbank-Schemas initialisieren
|
||||||
|
console.log('📊 Initialisiere Datenbank-Schemas...');
|
||||||
|
await initializeDatabase();
|
||||||
|
console.log('✅ Datenbank-Schemas initialisiert');
|
||||||
|
|
||||||
|
// 2. Associations setzen
|
||||||
|
console.log('🔗 Setze Associations...');
|
||||||
|
setupAssociations();
|
||||||
|
console.log('✅ Associations gesetzt');
|
||||||
|
|
||||||
|
// 3. Nur Tabellen synchronisieren (ohne Cleanup, ohne Initialisierung)
|
||||||
|
console.log('🔄 Synchronisiere Tabellen...');
|
||||||
|
await syncModelsAlways(models);
|
||||||
|
console.log('✅ Tabellen-Synchronisation erfolgreich abgeschlossen');
|
||||||
|
|
||||||
|
console.log('🎉 Tabellen-Synchronisation abgeschlossen!');
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler bei der Tabellen-Synchronisation:', error);
|
||||||
|
console.error('Stack Trace:', error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script ausführen
|
||||||
|
main();
|
||||||
@@ -282,7 +282,11 @@ async function initializeFalukantProducts() {
|
|||||||
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const productsToInsert = baseProducts;
|
const productsToInsert = baseProducts.map(p => ({
|
||||||
|
...p,
|
||||||
|
sellCostMinNeutral: Math.ceil(p.sellCost * factorMin),
|
||||||
|
sellCostMaxNeutral: Math.ceil(p.sellCost * factorMax),
|
||||||
|
}));
|
||||||
|
|
||||||
await ProductType.bulkCreate(productsToInsert, {
|
await ProductType.bulkCreate(productsToInsert, {
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import ReputationActionType from "../../models/falukant/type/reputation_action.j
|
|||||||
import VehicleType from "../../models/falukant/type/vehicle.js";
|
import VehicleType from "../../models/falukant/type/vehicle.js";
|
||||||
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
|
||||||
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
||||||
|
import ChurchOfficeType from "../../models/falukant/type/church_office_type.js";
|
||||||
|
import ChurchOfficeRequirement from "../../models/falukant/predefine/church_office_requirement.js";
|
||||||
import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js";
|
import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js";
|
||||||
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js";
|
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js";
|
||||||
import UndergroundType from "../../models/falukant/type/underground.js";
|
import UndergroundType from "../../models/falukant/type/underground.js";
|
||||||
@@ -47,6 +49,8 @@ export const initializeFalukantTypes = async () => {
|
|||||||
await initializePoliticalOfficeBenefitTypes();
|
await initializePoliticalOfficeBenefitTypes();
|
||||||
await initializePoliticalOfficeTypes();
|
await initializePoliticalOfficeTypes();
|
||||||
await initializePoliticalOfficePrerequisites();
|
await initializePoliticalOfficePrerequisites();
|
||||||
|
await initializeChurchOfficeTypes();
|
||||||
|
await initializeChurchOfficePrerequisites();
|
||||||
await initializeUndergroundTypes();
|
await initializeUndergroundTypes();
|
||||||
await initializeVehicleTypes();
|
await initializeVehicleTypes();
|
||||||
await initializeFalukantWeatherTypes();
|
await initializeFalukantWeatherTypes();
|
||||||
@@ -1024,6 +1028,136 @@ export const initializePoliticalOfficePrerequisites = async () => {
|
|||||||
console.log(`[Falukant] OfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`);
|
console.log(`[Falukant] OfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// — Church Offices —
|
||||||
|
|
||||||
|
const churchOffices = [
|
||||||
|
{ tr: "lay-preacher", seatsPerRegion: 3, regionType: "city", hierarchyLevel: 0 },
|
||||||
|
{ tr: "village-priest", seatsPerRegion: 1, regionType: "city", hierarchyLevel: 1 },
|
||||||
|
{ tr: "parish-priest", seatsPerRegion: 1, regionType: "city", hierarchyLevel: 2 },
|
||||||
|
{ tr: "dean", seatsPerRegion: 1, regionType: "county", hierarchyLevel: 3 },
|
||||||
|
{ tr: "archdeacon", seatsPerRegion: 1, regionType: "shire", hierarchyLevel: 4 },
|
||||||
|
{ tr: "bishop", seatsPerRegion: 1, regionType: "markgravate", hierarchyLevel: 5 },
|
||||||
|
{ tr: "archbishop", seatsPerRegion: 1, regionType: "duchy", hierarchyLevel: 6 },
|
||||||
|
{ tr: "cardinal", seatsPerRegion: 3, regionType: "country", hierarchyLevel: 7 },
|
||||||
|
{ tr: "pope", seatsPerRegion: 1, regionType: "country", hierarchyLevel: 8 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const churchOfficePrerequisites = [
|
||||||
|
{
|
||||||
|
officeTr: "lay-preacher",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: null // Einstiegsposition, keine Voraussetzung
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "village-priest",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "lay-preacher"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "parish-priest",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "village-priest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "dean",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "parish-priest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "archdeacon",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "dean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "bishop",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "archdeacon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "archbishop",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "bishop"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "cardinal",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "archbishop"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
officeTr: "pope",
|
||||||
|
prerequisite: {
|
||||||
|
prerequisiteOfficeTypeId: "cardinal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initializeChurchOfficeTypes = async () => {
|
||||||
|
for (const co of churchOffices) {
|
||||||
|
await ChurchOfficeType.findOrCreate({
|
||||||
|
where: { name: co.tr },
|
||||||
|
defaults: {
|
||||||
|
seatsPerRegion: co.seatsPerRegion,
|
||||||
|
regionType: co.regionType,
|
||||||
|
hierarchyLevel: co.hierarchyLevel
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[Falukant] ChurchOfficeTypes initialized`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initializeChurchOfficePrerequisites = async () => {
|
||||||
|
let created = 0;
|
||||||
|
let existing = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const prereq of churchOfficePrerequisites) {
|
||||||
|
const office = await ChurchOfficeType.findOne({ where: { name: prereq.officeTr } });
|
||||||
|
if (!office) { skipped++; continue; }
|
||||||
|
|
||||||
|
let prerequisiteOfficeTypeId = null;
|
||||||
|
if (prereq.prerequisite.prerequisiteOfficeTypeId) {
|
||||||
|
const prerequisiteOffice = await ChurchOfficeType.findOne({
|
||||||
|
where: { name: prereq.prerequisite.prerequisiteOfficeTypeId }
|
||||||
|
});
|
||||||
|
if (prerequisiteOffice) {
|
||||||
|
prerequisiteOfficeTypeId = prerequisiteOffice.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [record, wasCreated] = await ChurchOfficeRequirement.findOrCreate({
|
||||||
|
where: { officeTypeId: office.id },
|
||||||
|
defaults: {
|
||||||
|
officeTypeId: office.id,
|
||||||
|
prerequisiteOfficeTypeId: prerequisiteOfficeTypeId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (wasCreated) {
|
||||||
|
created++;
|
||||||
|
} else {
|
||||||
|
// Aktualisiere, falls sich die Voraussetzung geändert hat
|
||||||
|
if (record.prerequisiteOfficeTypeId !== prerequisiteOfficeTypeId) {
|
||||||
|
await record.update({ prerequisiteOfficeTypeId: prerequisiteOfficeTypeId });
|
||||||
|
created++; // Zähle als Update
|
||||||
|
} else {
|
||||||
|
existing++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (falukantDebug) console.error('[Falukant] ChurchOfficePrereq Fehler', { officeId: office?.id, error: e.message });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[Falukant] ChurchOfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const initializeUndergroundTypes = async () => {
|
export const initializeUndergroundTypes = async () => {
|
||||||
for (const underground of undergroundTypes) {
|
for (const underground of undergroundTypes) {
|
||||||
await UndergroundType.findOrCreate({
|
await UndergroundType.findOrCreate({
|
||||||
|
|||||||
30
backend/utils/initializeWidgetTypes.js
Normal file
30
backend/utils/initializeWidgetTypes.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import WidgetType from '../models/type/widget_type.js';
|
||||||
|
|
||||||
|
const DEFAULT_WIDGET_TYPES = [
|
||||||
|
{ label: 'Termine', endpoint: '/api/termine', description: 'Bevorstehende Termine', orderId: 1 },
|
||||||
|
{ label: 'Falukant', endpoint: '/api/falukant/dashboard-widget', description: 'Charakter, Geld, Nachrichten, Kinder', orderId: 2 },
|
||||||
|
{ label: 'News', endpoint: '/api/news?language=de&category=top', description: 'Nachrichten (newsdata.io), Counter für Pagination', orderId: 3 },
|
||||||
|
{ label: 'Geburtstage', endpoint: '/api/calendar/widget/birthdays', description: 'Nächste Geburtstage von Freunden', orderId: 4 },
|
||||||
|
{ label: 'Nächste Termine', endpoint: '/api/calendar/widget/upcoming', description: 'Anstehende Kalendertermine', orderId: 5 },
|
||||||
|
{ label: 'Kalender', endpoint: '/api/calendar/widget/mini', description: 'Mini-Kalenderansicht', orderId: 6 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die Standard-Widget-Typen in type.widget_type bereit.
|
||||||
|
* Idempotent: vorhandene Einträge werden nicht verändert.
|
||||||
|
*/
|
||||||
|
const initializeWidgetTypes = async () => {
|
||||||
|
for (const row of DEFAULT_WIDGET_TYPES) {
|
||||||
|
await WidgetType.findOrCreate({
|
||||||
|
where: { endpoint: row.endpoint },
|
||||||
|
defaults: {
|
||||||
|
label: row.label,
|
||||||
|
endpoint: row.endpoint,
|
||||||
|
description: row.description ?? null,
|
||||||
|
orderId: row.orderId ?? 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default initializeWidgetTypes;
|
||||||
@@ -38,6 +38,12 @@ if (!dbName || !dbUser || !dbHost) {
|
|||||||
throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST');
|
throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const poolMax = Number.parseInt(process.env.DB_POOL_MAX || '5', 10);
|
||||||
|
const poolMin = Number.parseInt(process.env.DB_POOL_MIN || '1', 10);
|
||||||
|
const poolAcquire = Number.parseInt(process.env.DB_POOL_ACQUIRE || '30000', 10);
|
||||||
|
const poolIdle = Number.parseInt(process.env.DB_POOL_IDLE || '10000', 10);
|
||||||
|
const poolEvict = Number.parseInt(process.env.DB_POOL_EVICT || '1000', 10);
|
||||||
|
|
||||||
const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||||
host: dbHost,
|
host: dbHost,
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
@@ -47,8 +53,55 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
|||||||
},
|
},
|
||||||
benchmark: SQL_BENCHMARK,
|
benchmark: SQL_BENCHMARK,
|
||||||
logging: sqlLogger,
|
logging: sqlLogger,
|
||||||
|
pool: {
|
||||||
|
max: poolMax, // Maximale Anzahl von Verbindungen im Pool
|
||||||
|
min: poolMin, // Minimale Anzahl von Verbindungen im Pool
|
||||||
|
acquire: poolAcquire, // Maximale Zeit (ms) zum Erwerb einer Verbindung
|
||||||
|
idle: poolIdle, // Maximale Zeit (ms), die eine Verbindung idle sein kann, bevor sie entfernt wird
|
||||||
|
evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen
|
||||||
|
handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust
|
||||||
|
},
|
||||||
|
dialectOptions: {
|
||||||
|
connectTimeout: 30000 // Timeout für Verbindungsaufbau (30 Sekunden)
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
max: 3, // Maximale Anzahl von Wiederholungsversuchen
|
||||||
|
match: [
|
||||||
|
/ConnectionError/,
|
||||||
|
/SequelizeConnectionError/,
|
||||||
|
/SequelizeConnectionRefusedError/,
|
||||||
|
/SequelizeHostNotFoundError/,
|
||||||
|
/SequelizeHostNotReachableError/,
|
||||||
|
/SequelizeInvalidConnectionError/,
|
||||||
|
/SequelizeConnectionTimedOutError/
|
||||||
|
]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper: Query mit Timeout (muss nach sequelize Initialisierung definiert werden)
|
||||||
|
const queryWithTimeout = async (query, timeoutMs = 10000, description = 'Query', replacements = null) => {
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryOptions = replacements
|
||||||
|
? { replacements, type: sequelize.QueryTypes.SELECT }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
sequelize.query(query, queryOptions),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('Timeout')) {
|
||||||
|
throw error; // Re-throw für bessere Fehlerbehandlung
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createSchemas = async () => {
|
const createSchemas = async () => {
|
||||||
await sequelize.query('CREATE SCHEMA IF NOT EXISTS community');
|
await sequelize.query('CREATE SCHEMA IF NOT EXISTS community');
|
||||||
await sequelize.query('CREATE SCHEMA IF NOT EXISTS logs');
|
await sequelize.query('CREATE SCHEMA IF NOT EXISTS logs');
|
||||||
@@ -435,12 +488,39 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: Sync mit Timeout
|
||||||
|
const syncModelWithTimeout = async (model, timeoutMs = 60000) => {
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Model sync timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
model.sync({ alter: true, force: false, constraints: false }),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('timeout')) {
|
||||||
|
console.warn(` ⚠️ ${model.name} sync timeout nach ${timeoutMs}ms - überspringe...`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// Immer Schema-Updates (für Deployment)
|
// Immer Schema-Updates (für Deployment)
|
||||||
const syncModelsAlways = async (models) => {
|
const syncModelsAlways = async (models) => {
|
||||||
console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...');
|
console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...');
|
||||||
|
|
||||||
|
const modelArray = Object.values(models);
|
||||||
|
const totalModels = modelArray.length;
|
||||||
|
let currentModel = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const model of Object.values(models)) {
|
for (const model of modelArray) {
|
||||||
|
currentModel++;
|
||||||
|
console.log(` 🔄 Syncing model ${model.name} (${currentModel}/${totalModels})...`);
|
||||||
// Temporarily remove VIRTUAL fields before sync to prevent sync errors
|
// Temporarily remove VIRTUAL fields before sync to prevent sync errors
|
||||||
const originalAttributes = model.rawAttributes;
|
const originalAttributes = model.rawAttributes;
|
||||||
const virtualFields = {};
|
const virtualFields = {};
|
||||||
@@ -520,72 +600,137 @@ const syncModelsAlways = async (models) => {
|
|||||||
const schema = model.options?.schema || 'public';
|
const schema = model.options?.schema || 'public';
|
||||||
|
|
||||||
console.log(` 🔍 Checking for foreign keys in ${schema}.${tableName}...`);
|
console.log(` 🔍 Checking for foreign keys in ${schema}.${tableName}...`);
|
||||||
const foreignKeys = await sequelize.query(`
|
// Verwende queryWithTimeout für Foreign Key Queries
|
||||||
SELECT tc.constraint_name
|
let foreignKeys = [];
|
||||||
FROM information_schema.table_constraints AS tc
|
try {
|
||||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
// Verwende direkte SQL-String-Interpolation, da Parameter-Binding bei queryWithTimeout Probleme macht
|
||||||
AND tc.table_name = :tableName
|
const result = await queryWithTimeout(`
|
||||||
AND tc.table_schema = :schema
|
SELECT tc.constraint_name
|
||||||
`, {
|
FROM information_schema.table_constraints AS tc
|
||||||
replacements: { tableName, schema },
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
type: sequelize.QueryTypes.SELECT
|
AND tc.table_name = '${tableName.replace(/'/g, "''")}'
|
||||||
});
|
AND tc.table_schema = '${schema.replace(/'/g, "''")}'
|
||||||
|
`, 10000, `FK check for ${model.name}`);
|
||||||
|
// Result ist ein Array von Objekten
|
||||||
|
foreignKeys = Array.isArray(result) ? result : [];
|
||||||
|
} catch (fkCheckError) {
|
||||||
|
if (fkCheckError.message && fkCheckError.message.includes('Timeout')) {
|
||||||
|
console.warn(` ⚠️ Timeout beim Prüfen der Foreign Keys für ${model.name} - überspringe FK-Check...`);
|
||||||
|
foreignKeys = [];
|
||||||
|
} else {
|
||||||
|
// Bei anderen Fehlern auch überspringen, nicht kritisch
|
||||||
|
console.warn(` ⚠️ Fehler beim Prüfen der Foreign Keys für ${model.name}:`, fkCheckError.message?.substring(0, 100));
|
||||||
|
foreignKeys = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (foreignKeys && foreignKeys.length > 0) {
|
if (foreignKeys && foreignKeys.length > 0) {
|
||||||
console.log(` ⚠️ Found ${foreignKeys.length} existing foreign keys:`, foreignKeys.map(fk => fk.constraint_name).join(', '));
|
const constraintNames = foreignKeys
|
||||||
console.log(` ⚠️ Removing ${foreignKeys.length} existing foreign keys from ${model.name} (schema: ${schema}) before sync`);
|
.map(fk => fk?.constraint_name)
|
||||||
for (const fk of foreignKeys) {
|
.filter(name => name && name !== 'undefined' && name !== undefined);
|
||||||
console.log(` 🗑️ Dropping constraint: ${fk.constraint_name}`);
|
|
||||||
await sequelize.query(`
|
if (constraintNames.length > 0) {
|
||||||
ALTER TABLE "${schema}"."${tableName}"
|
console.log(` ⚠️ Found ${constraintNames.length} existing foreign keys:`, constraintNames.join(', '));
|
||||||
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
|
console.log(` ⚠️ Removing ${constraintNames.length} existing foreign keys from ${model.name} (schema: ${schema}) before sync`);
|
||||||
`);
|
for (const constraintName of constraintNames) {
|
||||||
|
try {
|
||||||
|
console.log(` 🗑️ Dropping constraint: ${constraintName}`);
|
||||||
|
await queryWithTimeout(`
|
||||||
|
ALTER TABLE "${schema}"."${tableName}"
|
||||||
|
DROP CONSTRAINT IF EXISTS "${constraintName.replace(/"/g, '""')}" CASCADE
|
||||||
|
`, 10000, `Drop FK ${constraintName}`);
|
||||||
|
} catch (dropError) {
|
||||||
|
if (dropError.message && dropError.message.includes('Timeout')) {
|
||||||
|
console.warn(` ⚠️ Timeout beim Entfernen von ${constraintName} - überspringe...`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ Konnte ${constraintName} nicht entfernen:`, dropError.message?.substring(0, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ✅ Foreign key removal completed for ${model.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠️ Foreign keys gefunden, aber constraint_name ist undefined - überspringe FK-Entfernung`);
|
||||||
}
|
}
|
||||||
console.log(` ✅ All foreign keys removed for ${model.name}`);
|
|
||||||
} else {
|
} else {
|
||||||
console.log(` ✅ No foreign keys found for ${model.name}`);
|
console.log(` ✅ No foreign keys found for ${model.name}`);
|
||||||
}
|
}
|
||||||
} catch (fkError) {
|
} catch (fkError) {
|
||||||
console.warn(` ⚠️ Could not remove foreign keys for ${model.name}:`, fkError.message);
|
// Ignoriere Timeout-Fehler - nicht kritisch
|
||||||
console.warn(` ⚠️ Error details:`, fkError);
|
if (fkError.message && fkError.message.includes('Timeout')) {
|
||||||
|
console.warn(` ⚠️ Timeout beim Prüfen der Foreign Keys für ${model.name} - überspringe...`);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ Could not remove foreign keys for ${model.name}:`, fkError.message?.substring(0, 100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` 🔄 Syncing model ${model.name} with constraints: false`);
|
console.log(` 🔄 Syncing model ${model.name} with constraints: false`);
|
||||||
try {
|
try {
|
||||||
// Versuche doppelte pg_description Einträge vor dem Sync zu bereinigen
|
// Überspringe pg_description Cleanup komplett - benötigt Superuser-Rechte und blockiert oft
|
||||||
// Hinweis: Benötigt Superuser-Rechte oder spezielle Berechtigungen
|
// Diese Query ist nicht kritisch für die Funktionalität
|
||||||
|
|
||||||
|
// Prüfe ob Tabelle bereits existiert - wenn ja, überspringe Sync für große Tabellen
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
let tableExists = false;
|
||||||
try {
|
try {
|
||||||
const tableName = model.tableName;
|
const existsResult = await queryWithTimeout(`
|
||||||
const schema = model.options?.schema || 'public';
|
SELECT EXISTS (
|
||||||
// Verwende direkte Parameter-Einsetzung, da DO $$ keine Parameterbindung unterstützt
|
SELECT 1 FROM information_schema.tables
|
||||||
// Die Parameter sind sicher, da sie von Sequelize-Modell-Eigenschaften kommen
|
WHERE table_schema = :schema
|
||||||
await sequelize.query(`
|
AND table_name = :tableName
|
||||||
DELETE FROM pg_catalog.pg_description d1
|
) as exists
|
||||||
WHERE d1.objoid IN (
|
`, 5000, `Table exists check for ${model.name}`, { schema, tableName });
|
||||||
SELECT c.oid
|
tableExists = existsResult && existsResult[0] && existsResult[0].exists;
|
||||||
FROM pg_catalog.pg_class c
|
} catch (checkError) {
|
||||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
// Bei Fehler annehmen, dass Tabelle nicht existiert und Sync versuchen
|
||||||
WHERE c.relname = '${tableName.replace(/'/g, "''")}'
|
tableExists = false;
|
||||||
AND n.nspname = '${schema.replace(/'/g, "''")}'
|
|
||||||
)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_catalog.pg_description d2
|
|
||||||
WHERE d2.objoid = d1.objoid
|
|
||||||
AND d2.objsubid = d1.objsubid
|
|
||||||
AND d2.ctid < d1.ctid
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
} catch (descError) {
|
|
||||||
// Ignoriere Berechtigungsfehler - das ist normal, wenn der Benutzer keine Superuser-Rechte hat
|
|
||||||
if (descError.message && descError.message.includes('Berechtigung')) {
|
|
||||||
console.log(` ℹ️ Cannot clean up duplicate pg_description entries (requires superuser privileges): ${model.name}`);
|
|
||||||
} else {
|
|
||||||
console.warn(` ⚠️ Could not clean up duplicate pg_description entries for ${model.name}:`, descError.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await model.sync({ alter: true, force: false, constraints: false });
|
// Für große Tabellen (FalukantUser, FalukantCharacter) - wenn Tabelle existiert, überspringe Sync
|
||||||
|
const largeTables = ['FalukantUser', 'FalukantCharacter', 'Notification', 'RegionData'];
|
||||||
|
if (largeTables.includes(model.name) && tableExists) {
|
||||||
|
console.log(` ℹ️ ${model.name} Tabelle existiert bereits - überspringe Sync (zu groß für alter)`);
|
||||||
|
// Restore associations before continuing
|
||||||
|
if (associationKeys.length > 0) {
|
||||||
|
model.associations = originalAssociations;
|
||||||
|
}
|
||||||
|
// Restore virtual fields
|
||||||
|
for (const [key, attr] of Object.entries(virtualFields)) {
|
||||||
|
model.rawAttributes[key] = attr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für Tabellen mit problematischen ENUM-Änderungen - wenn Tabelle existiert, überspringe Sync
|
||||||
|
// Sequelize generiert fehlerhaftes SQL bei ENUM-Änderungen mit Kommentaren
|
||||||
|
const enumProblemTables = ['TaxiMapTileHouse'];
|
||||||
|
if (enumProblemTables.includes(model.name) && tableExists) {
|
||||||
|
console.log(` ℹ️ ${model.name} Tabelle existiert bereits - überspringe Sync (ENUM-Änderungen problematisch)`);
|
||||||
|
// Restore associations before continuing
|
||||||
|
if (associationKeys.length > 0) {
|
||||||
|
model.associations = originalAssociations;
|
||||||
|
}
|
||||||
|
// Restore virtual fields
|
||||||
|
for (const [key, attr] of Object.entries(virtualFields)) {
|
||||||
|
model.rawAttributes[key] = attr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verwende syncModelWithTimeout für große Tabellen
|
||||||
|
const syncSuccess = await syncModelWithTimeout(model, 60000);
|
||||||
|
if (!syncSuccess) {
|
||||||
|
console.warn(` ⚠️ ${model.name} wurde übersprungen aufgrund von Timeout`);
|
||||||
|
// Restore associations before continuing
|
||||||
|
if (associationKeys.length > 0) {
|
||||||
|
model.associations = originalAssociations;
|
||||||
|
}
|
||||||
|
// Restore virtual fields
|
||||||
|
for (const [key, attr] of Object.entries(virtualFields)) {
|
||||||
|
model.rawAttributes[key] = attr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
} catch (syncError) {
|
} catch (syncError) {
|
||||||
// Wenn Sequelize einen "mehr als eine Zeile" Fehler hat, überspringe das Model
|
// Wenn Sequelize einen "mehr als eine Zeile" Fehler hat, überspringe das Model
|
||||||
// Dies kann durch doppelte pg_description Einträge oder mehrere Tabellen mit demselben Namen verursacht werden
|
// Dies kann durch doppelte pg_description Einträge oder mehrere Tabellen mit demselben Namen verursacht werden
|
||||||
|
|||||||
@@ -1,6 +1,64 @@
|
|||||||
// syncDatabase.js
|
// syncDatabase.js
|
||||||
|
|
||||||
import { initializeDatabase, syncModelsWithUpdates, syncModelsAlways, sequelize } from './sequelize.js';
|
import { initializeDatabase, syncModelsWithUpdates, syncModelsAlways, sequelize } from './sequelize.js';
|
||||||
|
|
||||||
|
// Helper: Query mit Timeout
|
||||||
|
const queryWithTimeout = async (query, timeoutMs = 30000, description = 'Query') => {
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
sequelize.query(query),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('Timeout')) {
|
||||||
|
console.warn(`⚠️ ${description} hat Timeout nach ${timeoutMs}ms - überspringe...`);
|
||||||
|
return [null, 0]; // Return empty result
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: Retry wrapper for transient pool/connection issues
|
||||||
|
const runWithRetry = async (fn, { retries = 3, delayMs = 2000, description = 'operation' } = {}) => {
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const isAcquireTimeout = error?.name === 'SequelizeConnectionAcquireTimeoutError'
|
||||||
|
|| error?.message?.includes('ConnectionAcquireTimeoutError')
|
||||||
|
|| error?.message?.includes('Operation timeout');
|
||||||
|
if (!isAcquireTimeout || attempt === retries) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(`⚠️ ${description} fehlgeschlagen (AcquireTimeout). Retry ${attempt}/${retries} in ${delayMs}ms...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: Prüft ob Tabelle existiert
|
||||||
|
const tableExists = async (schema, tableName) => {
|
||||||
|
try {
|
||||||
|
const result = await sequelize.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = '${schema}'
|
||||||
|
AND table_name = '${tableName}'
|
||||||
|
);
|
||||||
|
`, { type: sequelize.QueryTypes.SELECT });
|
||||||
|
return result[0]?.exists || false;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
import initializeTypes from './initializeTypes.js';
|
import initializeTypes from './initializeTypes.js';
|
||||||
import initializeSettings from './initializeSettings.js';
|
import initializeSettings from './initializeSettings.js';
|
||||||
import initializeUserRights from './initializeUserRights.js';
|
import initializeUserRights from './initializeUserRights.js';
|
||||||
@@ -14,6 +72,7 @@ import initializeChat from './initializeChat.js';
|
|||||||
import initializeMatch3Data from './initializeMatch3.js';
|
import initializeMatch3Data from './initializeMatch3.js';
|
||||||
import updateExistingMatch3Levels from './updateExistingMatch3Levels.js';
|
import updateExistingMatch3Levels from './updateExistingMatch3Levels.js';
|
||||||
import initializeTaxi from './initializeTaxi.js';
|
import initializeTaxi from './initializeTaxi.js';
|
||||||
|
import initializeWidgetTypes from './initializeWidgetTypes.js';
|
||||||
|
|
||||||
// Normale Synchronisation (nur bei STAGE=dev Schema-Updates)
|
// Normale Synchronisation (nur bei STAGE=dev Schema-Updates)
|
||||||
const syncDatabase = async () => {
|
const syncDatabase = async () => {
|
||||||
@@ -33,6 +92,39 @@ const syncDatabase = async () => {
|
|||||||
console.log("Initializing database schemas...");
|
console.log("Initializing database schemas...");
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
|
// Dashboard: Widget-Typen-Tabelle (mögliche Widgets)
|
||||||
|
console.log("Ensuring widget_type table exists...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS type.widget_type (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
endpoint VARCHAR(255) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
order_id INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte type.widget_type nicht anlegen:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard: Benutzer-Konfiguration (Widgets pro User)
|
||||||
|
console.log("Ensuring user_dashboard table exists...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.user_dashboard (
|
||||||
|
user_id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
config JSONB NOT NULL DEFAULT '{"widgets":[]}'::jsonb,
|
||||||
|
CONSTRAINT user_dashboard_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte community.user_dashboard nicht anlegen:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
// Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations)
|
// Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations)
|
||||||
// Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt.
|
// Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt.
|
||||||
// Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an.
|
// Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an.
|
||||||
@@ -144,12 +236,330 @@ const syncDatabase = async () => {
|
|||||||
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
||||||
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
||||||
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
||||||
|
|
||||||
|
// Kurs-Tabellen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
native_language_id INTEGER,
|
||||||
|
difficulty_level INTEGER DEFAULT 1,
|
||||||
|
is_public BOOLEAN DEFAULT false,
|
||||||
|
share_code TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_owner_fk
|
||||||
|
FOREIGN KEY (owner_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_native_language_fk
|
||||||
|
FOREIGN KEY (native_language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE SET NULL,
|
||||||
|
CONSTRAINT vocab_course_share_code_uniq UNIQUE (share_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
chapter_id INTEGER,
|
||||||
|
lesson_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
week_number INTEGER,
|
||||||
|
day_number INTEGER,
|
||||||
|
lesson_type TEXT DEFAULT 'vocab',
|
||||||
|
audio_url TEXT,
|
||||||
|
cultural_notes TEXT,
|
||||||
|
target_minutes INTEGER,
|
||||||
|
target_score_percent INTEGER DEFAULT 80,
|
||||||
|
requires_review BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_lesson_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_lesson_chapter_fk
|
||||||
|
FOREIGN KEY (chapter_id)
|
||||||
|
REFERENCES community.vocab_chapter(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_lesson_unique UNIQUE (course_id, lesson_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_enrollment (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
enrolled_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_course_enrollment_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_enrollment_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_enrollment_unique UNIQUE (user_id, course_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_course_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
score INTEGER DEFAULT 0,
|
||||||
|
last_accessed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_course_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_course_fk
|
||||||
|
FOREIGN KEY (course_id)
|
||||||
|
REFERENCES community.vocab_course(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_course_progress_unique UNIQUE (user_id, lesson_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_owner_idx
|
||||||
|
ON community.vocab_course(owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_language_idx
|
||||||
|
ON community.vocab_course(language_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_native_language_idx
|
||||||
|
ON community.vocab_course(native_language_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_public_idx
|
||||||
|
ON community.vocab_course(is_public);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_course_idx
|
||||||
|
ON community.vocab_course_lesson(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_chapter_idx
|
||||||
|
ON community.vocab_course_lesson(chapter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_week_idx
|
||||||
|
ON community.vocab_course_lesson(course_id, week_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_lesson_type_idx
|
||||||
|
ON community.vocab_course_lesson(lesson_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_user_idx
|
||||||
|
ON community.vocab_course_enrollment(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_enrollment_course_idx
|
||||||
|
ON community.vocab_course_enrollment(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_user_idx
|
||||||
|
ON community.vocab_course_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_course_idx
|
||||||
|
ON community.vocab_course_progress(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_course_progress_lesson_idx
|
||||||
|
ON community.vocab_course_progress(lesson_id);
|
||||||
|
|
||||||
|
// Grammatik-Übungstypen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_type (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grammatik-Übungen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
lesson_id INTEGER NOT NULL,
|
||||||
|
exercise_type_id INTEGER NOT NULL,
|
||||||
|
exercise_number INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
instruction TEXT,
|
||||||
|
question_data JSONB NOT NULL,
|
||||||
|
answer_data JSONB NOT NULL,
|
||||||
|
explanation TEXT,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_grammar_exercise_lesson_fk
|
||||||
|
FOREIGN KEY (lesson_id)
|
||||||
|
REFERENCES community.vocab_course_lesson(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_type_fk
|
||||||
|
FOREIGN KEY (exercise_type_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise_type(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_unique UNIQUE (lesson_id, exercise_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fortschritt für Grammatik-Übungen
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_grammar_exercise_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
exercise_id INTEGER NOT NULL,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
correct_attempts INTEGER DEFAULT 0,
|
||||||
|
last_attempt_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
completed_at TIMESTAMP WITHOUT TIME ZONE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_exercise_fk
|
||||||
|
FOREIGN KEY (exercise_id)
|
||||||
|
REFERENCES community.vocab_grammar_exercise(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_grammar_exercise_progress_unique UNIQUE (user_id, exercise_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_lesson_idx
|
||||||
|
ON community.vocab_grammar_exercise(lesson_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_type_idx
|
||||||
|
ON community.vocab_grammar_exercise(exercise_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_user_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
|
||||||
|
ON community.vocab_grammar_exercise_progress(exercise_id);
|
||||||
|
|
||||||
|
-- Standard-Übungstypen einfügen
|
||||||
|
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
|
||||||
|
('gap_fill', 'Lückentext-Übung'),
|
||||||
|
('multiple_choice', 'Multiple-Choice-Fragen'),
|
||||||
|
('sentence_building', 'Satzbau-Übung'),
|
||||||
|
('transformation', 'Satzumformung'),
|
||||||
|
('conjugation', 'Konjugations-Übung'),
|
||||||
|
('declension', 'Deklinations-Übung')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
`);
|
`);
|
||||||
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
|
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
|
||||||
|
console.log("✅ Vocab-Course Tabellen sind vorhanden.");
|
||||||
|
console.log("✅ Vocab-Grammar-Exercise Tabellen sind vorhanden.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
|
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: ChurchApplication supervisor_id nullable machen (kritisch für Funktionalität)
|
||||||
|
console.log("Making church_application supervisor_id nullable...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Prüfe ob supervisor_id NOT NULL Constraint existiert
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_data'
|
||||||
|
AND table_name = 'church_application'
|
||||||
|
AND column_name = 'supervisor_id'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.church_application
|
||||||
|
ALTER COLUMN supervisor_id DROP NOT NULL;
|
||||||
|
RAISE NOTICE 'supervisor_id NOT NULL Constraint entfernt';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
console.log("✅ church_application supervisor_id ist jetzt nullable");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte church_application supervisor_id nicht nullable machen:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship-/Marriage-Proposal-Änderungen loggen (keine Einträge löschen; ohne db:migrate)
|
||||||
|
console.log("Ensuring relationship change log (falukant) exists...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS falukant_log.relationship_change_log (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
changed_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
table_name character varying(64) NOT NULL,
|
||||||
|
operation character varying(16) NOT NULL,
|
||||||
|
record_id integer,
|
||||||
|
payload_old jsonb,
|
||||||
|
payload_new jsonb
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS relationship_change_log_changed_at_idx
|
||||||
|
ON falukant_log.relationship_change_log (changed_at);
|
||||||
|
`);
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS relationship_change_log_table_operation_idx
|
||||||
|
ON falukant_log.relationship_change_log (table_name, operation);
|
||||||
|
`);
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION falukant_log.log_relationship_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_record_id INTEGER;
|
||||||
|
v_payload_old JSONB;
|
||||||
|
v_payload_new JSONB;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
v_record_id := NEW.id;
|
||||||
|
v_payload_old := NULL;
|
||||||
|
v_payload_new := to_jsonb(NEW);
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
v_record_id := NEW.id;
|
||||||
|
v_payload_old := to_jsonb(OLD);
|
||||||
|
v_payload_new := to_jsonb(NEW);
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
v_record_id := OLD.id;
|
||||||
|
v_payload_old := to_jsonb(OLD);
|
||||||
|
v_payload_new := NULL;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO falukant_log.relationship_change_log (
|
||||||
|
table_name, operation, record_id, payload_old, payload_new
|
||||||
|
) VALUES (
|
||||||
|
TG_TABLE_NAME, TG_OP, v_record_id, v_payload_old, v_payload_new
|
||||||
|
);
|
||||||
|
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
`);
|
||||||
|
await sequelize.query(`
|
||||||
|
DROP TRIGGER IF EXISTS trg_log_relationship_change ON falukant_data.relationship;
|
||||||
|
CREATE TRIGGER trg_log_relationship_change
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON falukant_data.relationship
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION falukant_log.log_relationship_change();
|
||||||
|
`);
|
||||||
|
await sequelize.query(`
|
||||||
|
DROP TRIGGER IF EXISTS trg_log_relationship_change ON falukant_data.marriage_proposals;
|
||||||
|
CREATE TRIGGER trg_log_relationship_change
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON falukant_data.marriage_proposals
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION falukant_log.log_relationship_change();
|
||||||
|
`);
|
||||||
|
console.log("✅ relationship_change_log und Trigger sind vorhanden.");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ relationship_change_log/Trigger konnten nicht sichergestellt werden:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preishistorie für Produkte (Zeitreihe) – nur Schema/Struktur, noch ohne Logik
|
||||||
|
console.log("Ensuring falukant_log.product_price_history exists...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS falukant_log.product_price_history (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
product_id integer NOT NULL,
|
||||||
|
region_id integer NOT NULL,
|
||||||
|
price numeric(12,2) NOT NULL,
|
||||||
|
recorded_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS product_price_history_product_region_recorded_idx
|
||||||
|
ON falukant_log.product_price_history (product_id, region_id, recorded_at);
|
||||||
|
`);
|
||||||
|
console.log("✅ product_price_history ist vorhanden.");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ product_price_history konnte nicht sichergestellt werden:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
// Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt
|
// Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt
|
||||||
console.log("Pre-ensure Taxi columns (traffic_light) ...");
|
console.log("Pre-ensure Taxi columns (traffic_light) ...");
|
||||||
try {
|
try {
|
||||||
@@ -277,14 +687,17 @@ const syncDatabase = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Setting up associations...");
|
// Associations werden vom Aufrufer (z. B. server.js) vor dem Aufruf von syncDatabase() gesetzt.
|
||||||
setupAssociations();
|
// Kein setupAssociations() hier, sonst doppelter Aufruf → AssociationError (alias "rooms" etc.).
|
||||||
|
|
||||||
console.log("Synchronizing models...");
|
console.log("Synchronizing models...");
|
||||||
await syncModelsWithUpdates(models);
|
await syncModelsWithUpdates(models);
|
||||||
|
|
||||||
console.log("Initializing settings...");
|
console.log("Initializing settings...");
|
||||||
await initializeSettings();
|
await runWithRetry(
|
||||||
|
() => initializeSettings(),
|
||||||
|
{ retries: 3, delayMs: 2000, description: 'initializeSettings' }
|
||||||
|
);
|
||||||
|
|
||||||
console.log("Initializing types...");
|
console.log("Initializing types...");
|
||||||
await initializeTypes();
|
await initializeTypes();
|
||||||
@@ -318,6 +731,9 @@ const syncDatabase = async () => {
|
|||||||
console.log("Initializing Taxi...");
|
console.log("Initializing Taxi...");
|
||||||
await initializeTaxi();
|
await initializeTaxi();
|
||||||
|
|
||||||
|
console.log("Initializing widget types...");
|
||||||
|
await initializeWidgetTypes();
|
||||||
|
|
||||||
console.log('Database synchronization complete.');
|
console.log('Database synchronization complete.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unable to synchronize the database:', error);
|
console.error('Unable to synchronize the database:', error);
|
||||||
@@ -399,89 +815,122 @@ const syncDatabaseForDeployment = async () => {
|
|||||||
console.warn('⚠️ Konnte Transport-Spalten nicht nullable machen:', e?.message || e);
|
console.warn('⚠️ Konnte Transport-Spalten nicht nullable machen:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: ChurchApplication supervisor_id nullable machen
|
||||||
|
console.log("Making church_application supervisor_id nullable...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Prüfe ob supervisor_id NOT NULL Constraint existiert
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_data'
|
||||||
|
AND table_name = 'church_application'
|
||||||
|
AND column_name = 'supervisor_id'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.church_application
|
||||||
|
ALTER COLUMN supervisor_id DROP NOT NULL;
|
||||||
|
RAISE NOTICE 'supervisor_id NOT NULL Constraint entfernt';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
console.log("✅ church_application supervisor_id ist jetzt nullable");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte church_application supervisor_id nicht nullable machen:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup: Entferne verwaiste Einträge vor Schema-Updates
|
// Cleanup: Entferne verwaiste Einträge vor Schema-Updates
|
||||||
console.log("Cleaning up orphaned entries...");
|
console.log("Cleaning up orphaned entries...");
|
||||||
try {
|
try {
|
||||||
// Cleanup user_param_visibility
|
// Cleanup user_param_visibility (optimiert mit LEFT JOIN)
|
||||||
const result1 = await sequelize.query(`
|
console.log(" → Prüfe user_param_visibility...");
|
||||||
|
const result1 = await queryWithTimeout(`
|
||||||
DELETE FROM community.user_param_visibility
|
DELETE FROM community.user_param_visibility
|
||||||
WHERE param_id NOT IN (
|
WHERE param_id NOT IN (
|
||||||
SELECT id FROM community.user_param
|
SELECT id FROM community.user_param
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'user_param_visibility cleanup');
|
||||||
const deletedCount1 = result1[1] || 0;
|
const deletedCount1 = result1[1] || 0;
|
||||||
if (deletedCount1 > 0) {
|
if (deletedCount1 > 0) {
|
||||||
console.log(`✅ ${deletedCount1} verwaiste user_param_visibility Einträge entfernt`);
|
console.log(`✅ ${deletedCount1} verwaiste user_param_visibility Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup stock mit ungültigen branch_id (0 oder nicht existierend)
|
// Cleanup stock mit ungültigen branch_id (0 oder nicht existierend)
|
||||||
const result2 = await sequelize.query(`
|
console.log(" → Prüfe stock...");
|
||||||
|
const result2 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_data.stock
|
DELETE FROM falukant_data.stock
|
||||||
WHERE branch_id = 0 OR branch_id NOT IN (
|
WHERE branch_id = 0 OR branch_id NOT IN (
|
||||||
SELECT id FROM falukant_data.branch
|
SELECT id FROM falukant_data.branch
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'stock cleanup');
|
||||||
const deletedCount2 = result2[1] || 0;
|
const deletedCount2 = result2[1] || 0;
|
||||||
if (deletedCount2 > 0) {
|
if (deletedCount2 > 0) {
|
||||||
console.log(`✅ ${deletedCount2} verwaiste stock Einträge entfernt`);
|
console.log(`✅ ${deletedCount2} verwaiste stock Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup knowledge mit ungültigen character_id oder product_id
|
// Cleanup knowledge mit ungültigen character_id oder product_id
|
||||||
const result3 = await sequelize.query(`
|
console.log(" → Prüfe knowledge...");
|
||||||
|
const result3 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_data.knowledge
|
DELETE FROM falukant_data.knowledge
|
||||||
WHERE character_id NOT IN (
|
WHERE character_id NOT IN (
|
||||||
SELECT id FROM falukant_data.character
|
SELECT id FROM falukant_data.character
|
||||||
) OR product_id NOT IN (
|
) OR product_id NOT IN (
|
||||||
SELECT id FROM falukant_type.product
|
SELECT id FROM falukant_type.product
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'knowledge cleanup');
|
||||||
const deletedCount3 = result3[1] || 0;
|
const deletedCount3 = result3[1] || 0;
|
||||||
if (deletedCount3 > 0) {
|
if (deletedCount3 > 0) {
|
||||||
console.log(`✅ ${deletedCount3} verwaiste knowledge Einträge entfernt`);
|
console.log(`✅ ${deletedCount3} verwaiste knowledge Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup notification mit ungültigen user_id
|
// Cleanup notification mit ungültigen user_id
|
||||||
const result4 = await sequelize.query(`
|
console.log(" → Prüfe notification...");
|
||||||
|
const result4 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_log.notification
|
DELETE FROM falukant_log.notification
|
||||||
WHERE user_id NOT IN (
|
WHERE user_id NOT IN (
|
||||||
SELECT id FROM falukant_data.falukant_user
|
SELECT id FROM falukant_data.falukant_user
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'notification cleanup');
|
||||||
const deletedCount4 = result4[1] || 0;
|
const deletedCount4 = result4[1] || 0;
|
||||||
if (deletedCount4 > 0) {
|
if (deletedCount4 > 0) {
|
||||||
console.log(`✅ ${deletedCount4} verwaiste notification Einträge entfernt`);
|
console.log(`✅ ${deletedCount4} verwaiste notification Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup promotional_gift mit ungültigen sender_character_id oder recipient_character_id
|
// Cleanup promotional_gift mit ungültigen sender_character_id oder recipient_character_id
|
||||||
const result5 = await sequelize.query(`
|
console.log(" → Prüfe promotional_gift...");
|
||||||
|
const result5 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_log.promotional_gift
|
DELETE FROM falukant_log.promotional_gift
|
||||||
WHERE sender_character_id NOT IN (
|
WHERE sender_character_id NOT IN (
|
||||||
SELECT id FROM falukant_data.character
|
SELECT id FROM falukant_data.character
|
||||||
) OR recipient_character_id NOT IN (
|
) OR recipient_character_id NOT IN (
|
||||||
SELECT id FROM falukant_data.character
|
SELECT id FROM falukant_data.character
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'promotional_gift cleanup');
|
||||||
const deletedCount5 = result5[1] || 0;
|
const deletedCount5 = result5[1] || 0;
|
||||||
if (deletedCount5 > 0) {
|
if (deletedCount5 > 0) {
|
||||||
console.log(`✅ ${deletedCount5} verwaiste promotional_gift Einträge entfernt`);
|
console.log(`✅ ${deletedCount5} verwaiste promotional_gift Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup user_house mit ungültigen house_type_id oder user_id
|
// Cleanup user_house mit ungültigen house_type_id oder user_id
|
||||||
const result6 = await sequelize.query(`
|
console.log(" → Prüfe user_house...");
|
||||||
|
const result6 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_data.user_house
|
DELETE FROM falukant_data.user_house
|
||||||
WHERE house_type_id NOT IN (
|
WHERE house_type_id NOT IN (
|
||||||
SELECT id FROM falukant_type.house
|
SELECT id FROM falukant_type.house
|
||||||
) OR user_id NOT IN (
|
) OR user_id NOT IN (
|
||||||
SELECT id FROM falukant_data.falukant_user
|
SELECT id FROM falukant_data.falukant_user
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'user_house cleanup');
|
||||||
const deletedCount6 = result6[1] || 0;
|
const deletedCount6 = result6[1] || 0;
|
||||||
if (deletedCount6 > 0) {
|
if (deletedCount6 > 0) {
|
||||||
console.log(`✅ ${deletedCount6} verwaiste user_house Einträge entfernt`);
|
console.log(`✅ ${deletedCount6} verwaiste user_house Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup child_relation mit ungültigen father_character_id, mother_character_id oder child_character_id
|
// Cleanup child_relation mit ungültigen father_character_id, mother_character_id oder child_character_id
|
||||||
const result7 = await sequelize.query(`
|
console.log(" → Prüfe child_relation...");
|
||||||
|
const result7 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_data.child_relation
|
DELETE FROM falukant_data.child_relation
|
||||||
WHERE father_character_id NOT IN (
|
WHERE father_character_id NOT IN (
|
||||||
SELECT id FROM falukant_data.character
|
SELECT id FROM falukant_data.character
|
||||||
@@ -490,14 +939,15 @@ const syncDatabaseForDeployment = async () => {
|
|||||||
) OR child_character_id NOT IN (
|
) OR child_character_id NOT IN (
|
||||||
SELECT id FROM falukant_data.character
|
SELECT id FROM falukant_data.character
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'child_relation cleanup');
|
||||||
const deletedCount7 = result7[1] || 0;
|
const deletedCount7 = result7[1] || 0;
|
||||||
if (deletedCount7 > 0) {
|
if (deletedCount7 > 0) {
|
||||||
console.log(`✅ ${deletedCount7} verwaiste child_relation Einträge entfernt`);
|
console.log(`✅ ${deletedCount7} verwaiste child_relation Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup political_office mit ungültigen character_id, office_type_id oder region_id
|
// Cleanup political_office mit ungültigen character_id, office_type_id oder region_id
|
||||||
const result8 = await sequelize.query(`
|
console.log(" → Prüfe political_office...");
|
||||||
|
const result8 = await queryWithTimeout(`
|
||||||
DELETE FROM falukant_data.political_office
|
DELETE FROM falukant_data.political_office
|
||||||
WHERE character_id NOT IN (
|
WHERE character_id NOT IN (
|
||||||
SELECT id FROM falukant_data.character
|
SELECT id FROM falukant_data.character
|
||||||
@@ -506,33 +956,74 @@ const syncDatabaseForDeployment = async () => {
|
|||||||
) OR region_id NOT IN (
|
) OR region_id NOT IN (
|
||||||
SELECT id FROM falukant_data.region
|
SELECT id FROM falukant_data.region
|
||||||
);
|
);
|
||||||
`);
|
`, 30000, 'political_office cleanup');
|
||||||
const deletedCount8 = result8[1] || 0;
|
const deletedCount8 = result8[1] || 0;
|
||||||
if (deletedCount8 > 0) {
|
if (deletedCount8 > 0) {
|
||||||
console.log(`✅ ${deletedCount8} verwaiste political_office Einträge entfernt`);
|
console.log(`✅ ${deletedCount8} verwaiste political_office Einträge entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup church_office mit ungültigen character_id, office_type_id oder region_id (nur wenn Tabelle existiert)
|
||||||
|
if (await tableExists('falukant_data', 'church_office')) {
|
||||||
|
console.log(" → Prüfe church_office...");
|
||||||
|
const result11 = await queryWithTimeout(`
|
||||||
|
DELETE FROM falukant_data.church_office
|
||||||
|
WHERE character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR office_type_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.church_office_type
|
||||||
|
) OR region_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.region
|
||||||
|
);
|
||||||
|
`, 30000, 'church_office cleanup');
|
||||||
|
const deletedCount11 = result11[1] || 0;
|
||||||
|
if (deletedCount11 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount11} verwaiste church_office Einträge entfernt`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup church_application mit ungültigen character_id, office_type_id, region_id oder supervisor_id (nur wenn Tabelle existiert)
|
||||||
|
if (await tableExists('falukant_data', 'church_application')) {
|
||||||
|
console.log(" → Prüfe church_application...");
|
||||||
|
const result12 = await queryWithTimeout(`
|
||||||
|
DELETE FROM falukant_data.church_application
|
||||||
|
WHERE character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR office_type_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.church_office_type
|
||||||
|
) OR region_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.region
|
||||||
|
) OR (supervisor_id IS NOT NULL AND supervisor_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
));
|
||||||
|
`, 30000, 'church_application cleanup');
|
||||||
|
const deletedCount12 = result12[1] || 0;
|
||||||
|
if (deletedCount12 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount12} verwaiste church_application Einträge entfernt`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup vehicle.condition: Legacy-Nulls + Range clamp (UI zeigt sonst "Unbekannt")
|
// Cleanup vehicle.condition: Legacy-Nulls + Range clamp (UI zeigt sonst "Unbekannt")
|
||||||
const result9 = await sequelize.query(`
|
console.log(" → Prüfe vehicle.condition...");
|
||||||
|
const result9 = await queryWithTimeout(`
|
||||||
UPDATE falukant_data.vehicle
|
UPDATE falukant_data.vehicle
|
||||||
SET condition = 100
|
SET condition = 100
|
||||||
WHERE condition IS NULL;
|
WHERE condition IS NULL;
|
||||||
`);
|
`, 30000, 'vehicle condition NULL update');
|
||||||
const updatedNullConditions = result9[1] || 0;
|
const updatedNullConditions = result9[1] || 0;
|
||||||
if (updatedNullConditions > 0) {
|
if (updatedNullConditions > 0) {
|
||||||
console.log(`✅ ${updatedNullConditions} vehicle.condition NULL → 100 gesetzt`);
|
console.log(`✅ ${updatedNullConditions} vehicle.condition NULL → 100 gesetzt`);
|
||||||
}
|
}
|
||||||
const result10 = await sequelize.query(`
|
const result10 = await queryWithTimeout(`
|
||||||
UPDATE falukant_data.vehicle
|
UPDATE falukant_data.vehicle
|
||||||
SET condition = GREATEST(0, LEAST(100, condition))
|
SET condition = GREATEST(0, LEAST(100, condition))
|
||||||
WHERE condition < 0 OR condition > 100;
|
WHERE condition < 0 OR condition > 100;
|
||||||
`);
|
`, 30000, 'vehicle condition clamp');
|
||||||
const clampedConditions = result10[1] || 0;
|
const clampedConditions = result10[1] || 0;
|
||||||
if (clampedConditions > 0) {
|
if (clampedConditions > 0) {
|
||||||
console.log(`✅ ${clampedConditions} vehicle.condition Werte auf 0..100 geklemmt`);
|
console.log(`✅ ${clampedConditions} vehicle.condition Werte auf 0..100 geklemmt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0 && deletedCount8 === 0 && updatedNullConditions === 0 && clampedConditions === 0) {
|
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0 && deletedCount8 === 0 && deletedCount11 === 0 && deletedCount12 === 0 && updatedNullConditions === 0 && clampedConditions === 0) {
|
||||||
console.log("✅ Keine verwaisten Einträge gefunden");
|
console.log("✅ Keine verwaisten Einträge gefunden");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -580,6 +1071,9 @@ const syncDatabaseForDeployment = async () => {
|
|||||||
console.log("Initializing Taxi...");
|
console.log("Initializing Taxi...");
|
||||||
await initializeTaxi();
|
await initializeTaxi();
|
||||||
|
|
||||||
|
console.log("Initializing widget types...");
|
||||||
|
await initializeWidgetTypes();
|
||||||
|
|
||||||
console.log('Database synchronization for deployment complete.');
|
console.log('Database synchronization for deployment complete.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unable to synchronize the database for deployment:', error);
|
console.error('Unable to synchronize the database for deployment:', error);
|
||||||
|
|||||||
113
backend/vacuum-database.js
Executable file
113
backend/vacuum-database.js
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script zum Ausführen von VACUUM auf Tabellen mit hohem Bloat
|
||||||
|
*
|
||||||
|
* Führt VACUUM ANALYZE auf Tabellen aus, die:
|
||||||
|
* - Hohen Bloat-Anteil haben (> 20% tote Zeilen)
|
||||||
|
* - Seit mehr als 7 Tagen nicht gevacuumt wurden
|
||||||
|
* - Viele tote Zeilen haben (> 1000)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './config/loadEnv.js';
|
||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🧹 Datenbank-Vacuum\n');
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
// Finde Tabellen, die Vacuum benötigen
|
||||||
|
const [tablesToVacuum] = await sequelize.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname || '.' || relname as table_name,
|
||||||
|
n_dead_tup,
|
||||||
|
n_live_tup,
|
||||||
|
CASE
|
||||||
|
WHEN n_live_tup > 0 THEN round((n_dead_tup::numeric / n_live_tup::numeric) * 100, 2)
|
||||||
|
ELSE 0
|
||||||
|
END as dead_percent,
|
||||||
|
last_vacuum,
|
||||||
|
last_autovacuum,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname||'.'||relname)) as table_size
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname IN ('falukant_data', 'falukant_type', 'community', 'logs')
|
||||||
|
AND (
|
||||||
|
(n_dead_tup > 1000 AND (n_live_tup = 0 OR (n_dead_tup::numeric / NULLIF(n_live_tup, 0)) > 0.2))
|
||||||
|
OR (last_vacuum IS NULL AND last_autovacuum IS NULL AND n_dead_tup > 0)
|
||||||
|
OR (
|
||||||
|
(last_vacuum < now() - interval '7 days' OR last_vacuum IS NULL)
|
||||||
|
AND (last_autovacuum < now() - interval '7 days' OR last_autovacuum IS NULL)
|
||||||
|
AND n_dead_tup > 100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY n_dead_tup DESC;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tablesToVacuum.length === 0) {
|
||||||
|
console.log('✅ Keine Tabellen benötigen Vacuum.\n');
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Gefunden: ${tablesToVacuum.length} Tabellen benötigen Vacuum\n`);
|
||||||
|
|
||||||
|
// Zeige Tabellen
|
||||||
|
console.log('Tabellen, die gevacuumt werden:');
|
||||||
|
tablesToVacuum.forEach((t, i) => {
|
||||||
|
console.log(` ${i + 1}. ${t.table_name}`);
|
||||||
|
console.log(` Größe: ${t.table_size}, Tote Zeilen: ${parseInt(t.n_dead_tup).toLocaleString()} (${t.dead_percent}%)`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Frage Bestätigung
|
||||||
|
const readline = require('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = await new Promise((resolve) => {
|
||||||
|
rl.question('Möchtest du VACUUM ANALYZE auf diesen Tabellen ausführen? (j/n): ', resolve);
|
||||||
|
});
|
||||||
|
rl.close();
|
||||||
|
|
||||||
|
if (answer.toLowerCase() !== 'j' && answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'ja' && answer.toLowerCase() !== 'yes') {
|
||||||
|
console.log('❌ Abgebrochen.\n');
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🧹 Starte Vacuum...\n');
|
||||||
|
|
||||||
|
// Führe VACUUM ANALYZE aus
|
||||||
|
for (let i = 0; i < tablesToVacuum.length; i++) {
|
||||||
|
const table = tablesToVacuum[i];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[${i + 1}/${tablesToVacuum.length}] Vacuuming ${table.table_name}...`);
|
||||||
|
|
||||||
|
await sequelize.query(`VACUUM ANALYZE ${table.table_name};`);
|
||||||
|
|
||||||
|
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
console.log(` ✅ Fertig in ${duration}s\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ Fehler: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('✅ Vacuum abgeschlossen\n');
|
||||||
|
|
||||||
|
await sequelize.close();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
78
build-local.sh
Executable file
78
build-local.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# YourPart Daemon Local Build Script für OpenSUSE Tumbleweed
|
||||||
|
# Führen Sie dieses Script lokal auf Ihrem Entwicklungsrechner aus
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Farben für Output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info "Starte lokalen Build für YourPart Daemon auf OpenSUSE Tumbleweed..."
|
||||||
|
|
||||||
|
# Prüfe ob wir im richtigen Verzeichnis sind
|
||||||
|
if [ ! -f "CMakeLists.txt" ] || [ ! -f "daemon.conf" ]; then
|
||||||
|
log_error "Bitte führen Sie dieses Script aus dem Projektverzeichnis aus!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prüfe Dependencies
|
||||||
|
log_info "Prüfe Dependencies..."
|
||||||
|
if ! command -v cmake &> /dev/null; then
|
||||||
|
log_error "CMake nicht gefunden. Führen Sie zuerst install-dependencies-opensuse.sh aus!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v g++ &> /dev/null; then
|
||||||
|
log_error "G++ nicht gefunden. Führen Sie zuerst install-dependencies-opensuse.sh aus!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Erstelle Build-Verzeichnis
|
||||||
|
log_info "Erstelle Build-Verzeichnis..."
|
||||||
|
if [ ! -d "build" ]; then
|
||||||
|
mkdir build
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd build
|
||||||
|
|
||||||
|
# Konfiguriere CMake
|
||||||
|
log_info "Konfiguriere CMake..."
|
||||||
|
cmake .. -DCMAKE_BUILD_TYPE=Release
|
||||||
|
|
||||||
|
# Kompiliere
|
||||||
|
log_info "Kompiliere Projekt..."
|
||||||
|
make -j$(nproc)
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
log_success "Lokaler Build abgeschlossen!"
|
||||||
|
log_info ""
|
||||||
|
log_info "Build-Ergebnisse:"
|
||||||
|
log_info "- Binärdatei: build/yourpart-daemon"
|
||||||
|
log_info "- Größe: $(du -h build/yourpart-daemon | cut -f1)"
|
||||||
|
log_info ""
|
||||||
|
log_info "Nächste Schritte:"
|
||||||
|
log_info "1. Testen Sie die Binärdatei lokal"
|
||||||
|
log_info "2. Deployen Sie auf den Server mit deploy.sh"
|
||||||
|
log_info "3. Oder verwenden Sie deploy-server.sh direkt auf dem Server"
|
||||||
45
check-apache-websocket.sh
Executable file
45
check-apache-websocket.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== Apache WebSocket-Konfiguration prüfen ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe, welche Module aktiviert sind
|
||||||
|
echo "Aktivierte Apache-Module:"
|
||||||
|
apache2ctl -M 2>/dev/null | grep -E "(proxy|rewrite|ssl|headers)" || echo "Keine relevanten Module gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe, ob die benötigten Module aktiviert sind
|
||||||
|
REQUIRED_MODULES=("proxy" "proxy_http" "proxy_wstunnel" "rewrite" "ssl" "headers")
|
||||||
|
MISSING_MODULES=()
|
||||||
|
|
||||||
|
for module in "${REQUIRED_MODULES[@]}"; do
|
||||||
|
if ! apache2ctl -M 2>/dev/null | grep -q "${module}_module"; then
|
||||||
|
MISSING_MODULES+=("$module")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#MISSING_MODULES[@]} -eq 0 ]; then
|
||||||
|
echo "✅ Alle benötigten Module sind aktiviert"
|
||||||
|
else
|
||||||
|
echo "❌ Fehlende Module:"
|
||||||
|
for module in "${MISSING_MODULES[@]}"; do
|
||||||
|
echo " - $module"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Aktivieren mit:"
|
||||||
|
for module in "${MISSING_MODULES[@]}"; do
|
||||||
|
echo " sudo a2enmod $module"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Apache-Konfiguration testen ==="
|
||||||
|
if sudo apache2ctl configtest 2>&1; then
|
||||||
|
echo "✅ Apache-Konfiguration ist gültig"
|
||||||
|
else
|
||||||
|
echo "❌ Apache-Konfiguration hat Fehler"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Aktive VirtualHosts ==="
|
||||||
|
apache2ctl -S 2>/dev/null | grep -E "(443|4443|4551)" || echo "Keine relevanten VirtualHosts gefunden"
|
||||||
33
check-lesson-exercises.sh
Executable file
33
check-lesson-exercises.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script zum Prüfen, ob Übungen für eine Lektion vorhanden sind
|
||||||
|
# Verwendung: ./check-lesson-exercises.sh [lesson_id]
|
||||||
|
|
||||||
|
LESSON_ID="${1:-1}"
|
||||||
|
|
||||||
|
echo "🔍 Prüfe Übungen für Lektion ID: $LESSON_ID"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
psql -U yourpart -d yp3 << EOF
|
||||||
|
-- Prüfe Lektion
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.title,
|
||||||
|
l.course_id,
|
||||||
|
COUNT(e.id) as exercise_count
|
||||||
|
FROM community.vocab_course_lesson l
|
||||||
|
LEFT JOIN community.vocab_grammar_exercise e ON e.lesson_id = l.id
|
||||||
|
WHERE l.id = $LESSON_ID
|
||||||
|
GROUP BY l.id, l.title, l.course_id;
|
||||||
|
|
||||||
|
-- Zeige alle Übungen für diese Lektion
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.exercise_number,
|
||||||
|
e.title,
|
||||||
|
e.exercise_type_id,
|
||||||
|
et.name as exercise_type_name
|
||||||
|
FROM community.vocab_grammar_exercise e
|
||||||
|
LEFT JOIN community.vocab_grammar_exercise_type et ON et.id = e.exercise_type_id
|
||||||
|
WHERE e.lesson_id = $LESSON_ID
|
||||||
|
ORDER BY e.exercise_number;
|
||||||
|
EOF
|
||||||
38
check-vocab-schema.sql
Normal file
38
check-vocab-schema.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- Prüfe ob alle notwendigen Spalten vorhanden sind
|
||||||
|
-- ============================================
|
||||||
|
-- Führe diese Queries auf dem Server aus, um zu prüfen, ob alles vorhanden ist
|
||||||
|
|
||||||
|
-- Prüfe native_language_id in vocab_course
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'community'
|
||||||
|
AND table_name = 'vocab_course'
|
||||||
|
AND column_name = 'native_language_id';
|
||||||
|
|
||||||
|
-- Prüfe cultural_notes in vocab_course_lesson
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'community'
|
||||||
|
AND table_name = 'vocab_course_lesson'
|
||||||
|
AND column_name = 'cultural_notes';
|
||||||
|
|
||||||
|
-- Prüfe ob vocab_grammar_exercise Tabelle existiert
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'community'
|
||||||
|
AND table_name = 'vocab_grammar_exercise'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Prüfe ob vocab_grammar_exercise_type Tabelle existiert
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'community'
|
||||||
|
AND table_name = 'vocab_grammar_exercise_type'
|
||||||
|
);
|
||||||
53
check-websocket-services.sh
Executable file
53
check-websocket-services.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== WebSocket-Services Diagnose ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Prüfe Backend-Service-Status:"
|
||||||
|
sudo systemctl status yourpart-backend --no-pager -l | head -20
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "2. Prüfe Daemon-Service-Status:"
|
||||||
|
sudo systemctl status yourpart-daemon --no-pager -l | head -20
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "3. Prüfe Backend-Logs (letzte 30 Zeilen):"
|
||||||
|
sudo journalctl -u yourpart-backend -n 30 --no-pager | grep -E "(Socket|HTTPS|TLS|Port|4443|2020)" || echo "Keine relevanten Logs gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "4. Prüfe Daemon-Logs (letzte 30 Zeilen):"
|
||||||
|
sudo journalctl -u yourpart-daemon -n 30 --no-pager | grep -E "(Daemon|WSS|TLS|Port|4551|connection)" || echo "Keine relevanten Logs gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "5. Prüfe, welche Ports lauschen:"
|
||||||
|
echo " Port 2020 (API):"
|
||||||
|
sudo netstat -tlnp 2>/dev/null | grep ":2020 " || echo " ❌ Port 2020 lauscht nicht"
|
||||||
|
echo " Port 4443 (Socket.io):"
|
||||||
|
sudo netstat -tlnp 2>/dev/null | grep ":4443 " || echo " ❌ Port 4443 lauscht nicht"
|
||||||
|
echo " Port 4551 (Daemon):"
|
||||||
|
sudo netstat -tlnp 2>/dev/null | grep ":4551 " || echo " ❌ Port 4551 lauscht nicht"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "6. Prüfe Firewall-Regeln:"
|
||||||
|
sudo ufw status | grep -E "(4443|4551)" || echo " Keine Firewall-Regeln für diese Ports gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "7. Prüfe Backend-Umgebungsvariablen:"
|
||||||
|
sudo systemctl show yourpart-backend --property=Environment --no-pager | grep -E "(SOCKET_IO|TLS)" || echo " Keine Socket.io TLS-Variablen gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "8. Prüfe Daemon-Umgebungsvariablen:"
|
||||||
|
sudo systemctl show yourpart-daemon --property=Environment --no-pager | grep -E "(DAEMON|TLS)" || echo " Keine Daemon TLS-Variablen gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "9. Teste TLS-Zertifikate:"
|
||||||
|
if [ -f "/etc/letsencrypt/live/www.your-part.de/privkey.pem" ]; then
|
||||||
|
echo " ✅ Privkey gefunden"
|
||||||
|
else
|
||||||
|
echo " ❌ Privkey nicht gefunden"
|
||||||
|
fi
|
||||||
|
if [ -f "/etc/letsencrypt/live/www.your-part.de/fullchain.pem" ]; then
|
||||||
|
echo " ✅ Fullchain gefunden"
|
||||||
|
else
|
||||||
|
echo " ❌ Fullchain nicht gefunden"
|
||||||
|
fi
|
||||||
175
cmake/install-config.cmake
Normal file
175
cmake/install-config.cmake
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# CMake-Skript für intelligente Konfigurationsdatei-Installation
|
||||||
|
# Fügt nur fehlende Keys hinzu, ohne bestehende Konfiguration zu überschreiben
|
||||||
|
|
||||||
|
# Pfade setzen
|
||||||
|
set(CONFIG_FILE "/etc/yourpart/daemon.conf")
|
||||||
|
set(TEMPLATE_FILE "/etc/yourpart/daemon.conf.example")
|
||||||
|
|
||||||
|
# Prüfe ob Template existiert (wurde von CMake installiert)
|
||||||
|
if(NOT EXISTS "${TEMPLATE_FILE}")
|
||||||
|
# Fallback 1: Versuche Template im Source-Verzeichnis zu finden
|
||||||
|
# CMAKE_CURRENT_LIST_DIR zeigt auf cmake/ während der Installation
|
||||||
|
get_filename_component(PROJECT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
||||||
|
set(TEMPLATE_FILE_FALLBACK "${PROJECT_ROOT}/daemon.conf")
|
||||||
|
|
||||||
|
# Fallback 2: Versuche über CMAKE_SOURCE_DIR (falls verfügbar)
|
||||||
|
if(DEFINED CMAKE_SOURCE_DIR AND EXISTS "${CMAKE_SOURCE_DIR}/daemon.conf")
|
||||||
|
set(TEMPLATE_FILE "${CMAKE_SOURCE_DIR}/daemon.conf")
|
||||||
|
message(STATUS "Verwende Template aus CMAKE_SOURCE_DIR: ${TEMPLATE_FILE}")
|
||||||
|
elseif(EXISTS "${TEMPLATE_FILE_FALLBACK}")
|
||||||
|
set(TEMPLATE_FILE "${TEMPLATE_FILE_FALLBACK}")
|
||||||
|
message(STATUS "Verwende Template aus Source-Verzeichnis: ${TEMPLATE_FILE}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "Template-Datei nicht gefunden!")
|
||||||
|
message(FATAL_ERROR " Gesucht in: ${TEMPLATE_FILE}")
|
||||||
|
message(FATAL_ERROR " Fallback 1: ${TEMPLATE_FILE_FALLBACK}")
|
||||||
|
if(DEFINED CMAKE_SOURCE_DIR)
|
||||||
|
message(FATAL_ERROR " Fallback 2: ${CMAKE_SOURCE_DIR}/daemon.conf")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
message(STATUS "Verwende installierte Template-Datei: ${TEMPLATE_FILE}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Prüfe ob Ziel-Verzeichnis existiert
|
||||||
|
if(NOT EXISTS "/etc/yourpart")
|
||||||
|
message(STATUS "Erstelle Verzeichnis /etc/yourpart...")
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory "/etc/yourpart"
|
||||||
|
RESULT_VARIABLE MKDIR_RESULT
|
||||||
|
)
|
||||||
|
if(NOT MKDIR_RESULT EQUAL 0)
|
||||||
|
message(FATAL_ERROR "Konnte Verzeichnis /etc/yourpart nicht erstellen")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Prüfe ob Config-Datei existiert
|
||||||
|
if(NOT EXISTS "${CONFIG_FILE}")
|
||||||
|
message(STATUS "Konfigurationsdatei existiert nicht, erstelle neue...")
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy "${TEMPLATE_FILE}" "${CONFIG_FILE}"
|
||||||
|
RESULT_VARIABLE COPY_RESULT
|
||||||
|
)
|
||||||
|
if(NOT COPY_RESULT EQUAL 0)
|
||||||
|
message(FATAL_ERROR "Konnte Konfigurationsdatei nicht erstellen: ${CONFIG_FILE}")
|
||||||
|
endif()
|
||||||
|
message(STATUS "Neue Konfigurationsdatei erstellt: ${CONFIG_FILE}")
|
||||||
|
else()
|
||||||
|
message(STATUS "Konfigurationsdatei existiert bereits, prüfe auf fehlende Keys...")
|
||||||
|
|
||||||
|
# Verwende ein Python-Skript für intelligentes Merging
|
||||||
|
# (CMake hat keine gute Unterstützung für komplexe String-Manipulation)
|
||||||
|
# Erstelle temporäres Python-Skript im Build-Verzeichnis
|
||||||
|
set(MERGE_SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/merge-config.py")
|
||||||
|
|
||||||
|
# Erstelle Python-Skript
|
||||||
|
file(WRITE "${MERGE_SCRIPT}"
|
||||||
|
"#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
|
def merge_config(template_file, config_file):
|
||||||
|
\"\"\"Fügt fehlende Keys aus Template zur Config hinzu, ohne bestehende zu überschreiben\"\"\"
|
||||||
|
|
||||||
|
# Lese bestehende Config
|
||||||
|
existing_keys = {}
|
||||||
|
existing_lines = []
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
existing_lines.append(line.rstrip())
|
||||||
|
# Extrahiere Key=Value Paare
|
||||||
|
match = re.match(r'^\\s*([^#=]+?)\\s*=\\s*(.+?)\\s*$', line)
|
||||||
|
if match:
|
||||||
|
key = match.group(1).strip()
|
||||||
|
value = match.group(2).strip()
|
||||||
|
existing_keys[key] = value
|
||||||
|
|
||||||
|
# Lese Template
|
||||||
|
new_keys = {}
|
||||||
|
if not os.path.exists(template_file):
|
||||||
|
print(f'Fehler: Template-Datei {template_file} nicht gefunden!', file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(template_file, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
# Extrahiere Key=Value Paare
|
||||||
|
match = re.match(r'^\\s*([^#=]+?)\\s*=\\s*(.+?)\\s*$', line)
|
||||||
|
if match:
|
||||||
|
key = match.group(1).strip()
|
||||||
|
value = match.group(2).strip()
|
||||||
|
new_keys[key] = value
|
||||||
|
|
||||||
|
# Füge fehlende Keys hinzu
|
||||||
|
added_count = 0
|
||||||
|
for key, value in new_keys.items():
|
||||||
|
if key not in existing_keys:
|
||||||
|
existing_lines.append(f'{key}={value}')
|
||||||
|
print(f'Füge fehlenden Key hinzu: {key}')
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
# Schreibe aktualisierte Config
|
||||||
|
if added_count > 0:
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
for line in existing_lines:
|
||||||
|
f.write(line + '\\n')
|
||||||
|
print(f'{added_count} neue Keys hinzugefügt')
|
||||||
|
else:
|
||||||
|
print('Keine neuen Keys hinzugefügt - Konfiguration ist aktuell')
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print('Verwendung: merge-config.py <template> <config>', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
template_file = sys.argv[1]
|
||||||
|
config_file = sys.argv[2]
|
||||||
|
|
||||||
|
if not merge_config(template_file, config_file):
|
||||||
|
sys.exit(1)
|
||||||
|
")
|
||||||
|
|
||||||
|
# Setze Ausführungsrechte
|
||||||
|
file(CHMOD "${MERGE_SCRIPT}" PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE)
|
||||||
|
|
||||||
|
# Führe Merge-Skript aus
|
||||||
|
execute_process(
|
||||||
|
COMMAND python3 "${MERGE_SCRIPT}" "${TEMPLATE_FILE}" "${CONFIG_FILE}"
|
||||||
|
RESULT_VARIABLE MERGE_RESULT
|
||||||
|
OUTPUT_VARIABLE MERGE_OUTPUT
|
||||||
|
ERROR_VARIABLE MERGE_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
if(NOT MERGE_RESULT EQUAL 0)
|
||||||
|
message(WARNING "Fehler beim Mergen der Config: ${MERGE_ERROR}")
|
||||||
|
else()
|
||||||
|
message(STATUS "${MERGE_OUTPUT}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Setze korrekte Berechtigungen (Fehler werden ignoriert, da Berechtigungen optional sind)
|
||||||
|
execute_process(
|
||||||
|
COMMAND chown yourpart:yourpart "${CONFIG_FILE}"
|
||||||
|
RESULT_VARIABLE CHOWN_RESULT
|
||||||
|
ERROR_QUIET
|
||||||
|
)
|
||||||
|
|
||||||
|
if(NOT CHOWN_RESULT EQUAL 0)
|
||||||
|
message(WARNING "Konnte Besitzer von ${CONFIG_FILE} nicht ändern (möglicherweise kein Root oder User existiert nicht)")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
execute_process(
|
||||||
|
COMMAND chmod 600 "${CONFIG_FILE}"
|
||||||
|
RESULT_VARIABLE CHMOD_RESULT
|
||||||
|
ERROR_QUIET
|
||||||
|
)
|
||||||
|
|
||||||
|
if(NOT CHMOD_RESULT EQUAL 0)
|
||||||
|
message(WARNING "Konnte Berechtigungen von ${CONFIG_FILE} nicht ändern")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
message(STATUS "Konfigurationsdatei-Verwaltung abgeschlossen: ${CONFIG_FILE}")
|
||||||
|
|
||||||
10
daemon.conf
Normal file
10
daemon.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=yp3
|
||||||
|
DB_USER=yourpart
|
||||||
|
DB_PASSWORD=hitomisan
|
||||||
|
THREAD_COUNT=4
|
||||||
|
WEBSOCKET_PORT=4551
|
||||||
|
WEBSOCKET_SSL_ENABLED=false
|
||||||
|
WEBSOCKET_SSL_CERT_PATH=/home/torsten/Programs/yourpart-daemon/ssl-certs/server.crt
|
||||||
|
WEBSOCKET_SSL_KEY_PATH=/home/torsten/Programs/yourpart-daemon/ssl-certs/server.key
|
||||||
5
daemon.log
Normal file
5
daemon.log
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
WebSocket Server starting on port 4551 (no SSL)
|
||||||
|
[2025/09/29 08:50:10:6854] N: lws_create_context: LWS: 4.3.5-unknown, NET CLI SRV H1 H2 WS ConMon IPv6-absent
|
||||||
|
[2025/09/29 08:50:10:6874] N: __lws_lc_tag: ++ [wsi|0|pipe] (1)
|
||||||
|
[2025/09/29 08:50:10:6874] N: __lws_lc_tag: ++ [vh|0|netlink] (1)
|
||||||
|
WebSocket-Server erfolgreich gestartet auf Port 4551
|
||||||
78
debug-lesson-exercises.js
Executable file
78
debug-lesson-exercises.js
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Debug-Script zum Prüfen, ob Übungen für eine Lektion vorhanden sind
|
||||||
|
* Verwendung: node debug-lesson-exercises.js [lessonId]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sequelize } from './backend/utils/sequelize.js';
|
||||||
|
import VocabCourseLesson from './backend/models/community/vocab_course_lesson.js';
|
||||||
|
import VocabGrammarExercise from './backend/models/community/vocab_grammar_exercise.js';
|
||||||
|
|
||||||
|
const lessonId = process.argv[2] ? parseInt(process.argv[2]) : 1;
|
||||||
|
|
||||||
|
async function debugLesson() {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✅ Datenbankverbindung erfolgreich\n');
|
||||||
|
|
||||||
|
// Lade Lektion mit Übungen (wie im Backend)
|
||||||
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('./backend/models/community/vocab_course.js').default,
|
||||||
|
as: 'course'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: VocabGrammarExercise,
|
||||||
|
as: 'grammarExercises',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: require('./backend/models/community/vocab_grammar_exercise_type.js').default,
|
||||||
|
as: 'exerciseType'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
required: false,
|
||||||
|
separate: true,
|
||||||
|
order: [['exerciseNumber', 'ASC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lesson) {
|
||||||
|
console.log(`❌ Lektion ${lessonId} nicht gefunden`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📚 Lektion: ${lesson.title} (ID: ${lesson.id})`);
|
||||||
|
console.log(` Kurs: ${lesson.course?.title || 'N/A'}`);
|
||||||
|
console.log(` Übungen (via Include): ${lesson.grammarExercises ? lesson.grammarExercises.length : 0}\n`);
|
||||||
|
|
||||||
|
// Prüfe direkt in der Datenbank
|
||||||
|
const directExercises = await VocabGrammarExercise.findAll({
|
||||||
|
where: { lessonId: lesson.id },
|
||||||
|
order: [['exerciseNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 Direkte Abfrage: ${directExercises.length} Übung(en) gefunden`);
|
||||||
|
directExercises.forEach((ex, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ex.title} (ID: ${ex.id}, Typ: ${ex.exerciseTypeId})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plain object
|
||||||
|
const plain = lesson.get({ plain: true });
|
||||||
|
console.log(`\n📦 Plain Object:`);
|
||||||
|
console.log(` grammarExercises: ${plain.grammarExercises ? plain.grammarExercises.length : 'undefined'}`);
|
||||||
|
if (plain.grammarExercises && plain.grammarExercises.length > 0) {
|
||||||
|
plain.grammarExercises.forEach((ex, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ex.title} (ID: ${ex.id})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler:', error);
|
||||||
|
} finally {
|
||||||
|
await sequelize.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLesson();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user