Compare commits
181 Commits
taxi
...
falukant-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74f7b852b | ||
|
|
92d6b15c3f | ||
|
|
91f59062f5 | ||
|
|
1674086c73 | ||
|
|
5ddb099f5a | ||
|
|
3722bcf8c8 | ||
|
|
0372d213c0 | ||
|
|
c322eb1e5a | ||
|
|
b34dcac685 | ||
|
|
4850f50c66 | ||
|
|
5996f819e8 | ||
|
|
4d967fe7a2 | ||
|
|
bb91c2bbe5 | ||
|
|
511df52c3c | ||
|
|
d42e1da14b | ||
|
|
75dbd78da1 | ||
|
|
c90b7785c0 | ||
|
|
c17af04cbf | ||
|
|
f5e3a9a4a2 | ||
|
|
dab3391aa2 | ||
|
|
0336c55560 | ||
|
|
8e618ab443 | ||
|
|
352d672bdd | ||
|
|
df64c0a4b5 | ||
|
|
83597d9e02 | ||
|
|
a09220b881 | ||
|
|
5623f3af09 | ||
|
|
820b5e8570 | ||
|
|
dc72ed2feb | ||
|
|
ea468c9878 | ||
|
|
d1b683344e | ||
|
|
a82ec7de3f | ||
|
|
560a9efc69 | ||
|
|
4f8b1e33fa | ||
|
|
38dd51f757 | ||
|
|
38f23cc6ae | ||
|
|
6cf8fa8a9c | ||
|
|
f9ea4715d7 | ||
|
|
b34b374f76 | ||
|
|
83d1168f26 | ||
|
|
91009f52cd | ||
|
|
c6dfca7052 | ||
|
|
aaeaeeed24 | ||
|
|
c5804f764c | ||
|
|
fbe0d1bcd1 | ||
|
|
2fb440f033 | ||
|
|
a8a136a9ce | ||
|
|
fcbb903839 | ||
|
|
ac45a2ba26 | ||
|
|
afe15dd4f5 | ||
|
|
e3df88bea0 | ||
|
|
c69a414f78 | ||
|
|
d08022ab94 | ||
|
|
66e6fab663 | ||
| 4da572822e | |||
| ee23bb3ba3 | |||
| d002e340dd | |||
| 0e1d87ddab | |||
| 2a4928c1b6 | |||
| efe2bd57ab | |||
|
|
a0aa678e7d | ||
|
|
a1b6e6ab59 | ||
|
|
73acf1d1cd | ||
|
|
48110e9a6f | ||
|
|
642e215c69 | ||
|
|
091b9ff70a | ||
|
|
86f753c745 | ||
|
|
c28f8b1384 | ||
|
|
9b36297171 | ||
|
|
7beed235d7 | ||
|
|
a0206dc8cb | ||
|
|
bf0eed3b03 | ||
|
|
c8072b8052 | ||
|
|
c66fbf1a62 | ||
|
|
e13a711a60 | ||
|
|
346a326bfd | ||
|
|
addb8e9a6d | ||
|
|
ea8b9e661d | ||
|
|
339ae844e9 | ||
|
|
a0a7e81927 | ||
|
|
31c23a0c40 | ||
|
|
c1f22246ea | ||
|
|
0a1388bf06 | ||
|
|
1a69b83983 | ||
|
|
63f9443b77 | ||
|
|
6a9b2b8d1d | ||
|
|
8e1e0968ae | ||
|
|
a486292880 | ||
|
|
ee4b0ee7c2 | ||
|
|
43d86cce18 | ||
|
|
25d7c70058 | ||
|
|
71c62cf5e8 | ||
|
|
a7350282ee | ||
|
|
676629bd8d | ||
|
|
1892877b11 | ||
|
|
be218aabf7 | ||
|
|
856f7d56bf | ||
|
|
000ebbdc2b | ||
|
|
791314bef2 | ||
|
|
bcb0b01324 | ||
|
|
03e3a21a25 | ||
|
|
e97a2a62c9 | ||
|
|
814f972287 | ||
|
|
274c2a3292 | ||
|
|
4dbcebfab8 | ||
|
|
fadc301d41 | ||
|
|
b1d29f2083 | ||
|
|
e756b3692d | ||
|
|
74a3d59800 | ||
|
|
0544a3dfde | ||
|
|
656c821986 | ||
|
|
865ef81012 | ||
|
|
5ad27a87e5 | ||
|
|
085b851925 | ||
|
|
98dea7dd39 | ||
|
|
e5ef334f7c | ||
|
|
d6ea09b3e2 | ||
|
|
a51b8a1ff6 | ||
|
|
3c885b6ab9 | ||
|
|
6b3b30108b | ||
|
|
7fab23d22b | ||
|
|
def88f6486 | ||
|
|
1797ae3e58 | ||
|
|
f768ba3b27 | ||
|
|
b3e48a0b06 | ||
|
|
3f56939421 | ||
|
|
87c720c3fe | ||
|
|
90fbcaf31d | ||
|
|
56c3569b68 | ||
|
|
e2969c1837 | ||
|
|
fe14c7b9f5 | ||
|
|
5d01b24c2d | ||
|
|
4eeb5021ee | ||
|
|
6ec62af606 | ||
|
|
3d6fdc65d2 | ||
|
|
956418f5f3 | ||
|
|
e57de7f983 | ||
|
|
08e2c87de8 | ||
|
|
ba1a12402d | ||
|
|
39716b1f40 | ||
|
|
adc7132404 | ||
|
|
8c8841705c | ||
|
|
f7fdd8ab08 | ||
|
|
5807c6f3d3 | ||
|
|
7e0691eea3 | ||
|
|
17d4d21620 | ||
|
|
d19feb8bc1 | ||
|
|
ab1e4bec60 | ||
|
|
672cec9c2a | ||
|
|
c3ea7eecc2 | ||
|
|
608e62c2bd | ||
|
|
c1b69389c6 | ||
|
|
182f38597c | ||
|
|
06ea259dc9 | ||
|
|
29dd7ec80c | ||
|
|
3f043fc315 | ||
|
|
5ed27e5a6a | ||
|
|
23725c20ee | ||
|
|
29b6db7ee9 | ||
|
|
6e7165fe7f | ||
|
|
43131250ed | ||
|
|
c3beb029e5 | ||
|
|
9f10ac9e96 | ||
|
|
d36901aa2b | ||
|
|
4510aa3d14 | ||
|
|
3b8736acd7 | ||
|
|
735075d1bd | ||
|
|
dc7001a80c | ||
|
|
8a9acf6c4a | ||
|
|
5ca017950e | ||
|
|
eadec50e30 | ||
|
|
e7f5918013 | ||
|
|
27b675cb19 | ||
|
|
016a37c116 | ||
|
|
d8b1efc3ca | ||
|
|
d13fe19198 | ||
|
|
762a2e9cf0 | ||
|
|
44a2c525e7 | ||
|
|
507b0275d3 | ||
|
|
ccd8bfba0d | ||
|
|
47f5def67c |
230
PERFORMANCE_ANALYSIS.md
Normal file
230
PERFORMANCE_ANALYSIS.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Backend Performance-Analyse: Sell-Funktionen
|
||||||
|
|
||||||
|
## Identifizierte Performance-Probleme
|
||||||
|
|
||||||
|
### 1. **N+1 Query Problem in `sellAllProducts()`**
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
Die Funktion `sellAllProducts()` macht für jedes Inventory-Item mehrere separate Datenbankabfragen:
|
||||||
|
|
||||||
|
1. **Erste Schleife (Zeile 1702-1711):**
|
||||||
|
- `calcRegionalSellPrice()` → macht `TownProductWorth.findOne()` für jedes Item
|
||||||
|
- `getCumulativeTaxPercentWithExemptions()` → macht mehrere Queries pro Item:
|
||||||
|
- `FalukantCharacter.findOne()`
|
||||||
|
- `PoliticalOffice.findAll()` mit Includes
|
||||||
|
- Rekursive SQL-Query für Steuerberechnung
|
||||||
|
- `addSellItem()` → macht `Branch.findOne()` und `DaySell.findOne()`/`create()` für jedes Item
|
||||||
|
|
||||||
|
2. **Zweite Schleife (Zeile 1714-1724):**
|
||||||
|
- `RegionData.findOne()` für jedes Item
|
||||||
|
- `getCumulativeTaxPercent()` → rekursive SQL-Query für jedes Item
|
||||||
|
- `calcRegionalSellPrice()` → erneut `TownProductWorth.findOne()` für jedes Item
|
||||||
|
|
||||||
|
**Beispiel:** Bei 10 Items werden gemacht:
|
||||||
|
- 10x `TownProductWorth.findOne()` (2x pro Item)
|
||||||
|
- 10x `RegionData.findOne()`
|
||||||
|
- 10x `getCumulativeTaxPercentWithExemptions()` (mit mehreren Queries)
|
||||||
|
- 10x `getCumulativeTaxPercent()` (rekursive SQL)
|
||||||
|
- 10x `addSellItem()` (mit 2 Queries pro Item)
|
||||||
|
- = **~70+ Datenbankabfragen für 10 Items**
|
||||||
|
|
||||||
|
### 2. **Ineffiziente `addSellItem()` Implementierung**
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
- Wird für jedes Item einzeln aufgerufen
|
||||||
|
- Macht `Branch.findOne()` für jedes Item (könnte gecacht werden)
|
||||||
|
- `DaySell.findOne()` und `create()`/`update()` für jedes Item
|
||||||
|
|
||||||
|
**Lösung:** Batch-Operation implementieren, die alle DaySell Einträge auf einmal verarbeitet.
|
||||||
|
|
||||||
|
### 3. **Doppelte Berechnungen in `sellAllProducts()`**
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
- Preis wird zweimal berechnet (Zeile 1705 und 1718)
|
||||||
|
- Steuer wird zweimal berechnet (Zeile 1706 und 1717)
|
||||||
|
- `calcRegionalSellPrice()` wird zweimal aufgerufen mit denselben Parametern
|
||||||
|
|
||||||
|
### 4. **Fehlende Indizes**
|
||||||
|
|
||||||
|
**Potenzielle fehlende Indizes:**
|
||||||
|
- `falukant_data.town_product_worth(product_id, region_id)` - sollte unique sein
|
||||||
|
- `falukant_data.inventory(stock_id, product_id, quality)` - für schnelle Lookups
|
||||||
|
- `falukant_data.knowledge(character_id, product_id)` - für Knowledge-Lookups
|
||||||
|
- `falukant_data.political_office(character_id)` - für Steuerbefreiungen
|
||||||
|
|
||||||
|
### 5. **Ineffiziente `getCumulativeTaxPercentWithExemptions()`**
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
- Lädt alle PoliticalOffices jedes Mal neu, auch wenn sich nichts geändert hat
|
||||||
|
- Macht komplexe rekursive SQL-Query für jedes Item separat
|
||||||
|
- Könnte gecacht werden (z.B. pro User+Region Kombination)
|
||||||
|
|
||||||
|
## Empfohlene Optimierungen
|
||||||
|
|
||||||
|
### 1. **Batch-Loading für `sellAllProducts()`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async sellAllProducts(hashedUserId, branchId) {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
// Batch-Load alle benötigten Daten VOR den Schleifen
|
||||||
|
const regionIds = [...new Set(inventory.map(item => item.stock.branch.regionId))];
|
||||||
|
const productIds = [...new Set(inventory.map(item => item.productType.id))];
|
||||||
|
|
||||||
|
// 1. Lade alle TownProductWorth Einträge auf einmal
|
||||||
|
const townWorths = await TownProductWorth.findAll({
|
||||||
|
where: {
|
||||||
|
productId: { [Op.in]: productIds },
|
||||||
|
regionId: { [Op.in]: regionIds }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const worthMap = new Map();
|
||||||
|
townWorths.forEach(tw => {
|
||||||
|
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Lade alle RegionData auf einmal
|
||||||
|
const regions = await RegionData.findAll({
|
||||||
|
where: { id: { [Op.in]: regionIds } }
|
||||||
|
});
|
||||||
|
const regionMap = new Map(regions.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
// 3. Berechne Steuern für alle Regionen auf einmal
|
||||||
|
const taxMap = new Map();
|
||||||
|
for (const regionId of regionIds) {
|
||||||
|
const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
|
||||||
|
taxMap.set(regionId, tax);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Berechne Preise und Steuern in einer Schleife
|
||||||
|
const sellItems = [];
|
||||||
|
for (const item of inventory) {
|
||||||
|
const regionId = item.stock.branch.regionId;
|
||||||
|
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
|
||||||
|
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
|
||||||
|
const pricePerUnit = calcRegionalSellPrice(item.productType, knowledgeVal, regionId, worthPercent);
|
||||||
|
const cumulativeTax = taxMap.get(regionId);
|
||||||
|
// ... rest of calculation ...
|
||||||
|
sellItems.push({ branchId: item.stock.branch.id, productId: item.productType.id, quantity: item.quantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Batch-Update DaySell Einträge
|
||||||
|
await this.addSellItemsBatch(sellItems);
|
||||||
|
|
||||||
|
// ... rest of code ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Batch-Operation für `addSellItem()`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async addSellItemsBatch(sellItems) {
|
||||||
|
// Gruppiere nach (regionId, productId, sellerId)
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const item of sellItems) {
|
||||||
|
const branch = await Branch.findByPk(item.branchId);
|
||||||
|
if (!branch) continue;
|
||||||
|
|
||||||
|
const key = `${branch.regionId}-${item.productId}-${item.sellerId}`;
|
||||||
|
if (!grouped.has(key)) {
|
||||||
|
grouped.set(key, {
|
||||||
|
regionId: branch.regionId,
|
||||||
|
productId: item.productId,
|
||||||
|
sellerId: item.sellerId,
|
||||||
|
quantity: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
grouped.get(key).quantity += item.quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-Update oder Create
|
||||||
|
for (const [key, data] of grouped) {
|
||||||
|
const [daySell, created] = await DaySell.findOrCreate({
|
||||||
|
where: {
|
||||||
|
regionId: data.regionId,
|
||||||
|
productId: data.productId,
|
||||||
|
sellerId: data.sellerId
|
||||||
|
},
|
||||||
|
defaults: { quantity: data.quantity }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
daySell.quantity += data.quantity;
|
||||||
|
await daySell.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Caching für `getCumulativeTaxPercentWithExemptions()`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Cache für Steuerberechnungen (z.B. 5 Minuten)
|
||||||
|
const taxCache = new Map();
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
|
||||||
|
|
||||||
|
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
|
||||||
|
const cacheKey = `${userId}-${regionId}`;
|
||||||
|
const cached = taxCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing calculation ...
|
||||||
|
|
||||||
|
taxCache.set(cacheKey, { value: tax, timestamp: Date.now() });
|
||||||
|
return tax;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Optimierte `calcRegionalSellPrice()`**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
|
||||||
|
// Wenn worthPercent nicht übergeben wurde UND wir es nicht aus dem Cache haben,
|
||||||
|
// dann hole es aus der DB
|
||||||
|
if (worthPercent === null) {
|
||||||
|
const townWorth = await TownProductWorth.findOne({
|
||||||
|
where: { productId: product.id, regionId: regionId }
|
||||||
|
});
|
||||||
|
worthPercent = townWorth?.worthPercent || 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of calculation ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. **Datenbank-Indizes hinzufügen**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Index für town_product_worth (sollte unique sein)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_town_product_worth_product_region
|
||||||
|
ON falukant_data.town_product_worth(product_id, region_id);
|
||||||
|
|
||||||
|
-- Index für inventory Lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_stock_product_quality
|
||||||
|
ON falukant_data.inventory(stock_id, product_id, quality);
|
||||||
|
|
||||||
|
-- Index für knowledge Lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_character_product
|
||||||
|
ON falukant_data.knowledge(character_id, product_id);
|
||||||
|
|
||||||
|
-- Index für political_office Lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_political_office_character
|
||||||
|
ON falukant_data.political_office(character_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geschätzter Performance-Gewinn
|
||||||
|
|
||||||
|
- **Vorher:** ~70+ Queries für 10 Items
|
||||||
|
- **Nachher:** ~15-20 Queries für 10 Items (Batch-Loading + Caching)
|
||||||
|
- **Geschätzte Verbesserung:** 70-80% weniger Datenbankabfragen
|
||||||
|
|
||||||
|
## Priorität
|
||||||
|
|
||||||
|
1. **Hoch:** Batch-Loading für `sellAllProducts()` (größter Impact)
|
||||||
|
2. **Hoch:** Batch-Operation für `addSellItem()`
|
||||||
|
3. **Mittel:** Caching für Steuerberechnungen
|
||||||
|
4. **Mittel:** Datenbank-Indizes
|
||||||
|
5. **Niedrig:** Doppelte Berechnungen entfernen
|
||||||
|
|
||||||
601
SELL_OVERVIEW.md
Normal file
601
SELL_OVERVIEW.md
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
# Übersicht: Sell-Funktionen und verwendete Models/Tabellen
|
||||||
|
|
||||||
|
## Sell-Funktionen in `falukantService.js`
|
||||||
|
|
||||||
|
### 1. `sellProduct(hashedUserId, branchId, productId, quality, quantity)`
|
||||||
|
Verkauft ein einzelnes Produkt mit bestimmter Qualität.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
1. Lädt User, Branch, Character, Stock
|
||||||
|
2. Lädt Inventory mit ProductType und Knowledge
|
||||||
|
3. Berechnet Preis pro Einheit mit `calcRegionalSellPrice()`
|
||||||
|
4. Berechnet kumulative Steuer mit politischen Befreiungen
|
||||||
|
5. Passt Preis an (Inflation basierend auf Steuer)
|
||||||
|
6. Berechnet Revenue, Tax, Net
|
||||||
|
7. Aktualisiert Geld für Verkäufer und Treasury
|
||||||
|
8. Entfernt verkaufte Items aus Inventory
|
||||||
|
9. Erstellt/aktualisiert DaySell Eintrag
|
||||||
|
10. Sendet Socket-Notifications
|
||||||
|
|
||||||
|
**Verwendete Models/Tabellen:**
|
||||||
|
- `FalukantUser` (`falukant_data.falukant_user`)
|
||||||
|
- `Branch` (`falukant_data.branch`)
|
||||||
|
- `FalukantCharacter` (`falukant_data.character`)
|
||||||
|
- `FalukantStock` (`falukant_data.stock`)
|
||||||
|
- `Inventory` (`falukant_data.inventory`)
|
||||||
|
- `ProductType` (`falukant_type.product`)
|
||||||
|
- `Knowledge` (`falukant_data.knowledge`)
|
||||||
|
- `TownProductWorth` (`falukant_data.town_product_worth`)
|
||||||
|
- `RegionData` (`falukant_data.region`)
|
||||||
|
- `RegionType` (`falukant_type.region`)
|
||||||
|
- `PoliticalOffice` (`falukant_data.political_office`)
|
||||||
|
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
|
||||||
|
- `DaySell` (`falukant_log.day_sell`)
|
||||||
|
- `MoneyFlow` (via `updateFalukantUserMoney`)
|
||||||
|
|
||||||
|
### 2. `sellAllProducts(hashedUserId, branchId)`
|
||||||
|
Verkauft alle Produkte eines Branches.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
1. Lädt User, Branch mit Stocks
|
||||||
|
2. Lädt alle Inventory Items mit ProductType, Knowledge, Stock, Branch
|
||||||
|
3. Für jedes Item:
|
||||||
|
- Berechnet Preis pro Einheit
|
||||||
|
- Berechnet kumulative Steuer
|
||||||
|
- Passt Preis an
|
||||||
|
- Erstellt/aktualisiert DaySell Eintrag
|
||||||
|
4. Berechnet Gesamt-Tax pro Region
|
||||||
|
5. Aktualisiert Geld für Verkäufer und Treasury
|
||||||
|
6. Löscht alle Inventory Items
|
||||||
|
7. Sendet Socket-Notifications
|
||||||
|
|
||||||
|
**Verwendete Models/Tabellen:**
|
||||||
|
- `FalukantUser` (`falukant_data.falukant_user`)
|
||||||
|
- `Branch` (`falukant_data.branch`)
|
||||||
|
- `FalukantStock` (`falukant_data.stock`)
|
||||||
|
- `FalukantStockType` (`falukant_type.stock`)
|
||||||
|
- `FalukantCharacter` (`falukant_data.character`)
|
||||||
|
- `Inventory` (`falukant_data.inventory`)
|
||||||
|
- `ProductType` (`falukant_type.product`)
|
||||||
|
- `Knowledge` (`falukant_data.knowledge`)
|
||||||
|
- `TownProductWorth` (`falukant_data.town_product_worth`)
|
||||||
|
- `RegionData` (`falukant_data.region`)
|
||||||
|
- `RegionType` (`falukant_type.region`)
|
||||||
|
- `PoliticalOffice` (`falukant_data.political_office`)
|
||||||
|
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
|
||||||
|
- `DaySell` (`falukant_log.day_sell`)
|
||||||
|
- `MoneyFlow` (via `updateFalukantUserMoney`)
|
||||||
|
|
||||||
|
### 3. `addSellItem(branchId, userId, productId, quantity)`
|
||||||
|
Erstellt oder aktualisiert einen DaySell Eintrag für einen Verkauf.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
1. Lädt Branch
|
||||||
|
2. Sucht nach existierendem DaySell Eintrag
|
||||||
|
3. Erstellt neuen oder aktualisiert existierenden Eintrag
|
||||||
|
|
||||||
|
**Verwendete Models/Tabellen:**
|
||||||
|
- `Branch` (`falukant_data.branch`)
|
||||||
|
- `DaySell` (`falukant_log.day_sell`)
|
||||||
|
|
||||||
|
## Hilfsfunktionen
|
||||||
|
|
||||||
|
### `calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null)`
|
||||||
|
Berechnet den Verkaufspreis eines Produkts basierend auf:
|
||||||
|
- Basispreis (`product.sellCost`)
|
||||||
|
- Regionalem Worth-Percent (`town_product_worth.worth_percent`)
|
||||||
|
- Knowledge-Faktor (0-100)
|
||||||
|
|
||||||
|
**Verwendete Models/Tabellen:**
|
||||||
|
- `ProductType` (`falukant_type.product`)
|
||||||
|
- `TownProductWorth` (`falukant_data.town_product_worth`)
|
||||||
|
|
||||||
|
### `getCumulativeTaxPercent(regionId)`
|
||||||
|
Berechnet die kumulative Steuer für eine Region und alle Vorfahren (rekursiv).
|
||||||
|
|
||||||
|
**SQL Query:**
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, parent_id, tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
WHERE id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT reg.id, reg.parent_id, reg.tax_percent
|
||||||
|
FROM falukant_data.region reg
|
||||||
|
JOIN ancestors a ON reg.id = a.parent_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verwendete Tabellen:**
|
||||||
|
- `falukant_data.region`
|
||||||
|
|
||||||
|
### `getCumulativeTaxPercentWithExemptions(userId, regionId)`
|
||||||
|
Berechnet die kumulative Steuer mit politischen Befreiungen.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
1. Lädt Character des Users
|
||||||
|
2. Lädt alle PoliticalOffices des Characters
|
||||||
|
3. Bestimmt befreite Region-Typen basierend auf Ämtern
|
||||||
|
4. Berechnet kumulative Steuer, aber schließt befreite Region-Typen aus
|
||||||
|
|
||||||
|
**SQL Query:**
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN falukant_type.region rt ON rt.id = r.region_type_id
|
||||||
|
WHERE r.id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
|
||||||
|
FROM falukant_data.region reg
|
||||||
|
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
|
||||||
|
JOIN ancestors a ON reg.id = a.parent_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN ARRAY[...] && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verwendete Models/Tabellen:**
|
||||||
|
- `FalukantCharacter` (`falukant_data.character`)
|
||||||
|
- `PoliticalOffice` (`falukant_data.political_office`)
|
||||||
|
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
|
||||||
|
- `RegionData` (`falukant_data.region`)
|
||||||
|
- `RegionType` (`falukant_type.region`)
|
||||||
|
|
||||||
|
**Politische Steuerbefreiungen:**
|
||||||
|
```javascript
|
||||||
|
const POLITICAL_TAX_EXEMPTIONS = {
|
||||||
|
'council': ['city'],
|
||||||
|
'taxman': ['city', 'county'],
|
||||||
|
'treasurerer': ['city', 'county', 'shire'],
|
||||||
|
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
|
||||||
|
'chancellor': ['city','county','shire','markgrave','duchy'] // = alle Typen
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model-Definitionen
|
||||||
|
|
||||||
|
### Inventory (`falukant_data.inventory`)
|
||||||
|
```javascript
|
||||||
|
// backend/models/falukant/data/inventory.js
|
||||||
|
- id
|
||||||
|
- stockId (FK zu falukant_data.stock)
|
||||||
|
- productId (FK zu falukant_type.product)
|
||||||
|
- quantity
|
||||||
|
- quality
|
||||||
|
- producedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
### DaySell (`falukant_log.day_sell`)
|
||||||
|
```javascript
|
||||||
|
// backend/models/falukant/log/daysell.js
|
||||||
|
- id
|
||||||
|
- regionId (FK zu falukant_data.region)
|
||||||
|
- productId (FK zu falukant_type.product)
|
||||||
|
- sellerId (FK zu falukant_data.falukant_user)
|
||||||
|
- quantity
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
### TownProductWorth (`falukant_data.town_product_worth`)
|
||||||
|
```javascript
|
||||||
|
// backend/models/falukant/data/town_product_worth.js
|
||||||
|
- id
|
||||||
|
- productId (FK zu falukant_type.product)
|
||||||
|
- regionId (FK zu falukant_data.region)
|
||||||
|
- worthPercent (0-100)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Knowledge (`falukant_data.knowledge`)
|
||||||
|
```javascript
|
||||||
|
// backend/models/falukant/data/product_knowledge.js
|
||||||
|
- id
|
||||||
|
- productId (FK zu falukant_type.product)
|
||||||
|
- characterId (FK zu falukant_data.character)
|
||||||
|
- knowledge (0-99)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige SQL-Queries
|
||||||
|
|
||||||
|
### 1. Inventory mit ProductType und Knowledge laden
|
||||||
|
```javascript
|
||||||
|
Inventory.findAll({
|
||||||
|
where: { quality },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: ProductType,
|
||||||
|
as: 'productType',
|
||||||
|
required: true,
|
||||||
|
where: { id: productId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Knowledge,
|
||||||
|
as: 'knowledges',
|
||||||
|
required: false,
|
||||||
|
where: { characterId: character.id }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Kumulative Steuer mit Befreiungen berechnen
|
||||||
|
Siehe `getCumulativeTaxPercentWithExemptions()` oben.
|
||||||
|
|
||||||
|
## Preisberechnung
|
||||||
|
|
||||||
|
### Formel für `calcRegionalSellPrice`:
|
||||||
|
1. Basispreis = `product.sellCost * (worthPercent / 100)`
|
||||||
|
2. Min = `basePrice * 0.6`
|
||||||
|
3. Max = `basePrice`
|
||||||
|
4. Preis = `min + (max - min) * (knowledgeFactor / 100)`
|
||||||
|
|
||||||
|
### Steueranpassung:
|
||||||
|
1. `inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100))`
|
||||||
|
2. `adjustedPricePerUnit = pricePerUnit * inflationFactor`
|
||||||
|
3. `revenue = quantity * adjustedPricePerUnit`
|
||||||
|
4. `taxValue = revenue * cumulativeTax / 100`
|
||||||
|
5. `net = revenue - taxValue`
|
||||||
|
|
||||||
|
## Vollständige Code-Snippets
|
||||||
|
|
||||||
|
### `calcRegionalSellPrice()`
|
||||||
|
```javascript
|
||||||
|
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
|
||||||
|
if (worthPercent === null) {
|
||||||
|
const townWorth = await TownProductWorth.findOne({
|
||||||
|
where: { productId: product.id, regionId: regionId }
|
||||||
|
});
|
||||||
|
worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basispreis basierend auf regionalem worthPercent
|
||||||
|
const basePrice = product.sellCost * (worthPercent / 100);
|
||||||
|
|
||||||
|
// Dann Knowledge-Faktor anwenden
|
||||||
|
const min = basePrice * 0.6;
|
||||||
|
const max = basePrice;
|
||||||
|
return min + (max - min) * (knowledgeFactor / 100);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getCumulativeTaxPercent()`
|
||||||
|
```javascript
|
||||||
|
async function getCumulativeTaxPercent(regionId) {
|
||||||
|
if (!regionId) return 0;
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, parent_id, tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
WHERE id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT reg.id, reg.parent_id, reg.tax_percent
|
||||||
|
FROM falukant_data.region reg
|
||||||
|
JOIN ancestors a ON reg.id = a.parent_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;`,
|
||||||
|
{
|
||||||
|
replacements: { id: regionId },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const val = rows?.[0]?.total ?? 0;
|
||||||
|
return parseFloat(val) || 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getCumulativeTaxPercentWithExemptions()` (vereinfacht)
|
||||||
|
```javascript
|
||||||
|
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
|
||||||
|
if (!regionId) return 0;
|
||||||
|
|
||||||
|
// Character finden
|
||||||
|
const character = await FalukantCharacter.findOne({
|
||||||
|
where: { userId },
|
||||||
|
attributes: ['id']
|
||||||
|
});
|
||||||
|
if (!character) return 0;
|
||||||
|
|
||||||
|
// Politische Ämter laden
|
||||||
|
const offices = await PoliticalOffice.findAll({
|
||||||
|
where: { characterId: character.id },
|
||||||
|
include: [
|
||||||
|
{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] },
|
||||||
|
{
|
||||||
|
model: RegionData,
|
||||||
|
as: 'region',
|
||||||
|
include: [{
|
||||||
|
model: RegionType,
|
||||||
|
as: 'regionType',
|
||||||
|
attributes: ['labelTr']
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Befreite Region-Typen bestimmen
|
||||||
|
const exemptTypes = new Set();
|
||||||
|
let hasChancellor = false;
|
||||||
|
for (const o of offices) {
|
||||||
|
const name = o.type?.name;
|
||||||
|
if (!name) continue;
|
||||||
|
if (name === 'chancellor') { hasChancellor = true; break; }
|
||||||
|
const allowed = POLITICAL_TAX_EXEMPTIONS[name];
|
||||||
|
if (allowed && Array.isArray(allowed)) {
|
||||||
|
for (const t of allowed) exemptTypes.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChancellor) return 0;
|
||||||
|
|
||||||
|
// SQL Query mit Befreiungen
|
||||||
|
const exemptTypesArray = Array.from(exemptTypes);
|
||||||
|
const exemptTypesString = exemptTypesArray.length > 0
|
||||||
|
? `ARRAY[${exemptTypesArray.map(t => `'${t.replace(/'/g, "''")}'`).join(',')}]`
|
||||||
|
: `ARRAY[]::text[]`;
|
||||||
|
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN falukant_type.region rt ON rt.id = r.region_type_id
|
||||||
|
WHERE r.id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
|
||||||
|
FROM falukant_data.region reg
|
||||||
|
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
|
||||||
|
JOIN ancestors a ON reg.id = a.parent_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN ${exemptTypesString} && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
|
||||||
|
{
|
||||||
|
replacements: { id: regionId },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const val = rows?.[0]?.total ?? 0;
|
||||||
|
return parseFloat(val) || 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sellProduct()` (Kern-Logik)
|
||||||
|
```javascript
|
||||||
|
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
|
||||||
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
|
const branch = await getBranchOrFail(user.id, branchId);
|
||||||
|
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
|
||||||
|
if (!character) throw new Error('No character found for user');
|
||||||
|
const stock = await FalukantStock.findOne({ where: { branchId: branch.id } });
|
||||||
|
if (!stock) throw new Error('Stock not found');
|
||||||
|
|
||||||
|
// Inventory laden
|
||||||
|
const inventory = await Inventory.findAll({
|
||||||
|
where: { quality },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: ProductType,
|
||||||
|
as: 'productType',
|
||||||
|
required: true,
|
||||||
|
where: { id: productId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Knowledge,
|
||||||
|
as: 'knowledges',
|
||||||
|
required: false,
|
||||||
|
where: { characterId: character.id }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if (!inventory.length) throw new Error('No inventory found');
|
||||||
|
|
||||||
|
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
|
||||||
|
if (available < quantity) throw new Error('Not enough inventory available');
|
||||||
|
|
||||||
|
const item = inventory[0].productType;
|
||||||
|
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
|
||||||
|
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
|
||||||
|
|
||||||
|
// Steuer berechnen
|
||||||
|
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId);
|
||||||
|
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||||||
|
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||||||
|
const revenue = quantity * adjustedPricePerUnit;
|
||||||
|
|
||||||
|
// Tax und Net berechnen
|
||||||
|
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
|
||||||
|
const net = Math.round((revenue - taxValue) * 100) / 100;
|
||||||
|
|
||||||
|
// Geld aktualisieren
|
||||||
|
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
|
||||||
|
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||||||
|
|
||||||
|
// Steuer an Treasury buchen
|
||||||
|
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
|
||||||
|
if (treasuryId && taxValue > 0) {
|
||||||
|
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id);
|
||||||
|
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory aktualisieren
|
||||||
|
let remaining = quantity;
|
||||||
|
for (const inv of inventory) {
|
||||||
|
if (inv.quantity <= remaining) {
|
||||||
|
remaining -= inv.quantity;
|
||||||
|
await inv.destroy();
|
||||||
|
} else {
|
||||||
|
await inv.update({ quantity: inv.quantity - remaining });
|
||||||
|
remaining = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaySell Eintrag erstellen/aktualisieren
|
||||||
|
await this.addSellItem(branchId, user.id, productId, quantity);
|
||||||
|
|
||||||
|
// Notifications senden
|
||||||
|
notifyUser(user.user.hashedId, 'falukantUpdateStatus', {});
|
||||||
|
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `addSellItem()`
|
||||||
|
```javascript
|
||||||
|
async addSellItem(branchId, userId, productId, quantity) {
|
||||||
|
const branch = await Branch.findOne({
|
||||||
|
where: { id: branchId },
|
||||||
|
});
|
||||||
|
const daySell = await DaySell.findOne({
|
||||||
|
where: {
|
||||||
|
regionId: branch.regionId,
|
||||||
|
productId: productId,
|
||||||
|
sellerId: userId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (daySell) {
|
||||||
|
daySell.quantity += quantity;
|
||||||
|
await daySell.save();
|
||||||
|
} else {
|
||||||
|
await DaySell.create({
|
||||||
|
regionId: branch.regionId,
|
||||||
|
productId: productId,
|
||||||
|
sellerId: userId,
|
||||||
|
quantity: quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige Hinweise
|
||||||
|
|
||||||
|
1. **Inventory wird nach Verkauf gelöscht/aktualisiert**: Items werden aus der Inventory entfernt oder die Menge reduziert.
|
||||||
|
|
||||||
|
2. **DaySell wird aggregiert**: Wenn bereits ein DaySell Eintrag für Region/Product/Seller existiert, wird die Menge addiert.
|
||||||
|
|
||||||
|
3. **Steuer wird an Treasury gebucht**: Wenn `TREASURY_FALUKANT_USER_ID` gesetzt ist, wird die Steuer an diesen User gebucht.
|
||||||
|
|
||||||
|
4. **Socket-Notifications**: Nach jedem Verkauf werden `falukantUpdateStatus` und `falukantBranchUpdate` Events gesendet.
|
||||||
|
|
||||||
|
5. **Politische Befreiungen**: Bestimmte politische Ämter befreien von Steuern in bestimmten Region-Typen. Chancellor befreit von allen Steuern.
|
||||||
|
|
||||||
|
6. **Preis-Inflation**: Der Preis wird basierend auf der Steuer inflatiert, damit der Netto-Betrag für den Verkäufer gleich bleibt.
|
||||||
|
|
||||||
|
## Tabellenübersicht
|
||||||
|
|
||||||
|
### `falukant_data.inventory`
|
||||||
|
- `id` (PK)
|
||||||
|
- `stock_id` (FK zu `falukant_data.stock`)
|
||||||
|
- `product_id` (FK zu `falukant_type.product`)
|
||||||
|
- `quantity` (INTEGER)
|
||||||
|
- `quality` (INTEGER)
|
||||||
|
- `produced_at` (DATE)
|
||||||
|
|
||||||
|
### `falukant_log.sell` (DaySell)
|
||||||
|
- `id` (PK)
|
||||||
|
- `region_id` (FK zu `falukant_data.region`)
|
||||||
|
- `product_id` (FK zu `falukant_type.product`)
|
||||||
|
- `seller_id` (FK zu `falukant_data.falukant_user`)
|
||||||
|
- `quantity` (INTEGER)
|
||||||
|
- `sell_timestamp` (DATE)
|
||||||
|
- **Unique Index**: `(seller_id, product_id, region_id)`
|
||||||
|
|
||||||
|
### `falukant_data.town_product_worth`
|
||||||
|
- `id` (PK)
|
||||||
|
- `product_id` (FK zu `falukant_type.product`)
|
||||||
|
- `region_id` (FK zu `falukant_data.region`)
|
||||||
|
- `worth_percent` (INTEGER, 0-100)
|
||||||
|
|
||||||
|
### `falukant_data.knowledge`
|
||||||
|
- `id` (PK)
|
||||||
|
- `product_id` (FK zu `falukant_type.product`)
|
||||||
|
- `character_id` (FK zu `falukant_data.character`)
|
||||||
|
- `knowledge` (INTEGER, 0-99)
|
||||||
|
|
||||||
|
### `falukant_data.political_office`
|
||||||
|
- `id` (PK)
|
||||||
|
- `office_type_id` (FK zu `falukant_type.political_office_type`)
|
||||||
|
- `character_id` (FK zu `falukant_data.character`)
|
||||||
|
- `region_id` (FK zu `falukant_data.region`)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### `falukant_type.political_office_type`
|
||||||
|
- `id` (PK)
|
||||||
|
- `name` (STRING) - z.B. 'council', 'taxman', 'treasurerer', 'super-state-administrator', 'chancellor'
|
||||||
|
- `seats_per_region` (INTEGER)
|
||||||
|
- `region_type` (STRING)
|
||||||
|
- `term_length` (INTEGER)
|
||||||
|
|
||||||
|
### `falukant_data.region`
|
||||||
|
- `id` (PK)
|
||||||
|
- `name` (STRING)
|
||||||
|
- `region_type_id` (FK zu `falukant_type.region`)
|
||||||
|
- `parent_id` (FK zu `falukant_data.region`, nullable)
|
||||||
|
- `map` (JSONB)
|
||||||
|
- `tax_percent` (DECIMAL)
|
||||||
|
|
||||||
|
### `falukant_type.region`
|
||||||
|
- `id` (PK)
|
||||||
|
- `label_tr` (STRING) - z.B. 'city', 'county', 'shire', 'markgrave', 'duchy'
|
||||||
|
- `parent_id` (FK zu `falukant_type.region`, nullable)
|
||||||
|
|
||||||
|
### `falukant_data.falukant_user`
|
||||||
|
- `id` (PK)
|
||||||
|
- `user_id` (FK zu `community.user`)
|
||||||
|
- `money` (DECIMAL)
|
||||||
|
- `credit_amount`, `today_credit_taken`, `credit_interest_rate`
|
||||||
|
- `certificate`
|
||||||
|
- `main_branch_region_id`
|
||||||
|
- `last_nobility_advance_at`
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### `falukant_data.character`
|
||||||
|
- `id` (PK)
|
||||||
|
- `user_id` (FK zu `falukant_data.falukant_user`)
|
||||||
|
- `region_id` (FK zu `falukant_data.region`)
|
||||||
|
- `first_name`, `last_name`
|
||||||
|
- `birthdate`, `gender`, `health`
|
||||||
|
- `title_of_nobility` (FK zu `falukant_type.title_of_nobility`)
|
||||||
|
- `mood_id` (FK zu `falukant_type.mood`)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### `falukant_data.branch`
|
||||||
|
- `id` (PK)
|
||||||
|
- `branch_type_id` (FK zu `falukant_type.branch`)
|
||||||
|
- `region_id` (FK zu `falukant_data.region`)
|
||||||
|
- `falukant_user_id` (FK zu `falukant_data.falukant_user`)
|
||||||
|
|
||||||
|
### `falukant_data.stock`
|
||||||
|
- `id` (PK)
|
||||||
|
- `branch_id` (FK zu `falukant_data.branch`)
|
||||||
|
- `stock_type_id` (FK zu `falukant_type.stock`)
|
||||||
|
- `quantity` (INTEGER)
|
||||||
|
- `product_quality` (INTEGER, nullable)
|
||||||
|
|
||||||
|
### `falukant_type.product`
|
||||||
|
- `id` (PK)
|
||||||
|
- `label_tr` (STRING, unique)
|
||||||
|
- `category` (INTEGER)
|
||||||
|
- `production_time` (INTEGER)
|
||||||
|
- `sell_cost` (INTEGER)
|
||||||
|
|
||||||
|
## Dateipfade
|
||||||
|
|
||||||
|
- **Service**: `backend/services/falukantService.js`
|
||||||
|
- **Models**:
|
||||||
|
- `backend/models/falukant/data/inventory.js`
|
||||||
|
- `backend/models/falukant/log/daysell.js`
|
||||||
|
- `backend/models/falukant/data/town_product_worth.js`
|
||||||
|
- `backend/models/falukant/data/product_knowledge.js`
|
||||||
|
- `backend/models/falukant/data/political_office.js`
|
||||||
|
- `backend/models/falukant/type/political_office_type.js`
|
||||||
|
- `backend/models/falukant/data/region.js`
|
||||||
|
- `backend/models/falukant/type/region.js`
|
||||||
|
- `backend/models/falukant/data/character.js`
|
||||||
|
- `backend/models/falukant/data/user.js`
|
||||||
|
- `backend/models/falukant/data/branch.js`
|
||||||
|
- `backend/models/falukant/data/stock.js`
|
||||||
|
- `backend/models/falukant/type/product.js`
|
||||||
|
|
||||||
23
backend/README_TAX.md
Normal file
23
backend/README_TAX.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Falukant Tax Migration & Configuration
|
||||||
|
|
||||||
|
This project now supports a per-region sales tax (`tax_percent`) for Falukant.
|
||||||
|
|
||||||
|
Migration
|
||||||
|
- A SQL migration was added: `backend/migrations/20260101000000-add-tax-percent-to-region.cjs`.
|
||||||
|
- It adds `tax_percent` numeric NOT NULL DEFAULT 7 to `falukant_data.region`.
|
||||||
|
|
||||||
|
Runtime configuration
|
||||||
|
- If you want taxes to be forwarded to a treasury account, set environment variable `TREASURY_FALUKANT_USER_ID` to a valid `falukant_user.id`.
|
||||||
|
- If `TREASURY_FALUKANT_USER_ID` is not set, taxes will be calculated and currently not forwarded to any account.
|
||||||
|
|
||||||
|
Implementation notes
|
||||||
|
- Backend service `sellProduct` and `sellAllProducts` now compute tax per-region and credit net to seller and tax to treasury (if configured).
|
||||||
|
- Tax arithmetic uses rounding to 2 decimals. The current implementation performs two separate DB calls (seller, treasury). For strict ledger atomicity consider implementing DB-side booking.
|
||||||
|
|
||||||
|
Cumulative tax behavior
|
||||||
|
- The system now sums `tax_percent` from the sale region and all ancestor regions (recursive up the region tree). This allows defining different tax rates on up to 6 region levels and summing them for final tax percent.
|
||||||
|
- To avoid reducing seller net by taxes, sale prices are inflated by factor = 1 / (1 - cumulativeTax/100). This way the seller receives the original net and the tax is collected separately.
|
||||||
|
|
||||||
|
Testing
|
||||||
|
- After running the migration, test with a small sale and verify `falukant_log.moneyflow` entries for seller and treasury.
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import crypto from 'crypto';
|
||||||
import chatRouter from './routers/chatRouter.js';
|
import chatRouter from './routers/chatRouter.js';
|
||||||
import authRouter from './routers/authRouter.js';
|
import authRouter from './routers/authRouter.js';
|
||||||
import navigationRouter from './routers/navigationRouter.js';
|
import navigationRouter from './routers/navigationRouter.js';
|
||||||
@@ -16,6 +17,8 @@ import match3Router from './routers/match3Router.js';
|
|||||||
import taxiRouter from './routers/taxiRouter.js';
|
import taxiRouter from './routers/taxiRouter.js';
|
||||||
import taxiMapRouter from './routers/taxiMapRouter.js';
|
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 vocabRouter from './routers/vocabRouter.js';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import './jobs/sessionCleanup.js';
|
import './jobs/sessionCleanup.js';
|
||||||
|
|
||||||
@@ -24,6 +27,25 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Request-Timing (aktivierbar per ENV)
|
||||||
|
// - LOG_SLOW_REQ_MS=200: Logge Requests, die länger dauern als X ms (Default 500)
|
||||||
|
// - LOG_ALL_REQ=1: Logge alle Requests
|
||||||
|
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
|
||||||
|
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
|
||||||
|
req.reqId = reqId;
|
||||||
|
res.setHeader('x-request-id', reqId);
|
||||||
|
const t0 = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
if (LOG_ALL_REQ || ms >= LOG_SLOW_REQ_MS) {
|
||||||
|
console.log(`⏱️ REQ ${ms}ms ${res.statusCode} ${req.method} ${req.originalUrl} rid=${reqId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const corsOptions = {
|
const corsOptions = {
|
||||||
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
|
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
|
||||||
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
||||||
@@ -48,10 +70,12 @@ app.use('/api/taxi/highscores', taxiHighscoreRouter);
|
|||||||
app.use('/images', express.static(path.join(__dirname, '../frontend/public/images')));
|
app.use('/images', express.static(path.join(__dirname, '../frontend/public/images')));
|
||||||
app.use('/api/contact', contactRouter);
|
app.use('/api/contact', contactRouter);
|
||||||
app.use('/api/socialnetwork', socialnetworkRouter);
|
app.use('/api/socialnetwork', socialnetworkRouter);
|
||||||
|
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/blog', blogRouter);
|
app.use('/api/blog', blogRouter);
|
||||||
|
app.use('/api/termine', termineRouter);
|
||||||
|
|
||||||
// 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
|
||||||
const frontendDir = path.join(__dirname, '../frontend');
|
const frontendDir = path.join(__dirname, '../frontend');
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AdminController {
|
|||||||
// User administration
|
// User administration
|
||||||
this.searchUsers = this.searchUsers.bind(this);
|
this.searchUsers = this.searchUsers.bind(this);
|
||||||
this.getUser = this.getUser.bind(this);
|
this.getUser = this.getUser.bind(this);
|
||||||
|
this.getUsers = this.getUsers.bind(this);
|
||||||
this.updateUser = this.updateUser.bind(this);
|
this.updateUser = this.updateUser.bind(this);
|
||||||
|
|
||||||
// Rights
|
// Rights
|
||||||
@@ -37,6 +38,14 @@ class AdminController {
|
|||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
this.getUserStatistics = this.getUserStatistics.bind(this);
|
this.getUserStatistics = this.getUserStatistics.bind(this);
|
||||||
|
this.getFalukantRegions = this.getFalukantRegions.bind(this);
|
||||||
|
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
|
||||||
|
this.getRegionDistances = this.getRegionDistances.bind(this);
|
||||||
|
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
|
||||||
|
this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
|
||||||
|
this.createNPCs = this.createNPCs.bind(this);
|
||||||
|
this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this);
|
||||||
|
this.getNPCsCreationStatus = this.getNPCsCreationStatus.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOpenInterests(req, res) {
|
async getOpenInterests(req, res) {
|
||||||
@@ -74,6 +83,30 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUsers(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
let { ids } = req.query;
|
||||||
|
if (!ids) {
|
||||||
|
return res.status(400).json({ error: 'ids query parameter is required' });
|
||||||
|
}
|
||||||
|
// Unterstütze sowohl Array-Format (ids[]=...) als auch komma-separierten String (ids=...)
|
||||||
|
let hashedIds;
|
||||||
|
if (Array.isArray(ids)) {
|
||||||
|
hashedIds = ids;
|
||||||
|
} else if (typeof ids === 'string') {
|
||||||
|
hashedIds = ids.split(',').map(id => id.trim()).filter(id => id.length > 0);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'ids must be an array or comma-separated string' });
|
||||||
|
}
|
||||||
|
const result = await AdminService.getUsersByHashedIds(requester, hashedIds);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateUser(req, res) {
|
async updateUser(req, res) {
|
||||||
try {
|
try {
|
||||||
const { userid: requester } = req.headers;
|
const { userid: requester } = req.headers;
|
||||||
@@ -290,6 +323,122 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFalukantRegions(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const regions = await AdminService.getFalukantRegions(userId);
|
||||||
|
res.status(200).json(regions);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFalukantRegionMap(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const { map } = req.body || {};
|
||||||
|
const region = await AdminService.updateFalukantRegionMap(userId, id, map);
|
||||||
|
res.status(200).json(region);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : (error.message === 'regionNotFound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRegionDistances(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const distances = await AdminService.getRegionDistances(userId);
|
||||||
|
res.status(200).json(distances);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRegionDistance(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const record = await AdminService.upsertRegionDistance(userId, req.body || {});
|
||||||
|
res.status(200).json(record);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 400;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegionDistance(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.deleteRegionDistance(userId, id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : (error.message === 'notfound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNPCs(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const { regionIds, minAge, maxAge, minTitleId, maxTitleId, count } = req.body;
|
||||||
|
const countValue = parseInt(count) || 1;
|
||||||
|
if (countValue < 1 || countValue > 500) {
|
||||||
|
return res.status(400).json({ error: 'Count must be between 1 and 500' });
|
||||||
|
}
|
||||||
|
console.log('[createNPCs] Request received:', { userId, regionIds, minAge, maxAge, minTitleId, maxTitleId, count: countValue });
|
||||||
|
const result = await AdminService.createNPCs(userId, {
|
||||||
|
regionIds: regionIds && regionIds.length > 0 ? regionIds : null,
|
||||||
|
minAge: parseInt(minAge) || 0,
|
||||||
|
maxAge: parseInt(maxAge) || 100,
|
||||||
|
minTitleId: parseInt(minTitleId) || 1,
|
||||||
|
maxTitleId: parseInt(maxTitleId) || 19,
|
||||||
|
count: countValue
|
||||||
|
});
|
||||||
|
console.log('[createNPCs] Job created:', result);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[createNPCs] Error:', error);
|
||||||
|
console.error('[createNPCs] Error stack:', error.stack);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTitlesOfNobility(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const titles = await AdminService.getTitlesOfNobility(userId);
|
||||||
|
res.status(200).json(titles);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNPCsCreationStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const { jobId } = req.params;
|
||||||
|
const status = await AdminService.getNPCsCreationStatus(userId, jobId);
|
||||||
|
res.status(200).json(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' || error.message === 'Access denied' ? 403 :
|
||||||
|
error.message === 'Job not found' ? 404 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getRoomTypes(req, res) {
|
async getRoomTypes(req, res) {
|
||||||
try {
|
try {
|
||||||
const userId = req.headers.userid;
|
const userId = req.headers.userid;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class FalukantController {
|
|||||||
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
|
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
|
||||||
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
|
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
|
||||||
this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch));
|
this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch));
|
||||||
|
this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId));
|
||||||
this.createProduction = this._wrapWithUser((userId, req) => {
|
this.createProduction = this._wrapWithUser((userId, req) => {
|
||||||
const { branchId, productId, quantity } = req.body;
|
const { branchId, productId, quantity } = req.body;
|
||||||
return this.service.createProduction(userId, branchId, productId, quantity);
|
return this.service.createProduction(userId, branchId, productId, quantity);
|
||||||
@@ -91,6 +92,9 @@ class FalukantController {
|
|||||||
if (!result) throw { status: 404, message: 'No family data found' };
|
if (!result) throw { status: 404, message: 'No family data found' };
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
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.getGifts = this._wrapWithUser((userId) => {
|
this.getGifts = this._wrapWithUser((userId) => {
|
||||||
console.log('🔍 getGifts called with userId:', userId);
|
console.log('🔍 getGifts called with userId:', userId);
|
||||||
@@ -114,6 +118,12 @@ 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;
|
||||||
@@ -143,6 +153,24 @@ class FalukantController {
|
|||||||
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
|
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
|
||||||
|
|
||||||
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
|
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
|
||||||
|
this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId));
|
||||||
|
this.getProductPriceInRegion = this._wrapWithUser((userId, req) => {
|
||||||
|
const productId = parseInt(req.query.productId, 10);
|
||||||
|
const regionId = parseInt(req.query.regionId, 10);
|
||||||
|
if (Number.isNaN(productId) || Number.isNaN(regionId)) {
|
||||||
|
throw new Error('productId and regionId are required');
|
||||||
|
}
|
||||||
|
return this.service.getProductPriceInRegion(userId, productId, regionId);
|
||||||
|
});
|
||||||
|
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
|
||||||
|
const productId = parseInt(req.query.productId, 10);
|
||||||
|
const currentPrice = parseFloat(req.query.currentPrice);
|
||||||
|
const currentRegionId = req.query.currentRegionId ? parseInt(req.query.currentRegionId, 10) : null;
|
||||||
|
if (Number.isNaN(productId) || Number.isNaN(currentPrice)) {
|
||||||
|
throw new Error('productId and currentPrice are required');
|
||||||
|
}
|
||||||
|
return this.service.getProductPricesInCities(userId, productId, currentPrice, 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));
|
||||||
|
|
||||||
@@ -181,6 +209,33 @@ class FalukantController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.getVehicleTypes = this._wrapWithUser((userId) => this.service.getVehicleTypes(userId));
|
||||||
|
this.buyVehicles = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.buyVehicles(userId, req.body),
|
||||||
|
{ successStatus: 201 }
|
||||||
|
);
|
||||||
|
this.getVehicles = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.getVehicles(userId, req.query.regionId)
|
||||||
|
);
|
||||||
|
this.createTransport = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.createTransport(userId, req.body),
|
||||||
|
{ successStatus: 201 }
|
||||||
|
);
|
||||||
|
this.getTransportRoute = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.getTransportRoute(userId, req.query)
|
||||||
|
);
|
||||||
|
this.getBranchTransports = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.getBranchTransports(userId, req.params.branchId)
|
||||||
|
);
|
||||||
|
this.repairVehicle = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.repairVehicle(userId, req.params.vehicleId),
|
||||||
|
{ successStatus: 200 }
|
||||||
|
);
|
||||||
|
this.repairAllVehicles = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.repairAllVehicles(userId, req.body.vehicleIds),
|
||||||
|
{ successStatus: 200 }
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import UserRight from '../models/community/user_right.js';
|
|||||||
import UserRightType from '../models/type/user_right.js';
|
import UserRightType from '../models/type/user_right.js';
|
||||||
import UserParamType from '../models/type/user_param.js';
|
import UserParamType from '../models/type/user_param.js';
|
||||||
import FalukantUser from '../models/falukant/data/user.js';
|
import FalukantUser from '../models/falukant/data/user.js';
|
||||||
|
import VocabService from '../services/vocabService.js';
|
||||||
|
|
||||||
const menuStructure = {
|
const menuStructure = {
|
||||||
home: {
|
home: {
|
||||||
@@ -49,6 +50,11 @@ 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"
|
||||||
@@ -117,10 +123,6 @@ const menuStructure = {
|
|||||||
visible: ["hasfalukantaccount"],
|
visible: ["hasfalukantaccount"],
|
||||||
path: "/falukant/branch"
|
path: "/falukant/branch"
|
||||||
},
|
},
|
||||||
directors: {
|
|
||||||
visible: ["hasfalukantaccount"],
|
|
||||||
path: "/falukant/directors"
|
|
||||||
},
|
|
||||||
family: {
|
family: {
|
||||||
visible: ["hasfalukantaccount"],
|
visible: ["hasfalukantaccount"],
|
||||||
path: "/falukant/family"
|
path: "/falukant/family"
|
||||||
@@ -251,10 +253,14 @@ const menuStructure = {
|
|||||||
visible: ["mainadmin", "chatrooms"],
|
visible: ["mainadmin", "chatrooms"],
|
||||||
path: "/admin/chatrooms"
|
path: "/admin/chatrooms"
|
||||||
},
|
},
|
||||||
|
servicesStatus: {
|
||||||
|
visible: ["mainadmin"],
|
||||||
|
path: "/admin/services/status"
|
||||||
|
},
|
||||||
interests: {
|
interests: {
|
||||||
visible: ["mainadmin", "interests"],
|
visible: ["mainadmin", "interests"],
|
||||||
path: "/admin/interests"
|
path: "/admin/interests"
|
||||||
},
|
},
|
||||||
falukant: {
|
falukant: {
|
||||||
visible: ["mainadmin", "falukant"],
|
visible: ["mainadmin", "falukant"],
|
||||||
children: {
|
children: {
|
||||||
@@ -270,6 +276,14 @@ const menuStructure = {
|
|||||||
visible: ["mainadmin", "falukant"],
|
visible: ["mainadmin", "falukant"],
|
||||||
path: "/admin/falukant/database"
|
path: "/admin/falukant/database"
|
||||||
},
|
},
|
||||||
|
mapEditor: {
|
||||||
|
visible: ["mainadmin", "falukant"],
|
||||||
|
path: "/admin/falukant/map"
|
||||||
|
},
|
||||||
|
createNPC: {
|
||||||
|
visible: ["mainadmin", "falukant"],
|
||||||
|
path: "/admin/falukant/create-npc"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
minigames: {
|
minigames: {
|
||||||
@@ -292,6 +306,7 @@ const menuStructure = {
|
|||||||
class NavigationController {
|
class NavigationController {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.menu = this.menu.bind(this);
|
this.menu = this.menu.bind(this);
|
||||||
|
this.vocabService = new VocabService();
|
||||||
}
|
}
|
||||||
|
|
||||||
calculateAge(birthDate) {
|
calculateAge(birthDate) {
|
||||||
@@ -361,6 +376,24 @@ class NavigationController {
|
|||||||
const age = this.calculateAge(birthDate);
|
const age = this.calculateAge(birthDate);
|
||||||
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)
|
||||||
|
// Wichtig: "Neue Sprache" soll IMMER sichtbar sein – auch wenn die DB-Abfrage (noch) fehlschlägt.
|
||||||
|
if (filteredMenu?.socialnetwork?.children?.vocabtrainer) {
|
||||||
|
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) {
|
||||||
console.error('Error fetching menu:', error);
|
console.error('Error fetching menu:', error);
|
||||||
|
|||||||
43
backend/controllers/termineController.js
Normal file
43
backend/controllers/termineController.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
class TermineController {
|
||||||
|
async getTermine(req, res) {
|
||||||
|
try {
|
||||||
|
const csvPath = path.join(__dirname, '../data/termine.csv');
|
||||||
|
const csvContent = fs.readFileSync(csvPath, 'utf-8');
|
||||||
|
|
||||||
|
const lines = csvContent.trim().split('\n');
|
||||||
|
const headers = lines[0].split(',');
|
||||||
|
|
||||||
|
const termine = lines.slice(1).map(line => {
|
||||||
|
const values = line.split(',');
|
||||||
|
const termin = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
termin[header] = values[index] || '';
|
||||||
|
});
|
||||||
|
return termin;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sortiere nach Datum
|
||||||
|
termine.sort((a, b) => new Date(a.datum) - new Date(b.datum));
|
||||||
|
|
||||||
|
// Filtere nur zukünftige Termine
|
||||||
|
const heute = new Date();
|
||||||
|
heute.setHours(0, 0, 0, 0);
|
||||||
|
const zukuenftigeTermine = termine.filter(t => new Date(t.datum) >= heute);
|
||||||
|
|
||||||
|
res.status(200).json(zukuenftigeTermine);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading termine.csv:', error);
|
||||||
|
res.status(500).json({ error: 'Could not load termine' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TermineController();
|
||||||
|
|
||||||
46
backend/controllers/vocabController.js
Normal file
46
backend/controllers/vocabController.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import VocabService from '../services/vocabService.js';
|
||||||
|
|
||||||
|
function extractHashedUserId(req) {
|
||||||
|
return req.headers?.userid;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VocabController {
|
||||||
|
constructor() {
|
||||||
|
this.service = new VocabService();
|
||||||
|
|
||||||
|
this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId));
|
||||||
|
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.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(userId, req.params.languageId));
|
||||||
|
|
||||||
|
this.listChapters = this._wrapWithUser((userId, req) => this.service.listChapters(userId, req.params.languageId));
|
||||||
|
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
|
||||||
|
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
|
||||||
|
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
|
||||||
|
|
||||||
|
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.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
||||||
|
return async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hashedUserId = extractHashedUserId(req);
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return res.status(400).json({ error: 'Missing user identifier' });
|
||||||
|
}
|
||||||
|
const result = await fn(hashedUserId, req, res);
|
||||||
|
res.status(successStatus).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Controller error:', error);
|
||||||
|
const status = error.status && typeof error.status === 'number' ? error.status : 500;
|
||||||
|
res.status(status).json({ error: error.message || 'Internal error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VocabController;
|
||||||
|
|
||||||
|
|
||||||
121
backend/daemonServer.js
Normal file
121
backend/daemonServer.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
import https from 'https';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const PORT = Number.parseInt(process.env.DAEMON_PORT || '4551', 10);
|
||||||
|
const USE_TLS = process.env.DAEMON_TLS === '1';
|
||||||
|
const TLS_KEY_PATH = process.env.DAEMON_TLS_KEY_PATH;
|
||||||
|
const TLS_CERT_PATH = process.env.DAEMON_TLS_CERT_PATH;
|
||||||
|
const TLS_CA_PATH = process.env.DAEMON_TLS_CA_PATH; // optional
|
||||||
|
|
||||||
|
// Einfache In-Memory-Struktur für Verbindungen (für spätere Erweiterungen)
|
||||||
|
const connections = new Set();
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
let wss;
|
||||||
|
|
||||||
|
if (USE_TLS) {
|
||||||
|
if (!TLS_KEY_PATH || !TLS_CERT_PATH) {
|
||||||
|
console.error('[Daemon] DAEMON_TLS=1 gesetzt, aber DAEMON_TLS_KEY_PATH/DAEMON_TLS_CERT_PATH fehlen.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const 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,
|
||||||
|
});
|
||||||
|
wss = new WebSocketServer({ server: httpsServer });
|
||||||
|
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
wss = new WebSocketServer({ port: PORT });
|
||||||
|
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
const peer = req.socket.remoteAddress + ':' + req.socket.remotePort;
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.userId = null;
|
||||||
|
connections.add(ws);
|
||||||
|
|
||||||
|
console.log(`[Daemon] Neue Verbindung von ${peer}`);
|
||||||
|
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
try {
|
||||||
|
if (message.toString() === 'pong') {
|
||||||
|
// Client-Pong für unser Ping
|
||||||
|
ws.isAlive = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
|
||||||
|
// Vom Frontend gesendet nach Verbindungsaufbau
|
||||||
|
if (data.event === 'setUserId' && data.data?.userId) {
|
||||||
|
ws.userId = data.data.userId;
|
||||||
|
console.log(`[Daemon] setUserId erhalten: ${ws.userId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-Dialog: WebSocket-Log anfordern
|
||||||
|
if (data.event === 'getWebsocketLog') {
|
||||||
|
const response = {
|
||||||
|
event: 'getWebsocketLogResponse',
|
||||||
|
entries: [] // aktuell keine Log-Historie implementiert
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(response));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platzhalter für spätere Events
|
||||||
|
// console.log('[Daemon] Unbekanntes Event:', data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Daemon] Fehler beim Verarbeiten einer Nachricht:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
connections.delete(ws);
|
||||||
|
console.log('[Daemon] Verbindung geschlossen');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error('[Daemon] WebSocket-Fehler (Verbindung):', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Einfache Ping/Pong-Mechanik, damit Verbindungen sauber erkannt werden
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
for (const ws of connections) {
|
||||||
|
if (ws.isAlive === false) {
|
||||||
|
console.log('[Daemon] Verbindung wegen fehlendem Pong beendet');
|
||||||
|
ws.terminate();
|
||||||
|
connections.delete(ws);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ws.isAlive = false;
|
||||||
|
try {
|
||||||
|
ws.send('ping');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Daemon] Fehler beim Senden von Ping:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
wss.on('close', () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
connections.clear();
|
||||||
|
console.log('[Daemon] Server gestoppt');
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('error', (err) => {
|
||||||
|
console.error('[Daemon] Server-Fehler:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
createServer();
|
||||||
|
|
||||||
|
|
||||||
7
backend/data/termine.csv
Normal file
7
backend/data/termine.csv
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
datum,titel,beschreibung,ort,uhrzeit
|
||||||
|
2025-10-07,Vereinsmeisterschaften 2025 Doppel,Die Vereinsmeisterschaften 2025 im Doppel finden im Rahmen des Erwachsenentrainings statt.,,,
|
||||||
|
2026-01-17,Vereinsmeisterschaften 2025 Einzel,Die Vereinsmeisterschaften 2025 im Einzel finden in der Schulturnhalle statt. Bitte vormerken!,,10:00
|
||||||
|
2025-12-18,Weihnachtsfeier 2025,Die Weihnachtsfeier 2025 findet im Gasthaus „Zum Einhorn" in FFM-Bonames statt. Beginn 19:00 Uhr (bitte vormerken),Gasthaus „Zum Einhorn" FFM-Bonames,19:00
|
||||||
|
2025-09-14,VR-Cup,Zwei VR-Cups am 14.09.2025 (jeweils 12 und 16 Uhr),,12:00 und 16:00
|
||||||
|
2025-10-19,VR-Cup,Zwei VR-Cups am 19.10.2025 (jeweils 12 und 16 Uhr),,12:00 und 16:00
|
||||||
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 4 and column 91.
|
34
backend/fix-pgcrypto-extension.js
Normal file
34
backend/fix-pgcrypto-extension.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { sequelize } from './utils/sequelize.js';
|
||||||
|
|
||||||
|
async function fixPgCryptoExtension() {
|
||||||
|
try {
|
||||||
|
console.log('🔧 Aktiviere pgcrypto Erweiterung...');
|
||||||
|
|
||||||
|
await sequelize.query('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
|
||||||
|
|
||||||
|
console.log('✅ pgcrypto Erweiterung erfolgreich aktiviert');
|
||||||
|
|
||||||
|
// Prüfe ob die Erweiterung aktiviert ist
|
||||||
|
const result = await sequelize.query(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto'
|
||||||
|
) as extension_exists;
|
||||||
|
`, { type: sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
|
if (result[0]?.extension_exists) {
|
||||||
|
console.log('✅ Bestätigung: pgcrypto Erweiterung ist aktiviert');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Warnung: pgcrypto Erweiterung konnte nicht aktiviert werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Aktivieren der pgcrypto Erweiterung:', error.message);
|
||||||
|
console.error('Stack:', error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixPgCryptoExtension();
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
{
|
||||||
|
tableName: 'falukant_user',
|
||||||
|
schema: 'falukant_data'
|
||||||
|
},
|
||||||
|
'last_nobility_advance_at',
|
||||||
|
{
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn(
|
||||||
|
{
|
||||||
|
tableName: 'falukant_user',
|
||||||
|
schema: 'falukant_data'
|
||||||
|
},
|
||||||
|
'last_nobility_advance_at'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// 1) Add character_name column to notification table
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_log.notification
|
||||||
|
ADD COLUMN IF NOT EXISTS character_name text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 1b) Add character_id column so triggers and application can set a reference
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_log.notification
|
||||||
|
ADD COLUMN IF NOT EXISTS character_id integer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create an index on character_id to speed lookups (if not exists)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'i' AND c.relname = 'idx_notification_character_id' AND n.nspname = 'falukant_log'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX idx_notification_character_id ON falukant_log.notification (character_id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2) Create helper function to populate character_name from character_id or user_id
|
||||||
|
// - Resolve name via character_id if present
|
||||||
|
// - Fallback to a character for the same user_id when character_id is NULL
|
||||||
|
// - Only set NEW.character_name when the column exists and is NULL
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name()
|
||||||
|
RETURNS TRIGGER AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_first_name TEXT;
|
||||||
|
v_last_name TEXT;
|
||||||
|
v_char_id INTEGER;
|
||||||
|
v_column_exists BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- check if target column exists in the notification table
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_log' AND table_name = 'notification' AND column_name = 'character_name'
|
||||||
|
) INTO v_column_exists;
|
||||||
|
|
||||||
|
IF NOT v_column_exists THEN
|
||||||
|
-- Nothing to do when target column absent
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- only populate when column is NULL
|
||||||
|
IF NEW.character_name IS NOT NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- prefer explicit character_id
|
||||||
|
v_char_id := NEW.character_id;
|
||||||
|
|
||||||
|
-- when character_id is null, try to find a character for the user_id
|
||||||
|
IF v_char_id IS NULL AND NEW.user_id IS NOT NULL THEN
|
||||||
|
-- choose a representative character: the one with highest id for this user (change if different policy required)
|
||||||
|
SELECT id INTO v_char_id
|
||||||
|
FROM falukant_data.character
|
||||||
|
WHERE user_id = NEW.user_id
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_char_id IS NOT NULL THEN
|
||||||
|
SELECT pf.name, pl.name
|
||||||
|
INTO v_first_name, v_last_name
|
||||||
|
FROM falukant_data.character c
|
||||||
|
LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name
|
||||||
|
LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name
|
||||||
|
WHERE c.id = v_char_id;
|
||||||
|
|
||||||
|
IF v_first_name IS NOT NULL OR v_last_name IS NOT NULL THEN
|
||||||
|
NEW.character_name := COALESCE(v_first_name, '') || CASE WHEN v_first_name IS NOT NULL AND v_last_name IS NOT NULL THEN ' ' ELSE '' END || COALESCE(v_last_name, '');
|
||||||
|
ELSE
|
||||||
|
NEW.character_name := ('#' || v_char_id::text);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
-- last resort fallback: use user_id as identifier if present
|
||||||
|
IF NEW.user_id IS NOT NULL THEN
|
||||||
|
NEW.character_name := ('#u' || NEW.user_id::text);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$ LANGUAGE plpgsql;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3) Create trigger that runs before insert to populate the column
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification;
|
||||||
|
CREATE TRIGGER trg_populate_notification_character_name
|
||||||
|
BEFORE INSERT ON falukant_log.notification
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION falukant_log.populate_notification_character_name();
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP FUNCTION IF EXISTS falukant_log.populate_notification_character_name();
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
-- drop index if exists
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'i' AND c.relname = 'idx_notification_character_id' AND n.nspname = 'falukant_log'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'DROP INDEX falukant_log.idx_notification_character_id';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_log.notification
|
||||||
|
DROP COLUMN IF EXISTS character_name;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_log.notification
|
||||||
|
DROP COLUMN IF EXISTS character_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Add nullable weather_type_id column
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.production
|
||||||
|
ADD COLUMN IF NOT EXISTS weather_type_id integer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add foreign key constraint if not exists
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name AND kcu.constraint_schema = tc.constraint_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.constraint_schema = 'falukant_data'
|
||||||
|
AND tc.table_name = 'production'
|
||||||
|
AND kcu.column_name = 'weather_type_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.production
|
||||||
|
ADD CONSTRAINT fk_production_weather_type
|
||||||
|
FOREIGN KEY (weather_type_id) REFERENCES falukant_type.weather(id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// create index to speed lookups
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'i' AND c.relname = 'idx_production_weather_type_id' AND n.nspname = 'falukant_data'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX idx_production_weather_type_id ON falukant_data.production (weather_type_id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.production
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_production_weather_type;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'i' AND c.relname = 'idx_production_weather_type_id' AND n.nspname = 'falukant_data'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'DROP INDEX falukant_data.idx_production_weather_type_id';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.production
|
||||||
|
DROP COLUMN IF EXISTS weather_type_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.stock
|
||||||
|
ADD COLUMN IF NOT EXISTS product_quality integer;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.stock
|
||||||
|
DROP COLUMN IF EXISTS product_quality;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// falukant_data.character.reputation (integer, default random 20..80)
|
||||||
|
// Wichtig: Schema explizit angeben
|
||||||
|
// Vorgehen:
|
||||||
|
// - Spalte anlegen (falls noch nicht vorhanden)
|
||||||
|
// - bestehende Zeilen initialisieren (random 20..80)
|
||||||
|
// - DEFAULT setzen (random 20..80)
|
||||||
|
// - NOT NULL + CHECK 0..100 erzwingen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_data'
|
||||||
|
AND table_name = 'character'
|
||||||
|
AND column_name = 'reputation'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data."character"
|
||||||
|
ADD COLUMN reputation integer;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill: nur NULLs initialisieren (damit bestehende Werte nicht überschrieben werden)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_data."character"
|
||||||
|
SET reputation = (floor(random()*61)+20)::int
|
||||||
|
WHERE reputation IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// DEFAULT + NOT NULL (nach Backfill)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data."character"
|
||||||
|
ALTER COLUMN reputation SET DEFAULT (floor(random()*61)+20)::int;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data."character"
|
||||||
|
ALTER COLUMN reputation SET NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Enforce 0..100 at DB level (percent)
|
||||||
|
// (IF NOT EXISTS pattern, because deployments can be re-run)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE c.conname = 'character_reputation_0_100_chk'
|
||||||
|
AND n.nspname = 'falukant_data'
|
||||||
|
AND t.relname = 'character'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data."character"
|
||||||
|
ADD CONSTRAINT character_reputation_0_100_chk
|
||||||
|
CHECK (reputation >= 0 AND reputation <= 100);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data."character"
|
||||||
|
DROP CONSTRAINT IF EXISTS character_reputation_0_100_chk;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data."character"
|
||||||
|
DROP COLUMN IF EXISTS reputation;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
47
backend/migrations/20251220001000-add-reputation-actions.cjs
Normal file
47
backend/migrations/20251220001000-add-reputation-actions.cjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Typ-Tabelle (konfigurierbar ohne Code): falukant_type.reputation_action
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS falukant_type.reputation_action (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
tr text NOT NULL UNIQUE,
|
||||||
|
cost integer NOT NULL CHECK (cost >= 0),
|
||||||
|
base_gain integer NOT NULL CHECK (base_gain >= 0),
|
||||||
|
decay_factor double precision NOT NULL CHECK (decay_factor > 0 AND decay_factor <= 1),
|
||||||
|
min_gain integer NOT NULL DEFAULT 0 CHECK (min_gain >= 0),
|
||||||
|
decay_window_days integer NOT NULL DEFAULT 7 CHECK (decay_window_days >= 1 AND decay_window_days <= 365)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Log-Tabelle: falukant_log.reputation_action
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS falukant_log.reputation_action (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
falukant_user_id integer NOT NULL,
|
||||||
|
action_type_id integer NOT NULL,
|
||||||
|
cost integer NOT NULL CHECK (cost >= 0),
|
||||||
|
base_gain integer NOT NULL CHECK (base_gain >= 0),
|
||||||
|
gain integer NOT NULL CHECK (gain >= 0),
|
||||||
|
times_used_before integer NOT NULL CHECK (times_used_before >= 0),
|
||||||
|
action_timestamp timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS reputation_action_log_user_type_idx
|
||||||
|
ON falukant_log.reputation_action (falukant_user_id, action_type_id);
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS reputation_action_log_ts_idx
|
||||||
|
ON falukant_log.reputation_action (action_timestamp);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_log.reputation_action;`);
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_type.reputation_action;`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Für bereits existierende Installationen: Spalte sicherstellen + Backfill
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
ADD COLUMN IF NOT EXISTS decay_window_days integer;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_type.reputation_action
|
||||||
|
SET decay_window_days = 7
|
||||||
|
WHERE decay_window_days IS NULL;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
ALTER COLUMN decay_window_days SET DEFAULT 7;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
ALTER COLUMN decay_window_days SET NOT NULL;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
ADD CONSTRAINT reputation_action_decay_window_days_chk
|
||||||
|
CHECK (decay_window_days >= 1 AND decay_window_days <= 365);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// optional: wieder entfernen
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_type.reputation_action
|
||||||
|
DROP COLUMN IF EXISTS decay_window_days;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Idempotentes Seed: legt Ruf-Aktionen an bzw. aktualisiert sie anhand "tr"
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
INSERT INTO falukant_type.reputation_action
|
||||||
|
(tr, cost, base_gain, decay_factor, min_gain, decay_window_days)
|
||||||
|
VALUES
|
||||||
|
('soup_kitchen', 500, 2, 0.85, 0, 7),
|
||||||
|
('library_donation', 5000, 4, 0.88, 0, 7),
|
||||||
|
('well_build', 8000, 4, 0.87, 0, 7),
|
||||||
|
('scholarships', 10000, 5, 0.87, 0, 7),
|
||||||
|
('church_hospice', 12000, 5, 0.87, 0, 7),
|
||||||
|
('school_funding', 15000, 6, 0.88, 0, 7),
|
||||||
|
('orphanage_build', 20000, 7, 0.90, 0, 7),
|
||||||
|
('bridge_build', 25000, 7, 0.90, 0, 7),
|
||||||
|
('hospital_donation', 30000, 8, 0.90, 0, 7),
|
||||||
|
('patronage', 40000, 9, 0.91, 0, 7),
|
||||||
|
('statue_build', 50000, 10, 0.92, 0, 7)
|
||||||
|
ON CONFLICT (tr) DO UPDATE SET
|
||||||
|
cost = EXCLUDED.cost,
|
||||||
|
base_gain = EXCLUDED.base_gain,
|
||||||
|
decay_factor = EXCLUDED.decay_factor,
|
||||||
|
min_gain = EXCLUDED.min_gain,
|
||||||
|
decay_window_days = EXCLUDED.decay_window_days;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// Entfernt nur die gesetzten Seeds (tr-basiert)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DELETE FROM falukant_type.reputation_action
|
||||||
|
WHERE tr IN (
|
||||||
|
'soup_kitchen',
|
||||||
|
'library_donation',
|
||||||
|
'well_build',
|
||||||
|
'scholarships',
|
||||||
|
'church_hospice',
|
||||||
|
'school_funding',
|
||||||
|
'orphanage_build',
|
||||||
|
'bridge_build',
|
||||||
|
'hospital_donation',
|
||||||
|
'patronage',
|
||||||
|
'statue_build'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Ensure column exists
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ADD COLUMN IF NOT EXISTS condition integer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill nulls (legacy data)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_data.vehicle
|
||||||
|
SET condition = 100
|
||||||
|
WHERE condition IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Clamp out-of-range values defensively
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_data.vehicle
|
||||||
|
SET condition = GREATEST(0, LEAST(100, condition))
|
||||||
|
WHERE condition < 0 OR condition > 100;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Default + NOT NULL
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ALTER COLUMN condition SET DEFAULT 100;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ALTER COLUMN condition SET NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Check constraint 0..100
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
DROP CONSTRAINT IF EXISTS vehicle_condition_0_100_chk;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ADD CONSTRAINT vehicle_condition_0_100_chk
|
||||||
|
CHECK (condition >= 0 AND condition <= 100);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// Keep the column, but remove constraint/default to be reversible
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
DROP CONSTRAINT IF EXISTS vehicle_condition_0_100_chk;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ALTER COLUMN condition DROP DEFAULT;
|
||||||
|
`);
|
||||||
|
// NOT NULL not reverted to avoid introducing NULLs on rollback; can be adjusted if needed
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.director
|
||||||
|
ADD COLUMN IF NOT EXISTS may_repair_vehicles boolean;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_data.director
|
||||||
|
SET may_repair_vehicles = true
|
||||||
|
WHERE may_repair_vehicles IS NULL;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.director
|
||||||
|
ALTER COLUMN may_repair_vehicles SET DEFAULT true;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.director
|
||||||
|
ALTER COLUMN may_repair_vehicles SET NOT NULL;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// optional rollback: drop column
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.director
|
||||||
|
DROP COLUMN IF EXISTS may_repair_vehicles;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
// Sprache / Set, das geteilt werden kann
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_language (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
share_code TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_language_owner_fk
|
||||||
|
FOREIGN KEY (owner_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Abos (Freunde)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_language_subscription_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_language_subscription_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
|
||||||
|
ON community.vocab_language(owner_user_id);
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
|
||||||
|
ON community.vocab_language_subscription(user_id);
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
|
||||||
|
ON community.vocab_language_subscription(language_id);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language_subscription;`);
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language;`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
// Kapitel innerhalb einer Sprache
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_chapter_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chapter_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
|
||||||
|
ON community.vocab_chapter(language_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Lexeme/Wörter (wir deduplizieren pro Sprache über normalized)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
normalized TEXT NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_lexeme_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_lexeme_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
|
||||||
|
ON community.vocab_lexeme(language_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// n:m Zuordnung pro Kapitel: Lernwort ↔ Referenzwort (Mehrdeutigkeiten möglich)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
chapter_id INTEGER NOT NULL,
|
||||||
|
learning_lexeme_id INTEGER NOT NULL,
|
||||||
|
reference_lexeme_id INTEGER NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_chlex_chapter_fk
|
||||||
|
FOREIGN KEY (chapter_id)
|
||||||
|
REFERENCES community.vocab_chapter(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_learning_fk
|
||||||
|
FOREIGN KEY (learning_lexeme_id)
|
||||||
|
REFERENCES community.vocab_lexeme(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_reference_fk
|
||||||
|
FOREIGN KEY (reference_lexeme_id)
|
||||||
|
REFERENCES community.vocab_lexeme(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
|
||||||
|
ON community.vocab_chapter_lexeme(chapter_id);
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
|
||||||
|
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
||||||
|
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter_lexeme;`);
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_lexeme;`);
|
||||||
|
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter;`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.region
|
||||||
|
ADD COLUMN IF NOT EXISTS tax_percent numeric NOT NULL DEFAULT 7;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.region
|
||||||
|
DROP COLUMN IF EXISTS tax_percent;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// 1) add backup column for original sell_cost (idempotent)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
ADD COLUMN IF NOT EXISTS original_sell_cost numeric;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2) if original_sell_cost is not set, copy current sell_cost into it
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_type.product
|
||||||
|
SET original_sell_cost = sell_cost
|
||||||
|
WHERE original_sell_cost IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3) compute max cumulative tax across regions and increase sell_cost accordingly
|
||||||
|
// We use the maximum cumulative tax (worst-case) so sellers are neutral across regions.
|
||||||
|
// Formula: neutral_sell = CEIL(original_sell_cost * (1 / (1 - max_total/100)))
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
), totals AS (
|
||||||
|
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
), mm AS (
|
||||||
|
SELECT COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
)
|
||||||
|
UPDATE falukant_type.product
|
||||||
|
SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - mm.max_total/100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total/100)) END))
|
||||||
|
FROM mm
|
||||||
|
WHERE original_sell_cost IS NOT NULL;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
DROP COLUMN IF EXISTS sell_cost_min_neutral;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
DROP COLUMN IF EXISTS sell_cost_max_neutral;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Create index on (user_id, shown) to optimize markNotificationsShown queries
|
||||||
|
// This prevents deadlocks by allowing fast lookups and reducing lock contention
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind = 'i'
|
||||||
|
AND c.relname = 'idx_notification_user_id_shown'
|
||||||
|
AND n.nspname = 'falukant_log'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX idx_notification_user_id_shown
|
||||||
|
ON falukant_log.notification (user_id, shown);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP INDEX IF EXISTS falukant_log.idx_notification_user_id_shown;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
20
backend/migrations/add_condition_to_vehicle.sql
Normal file
20
backend/migrations/add_condition_to_vehicle.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: Add condition and available_from columns to vehicle table
|
||||||
|
-- Date: 2024-12-02
|
||||||
|
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ADD COLUMN IF NOT EXISTS condition INTEGER NOT NULL DEFAULT 100;
|
||||||
|
|
||||||
|
ALTER TABLE falukant_data.vehicle
|
||||||
|
ADD COLUMN IF NOT EXISTS available_from TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN falukant_data.vehicle.condition IS 'Current condition of the vehicle (0-100)';
|
||||||
|
COMMENT ON COLUMN falukant_data.vehicle.available_from IS 'Timestamp when the vehicle becomes available for use';
|
||||||
|
|
||||||
|
-- Migration: Add build_time_minutes column to vehicle type table
|
||||||
|
-- Date: 2024-12-03
|
||||||
|
|
||||||
|
ALTER TABLE falukant_type.vehicle
|
||||||
|
ADD COLUMN IF NOT EXISTS build_time_minutes INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN falukant_type.vehicle.build_time_minutes IS 'Time to construct the vehicle, in minutes';
|
||||||
|
|
||||||
9
backend/migrations/add_is_heir_to_child_relation.sql
Normal file
9
backend/migrations/add_is_heir_to_child_relation.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Add is_heir column to child_relation table
|
||||||
|
-- Date: 2025-12-08
|
||||||
|
-- Description: Adds a boolean field to mark a child as the heir
|
||||||
|
|
||||||
|
ALTER TABLE falukant_data.child_relation
|
||||||
|
ADD COLUMN IF NOT EXISTS is_heir BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN falukant_data.child_relation.is_heir IS 'Marks whether this child is set as the heir';
|
||||||
|
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
// Aktiviere die pgcrypto Erweiterung, die die digest() Funktion bereitstellt
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
`);
|
||||||
|
|
||||||
await queryInterface.sequelize.query(`
|
await queryInterface.sequelize.query(`
|
||||||
CREATE OR REPLACE FUNCTION community.update_hashed_id() RETURNS TRIGGER AS $$
|
CREATE OR REPLACE FUNCTION community.update_hashed_id() RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|||||||
7
backend/migrations/make_transport_product_nullable.sql
Normal file
7
backend/migrations/make_transport_product_nullable.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Migration: Make productId and size nullable in transport table
|
||||||
|
-- This allows empty transports (moving vehicles without products)
|
||||||
|
|
||||||
|
ALTER TABLE falukant_data.transport
|
||||||
|
ALTER COLUMN product_id DROP NOT NULL,
|
||||||
|
ALTER COLUMN size DROP NOT NULL;
|
||||||
|
|
||||||
@@ -95,6 +95,13 @@ 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 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 Vehicle from './falukant/data/vehicle.js';
|
||||||
|
import Transport from './falukant/data/transport.js';
|
||||||
|
import RegionDistance from './falukant/data/region_distance.js';
|
||||||
|
import WeatherType from './falukant/type/weather.js';
|
||||||
|
import Weather from './falukant/data/weather.js';
|
||||||
|
import ProductWeatherEffect from './falukant/type/product_weather_effect.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 Campaign from './match3/campaign.js';
|
import Campaign from './match3/campaign.js';
|
||||||
@@ -284,6 +291,21 @@ export default function setupAssociations() {
|
|||||||
RegionData.belongsTo(RegionType, { foreignKey: 'regionTypeId', as: 'regionType' });
|
RegionData.belongsTo(RegionType, { foreignKey: 'regionTypeId', as: 'regionType' });
|
||||||
RegionType.hasMany(RegionData, { foreignKey: 'regionTypeId', as: 'regions' });
|
RegionType.hasMany(RegionData, { foreignKey: 'regionTypeId', as: 'regions' });
|
||||||
|
|
||||||
|
Weather.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' });
|
||||||
|
RegionData.hasOne(Weather, { foreignKey: 'regionId', as: 'weather' });
|
||||||
|
|
||||||
|
Weather.belongsTo(WeatherType, { foreignKey: 'weatherTypeId', as: 'weatherType' });
|
||||||
|
WeatherType.hasMany(Weather, { foreignKey: 'weatherTypeId', as: 'weathers' });
|
||||||
|
|
||||||
|
ProductWeatherEffect.belongsTo(ProductType, { foreignKey: 'productId', as: 'product' });
|
||||||
|
ProductType.hasMany(ProductWeatherEffect, { foreignKey: 'productId', as: 'weatherEffects' });
|
||||||
|
|
||||||
|
ProductWeatherEffect.belongsTo(WeatherType, { foreignKey: 'weatherTypeId', as: 'weatherType' });
|
||||||
|
WeatherType.hasMany(ProductWeatherEffect, { foreignKey: 'weatherTypeId', as: 'productEffects' });
|
||||||
|
|
||||||
|
Production.belongsTo(WeatherType, { foreignKey: 'weatherTypeId', as: 'weatherType' });
|
||||||
|
WeatherType.hasMany(Production, { foreignKey: 'weatherTypeId', as: 'productions' });
|
||||||
|
|
||||||
FalukantUser.belongsTo(RegionData, { foreignKey: 'mainBranchRegionId', as: 'mainBranchRegion' });
|
FalukantUser.belongsTo(RegionData, { foreignKey: 'mainBranchRegionId', as: 'mainBranchRegion' });
|
||||||
RegionData.hasMany(FalukantUser, { foreignKey: 'mainBranchRegionId', as: 'users' });
|
RegionData.hasMany(FalukantUser, { foreignKey: 'mainBranchRegionId', as: 'users' });
|
||||||
|
|
||||||
@@ -421,6 +443,89 @@ export default function setupAssociations() {
|
|||||||
PromotionalGiftLog.belongsTo(FalukantCharacter, { foreignKey: 'recipientCharacterId', as: 'recipient' });
|
PromotionalGiftLog.belongsTo(FalukantCharacter, { foreignKey: 'recipientCharacterId', as: 'recipient' });
|
||||||
FalukantCharacter.hasMany(PromotionalGiftLog, { foreignKey: 'recipientCharacterId', as: 'giftlogs' });
|
FalukantCharacter.hasMany(PromotionalGiftLog, { foreignKey: 'recipientCharacterId', as: 'giftlogs' });
|
||||||
|
|
||||||
|
// Vehicles & Transports
|
||||||
|
|
||||||
|
VehicleType.hasMany(Vehicle, {
|
||||||
|
foreignKey: 'vehicleTypeId',
|
||||||
|
as: 'vehicles',
|
||||||
|
});
|
||||||
|
Vehicle.belongsTo(VehicleType, {
|
||||||
|
foreignKey: 'vehicleTypeId',
|
||||||
|
as: 'type',
|
||||||
|
});
|
||||||
|
|
||||||
|
FalukantUser.hasMany(Vehicle, {
|
||||||
|
foreignKey: 'falukantUserId',
|
||||||
|
as: 'vehicles',
|
||||||
|
});
|
||||||
|
Vehicle.belongsTo(FalukantUser, {
|
||||||
|
foreignKey: 'falukantUserId',
|
||||||
|
as: 'owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
RegionData.hasMany(Vehicle, {
|
||||||
|
foreignKey: 'regionId',
|
||||||
|
as: 'vehicles',
|
||||||
|
});
|
||||||
|
Vehicle.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'regionId',
|
||||||
|
as: 'region',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Region distances
|
||||||
|
RegionData.hasMany(RegionDistance, {
|
||||||
|
foreignKey: 'sourceRegionId',
|
||||||
|
as: 'distancesFrom',
|
||||||
|
});
|
||||||
|
RegionData.hasMany(RegionDistance, {
|
||||||
|
foreignKey: 'targetRegionId',
|
||||||
|
as: 'distancesTo',
|
||||||
|
});
|
||||||
|
RegionDistance.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'sourceRegionId',
|
||||||
|
as: 'sourceRegion',
|
||||||
|
});
|
||||||
|
RegionDistance.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'targetRegionId',
|
||||||
|
as: 'targetRegion',
|
||||||
|
});
|
||||||
|
|
||||||
|
Transport.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'sourceRegionId',
|
||||||
|
as: 'sourceRegion',
|
||||||
|
});
|
||||||
|
Transport.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'targetRegionId',
|
||||||
|
as: 'targetRegion',
|
||||||
|
});
|
||||||
|
|
||||||
|
RegionData.hasMany(Transport, {
|
||||||
|
foreignKey: 'sourceRegionId',
|
||||||
|
as: 'outgoingTransports',
|
||||||
|
});
|
||||||
|
RegionData.hasMany(Transport, {
|
||||||
|
foreignKey: 'targetRegionId',
|
||||||
|
as: 'incomingTransports',
|
||||||
|
});
|
||||||
|
|
||||||
|
Transport.belongsTo(ProductType, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'productType',
|
||||||
|
});
|
||||||
|
ProductType.hasMany(Transport, {
|
||||||
|
foreignKey: 'productId',
|
||||||
|
as: 'transports',
|
||||||
|
});
|
||||||
|
|
||||||
|
Transport.belongsTo(Vehicle, {
|
||||||
|
foreignKey: 'vehicleId',
|
||||||
|
as: 'vehicle',
|
||||||
|
});
|
||||||
|
Vehicle.hasMany(Transport, {
|
||||||
|
foreignKey: 'vehicleId',
|
||||||
|
as: 'transports',
|
||||||
|
});
|
||||||
|
|
||||||
PromotionalGift.hasMany(PromotionalGiftCharacterTrait, { foreignKey: 'gift_id', as: 'characterTraits' });
|
PromotionalGift.hasMany(PromotionalGiftCharacterTrait, { foreignKey: 'gift_id', as: 'characterTraits' });
|
||||||
PromotionalGift.hasMany(PromotionalGiftMood, { foreignKey: 'gift_id', as: 'promotionalgiftmoods' });
|
PromotionalGift.hasMany(PromotionalGiftMood, { foreignKey: 'gift_id', as: 'promotionalgiftmoods' });
|
||||||
|
|
||||||
@@ -493,44 +598,52 @@ export default function setupAssociations() {
|
|||||||
|
|
||||||
Learning.belongsTo(LearnRecipient, {
|
Learning.belongsTo(LearnRecipient, {
|
||||||
foreignKey: 'learningRecipientId',
|
foreignKey: 'learningRecipientId',
|
||||||
as: 'recipient'
|
as: 'recipient',
|
||||||
|
constraints: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
LearnRecipient.hasMany(Learning, {
|
LearnRecipient.hasMany(Learning, {
|
||||||
foreignKey: 'learningRecipientId',
|
foreignKey: 'learningRecipientId',
|
||||||
as: 'learnings'
|
as: 'learnings',
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
|
|
||||||
Learning.belongsTo(FalukantUser, {
|
Learning.belongsTo(FalukantUser, {
|
||||||
foreignKey: 'associatedFalukantUserId',
|
foreignKey: 'associatedFalukantUserId',
|
||||||
as: 'learner'
|
as: 'learner',
|
||||||
|
constraints: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
FalukantUser.hasMany(Learning, {
|
FalukantUser.hasMany(Learning, {
|
||||||
foreignKey: 'associatedFalukantUserId',
|
foreignKey: 'associatedFalukantUserId',
|
||||||
as: 'learnings'
|
as: 'learnings',
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
|
|
||||||
Learning.belongsTo(ProductType, {
|
Learning.belongsTo(ProductType, {
|
||||||
foreignKey: 'productId',
|
foreignKey: 'productId',
|
||||||
as: 'productType'
|
as: 'productType',
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
|
|
||||||
ProductType.hasMany(Learning, {
|
ProductType.hasMany(Learning, {
|
||||||
foreignKey: 'productId',
|
foreignKey: 'productId',
|
||||||
as: 'learnings'
|
as: 'learnings',
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
|
|
||||||
Learning.belongsTo(FalukantCharacter, {
|
Learning.belongsTo(FalukantCharacter, {
|
||||||
foreignKey: 'associatedLearningCharacterId',
|
foreignKey: 'associatedLearningCharacterId',
|
||||||
as: 'learningCharacter'
|
as: 'learningCharacter',
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
|
|
||||||
FalukantCharacter.hasMany(Learning, {
|
FalukantCharacter.hasMany(Learning, {
|
||||||
foreignKey: 'associatedLearningCharacterId',
|
foreignKey: 'associatedLearningCharacterId',
|
||||||
as: 'learningsCharacter'
|
as: 'learningsCharacter',
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
|
|
||||||
FalukantUser.hasMany(Credit, {
|
FalukantUser.hasMany(Credit, {
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ const Folder = sequelize.define('folder', {
|
|||||||
allowNull: false},
|
allowNull: false},
|
||||||
parentId: {
|
parentId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true,
|
allowNull: true
|
||||||
references: {
|
},
|
||||||
model: 'folder',
|
|
||||||
key: 'id'}},
|
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
}}, {
|
||||||
model: 'user',
|
|
||||||
key: 'id'}}}, {
|
|
||||||
tableName: 'folder',
|
tableName: 'folder',
|
||||||
schema: 'community',
|
schema: 'community',
|
||||||
underscored: true,
|
underscored: true,
|
||||||
|
|||||||
@@ -10,22 +10,11 @@ const FolderImageVisibility = sequelize.define('folder_image_visibility', {
|
|||||||
},
|
},
|
||||||
folderId: {
|
folderId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'folder',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
visibilityTypeId: {
|
visibilityTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: {
|
|
||||||
schema: 'type',
|
|
||||||
tableName: 'image_visibility_type'
|
|
||||||
},
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'folder_image_visibility',
|
tableName: 'folder_image_visibility',
|
||||||
|
|||||||
@@ -10,19 +10,11 @@ const FolderVisibilityUser = sequelize.define('folder_visibility_user', {
|
|||||||
},
|
},
|
||||||
folderId: {
|
folderId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'folder',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
visibilityUserId: {
|
visibilityUserId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'image_visibility_user',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'folder_visibility_user',
|
tableName: 'folder_visibility_user',
|
||||||
|
|||||||
@@ -10,19 +10,11 @@ const GuestbookEntry = sequelize.define('guestbook_entry', {
|
|||||||
allowNull: false},
|
allowNull: false},
|
||||||
recipientId: {
|
recipientId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: User,
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
senderId: {
|
senderId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true,
|
allowNull: true
|
||||||
references: {
|
|
||||||
model: User,
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
senderUsername: {
|
senderUsername: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
|
|||||||
@@ -18,16 +18,12 @@ const Image = sequelize.define('image', {
|
|||||||
unique: true},
|
unique: true},
|
||||||
folderId: {
|
folderId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
},
|
||||||
model: 'folder',
|
|
||||||
key: 'id'}},
|
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
}}, {
|
||||||
model: 'user',
|
|
||||||
key: 'id'}}}, {
|
|
||||||
tableName: 'image',
|
tableName: 'image',
|
||||||
schema: 'community',
|
schema: 'community',
|
||||||
underscored: true,
|
underscored: true,
|
||||||
|
|||||||
@@ -10,22 +10,11 @@ const ImageImageVisibility = sequelize.define('image_image_visibility', {
|
|||||||
},
|
},
|
||||||
imageId: {
|
imageId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'image',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
visibilityTypeId: {
|
visibilityTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: {
|
|
||||||
schema: 'type',
|
|
||||||
tableName: 'image_visibility_type'
|
|
||||||
},
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'image_image_visibility',
|
tableName: 'image_image_visibility',
|
||||||
|
|||||||
@@ -7,19 +7,11 @@ import { encrypt, decrypt } from '../../utils/encryption.js';
|
|||||||
const UserParam = sequelize.define('user_param', {
|
const UserParam = sequelize.define('user_param', {
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: User,
|
|
||||||
key: 'id',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
paramTypeId: {
|
paramTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: UserParamType,
|
|
||||||
key: 'id',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
|
|||||||
@@ -6,19 +6,11 @@ import UserRightType from '../type/user_right.js';
|
|||||||
const UserRight = sequelize.define('user_right', {
|
const UserRight = sequelize.define('user_right', {
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: User,
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
rightTypeId: {
|
rightTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: UserRightType,
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
}}, {
|
}}, {
|
||||||
tableName: 'user_right',
|
tableName: 'user_right',
|
||||||
schema: 'community',
|
schema: 'community',
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ FalukantCharacter.init(
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 1}
|
defaultValue: 1}
|
||||||
|
,
|
||||||
|
reputation: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
// Initialisierung: zufällig 20..80 (Prozent)
|
||||||
|
// DB-seitig per DEFAULT umgesetzt, damit es auch ohne App-Logic gilt.
|
||||||
|
defaultValue: sequelize.literal('(floor(random()*61)+20)'),
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ Director.init({
|
|||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: true},
|
defaultValue: true},
|
||||||
|
mayRepairVehicles: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true},
|
||||||
lastSalaryPayout: {
|
lastSalaryPayout: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Model, DataTypes } from 'sequelize';
|
import { Model, DataTypes } from 'sequelize';
|
||||||
import { sequelize } from '../../../utils/sequelize.js';
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
import WeatherType from '../type/weather.js';
|
||||||
|
|
||||||
class Production extends Model { }
|
class Production extends Model { }
|
||||||
|
|
||||||
@@ -13,6 +14,11 @@ Production.init({
|
|||||||
quantity: {
|
quantity: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false},
|
allowNull: false},
|
||||||
|
weatherTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Wetter zum Zeitpunkt der Produktionserstellung'
|
||||||
|
},
|
||||||
startTimestamp: {
|
startTimestamp: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -10,26 +10,24 @@ 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,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: {}
|
defaultValue: {}
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
taxPercent: {
|
||||||
|
type: DataTypes.DECIMAL,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 7,
|
||||||
|
field: 'tax_percent'
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'RegionData',
|
modelName: 'RegionData',
|
||||||
|
|||||||
41
backend/models/falukant/data/region_distance.js
Normal file
41
backend/models/falukant/data/region_distance.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
import RegionData from './region.js';
|
||||||
|
|
||||||
|
class RegionDistance extends Model {}
|
||||||
|
|
||||||
|
RegionDistance.init(
|
||||||
|
{
|
||||||
|
sourceRegionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
targetRegionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
transportMode: {
|
||||||
|
// e.g. 'land', 'water', 'air' – should match VehicleType.transportMode
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
distance: {
|
||||||
|
// distance between regions (e.g. in abstract units, used for travel time etc.)
|
||||||
|
type: DataTypes.DOUBLE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'RegionDistance',
|
||||||
|
tableName: 'region_distance',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RegionDistance;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -8,18 +8,10 @@ Relationship.init(
|
|||||||
{
|
{
|
||||||
character1Id: {
|
character1Id: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false},
|
||||||
references: {
|
|
||||||
model: FalukantCharacter,
|
|
||||||
key: 'id'},
|
|
||||||
onDelete: 'CASCADE'},
|
|
||||||
character2Id: {
|
character2Id: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false},
|
||||||
references: {
|
|
||||||
model: FalukantCharacter,
|
|
||||||
key: 'id'},
|
|
||||||
onDelete: 'CASCADE'},
|
|
||||||
relationshipTypeId: {
|
relationshipTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ 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,
|
||||||
allowNull: false},
|
allowNull: false},
|
||||||
quantity: {
|
quantity: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false}}, {
|
allowNull: false},
|
||||||
|
productQuality: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Quality of the stored product (0-100)'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'StockData',
|
modelName: 'StockData',
|
||||||
tableName: 'stock',
|
tableName: 'stock',
|
||||||
|
|||||||
41
backend/models/falukant/data/transport.js
Normal file
41
backend/models/falukant/data/transport.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class Transport extends Model {}
|
||||||
|
|
||||||
|
Transport.init(
|
||||||
|
{
|
||||||
|
sourceRegionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
targetRegionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true, // Nullable für leere Transporte (nur Fahrzeuge bewegen)
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true, // Nullable für leere Transporte (nur Fahrzeuge bewegen)
|
||||||
|
},
|
||||||
|
vehicleId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'Transport',
|
||||||
|
tableName: 'transport',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Transport;
|
||||||
|
|
||||||
|
|
||||||
@@ -8,13 +8,6 @@ FalukantUser.init({
|
|||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
references: {
|
|
||||||
model: {
|
|
||||||
tableName: 'user',
|
|
||||||
schema: 'community'
|
|
||||||
},
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
unique: true},
|
unique: true},
|
||||||
money: {
|
money: {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
@@ -38,12 +31,11 @@ FalukantUser.init({
|
|||||||
defaultValue: 1},
|
defaultValue: 1},
|
||||||
mainBranchRegionId: {
|
mainBranchRegionId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true,
|
allowNull: true
|
||||||
references: {
|
},
|
||||||
model: RegionData,
|
lastNobilityAdvanceAt: {
|
||||||
key: 'id',
|
type: DataTypes.DATE,
|
||||||
schema: 'falukant_data'
|
allowNull: true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
@@ -26,13 +26,11 @@ UserHouse.init({
|
|||||||
},
|
},
|
||||||
houseTypeId: {
|
houseTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
defaultValue: 1
|
|
||||||
},
|
},
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
defaultValue: 1
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
45
backend/models/falukant/data/vehicle.js
Normal file
45
backend/models/falukant/data/vehicle.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class Vehicle extends Model {}
|
||||||
|
|
||||||
|
Vehicle.init(
|
||||||
|
{
|
||||||
|
vehicleTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
falukantUserId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
regionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
condition: {
|
||||||
|
// current condition of the vehicle (0–100)
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 100,
|
||||||
|
},
|
||||||
|
availableFrom: {
|
||||||
|
// timestamp when the vehicle becomes available for use
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'Vehicle',
|
||||||
|
tableName: 'vehicle',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Vehicle;
|
||||||
|
|
||||||
|
|
||||||
30
backend/models/falukant/data/weather.js
Normal file
30
backend/models/falukant/data/weather.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
import RegionData from './region.js';
|
||||||
|
import WeatherType from '../type/weather.js';
|
||||||
|
|
||||||
|
class Weather extends Model {}
|
||||||
|
|
||||||
|
Weather.init(
|
||||||
|
{
|
||||||
|
regionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
weatherTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'Weather',
|
||||||
|
tableName: 'weather',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Weather;
|
||||||
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Model, DataTypes } from 'sequelize';
|
import { Model, DataTypes } from 'sequelize';
|
||||||
import { sequelize } from '../../../utils/sequelize.js';
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
class Notification extends Model { }
|
class Notification extends Model {
|
||||||
|
// Getter für characterName - wird nicht synchronisiert, da es kein Datenbankfeld ist
|
||||||
|
get characterName() {
|
||||||
|
return this.getDataValue('character_name') || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Notification.init({
|
Notification.init({
|
||||||
userId: {
|
userId: {
|
||||||
@@ -10,6 +15,11 @@ Notification.init({
|
|||||||
tr: {
|
tr: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false},
|
allowNull: false},
|
||||||
|
character_name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'character_name'
|
||||||
|
},
|
||||||
shown: {
|
shown: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
59
backend/models/falukant/log/reputation_action.js
Normal file
59
backend/models/falukant/log/reputation_action.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class ReputationActionLog extends Model {}
|
||||||
|
|
||||||
|
ReputationActionLog.init(
|
||||||
|
{
|
||||||
|
falukantUserId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'falukant_user_id',
|
||||||
|
},
|
||||||
|
actionTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'action_type_id',
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
baseGain: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'base_gain',
|
||||||
|
},
|
||||||
|
gain: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
timesUsedBefore: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'times_used_before',
|
||||||
|
},
|
||||||
|
actionTimestamp: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
field: 'action_timestamp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ReputationActionLog',
|
||||||
|
tableName: 'reputation_action',
|
||||||
|
schema: 'falukant_log',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['falukant_user_id', 'action_type_id'] },
|
||||||
|
{ fields: ['action_timestamp'] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReputationActionLog;
|
||||||
|
|
||||||
|
|
||||||
@@ -10,13 +10,11 @@ PromotionalGiftCharacterTrait.init(
|
|||||||
giftId: {
|
giftId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'gift_id',
|
field: 'gift_id',
|
||||||
references: { model: PromotionalGift, key: 'id' },
|
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
traitId: {
|
traitId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'trait_id',
|
field: 'trait_id',
|
||||||
references: { model: CharacterTrait, key: 'id' },
|
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
suitability: {
|
suitability: {
|
||||||
|
|||||||
@@ -10,19 +10,11 @@ PromotionalGiftMood.init(
|
|||||||
giftId: {
|
giftId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'gift_id',
|
field: 'gift_id',
|
||||||
references: {
|
|
||||||
model: PromotionalGift,
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
moodId: {
|
moodId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
field: 'mood_id',
|
field: 'mood_id',
|
||||||
references: {
|
|
||||||
model: Mood,
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
suitability: {
|
suitability: {
|
||||||
|
|||||||
41
backend/models/falukant/type/product_weather_effect.js
Normal file
41
backend/models/falukant/type/product_weather_effect.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
import ProductType from './product.js';
|
||||||
|
import WeatherType from './weather.js';
|
||||||
|
|
||||||
|
class ProductWeatherEffect extends Model {}
|
||||||
|
|
||||||
|
ProductWeatherEffect.init(
|
||||||
|
{
|
||||||
|
productId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
weatherTypeId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
qualityEffect: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Effekt auf Qualität: -2 (sehr negativ), -1 (negativ), 0 (neutral), 1 (positiv), 2 (sehr positiv)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ProductWeatherEffect',
|
||||||
|
tableName: 'product_weather_effect',
|
||||||
|
schema: 'falukant_type',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
fields: ['product_id', 'weather_type_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ProductWeatherEffect;
|
||||||
|
|
||||||
@@ -9,11 +9,7 @@ RegionType.init({
|
|||||||
allowNull: false},
|
allowNull: false},
|
||||||
parentId: {
|
parentId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true,
|
allowNull: true
|
||||||
references: {
|
|
||||||
model: 'region',
|
|
||||||
key: 'id',
|
|
||||||
schema: 'falukant_type'}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
51
backend/models/falukant/type/reputation_action.js
Normal file
51
backend/models/falukant/type/reputation_action.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class ReputationActionType extends Model {}
|
||||||
|
|
||||||
|
ReputationActionType.init(
|
||||||
|
{
|
||||||
|
tr: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
baseGain: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'base_gain',
|
||||||
|
},
|
||||||
|
decayFactor: {
|
||||||
|
type: DataTypes.FLOAT,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'decay_factor',
|
||||||
|
},
|
||||||
|
minGain: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
field: 'min_gain',
|
||||||
|
},
|
||||||
|
decayWindowDays: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 7,
|
||||||
|
field: 'decay_window_days',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'ReputationActionType',
|
||||||
|
tableName: 'reputation_action',
|
||||||
|
schema: 'falukant_type',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReputationActionType;
|
||||||
|
|
||||||
|
|
||||||
52
backend/models/falukant/type/vehicle.js
Normal file
52
backend/models/falukant/type/vehicle.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class VehicleType extends Model {}
|
||||||
|
|
||||||
|
VehicleType.init(
|
||||||
|
{
|
||||||
|
tr: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
// base purchase cost of the vehicle
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
buildTimeMinutes: {
|
||||||
|
// time to construct the vehicle, in minutes
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
capacity: {
|
||||||
|
// transport capacity (e.g. in units of goods)
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
transportMode: {
|
||||||
|
// e.g. 'land', 'water', 'air'
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
speed: {
|
||||||
|
// abstract speed value, higher = faster
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'VehicleType',
|
||||||
|
tableName: 'vehicle',
|
||||||
|
schema: 'falukant_type',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default VehicleType;
|
||||||
|
|
||||||
|
|
||||||
25
backend/models/falukant/type/weather.js
Normal file
25
backend/models/falukant/type/weather.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class WeatherType extends Model {}
|
||||||
|
|
||||||
|
WeatherType.init(
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true},
|
||||||
|
tr: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false}},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'WeatherType',
|
||||||
|
tableName: 'weather',
|
||||||
|
schema: 'falukant_type',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default WeatherType;
|
||||||
|
|
||||||
@@ -4,19 +4,11 @@ import { DataTypes } from 'sequelize';
|
|||||||
const ForumForumPermission = sequelize.define('forum_forum_permission', {
|
const ForumForumPermission = sequelize.define('forum_forum_permission', {
|
||||||
forumId: {
|
forumId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'forum',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
permissionId: {
|
permissionId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'forum_permission',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'forum_forum_permission',
|
tableName: 'forum_forum_permission',
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
|
|||||||
import MusicType from './falukant/type/music.js';
|
import MusicType from './falukant/type/music.js';
|
||||||
import BanquetteType from './falukant/type/banquette.js';
|
import BanquetteType from './falukant/type/banquette.js';
|
||||||
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
|
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
|
||||||
|
import ReputationActionType from './falukant/type/reputation_action.js';
|
||||||
|
import ReputationActionLog from './falukant/log/reputation_action.js';
|
||||||
import ChildRelation from './falukant/data/child_relation.js';
|
import ChildRelation from './falukant/data/child_relation.js';
|
||||||
import LearnRecipient from './falukant/type/learn_recipient.js';
|
import LearnRecipient from './falukant/type/learn_recipient.js';
|
||||||
import Learning from './falukant/data/learning.js';
|
import Learning from './falukant/data/learning.js';
|
||||||
@@ -113,6 +115,13 @@ 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 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 Vehicle from './falukant/data/vehicle.js';
|
||||||
|
import Transport from './falukant/data/transport.js';
|
||||||
|
import RegionDistance from './falukant/data/region_distance.js';
|
||||||
|
import WeatherType from './falukant/type/weather.js';
|
||||||
|
import Weather from './falukant/data/weather.js';
|
||||||
|
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
|
||||||
|
|
||||||
import Room from './chat/room.js';
|
import Room from './chat/room.js';
|
||||||
import ChatUser from './chat/user.js';
|
import ChatUser from './chat/user.js';
|
||||||
@@ -201,12 +210,18 @@ const models = {
|
|||||||
BanquetteType,
|
BanquetteType,
|
||||||
Party,
|
Party,
|
||||||
PartyInvitedNobility,
|
PartyInvitedNobility,
|
||||||
|
ReputationActionType,
|
||||||
|
ReputationActionLog,
|
||||||
ChildRelation,
|
ChildRelation,
|
||||||
LearnRecipient,
|
LearnRecipient,
|
||||||
Learning,
|
Learning,
|
||||||
Credit,
|
Credit,
|
||||||
DebtorsPrism,
|
DebtorsPrism,
|
||||||
HealthActivity,
|
HealthActivity,
|
||||||
|
RegionDistance,
|
||||||
|
VehicleType,
|
||||||
|
Vehicle,
|
||||||
|
Transport,
|
||||||
PoliticalOfficeType,
|
PoliticalOfficeType,
|
||||||
PoliticalOfficeRequirement,
|
PoliticalOfficeRequirement,
|
||||||
PoliticalOfficeBenefitType,
|
PoliticalOfficeBenefitType,
|
||||||
@@ -220,6 +235,9 @@ const models = {
|
|||||||
ElectionHistory,
|
ElectionHistory,
|
||||||
UndergroundType,
|
UndergroundType,
|
||||||
Underground,
|
Underground,
|
||||||
|
WeatherType,
|
||||||
|
Weather,
|
||||||
|
ProductWeatherEffect,
|
||||||
Room,
|
Room,
|
||||||
ChatUser,
|
ChatUser,
|
||||||
ChatRight,
|
ChatRight,
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ const Match3Level = sequelize.define('Match3Level', {
|
|||||||
},
|
},
|
||||||
campaignId: {
|
campaignId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'match3_campaigns',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
|
|||||||
@@ -10,19 +10,11 @@ const Match3LevelTileType = sequelize.define('Match3LevelTileType', {
|
|||||||
levelId: {
|
levelId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
references: {
|
|
||||||
model: 'match3_levels',
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
comment: 'Referenz auf den Level'
|
comment: 'Referenz auf den Level'
|
||||||
},
|
},
|
||||||
tileTypeId: {
|
tileTypeId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
references: {
|
|
||||||
model: 'match3_tile_types',
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
comment: 'Referenz auf den Tile-Typ'
|
comment: 'Referenz auf den Tile-Typ'
|
||||||
},
|
},
|
||||||
weight: {
|
weight: {
|
||||||
|
|||||||
@@ -9,14 +9,7 @@ const TaxiHighscore = sequelize.define('TaxiHighscore', {
|
|||||||
},
|
},
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true, // Kann null sein, falls User gelöscht wird
|
allowNull: true // Kann null sein, falls User gelöscht wird
|
||||||
references: {
|
|
||||||
model: {
|
|
||||||
tableName: 'user',
|
|
||||||
schema: 'community'
|
|
||||||
},
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
nickname: {
|
nickname: {
|
||||||
type: DataTypes.STRING(100),
|
type: DataTypes.STRING(100),
|
||||||
@@ -44,13 +37,6 @@ const TaxiHighscore = sequelize.define('TaxiHighscore', {
|
|||||||
mapId: {
|
mapId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
references: {
|
|
||||||
model: {
|
|
||||||
tableName: 'taxi_map',
|
|
||||||
schema: 'taxi'
|
|
||||||
},
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
comment: 'ID der gespielten Map'
|
comment: 'ID der gespielten Map'
|
||||||
},
|
},
|
||||||
mapName: {
|
mapName: {
|
||||||
|
|||||||
@@ -378,16 +378,19 @@ export async function createTriggers() {
|
|||||||
tp.election_id,
|
tp.election_id,
|
||||||
tp.tp_office_type_id,
|
tp.tp_office_type_id,
|
||||||
tp.tp_election_date,
|
tp.tp_election_date,
|
||||||
(
|
COALESCE(
|
||||||
SELECT json_agg(vr)
|
(
|
||||||
FROM votes vr
|
SELECT json_agg(vr)
|
||||||
WHERE vr.election_id = tp.election_id
|
FROM votes vr
|
||||||
|
WHERE vr.election_id = tp.election_id
|
||||||
|
),
|
||||||
|
'[]'::json -- oder '{}'::json, wenn dir ein Objekt lieber ist
|
||||||
),
|
),
|
||||||
NOW() AS created_at,
|
NOW() AS created_at,
|
||||||
NOW() AS updated_at
|
NOW() AS updated_at
|
||||||
FROM to_process tp
|
FROM to_process tp
|
||||||
),
|
),
|
||||||
|
|
||||||
-- 10) Cleanup: Stimmen, Kandidaten und Wahlen löschen
|
-- 10) Cleanup: Stimmen, Kandidaten und Wahlen löschen
|
||||||
_del_votes AS (
|
_del_votes AS (
|
||||||
DELETE FROM falukant_data.vote
|
DELETE FROM falukant_data.vote
|
||||||
|
|||||||
@@ -13,13 +13,7 @@ const interestTranslation = sequelize.define('interest_translation_type', {
|
|||||||
},
|
},
|
||||||
interestsId: {
|
interestsId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: Interest,
|
|
||||||
key: 'id'
|
|
||||||
},
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'CASCADE'
|
|
||||||
}}, {
|
}}, {
|
||||||
tableName: 'interest_translation',
|
tableName: 'interest_translation',
|
||||||
schema: 'type',
|
schema: 'type',
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ const UserParamType = sequelize.define('user_param_type', {
|
|||||||
},
|
},
|
||||||
settingsId: {
|
settingsId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false
|
||||||
references: {
|
|
||||||
model: 'settings',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
orderId: {
|
orderId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const UserRightType = sequelize.define('user_right_type', {
|
|||||||
allowNull: false
|
allowNull: false
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'user_right_type',
|
tableName: 'user_right',
|
||||||
schema: 'type',
|
schema: 'type',
|
||||||
underscored: true
|
underscored: true
|
||||||
});
|
});
|
||||||
|
|||||||
1726
backend/package-lock.json
generated
1726
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "NODE_ENV=development node server.js",
|
"dev": "NODE_ENV=development node server.js",
|
||||||
|
"start-daemon": "node daemonServer.js",
|
||||||
"sync-db": "node sync-database.js",
|
"sync-db": "node sync-database.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"amqplib": "^0.10.4",
|
"amqplib": "^0.10.4",
|
||||||
"bcrypt": "^5.1.1",
|
"bcryptjs": "^2.4.3",
|
||||||
"connect-redis": "^7.1.1",
|
"connect-redis": "^7.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -26,9 +27,9 @@
|
|||||||
"i18n": "^0.15.1",
|
"i18n": "^0.15.1",
|
||||||
"joi": "^17.13.3",
|
"joi": "^17.13.3",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.0",
|
||||||
"mysql2": "^3.10.3",
|
"mysql2": "^3.10.3",
|
||||||
"nodemailer": "^6.9.14",
|
"nodemailer": "^7.0.11",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom);
|
|||||||
// --- Users Admin ---
|
// --- Users Admin ---
|
||||||
router.get('/users/search', authenticate, adminController.searchUsers);
|
router.get('/users/search', authenticate, adminController.searchUsers);
|
||||||
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
|
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
|
||||||
|
router.get('/users/batch', authenticate, adminController.getUsers);
|
||||||
router.get('/users/:id', authenticate, adminController.getUser);
|
router.get('/users/:id', authenticate, adminController.getUser);
|
||||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||||
|
|
||||||
@@ -40,6 +41,14 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
|
|||||||
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
||||||
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
||||||
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
||||||
|
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
|
||||||
|
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
|
||||||
|
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
|
||||||
|
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
|
||||||
|
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
|
||||||
|
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
|
||||||
|
router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus);
|
||||||
|
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
|
||||||
|
|
||||||
// --- Minigames Admin ---
|
// --- Minigames Admin ---
|
||||||
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);
|
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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);
|
||||||
router.post('/branches', falukantController.createBranch);
|
router.post('/branches', falukantController.createBranch);
|
||||||
|
router.post('/branches/upgrade', falukantController.upgradeBranch);
|
||||||
router.get('/productions', falukantController.getAllProductions);
|
router.get('/productions', falukantController.getAllProductions);
|
||||||
router.post('/production', falukantController.createProduction);
|
router.post('/production', falukantController.createProduction);
|
||||||
router.get('/production/:branchId', falukantController.getProduction);
|
router.get('/production/:branchId', falukantController.getProduction);
|
||||||
@@ -37,6 +38,9 @@ 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/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);
|
||||||
@@ -51,6 +55,8 @@ 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('/education', falukantController.getEducation);
|
router.get('/education', falukantController.getEducation);
|
||||||
@@ -69,6 +75,17 @@ router.post('/politics/elections', falukantController.vote);
|
|||||||
router.get('/politics/open', falukantController.getOpenPolitics);
|
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/prices-in-cities', falukantController.getProductPricesInCities);
|
||||||
|
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
||||||
|
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||||
|
router.post('/vehicles', falukantController.buyVehicles);
|
||||||
|
router.get('/vehicles', falukantController.getVehicles);
|
||||||
|
router.post('/vehicles/:vehicleId/repair', falukantController.repairVehicle);
|
||||||
|
router.post('/vehicles/repair-all', falukantController.repairAllVehicles);
|
||||||
|
router.post('/transports', falukantController.createTransport);
|
||||||
|
router.get('/transports/route', falukantController.getTransportRoute);
|
||||||
|
router.get('/transports/branch/:branchId', falukantController.getBranchTransports);
|
||||||
router.get('/underground/types', falukantController.getUndergroundTypes);
|
router.get('/underground/types', falukantController.getUndergroundTypes);
|
||||||
router.get('/notifications', falukantController.getNotifications);
|
router.get('/notifications', falukantController.getNotifications);
|
||||||
router.get('/notifications/all', falukantController.getAllNotifications);
|
router.get('/notifications/all', falukantController.getAllNotifications);
|
||||||
|
|||||||
9
backend/routers/termineRouter.js
Normal file
9
backend/routers/termineRouter.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import termineController from '../controllers/termineController.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', termineController.getTermine);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
27
backend/routers/vocabRouter.js
Normal file
27
backend/routers/vocabRouter.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { authenticate } from '../middleware/authMiddleware.js';
|
||||||
|
import VocabController from '../controllers/vocabController.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const vocabController = new VocabController();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.get('/languages', vocabController.listLanguages);
|
||||||
|
router.post('/languages', vocabController.createLanguage);
|
||||||
|
router.post('/subscribe', vocabController.subscribe);
|
||||||
|
router.get('/languages/:languageId', vocabController.getLanguage);
|
||||||
|
|
||||||
|
// Kapitel
|
||||||
|
router.get('/languages/:languageId/chapters', vocabController.listChapters);
|
||||||
|
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
||||||
|
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
||||||
|
router.get('/languages/:languageId/search', vocabController.searchVocabs);
|
||||||
|
|
||||||
|
router.get('/chapters/:chapterId', vocabController.getChapter);
|
||||||
|
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||||
|
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ import UserParamType from "../models/type/user_param.js";
|
|||||||
import ContactMessage from "../models/service/contactmessage.js";
|
import ContactMessage from "../models/service/contactmessage.js";
|
||||||
import ContactService from "./ContactService.js";
|
import ContactService from "./ContactService.js";
|
||||||
import { sendAnswerEmail } from './emailService.js';
|
import { sendAnswerEmail } from './emailService.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op, Sequelize } from 'sequelize';
|
||||||
import FalukantUser from "../models/falukant/data/user.js";
|
import FalukantUser from "../models/falukant/data/user.js";
|
||||||
import FalukantCharacter from "../models/falukant/data/character.js";
|
import FalukantCharacter from "../models/falukant/data/character.js";
|
||||||
import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js";
|
import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js";
|
||||||
@@ -19,9 +19,15 @@ import Branch from "../models/falukant/data/branch.js";
|
|||||||
import FalukantStock from "../models/falukant/data/stock.js";
|
import FalukantStock from "../models/falukant/data/stock.js";
|
||||||
import FalukantStockType from "../models/falukant/type/stock.js";
|
import FalukantStockType from "../models/falukant/type/stock.js";
|
||||||
import RegionData from "../models/falukant/data/region.js";
|
import RegionData from "../models/falukant/data/region.js";
|
||||||
|
import RegionType from "../models/falukant/type/region.js";
|
||||||
import BranchType from "../models/falukant/type/branch.js";
|
import BranchType from "../models/falukant/type/branch.js";
|
||||||
|
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||||
import Room from '../models/chat/room.js';
|
import Room from '../models/chat/room.js';
|
||||||
import UserParam from '../models/community/user_param.js';
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import npcCreationJobService from './npcCreationJobService.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
class AdminService {
|
class AdminService {
|
||||||
async hasUserAccess(userId, section) {
|
async hasUserAccess(userId, section) {
|
||||||
@@ -298,6 +304,115 @@ class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFalukantRegions(userId) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = await RegionData.findAll({
|
||||||
|
attributes: ['id', 'name', 'map'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: RegionType,
|
||||||
|
as: 'regionType',
|
||||||
|
where: { labelTr: 'city' },
|
||||||
|
attributes: ['labelTr'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['name', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTitlesOfNobility(userId) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
const titles = await TitleOfNobility.findAll({
|
||||||
|
order: [['id', 'ASC']],
|
||||||
|
attributes: ['id', 'labelTr', 'level']
|
||||||
|
});
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFalukantRegionMap(userId, regionId, map) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = await RegionData.findByPk(regionId);
|
||||||
|
if (!region) {
|
||||||
|
throw new Error('regionNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
region.map = map || {};
|
||||||
|
await region.save();
|
||||||
|
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRegionDistances(userId) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const distances = await RegionDistance.findAll();
|
||||||
|
return distances;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRegionDistance(userId, { sourceRegionId, targetRegionId, transportMode, distance }) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceRegionId || !targetRegionId || !transportMode) {
|
||||||
|
throw new Error('missingParameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = await RegionData.findByPk(sourceRegionId);
|
||||||
|
const tgt = await RegionData.findByPk(targetRegionId);
|
||||||
|
if (!src || !tgt) {
|
||||||
|
throw new Error('regionNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = String(transportMode);
|
||||||
|
const dist = Number(distance);
|
||||||
|
if (!Number.isFinite(dist) || dist <= 0) {
|
||||||
|
throw new Error('invalidDistance');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [record] = await RegionDistance.findOrCreate({
|
||||||
|
where: {
|
||||||
|
sourceRegionId: src.id,
|
||||||
|
targetRegionId: tgt.id,
|
||||||
|
transportMode: mode,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
distance: dist,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record.distance !== dist) {
|
||||||
|
record.distance = dist;
|
||||||
|
await record.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegionDistance(userId, id) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
const record = await RegionDistance.findByPk(id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
await record.destroy();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
async updateFalukantStock(userId, stockId, quantity) {
|
async updateFalukantStock(userId, stockId, quantity) {
|
||||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
throw new Error('noaccess');
|
throw new Error('noaccess');
|
||||||
@@ -441,6 +556,30 @@ class AdminService {
|
|||||||
return { id: user.hashedId, username: user.username, active: user.active, registrationDate: user.registrationDate };
|
return { id: user.hashedId, username: user.username, active: user.active, registrationDate: user.registrationDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUsersByHashedIds(requestingHashedUserId, targetHashedIds) {
|
||||||
|
if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(targetHashedIds) || targetHashedIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const users = await User.findAll({
|
||||||
|
where: { hashedId: { [Op.in]: targetHashedIds } },
|
||||||
|
attributes: ['id', 'hashedId', 'username', 'active', 'registrationDate']
|
||||||
|
});
|
||||||
|
// Erstelle ein Map für schnellen Zugriff
|
||||||
|
const userMap = {};
|
||||||
|
users.forEach(user => {
|
||||||
|
userMap[user.hashedId] = {
|
||||||
|
id: user.hashedId,
|
||||||
|
username: user.username,
|
||||||
|
active: user.active,
|
||||||
|
registrationDate: user.registrationDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return userMap;
|
||||||
|
}
|
||||||
|
|
||||||
async updateUser(requestingHashedUserId, targetHashedId, data) {
|
async updateUser(requestingHashedUserId, targetHashedId, data) {
|
||||||
if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) {
|
if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) {
|
||||||
throw new Error('noaccess');
|
throw new Error('noaccess');
|
||||||
@@ -961,6 +1100,216 @@ class AdminService {
|
|||||||
ageGroups
|
ageGroups
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createNPCs(userId, options) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
regionIds, // Array von Region-IDs oder null für alle Städte
|
||||||
|
minAge, // Mindestalter in Jahren
|
||||||
|
maxAge, // Maximalalter in Jahren
|
||||||
|
minTitleId, // Minimale Title-ID
|
||||||
|
maxTitleId, // Maximale Title-ID
|
||||||
|
count // Anzahl der zu erstellenden NPCs
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Berechne zuerst die Gesamtanzahl, um den Job richtig zu initialisieren
|
||||||
|
// WICHTIG: Nur Städte (city) verwenden, keine anderen Region-Typen
|
||||||
|
let targetRegions = [];
|
||||||
|
if (regionIds && regionIds.length > 0) {
|
||||||
|
targetRegions = await RegionData.findAll({
|
||||||
|
where: {
|
||||||
|
id: { [Op.in]: regionIds }
|
||||||
|
},
|
||||||
|
include: [{
|
||||||
|
model: RegionType,
|
||||||
|
as: 'regionType',
|
||||||
|
where: { labelTr: 'city' },
|
||||||
|
required: true // INNER JOIN - nur Regionen mit city-Type
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
targetRegions = await RegionData.findAll({
|
||||||
|
include: [{
|
||||||
|
model: RegionType,
|
||||||
|
as: 'regionType',
|
||||||
|
where: { labelTr: 'city' },
|
||||||
|
required: true // INNER JOIN - nur Regionen mit city-Type
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusätzliche Sicherheit: Filtere explizit nach city-Type
|
||||||
|
targetRegions = targetRegions.filter(region => {
|
||||||
|
return region.regionType && region.regionType.labelTr === 'city';
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[createNPCs] Found ${targetRegions.length} cities (filtered)`);
|
||||||
|
if (targetRegions.length > 0) {
|
||||||
|
console.log(`[createNPCs] City names: ${targetRegions.map(r => r.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetRegions.length === 0) {
|
||||||
|
throw new Error('No cities found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles = await TitleOfNobility.findAll({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.between]: [minTitleId, maxTitleId]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: [['id', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (titles.length === 0) {
|
||||||
|
throw new Error('No titles found in specified range');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNPCs = targetRegions.length * titles.length * count;
|
||||||
|
|
||||||
|
// Erstelle Job-ID
|
||||||
|
const jobId = uuidv4();
|
||||||
|
npcCreationJobService.createJob(userId, jobId);
|
||||||
|
npcCreationJobService.updateProgress(jobId, 0, totalNPCs);
|
||||||
|
npcCreationJobService.setStatus(jobId, 'running');
|
||||||
|
|
||||||
|
// Starte asynchronen Prozess
|
||||||
|
this._createNPCsAsync(jobId, userId, {
|
||||||
|
regionIds,
|
||||||
|
minAge,
|
||||||
|
maxAge,
|
||||||
|
minTitleId,
|
||||||
|
maxTitleId,
|
||||||
|
count,
|
||||||
|
targetRegions,
|
||||||
|
titles
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error in _createNPCsAsync:', error);
|
||||||
|
const errorMessage = error?.message || error?.toString() || 'Unknown error occurred';
|
||||||
|
npcCreationJobService.setError(jobId, errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _createNPCsAsync(jobId, userId, options) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
regionIds,
|
||||||
|
minAge,
|
||||||
|
maxAge,
|
||||||
|
minTitleId,
|
||||||
|
maxTitleId,
|
||||||
|
count,
|
||||||
|
targetRegions,
|
||||||
|
titles
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const genders = ['male', 'female'];
|
||||||
|
const createdNPCs = [];
|
||||||
|
const totalNPCs = targetRegions.length * titles.length * count;
|
||||||
|
let currentNPC = 0;
|
||||||
|
|
||||||
|
console.log(`[NPC Creation Job ${jobId}] Starting creation of ${totalNPCs} NPCs`);
|
||||||
|
|
||||||
|
// Erstelle NPCs in einer Transaktion
|
||||||
|
// Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt
|
||||||
|
await sequelize.transaction(async (t) => {
|
||||||
|
for (const region of targetRegions) {
|
||||||
|
for (const title of titles) {
|
||||||
|
// Erstelle 'count' NPCs für diese Stadt-Titel-Kombination
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Zufälliges Geschlecht
|
||||||
|
const gender = genders[Math.floor(Math.random() * genders.length)];
|
||||||
|
|
||||||
|
// Zufälliger Vorname
|
||||||
|
const firstName = await FalukantPredefineFirstname.findAll({
|
||||||
|
where: { gender },
|
||||||
|
order: sequelize.fn('RANDOM'),
|
||||||
|
limit: 1,
|
||||||
|
transaction: t
|
||||||
|
});
|
||||||
|
if (firstName.length === 0) {
|
||||||
|
throw new Error(`No first names found for gender: ${gender}`);
|
||||||
|
}
|
||||||
|
const fnObj = firstName[0];
|
||||||
|
|
||||||
|
// Zufälliger Nachname
|
||||||
|
const lastName = await FalukantPredefineLastname.findAll({
|
||||||
|
order: sequelize.fn('RANDOM'),
|
||||||
|
limit: 1,
|
||||||
|
transaction: t
|
||||||
|
});
|
||||||
|
if (lastName.length === 0) {
|
||||||
|
throw new Error('No last names found');
|
||||||
|
}
|
||||||
|
const lnObj = lastName[0];
|
||||||
|
|
||||||
|
// Zufälliges Alter (in Jahren, wird in Tage umgerechnet)
|
||||||
|
const randomAge = Math.floor(Math.random() * (maxAge - minAge + 1)) + minAge;
|
||||||
|
const birthdate = new Date();
|
||||||
|
birthdate.setDate(birthdate.getDate() - randomAge); // 5 Tage = 5 Jahre alt
|
||||||
|
|
||||||
|
// Erstelle den NPC-Charakter (ohne userId = NPC)
|
||||||
|
const npc = await FalukantCharacter.create({
|
||||||
|
userId: null, // Wichtig: null = NPC
|
||||||
|
regionId: region.id,
|
||||||
|
firstName: fnObj.id,
|
||||||
|
lastName: lnObj.id,
|
||||||
|
gender: gender,
|
||||||
|
birthdate: birthdate,
|
||||||
|
titleOfNobility: title.id,
|
||||||
|
health: 100,
|
||||||
|
moodId: 1
|
||||||
|
}, { transaction: t });
|
||||||
|
|
||||||
|
createdNPCs.push({
|
||||||
|
id: npc.id,
|
||||||
|
firstName: fnObj.name,
|
||||||
|
lastName: lnObj.name,
|
||||||
|
gender: gender,
|
||||||
|
age: randomAge,
|
||||||
|
region: region.name,
|
||||||
|
title: title.labelTr
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Progress
|
||||||
|
currentNPC++;
|
||||||
|
npcCreationJobService.updateProgress(jobId, currentNPC, totalNPCs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[NPC Creation Job ${jobId}] Completed: ${createdNPCs.length} NPCs created`);
|
||||||
|
|
||||||
|
// Job abschließen
|
||||||
|
npcCreationJobService.setResult(jobId, {
|
||||||
|
success: true,
|
||||||
|
count: createdNPCs.length,
|
||||||
|
countPerCombination: count,
|
||||||
|
totalCombinations: targetRegions.length * titles.length,
|
||||||
|
npcs: createdNPCs
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[NPC Creation Job ${jobId}] Error:`, error);
|
||||||
|
throw error; // Re-throw für den catch-Block in createNPCs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getNPCsCreationStatus(userId, jobId) {
|
||||||
|
const job = npcCreationJobService.getJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
throw new Error('Job not found');
|
||||||
|
}
|
||||||
|
if (job.userId !== userId) {
|
||||||
|
throw new Error('Access denied');
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AdminService();
|
export default new AdminService();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import User from '../models/community/user.js';
|
import User from '../models/community/user.js';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
86
backend/services/npcCreationJobService.js
Normal file
86
backend/services/npcCreationJobService.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// In-Memory Job-Status-Service für NPC-Erstellung
|
||||||
|
// Für Produktion sollte man Redis oder eine Datenbank verwenden
|
||||||
|
|
||||||
|
const jobs = new Map();
|
||||||
|
|
||||||
|
class NPCCreationJobService {
|
||||||
|
createJob(userId, jobId) {
|
||||||
|
jobs.set(jobId, {
|
||||||
|
userId,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
total: 0,
|
||||||
|
current: 0,
|
||||||
|
startTime: Date.now(),
|
||||||
|
estimatedTimeRemaining: null,
|
||||||
|
error: null,
|
||||||
|
result: null
|
||||||
|
});
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getJob(jobId) {
|
||||||
|
return jobs.get(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress(jobId, current, total) {
|
||||||
|
const job = jobs.get(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
job.current = current;
|
||||||
|
job.total = total;
|
||||||
|
job.progress = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
|
||||||
|
// Berechne verbleibende Zeit basierend auf bisheriger Geschwindigkeit
|
||||||
|
if (current > 0 && job.progress < 100) {
|
||||||
|
const elapsed = Date.now() - job.startTime;
|
||||||
|
const avgTimePerItem = elapsed / current;
|
||||||
|
const remaining = total - current;
|
||||||
|
job.estimatedTimeRemaining = Math.round(remaining * avgTimePerItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(jobId, status) {
|
||||||
|
const job = jobs.get(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
job.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(jobId, error) {
|
||||||
|
const job = jobs.get(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
job.status = 'error';
|
||||||
|
job.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(jobId, result) {
|
||||||
|
const job = jobs.get(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
job.status = 'completed';
|
||||||
|
job.result = result;
|
||||||
|
job.progress = 100;
|
||||||
|
job.estimatedTimeRemaining = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteJob(jobId) {
|
||||||
|
jobs.delete(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup alte Jobs (älter als 1 Stunde)
|
||||||
|
cleanupOldJobs() {
|
||||||
|
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
||||||
|
for (const [jobId, job] of jobs.entries()) {
|
||||||
|
if (job.startTime < oneHourAgo) {
|
||||||
|
jobs.delete(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup alle 10 Minuten
|
||||||
|
setInterval(() => {
|
||||||
|
const service = new NPCCreationJobService();
|
||||||
|
service.cleanupOldJobs();
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
|
||||||
|
export default new NPCCreationJobService();
|
||||||
@@ -328,7 +328,7 @@ class SettingsService extends BaseService{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify old password
|
// Verify old password
|
||||||
const bcrypt = await import('bcrypt');
|
const bcrypt = await import('bcryptjs');
|
||||||
const match = await bcrypt.compare(settings.oldpassword, user.password);
|
const match = await bcrypt.compare(settings.oldpassword, user.password);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Old password is incorrect');
|
throw new Error('Old password is incorrect');
|
||||||
|
|||||||
532
backend/services/vocabService.js
Normal file
532
backend/services/vocabService.js
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import User from '../models/community/user.js';
|
||||||
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import { notifyUser } from '../utils/socket.js';
|
||||||
|
|
||||||
|
export default class VocabService {
|
||||||
|
async _getUserByHashedId(hashedUserId) {
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||||
|
if (!user) {
|
||||||
|
const err = new Error('User not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeLexeme(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getLanguageAccess(userId, languageId) {
|
||||||
|
const id = Number.parseInt(languageId, 10);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
const err = new Error('Invalid language id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
(l.owner_user_id = :userId) AS "isOwner"
|
||||||
|
FROM community.vocab_language l
|
||||||
|
WHERE l.id = :languageId
|
||||||
|
AND (
|
||||||
|
l.owner_user_id = :userId
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM community.vocab_language_subscription s
|
||||||
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { userId, languageId: id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
const err = new Error('Language not found or no access');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getChapterAccess(userId, chapterId) {
|
||||||
|
const id = Number.parseInt(chapterId, 10);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
const err = new Error('Invalid chapter id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.language_id AS "languageId",
|
||||||
|
c.title,
|
||||||
|
(l.owner_user_id = :userId) AS "isOwner"
|
||||||
|
FROM community.vocab_chapter c
|
||||||
|
JOIN community.vocab_language l ON l.id = c.language_id
|
||||||
|
WHERE c.id = :chapterId
|
||||||
|
AND (
|
||||||
|
l.owner_user_id = :userId
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM community.vocab_language_subscription s
|
||||||
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { userId, chapterId: id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
const err = new Error('Chapter not found or no access');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLanguages(hashedUserId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.name,
|
||||||
|
l.share_code AS "shareCode",
|
||||||
|
TRUE AS "isOwner"
|
||||||
|
FROM community.vocab_language l
|
||||||
|
WHERE l.owner_user_id = :userId
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.name,
|
||||||
|
NULL::text AS "shareCode",
|
||||||
|
FALSE AS "isOwner"
|
||||||
|
FROM community.vocab_language_subscription s
|
||||||
|
JOIN community.vocab_language l ON l.id = s.language_id
|
||||||
|
WHERE s.user_id = :userId
|
||||||
|
|
||||||
|
ORDER BY name ASC
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { userId: user.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { languages: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLanguagesForMenu(userId) {
|
||||||
|
// userId ist die numerische community.user.id
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT l.id, l.name
|
||||||
|
FROM community.vocab_language l
|
||||||
|
WHERE l.owner_user_id = :userId
|
||||||
|
UNION
|
||||||
|
SELECT l.id, l.name
|
||||||
|
FROM community.vocab_language_subscription s
|
||||||
|
JOIN community.vocab_language l ON l.id = s.language_id
|
||||||
|
WHERE s.user_id = :userId
|
||||||
|
ORDER BY name ASC
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { userId },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLanguage(hashedUserId, { name }) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const cleanName = typeof name === 'string' ? name.trim() : '';
|
||||||
|
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
|
||||||
|
const err = new Error('Invalid language name');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16 hex chars => ausreichend kurz, gut teilbar
|
||||||
|
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, name, share_code AS "shareCode"
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
|
||||||
|
try {
|
||||||
|
notifyUser(user.hashedId, 'reloadmenu', {});
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeByShareCode(hashedUserId, { shareCode }) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
|
||||||
|
if (!code || code.length < 6 || code.length > 128) {
|
||||||
|
const err = new Error('Invalid share code');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lang] = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT id, owner_user_id AS "ownerUserId", name
|
||||||
|
FROM community.vocab_language
|
||||||
|
WHERE share_code = :shareCode
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { shareCode: code },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lang) {
|
||||||
|
const err = new Error('Language not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner braucht kein Abo
|
||||||
|
if (lang.ownerUserId === user.id) {
|
||||||
|
return { subscribed: false, message: 'Already owner', languageId: lang.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
await sequelize.query(
|
||||||
|
`
|
||||||
|
INSERT INTO community.vocab_language_subscription (user_id, language_id)
|
||||||
|
VALUES (:userId, :languageId)
|
||||||
|
ON CONFLICT (user_id, language_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { userId: user.id, languageId: lang.id },
|
||||||
|
type: sequelize.QueryTypes.INSERT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
notifyUser(user.hashedId, 'reloadmenu', {});
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return { subscribed: true, languageId: lang.id, name: lang.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLanguage(hashedUserId, languageId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const id = Number.parseInt(languageId, 10);
|
||||||
|
if (!Number.isFinite(id)) {
|
||||||
|
const err = new Error('Invalid language id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.name,
|
||||||
|
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
|
||||||
|
(l.owner_user_id = :userId) AS "isOwner"
|
||||||
|
FROM community.vocab_language l
|
||||||
|
WHERE l.id = :languageId
|
||||||
|
AND (
|
||||||
|
l.owner_user_id = :userId
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM community.vocab_language_subscription s
|
||||||
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { userId: user.id, languageId: id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
const err = new Error('Language not found or no access');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listChapters(hashedUserId, languageId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const access = await this._getLanguageAccess(user.id, languageId);
|
||||||
|
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.title,
|
||||||
|
c.created_at AS "createdAt",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM community.vocab_chapter_lexeme cl
|
||||||
|
WHERE cl.chapter_id = c.id
|
||||||
|
)::int AS "vocabCount"
|
||||||
|
FROM community.vocab_chapter c
|
||||||
|
WHERE c.language_id = :languageId
|
||||||
|
ORDER BY c.title ASC
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: access.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { chapters: rows, isOwner: access.isOwner };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChapter(hashedUserId, languageId, { title }) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const access = await this._getLanguageAccess(user.id, languageId);
|
||||||
|
if (!access.isOwner) {
|
||||||
|
const err = new Error('Only owner can create chapters');
|
||||||
|
err.status = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanTitle = typeof title === 'string' ? title.trim() : '';
|
||||||
|
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
|
||||||
|
const err = new Error('Invalid chapter title');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await sequelize.query(
|
||||||
|
`
|
||||||
|
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
|
||||||
|
VALUES (:languageId, :title, :userId)
|
||||||
|
RETURNING id, title, created_at AS "createdAt"
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChapter(hashedUserId, chapterId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||||
|
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listChapterVocabs(hashedUserId, chapterId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||||
|
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
cl.id,
|
||||||
|
l1.text AS "learning",
|
||||||
|
l2.text AS "reference",
|
||||||
|
cl.created_at AS "createdAt"
|
||||||
|
FROM community.vocab_chapter_lexeme cl
|
||||||
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
||||||
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
||||||
|
WHERE cl.chapter_id = :chapterId
|
||||||
|
ORDER BY l1.text ASC, l2.text ASC
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { chapterId: ch.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLanguageVocabs(hashedUserId, languageId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const access = await this._getLanguageAccess(user.id, languageId);
|
||||||
|
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
cl.id,
|
||||||
|
c.id AS "chapterId",
|
||||||
|
c.title AS "chapterTitle",
|
||||||
|
l1.text AS "learning",
|
||||||
|
l2.text AS "reference",
|
||||||
|
cl.created_at AS "createdAt"
|
||||||
|
FROM community.vocab_chapter_lexeme cl
|
||||||
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
||||||
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
||||||
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
||||||
|
WHERE c.language_id = :languageId
|
||||||
|
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: access.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const access = await this._getLanguageAccess(user.id, languageId);
|
||||||
|
|
||||||
|
const query = typeof q === 'string' ? q.trim() : '';
|
||||||
|
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
|
||||||
|
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
|
||||||
|
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
|
||||||
|
const effective = query || learningTerm || motherTerm;
|
||||||
|
|
||||||
|
if (!effective) {
|
||||||
|
const err = new Error('Missing search term');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const like = `%${effective}%`;
|
||||||
|
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
cl.id,
|
||||||
|
c.id AS "chapterId",
|
||||||
|
c.title AS "chapterTitle",
|
||||||
|
l1.text AS "learning",
|
||||||
|
l2.text AS "motherTongue"
|
||||||
|
FROM community.vocab_chapter_lexeme cl
|
||||||
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
||||||
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
||||||
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
||||||
|
WHERE c.language_id = :languageId
|
||||||
|
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
|
||||||
|
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
|
||||||
|
LIMIT 200
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: {
|
||||||
|
languageId: access.id,
|
||||||
|
like,
|
||||||
|
},
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { languageId: access.id, results: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||||
|
if (!ch.isOwner) {
|
||||||
|
const err = new Error('Only owner can add vocab');
|
||||||
|
err.status = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const learningText = typeof learning === 'string' ? learning.trim() : '';
|
||||||
|
const referenceText = typeof reference === 'string' ? reference.trim() : '';
|
||||||
|
if (!learningText || !referenceText) {
|
||||||
|
const err = new Error('Invalid vocab');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const learningNorm = this._normalizeLexeme(learningText);
|
||||||
|
const referenceNorm = this._normalizeLexeme(referenceText);
|
||||||
|
|
||||||
|
// Transaktion: Lexeme upserten + Zuordnung setzen
|
||||||
|
return await sequelize.transaction(async (t) => {
|
||||||
|
const [learningLex] = await sequelize.query(
|
||||||
|
`
|
||||||
|
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
|
||||||
|
VALUES (:languageId, :text, :normalized, :userId)
|
||||||
|
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
transaction: t,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [referenceLex] = await sequelize.query(
|
||||||
|
`
|
||||||
|
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
|
||||||
|
VALUES (:languageId, :text, :normalized, :userId)
|
||||||
|
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
transaction: t,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mapping] = await sequelize.query(
|
||||||
|
`
|
||||||
|
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
|
||||||
|
VALUES (:chapterId, :learningId, :referenceId, :userId)
|
||||||
|
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: {
|
||||||
|
chapterId: ch.id,
|
||||||
|
learningId: learningLex.id,
|
||||||
|
referenceId: referenceLex.id,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
transaction: t,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { created: Boolean(mapping?.id) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
88
backend/sql/add_character_name_to_notification.sql
Normal file
88
backend/sql/add_character_name_to_notification.sql
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
-- Migration script: add_character_name_to_notification.sql
|
||||||
|
-- Fügt character_name und character_id zur falukant_log.notification Tabelle hinzu,
|
||||||
|
-- legt Index an, erzeugt die Helper-Funktion und den Trigger.
|
||||||
|
-- Idempotent und mit Down-Schritten zum Entfernen.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) Spalten anlegen
|
||||||
|
ALTER TABLE IF EXISTS falukant_log.notification
|
||||||
|
ADD COLUMN IF NOT EXISTS character_name text;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS falukant_log.notification
|
||||||
|
ADD COLUMN IF NOT EXISTS character_id integer;
|
||||||
|
|
||||||
|
-- 2) Index (idempotent)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notification_character_id
|
||||||
|
ON falukant_log.notification (character_id);
|
||||||
|
|
||||||
|
-- 3) Trigger-Funktion anlegen (idempotent)
|
||||||
|
CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name()
|
||||||
|
RETURNS TRIGGER AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_first_name TEXT;
|
||||||
|
v_last_name TEXT;
|
||||||
|
v_char_id INTEGER;
|
||||||
|
v_column_exists BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- prüfen, ob Zielspalte existiert
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_log' AND table_name = 'notification' AND column_name = 'character_name'
|
||||||
|
) INTO v_column_exists;
|
||||||
|
|
||||||
|
IF NOT v_column_exists THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.character_name IS NOT NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_char_id := NEW.character_id;
|
||||||
|
|
||||||
|
IF v_char_id IS NULL AND NEW.user_id IS NOT NULL THEN
|
||||||
|
SELECT id INTO v_char_id
|
||||||
|
FROM falukant_data.character
|
||||||
|
WHERE user_id = NEW.user_id
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_char_id IS NOT NULL THEN
|
||||||
|
SELECT pf.name, pl.name
|
||||||
|
INTO v_first_name, v_last_name
|
||||||
|
FROM falukant_data.character c
|
||||||
|
LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name
|
||||||
|
LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name
|
||||||
|
WHERE c.id = v_char_id;
|
||||||
|
|
||||||
|
IF v_first_name IS NOT NULL OR v_last_name IS NOT NULL THEN
|
||||||
|
NEW.character_name := COALESCE(v_first_name, '') || CASE WHEN v_first_name IS NOT NULL AND v_last_name IS NOT NULL THEN ' ' ELSE '' END || COALESCE(v_last_name, '');
|
||||||
|
ELSE
|
||||||
|
NEW.character_name := ('#' || v_char_id::text);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
IF NEW.user_id IS NOT NULL THEN
|
||||||
|
NEW.character_name := ('#u' || NEW.user_id::text);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$function$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 4) Trigger anlegen (BEFORE INSERT)
|
||||||
|
DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification;
|
||||||
|
CREATE TRIGGER trg_populate_notification_character_name
|
||||||
|
BEFORE INSERT ON falukant_log.notification
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION falukant_log.populate_notification_character_name();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Down / Rollback (falls benötigt):
|
||||||
|
-- Die folgenden Statements entfernen Trigger, Funktion, Index und Spalten.
|
||||||
|
|
||||||
|
-- Hinweis: Ausführbar separat; zur Anwendung einfach die folgenden Zeilen verwenden:
|
||||||
|
-- BEGIN; DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification; DROP FUNCTION IF EXISTS falukant_log.populate_notification_character_name(); DROP INDEX IF EXISTS falukant_log.idx_notification_character_id; ALTER TABLE IF EXISTS falukant_log.notification DROP COLUMN IF EXISTS character_name; ALTER TABLE IF EXISTS falukant_log.notification DROP COLUMN IF EXISTS character_id; COMMIT;
|
||||||
11
backend/sql/add_product_quality_to_stock.sql
Normal file
11
backend/sql/add_product_quality_to_stock.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration script: add_product_quality_to_stock.sql
|
||||||
|
-- Fügt die Spalte product_quality zur Tabelle falukant_data.stock hinzu (nullable, idempotent)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.stock
|
||||||
|
ADD COLUMN IF NOT EXISTS product_quality integer;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Ende
|
||||||
38
backend/sql/add_weather_type_to_production.sql
Normal file
38
backend/sql/add_weather_type_to_production.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Migration script: add_weather_type_to_production.sql
|
||||||
|
-- Legt die Spalte weather_type_id in falukant_data.production an,
|
||||||
|
-- fügt optional einen Foreign Key zu falukant_type.weather(id) hinzu
|
||||||
|
-- und erstellt einen Index. Idempotent (mehrfaches Ausführen ist unproblematisch).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) Spalte anlegen (nullable, idempotent)
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.production
|
||||||
|
ADD COLUMN IF NOT EXISTS weather_type_id integer;
|
||||||
|
|
||||||
|
-- 2) Fremdschlüssel nur hinzufügen, falls noch kein FK für diese Spalte existiert
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON kcu.constraint_name = tc.constraint_name
|
||||||
|
AND kcu.constraint_schema = tc.constraint_schema
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.constraint_schema = 'falukant_data'
|
||||||
|
AND tc.table_name = 'production'
|
||||||
|
AND kcu.column_name = 'weather_type_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.production
|
||||||
|
ADD CONSTRAINT fk_production_weather_type
|
||||||
|
FOREIGN KEY (weather_type_id) REFERENCES falukant_type.weather(id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- 3) Index (Postgres: CREATE INDEX IF NOT EXISTS)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_production_weather_type_id
|
||||||
|
ON falukant_data.production (weather_type_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Ende
|
||||||
23
backend/sql/cleanup_orphaned_user_param_visibility.sql
Normal file
23
backend/sql/cleanup_orphaned_user_param_visibility.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- Cleanup script: Entfernt verwaiste Einträge aus user_param_visibility
|
||||||
|
-- Diese Einträge verweisen auf nicht existierende user_param Einträge
|
||||||
|
-- und verhindern das Hinzufügen des Foreign Key Constraints
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Lösche alle user_param_visibility Einträge, deren param_id nicht mehr in user_param existiert
|
||||||
|
DELETE FROM community.user_param_visibility
|
||||||
|
WHERE param_id NOT IN (
|
||||||
|
SELECT id FROM community.user_param
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Zeige an, wie viele Einträge gelöscht wurden
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Gelöschte verwaiste Einträge: %', deleted_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
69
backend/sql/update_product_sell_costs.sql
Normal file
69
backend/sql/update_product_sell_costs.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- Backup original sell_cost values (just in case)
|
||||||
|
-- Run this once: will add a column original_sell_cost and copy existing sell_cost into it
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
ADD COLUMN IF NOT EXISTS original_sell_cost numeric;
|
||||||
|
|
||||||
|
UPDATE falukant_type.product
|
||||||
|
SET sell_cost = sell_cost * ((6 * 7 / 100) + 100);
|
||||||
|
|
||||||
|
-- Compute min and max cumulative tax across all regions
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
), totals AS (
|
||||||
|
SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
), mm AS (
|
||||||
|
SELECT COALESCE(MIN(total),0) AS min_total, COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
)
|
||||||
|
SELECT * FROM mm;
|
||||||
|
|
||||||
|
-- Choose one of the following update blocks to run:
|
||||||
|
|
||||||
|
-- 1) MIN-STRATEGY: increase sell_cost so that taxes at the minimal cumulative tax have no effect
|
||||||
|
-- (this will set sell_cost = CEIL(original_sell_cost * (1 / (1 - min_total/100))))
|
||||||
|
|
||||||
|
-- BEGIN MIN-STRATEGY
|
||||||
|
-- WITH mm AS (
|
||||||
|
-- WITH RECURSIVE ancestors AS (
|
||||||
|
-- SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
-- FROM falukant_data.region r
|
||||||
|
-- JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
-- ), totals AS (
|
||||||
|
-- SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
-- )
|
||||||
|
-- SELECT COALESCE(MIN(total),0) AS min_total FROM totals
|
||||||
|
-- )
|
||||||
|
-- UPDATE falukant_type.product
|
||||||
|
-- SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - (SELECT min_total FROM mm)/100) <= 0 THEN 1 ELSE (1 / (1 - (SELECT min_total FROM mm)/100)) END));
|
||||||
|
-- END MIN-STRATEGY
|
||||||
|
|
||||||
|
-- 2) MAX-STRATEGY: increase sell_cost so that taxes at the maximal cumulative tax have no effect
|
||||||
|
-- (this will set sell_cost = CEIL(original_sell_cost * (1 / (1 - max_total/100))))
|
||||||
|
|
||||||
|
-- BEGIN MAX-STRATEGY
|
||||||
|
-- WITH mm AS (
|
||||||
|
-- WITH RECURSIVE ancestors AS (
|
||||||
|
-- SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
-- FROM falukant_data.region r
|
||||||
|
-- JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
-- ), totals AS (
|
||||||
|
-- SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
-- )
|
||||||
|
-- SELECT COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
-- )
|
||||||
|
-- UPDATE falukant_type.product
|
||||||
|
-- SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - (SELECT max_total FROM mm)/100) <= 0 THEN 1 ELSE (1 / (1 - (SELECT max_total FROM mm)/100)) END));
|
||||||
|
-- END MAX-STRATEGY
|
||||||
|
|
||||||
|
-- Notes:
|
||||||
|
-- 1) Uncomment exactly one strategy block (MIN or MAX) and run the script.
|
||||||
|
-- 2) The script creates `original_sell_cost` as a backup; keep it for safety.
|
||||||
|
-- 3) CEIL is used to avoid undercompensating due to rounding. If you prefer ROUND use ROUND(...).
|
||||||
|
-- 4) Test on a staging DB first.
|
||||||
@@ -228,10 +228,29 @@ async function initializeFalukantStockTypes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeFalukantProducts() {
|
async function initializeFalukantProducts() {
|
||||||
await ProductType.bulkCreate([
|
// compute min/max cumulative tax across regions
|
||||||
|
const taxRows = await sequelize.query(`
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
), totals AS (
|
||||||
|
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(MIN(total),0) AS min_total, COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
`, { type: sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
|
const minTax = parseFloat(taxRows?.[0]?.min_total) || 0;
|
||||||
|
const maxTax = parseFloat(taxRows?.[0]?.max_total) || 0;
|
||||||
|
const factorMin = (minTax >= 100) ? 1 : (1 / (1 - minTax / 100));
|
||||||
|
const factorMax = (maxTax >= 100) ? 1 : (1 / (1 - maxTax / 100));
|
||||||
|
|
||||||
|
const baseProducts = [
|
||||||
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 46},
|
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 5},
|
||||||
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
@@ -261,7 +280,11 @@ async function initializeFalukantProducts() {
|
|||||||
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 },
|
||||||
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 },
|
||||||
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
||||||
], {
|
];
|
||||||
|
|
||||||
|
const productsToInsert = baseProducts;
|
||||||
|
|
||||||
|
await ProductType.bulkCreate(productsToInsert, {
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
|
|||||||
import PartyType from "../../models/falukant/type/party.js";
|
import PartyType from "../../models/falukant/type/party.js";
|
||||||
import MusicType from "../../models/falukant/type/music.js";
|
import MusicType from "../../models/falukant/type/music.js";
|
||||||
import BanquetteType from "../../models/falukant/type/banquette.js";
|
import BanquetteType from "../../models/falukant/type/banquette.js";
|
||||||
|
import ReputationActionType from "../../models/falukant/type/reputation_action.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 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";
|
||||||
|
import WeatherType from "../../models/falukant/type/weather.js";
|
||||||
|
import Weather from "../../models/falukant/data/weather.js";
|
||||||
|
import ProductWeatherEffect from "../../models/falukant/type/product_weather_effect.js";
|
||||||
|
import ProductType from "../../models/falukant/type/product.js";
|
||||||
|
|
||||||
// Debug-Flag: Nur wenn DEBUG_FALUKANT=1 gesetzt ist, werden ausführliche Logs ausgegeben.
|
// Debug-Flag: Nur wenn DEBUG_FALUKANT=1 gesetzt ist, werden ausführliche Logs ausgegeben.
|
||||||
const falukantDebug = process.env.DEBUG_FALUKANT === '1';
|
const falukantDebug = process.env.DEBUG_FALUKANT === '1';
|
||||||
@@ -36,13 +42,67 @@ export const initializeFalukantTypes = async () => {
|
|||||||
await initializeFalukantPartyTypes();
|
await initializeFalukantPartyTypes();
|
||||||
await initializeFalukantMusicTypes();
|
await initializeFalukantMusicTypes();
|
||||||
await initializeFalukantBanquetteTypes();
|
await initializeFalukantBanquetteTypes();
|
||||||
|
await initializeFalukantReputationActions();
|
||||||
await initializeLearnerTypes();
|
await initializeLearnerTypes();
|
||||||
await initializePoliticalOfficeBenefitTypes();
|
await initializePoliticalOfficeBenefitTypes();
|
||||||
await initializePoliticalOfficeTypes();
|
await initializePoliticalOfficeTypes();
|
||||||
await initializePoliticalOfficePrerequisites();
|
await initializePoliticalOfficePrerequisites();
|
||||||
await initializeUndergroundTypes();
|
await initializeUndergroundTypes();
|
||||||
|
await initializeVehicleTypes();
|
||||||
|
await initializeFalukantWeatherTypes();
|
||||||
|
await initializeFalukantWeathers();
|
||||||
|
await initializeFalukantProductWeatherEffects();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reputationActions = [
|
||||||
|
// Günstig / häufig: schnelle Abnutzung
|
||||||
|
{ tr: "soup_kitchen", cost: 500, baseGain: 2, decayFactor: 0.85, minGain: 0, decayWindowDays: 7 },
|
||||||
|
// Mittel: gesellschaftlich anerkannt
|
||||||
|
{ tr: "library_donation", cost: 5000, baseGain: 4, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "scholarships", cost: 10000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "church_hospice", cost: 12000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "school_funding", cost: 15000, baseGain: 6, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
|
||||||
|
// Großprojekte: teurer, langsamerer Rufverfall
|
||||||
|
{ tr: "orphanage_build", cost: 20000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "bridge_build", cost: 25000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "hospital_donation", cost: 30000, baseGain: 8, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "patronage", cost: 40000, baseGain: 9, decayFactor: 0.91, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "statue_build", cost: 50000, baseGain: 10, decayFactor: 0.92, minGain: 0, decayWindowDays: 7 },
|
||||||
|
{ tr: "well_build", cost: 8000, baseGain: 4, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function initializeFalukantReputationActions() {
|
||||||
|
// robustes Upsert ohne FK/Constraints-Ärger:
|
||||||
|
// - finde existierende nach tr
|
||||||
|
// - update bei Änderungen
|
||||||
|
// - create wenn fehlt
|
||||||
|
const existing = await ReputationActionType.findAll({ attributes: ['id', 'tr', 'cost', 'baseGain', 'decayFactor', 'minGain'] });
|
||||||
|
const existingByTr = new Map(existing.map(e => [e.tr, e]));
|
||||||
|
|
||||||
|
for (const a of reputationActions) {
|
||||||
|
const found = existingByTr.get(a.tr);
|
||||||
|
if (!found) {
|
||||||
|
await ReputationActionType.create(a);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const needsUpdate =
|
||||||
|
Number(found.cost) !== Number(a.cost) ||
|
||||||
|
Number(found.baseGain) !== Number(a.baseGain) ||
|
||||||
|
Number(found.decayFactor) !== Number(a.decayFactor) ||
|
||||||
|
Number(found.minGain) !== Number(a.minGain) ||
|
||||||
|
Number(found.decayWindowDays || 7) !== Number(a.decayWindowDays || 7);
|
||||||
|
if (needsUpdate) {
|
||||||
|
await found.update({
|
||||||
|
cost: a.cost,
|
||||||
|
baseGain: a.baseGain,
|
||||||
|
decayFactor: a.decayFactor,
|
||||||
|
minGain: a.minGain,
|
||||||
|
decayWindowDays: a.decayWindowDays ?? 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const regionTypes = [];
|
const regionTypes = [];
|
||||||
const regionTypeTrs = [
|
const regionTypeTrs = [
|
||||||
"country",
|
"country",
|
||||||
@@ -273,6 +333,17 @@ const learnerTypes = [
|
|||||||
{ tr: 'director', },
|
{ tr: 'director', },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const vehicleTypes = [
|
||||||
|
// build times (in minutes): 60, 90, 180, 300, 720, 120, 1440
|
||||||
|
{ tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1, buildTimeMinutes: 60 },
|
||||||
|
{ tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2, buildTimeMinutes: 90 },
|
||||||
|
{ tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3, buildTimeMinutes: 180 },
|
||||||
|
{ tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3, buildTimeMinutes: 300 },
|
||||||
|
{ tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4, buildTimeMinutes: 720 },
|
||||||
|
{ tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1, buildTimeMinutes: 120 },
|
||||||
|
{ tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3, buildTimeMinutes: 1440 },
|
||||||
|
];
|
||||||
|
|
||||||
const politicalOfficeBenefitTypes = [
|
const politicalOfficeBenefitTypes = [
|
||||||
{ tr: 'salary' },
|
{ tr: 'salary' },
|
||||||
{ tr: 'reputation' },
|
{ tr: 'reputation' },
|
||||||
@@ -282,6 +353,7 @@ const politicalOfficeBenefitTypes = [
|
|||||||
{ tr: 'tax_exemption' },
|
{ tr: 'tax_exemption' },
|
||||||
{ tr: 'guard_protection' },
|
{ tr: 'guard_protection' },
|
||||||
{ tr: 'court_immunity' },
|
{ tr: 'court_immunity' },
|
||||||
|
{ tr: 'set_regionl_tax' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const politicalOffices = [
|
const politicalOffices = [
|
||||||
@@ -883,6 +955,31 @@ export const initializeLearnerTypes = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const initializeVehicleTypes = async () => {
|
||||||
|
for (const v of vehicleTypes) {
|
||||||
|
const existing = await VehicleType.findOne({ where: { tr: v.tr } });
|
||||||
|
if (!existing) {
|
||||||
|
await VehicleType.create({
|
||||||
|
tr: v.tr,
|
||||||
|
cost: v.cost,
|
||||||
|
capacity: v.capacity,
|
||||||
|
transportMode: v.transportMode,
|
||||||
|
speed: v.speed,
|
||||||
|
buildTimeMinutes: v.buildTimeMinutes,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ensure new fields like cost/buildTime are updated if missing
|
||||||
|
await existing.update({
|
||||||
|
cost: v.cost,
|
||||||
|
capacity: v.capacity,
|
||||||
|
transportMode: v.transportMode,
|
||||||
|
speed: v.speed,
|
||||||
|
buildTimeMinutes: v.buildTimeMinutes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const initializePoliticalOfficeBenefitTypes = async () => {
|
export const initializePoliticalOfficeBenefitTypes = async () => {
|
||||||
for (const benefitType of politicalOfficeBenefitTypes) {
|
for (const benefitType of politicalOfficeBenefitTypes) {
|
||||||
await PoliticalOfficeBenefitType.findOrCreate({
|
await PoliticalOfficeBenefitType.findOrCreate({
|
||||||
@@ -972,3 +1069,445 @@ export const initializeFalukantTitlesOfNobility = async () => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const weatherTypes = [
|
||||||
|
{ tr: "sunny" },
|
||||||
|
{ tr: "cloudy" },
|
||||||
|
{ tr: "rainy" },
|
||||||
|
{ tr: "stormy" },
|
||||||
|
{ tr: "snowy" },
|
||||||
|
{ tr: "foggy" },
|
||||||
|
{ tr: "windy" },
|
||||||
|
{ tr: "clear" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initializeFalukantWeatherTypes = async () => {
|
||||||
|
try {
|
||||||
|
for (const weatherType of weatherTypes) {
|
||||||
|
await WeatherType.findOrCreate({
|
||||||
|
where: { tr: weatherType.tr },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[Falukant] Wettertypen initialisiert: ${weatherTypes.length} Typen`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Initialisieren der Falukant-Wettertypen:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initializeFalukantWeathers = async () => {
|
||||||
|
try {
|
||||||
|
// Hole alle Städte (Regions vom Typ "city")
|
||||||
|
const cityRegionType = await RegionType.findOne({ where: { labelTr: 'city' } });
|
||||||
|
if (!cityRegionType) {
|
||||||
|
console.warn('[Falukant] Kein RegionType "city" gefunden, überspringe Wetter-Initialisierung');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cities = await RegionData.findAll({
|
||||||
|
where: { regionTypeId: cityRegionType.id },
|
||||||
|
attributes: ['id', 'name']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hole alle Wettertypen
|
||||||
|
const allWeatherTypes = await WeatherType.findAll();
|
||||||
|
if (allWeatherTypes.length === 0) {
|
||||||
|
console.warn('[Falukant] Keine Wettertypen gefunden, überspringe Wetter-Initialisierung');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weise jeder Stadt zufällig ein Wetter zu
|
||||||
|
for (const city of cities) {
|
||||||
|
const randomWeatherType = allWeatherTypes[Math.floor(Math.random() * allWeatherTypes.length)];
|
||||||
|
await Weather.findOrCreate({
|
||||||
|
where: { regionId: city.id },
|
||||||
|
defaults: {
|
||||||
|
weatherTypeId: randomWeatherType.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Falukant] Wetter für ${cities.length} Städte initialisiert`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Initialisieren der Falukant-Wetter:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initializeFalukantProductWeatherEffects = async () => {
|
||||||
|
try {
|
||||||
|
// Hole alle Produkte und Wettertypen
|
||||||
|
const products = await ProductType.findAll();
|
||||||
|
const weatherTypes = await WeatherType.findAll();
|
||||||
|
|
||||||
|
if (products.length === 0 || weatherTypes.length === 0) {
|
||||||
|
console.warn('[Falukant] Keine Produkte oder Wettertypen gefunden, überspringe Produkt-Wetter-Effekte');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Map für schnellen Zugriff
|
||||||
|
const productMap = new Map(products.map(p => [p.labelTr, p.id]));
|
||||||
|
const weatherMap = new Map(weatherTypes.map(w => [w.tr, w.id]));
|
||||||
|
|
||||||
|
// Definiere Effekte für jedes Produkt-Wetter-Paar
|
||||||
|
// Format: { productLabel: { weatherTr: effectValue } }
|
||||||
|
// effectValue: -2 (sehr negativ), -1 (negativ), 0 (neutral), 1 (positiv), 2 (sehr positiv)
|
||||||
|
const effects = {
|
||||||
|
// Landwirtschaftliche Produkte
|
||||||
|
wheat: {
|
||||||
|
sunny: 1, // Gutes Wachstum
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 2, // Wasser ist essentiell
|
||||||
|
stormy: -1, // Kann Ernte beschädigen
|
||||||
|
snowy: -2, // Kein Wachstum
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
grain: {
|
||||||
|
sunny: 1,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 2,
|
||||||
|
stormy: -1,
|
||||||
|
snowy: -2,
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
carrot: {
|
||||||
|
sunny: 1,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 2,
|
||||||
|
stormy: -1,
|
||||||
|
snowy: -2,
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
fish: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: -2, // Gefährlich zu fischen
|
||||||
|
snowy: -1, // Kaltes Wasser
|
||||||
|
foggy: -1, // Schlechte Sicht
|
||||||
|
windy: -1, // Schwierig zu fischen
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
meat: {
|
||||||
|
sunny: -1, // Kann verderben
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 1, // Kühlt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
leather: {
|
||||||
|
sunny: -1, // Kann austrocknen
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 1, // Kühlt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
wood: {
|
||||||
|
sunny: 1, // Trocknet gut
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -2, // Kann beschädigt werden
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
stone: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: 0,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
milk: {
|
||||||
|
sunny: -1, // Kann sauer werden
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: -1,
|
||||||
|
snowy: 1, // Kühlt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
cheese: {
|
||||||
|
sunny: -1,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -1,
|
||||||
|
snowy: 1, // Kühlt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
bread: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -1,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
beer: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: 0,
|
||||||
|
snowy: 1, // Kühlt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
iron: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Rost
|
||||||
|
stormy: -2, // Rost
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
copper: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Oxidation
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
spices: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -1,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
salt: {
|
||||||
|
sunny: 1, // Trocknet gut
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -2, // Löst sich auf
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
sugar: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -2, // Löst sich auf
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
vinegar: {
|
||||||
|
sunny: 1, // Heißes Wetter fördert Gärung
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: 0,
|
||||||
|
snowy: -1, // Kaltes Wetter hemmt Gärung
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 1 // Heißes Wetter fördert Gärung
|
||||||
|
},
|
||||||
|
cotton: {
|
||||||
|
sunny: 1, // Trocknet gut
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
wine: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: 0,
|
||||||
|
snowy: 1, // Kühlt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
gold: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: 0,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
diamond: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: 0,
|
||||||
|
stormy: 0,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
furniture: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
clothing: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
jewelry: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -2, // Kann beschädigt werden
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
painting: {
|
||||||
|
sunny: -1, // Kann verblassen
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -2, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: -1
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
sunny: -1, // Kann verblassen
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -2, // Feucht
|
||||||
|
stormy: -2,
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: -1
|
||||||
|
},
|
||||||
|
weapon: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Rost
|
||||||
|
stormy: -2, // Rost
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
armor: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Rost
|
||||||
|
stormy: -2, // Rost
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
shield: {
|
||||||
|
sunny: 0,
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Rost
|
||||||
|
stormy: -2, // Rost
|
||||||
|
snowy: 0,
|
||||||
|
foggy: -1, // Feucht
|
||||||
|
windy: 0,
|
||||||
|
clear: 0
|
||||||
|
},
|
||||||
|
horse: {
|
||||||
|
sunny: 1, // Gutes Wetter
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Nass
|
||||||
|
stormy: -2, // Angst
|
||||||
|
snowy: -1, // Kalt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
},
|
||||||
|
ox: {
|
||||||
|
sunny: 1, // Gutes Wetter
|
||||||
|
cloudy: 0,
|
||||||
|
rainy: -1, // Nass
|
||||||
|
stormy: -2, // Angst
|
||||||
|
snowy: -1, // Kalt
|
||||||
|
foggy: 0,
|
||||||
|
windy: 0,
|
||||||
|
clear: 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erstelle alle Produkt-Wetter-Effekte
|
||||||
|
const effectEntries = [];
|
||||||
|
for (const [productLabel, weatherEffects] of Object.entries(effects)) {
|
||||||
|
const productId = productMap.get(productLabel);
|
||||||
|
if (!productId) {
|
||||||
|
console.warn(`[Falukant] Produkt "${productLabel}" nicht gefunden, überspringe`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [weatherTr, effectValue] of Object.entries(weatherEffects)) {
|
||||||
|
const weatherTypeId = weatherMap.get(weatherTr);
|
||||||
|
if (!weatherTypeId) {
|
||||||
|
console.warn(`[Falukant] Wettertyp "${weatherTr}" nicht gefunden, überspringe`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
effectEntries.push({
|
||||||
|
productId,
|
||||||
|
weatherTypeId,
|
||||||
|
qualityEffect: effectValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk insert mit ignoreDuplicates
|
||||||
|
await ProductWeatherEffect.bulkCreate(effectEntries, {
|
||||||
|
ignoreDuplicates: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Falukant] Produkt-Wetter-Effekte initialisiert: ${effectEntries.length} Einträge`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Initialisieren der Produkt-Wetter-Effekte:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
|
|||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
// Optionales Performance-Logging (aktivierbar per ENV)
|
||||||
|
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
|
||||||
|
// - SQL_SLOW_MS=200: Logge nur Queries ab dieser Dauer (wenn SQL_LOG_ALL nicht gesetzt)
|
||||||
|
// - SQL_LOG_ALL=1: Logge alle Queries (auch ohne benchmark)
|
||||||
|
const SQL_BENCHMARK = process.env.SQL_BENCHMARK === '1';
|
||||||
|
const SQL_LOG_ALL = process.env.SQL_LOG_ALL === '1';
|
||||||
|
const SQL_SLOW_MS = Number.parseInt(process.env.SQL_SLOW_MS || '200', 10);
|
||||||
|
const sqlLogger = (sql, timing) => {
|
||||||
|
// Sequelize ruft logging(sql) oder logging(sql, timing) abhängig von benchmark auf.
|
||||||
|
if (!SQL_BENCHMARK) {
|
||||||
|
if (SQL_LOG_ALL) console.log(sql);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ms = typeof timing === 'number' ? timing : 0;
|
||||||
|
if (SQL_LOG_ALL || ms >= SQL_SLOW_MS) {
|
||||||
|
console.log(`🛢️ SQL ${ms}ms: ${sql}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Validiere Umgebungsvariablen
|
// Validiere Umgebungsvariablen
|
||||||
const dbName = process.env.DB_NAME;
|
const dbName = process.env.DB_NAME;
|
||||||
const dbUser = process.env.DB_USER;
|
const dbUser = process.env.DB_USER;
|
||||||
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
|||||||
timestamps: false,
|
timestamps: false,
|
||||||
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
||||||
},
|
},
|
||||||
|
benchmark: SQL_BENCHMARK,
|
||||||
|
logging: sqlLogger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSchemas = async () => {
|
const createSchemas = async () => {
|
||||||
@@ -45,6 +66,16 @@ const createSchemas = async () => {
|
|||||||
|
|
||||||
const initializeDatabase = async () => {
|
const initializeDatabase = async () => {
|
||||||
await createSchemas();
|
await createSchemas();
|
||||||
|
|
||||||
|
// Aktiviere die pgcrypto Erweiterung für die digest() Funktion
|
||||||
|
try {
|
||||||
|
await sequelize.query('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
|
||||||
|
console.log('✅ pgcrypto Erweiterung aktiviert');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Konnte pgcrypto Erweiterung nicht aktivieren:', error.message);
|
||||||
|
// Fortfahren, da die Erweiterung möglicherweise bereits aktiviert ist
|
||||||
|
}
|
||||||
|
|
||||||
// Modelle nur laden, aber an dieser Stelle NICHT syncen.
|
// Modelle nur laden, aber an dieser Stelle NICHT syncen.
|
||||||
// Das Syncing (inkl. alter: true bei Bedarf) wird anschließend zentral
|
// Das Syncing (inkl. alter: true bei Bedarf) wird anschließend zentral
|
||||||
// über syncModelsWithUpdates()/syncModelsAlways gesteuert.
|
// über syncModelsWithUpdates()/syncModelsAlways gesteuert.
|
||||||
@@ -95,7 +126,8 @@ const syncModelsWithUpdates = async (models) => {
|
|||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
console.log('🔄 Schema-Updates nötig - verwende alter: true');
|
console.log('🔄 Schema-Updates nötig - verwende alter: true');
|
||||||
for (const model of Object.values(models)) {
|
for (const model of Object.values(models)) {
|
||||||
await model.sync({ alter: true, force: false });
|
// constraints: false verhindert, dass Sequelize Foreign Keys automatisch erstellt
|
||||||
|
await model.sync({ alter: true, force: false, constraints: false });
|
||||||
}
|
}
|
||||||
console.log('✅ Schema-Updates abgeschlossen');
|
console.log('✅ Schema-Updates abgeschlossen');
|
||||||
} else {
|
} else {
|
||||||
@@ -363,12 +395,13 @@ const getExpectedDefaultValue = (defaultValue) => {
|
|||||||
const updateSchema = async (models) => {
|
const updateSchema = async (models) => {
|
||||||
console.log('🔄 Aktualisiere Datenbankschema...');
|
console.log('🔄 Aktualisiere Datenbankschema...');
|
||||||
for (const model of Object.values(models)) {
|
for (const model of Object.values(models)) {
|
||||||
await model.sync({ alter: true, force: false });
|
// constraints: false verhindert, dass Sequelize Foreign Keys automatisch erstellt
|
||||||
|
await model.sync({ alter: true, force: false, constraints: false });
|
||||||
}
|
}
|
||||||
console.log('✅ Datenbankschema aktualisiert');
|
console.log('✅ Datenbankschema aktualisiert');
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) {
|
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
|
||||||
try {
|
try {
|
||||||
const result = await sequelize.query(
|
const result = await sequelize.query(
|
||||||
`SELECT falukant_data.update_money(
|
`SELECT falukant_data.update_money(
|
||||||
@@ -385,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
|
|||||||
changedBy,
|
changedBy,
|
||||||
},
|
},
|
||||||
type: sequelize.QueryTypes.SELECT,
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
transaction: transaction || undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -406,8 +440,311 @@ const syncModelsAlways = async (models) => {
|
|||||||
console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...');
|
console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const model of Object.values(models)) {
|
for (const model of Object.values(models)) {
|
||||||
await model.sync({ alter: true, force: false });
|
// Temporarily remove VIRTUAL fields before sync to prevent sync errors
|
||||||
|
const originalAttributes = model.rawAttributes;
|
||||||
|
const virtualFields = {};
|
||||||
|
|
||||||
|
// Find and temporarily remove VIRTUAL fields
|
||||||
|
// Check multiple ways to identify VIRTUAL fields
|
||||||
|
for (const [key, attr] of Object.entries(originalAttributes)) {
|
||||||
|
// Check if it's a VIRTUAL field by checking the type
|
||||||
|
let isVirtual = false;
|
||||||
|
|
||||||
|
if (attr.type) {
|
||||||
|
// Method 1: Check if type key is VIRTUAL (most reliable)
|
||||||
|
if (attr.type.key === 'VIRTUAL') {
|
||||||
|
isVirtual = true;
|
||||||
|
}
|
||||||
|
// Method 2: Direct comparison with DataTypes.VIRTUAL
|
||||||
|
else if (attr.type === DataTypes.VIRTUAL) {
|
||||||
|
isVirtual = true;
|
||||||
|
}
|
||||||
|
// Method 3: Check toString representation
|
||||||
|
else if (typeof attr.type.toString === 'function') {
|
||||||
|
const typeStr = attr.type.toString();
|
||||||
|
if (typeStr === 'VIRTUAL' || typeStr.includes('VIRTUAL')) {
|
||||||
|
isVirtual = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Method 4: Check constructor name
|
||||||
|
else if (attr.type.constructor && attr.type.constructor.name === 'VIRTUAL') {
|
||||||
|
isVirtual = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if field has a getter but no setter and no field mapping (common pattern for VIRTUAL fields)
|
||||||
|
// But only if it doesn't have a 'field' property, which means it's not mapped to a database column
|
||||||
|
if (!isVirtual && attr.get && !attr.set && !attr.field) {
|
||||||
|
// This might be a VIRTUAL field, but be careful not to remove real fields
|
||||||
|
// Only remove if we're certain it's VIRTUAL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVirtual) {
|
||||||
|
virtualFields[key] = attr;
|
||||||
|
delete model.rawAttributes[key];
|
||||||
|
console.log(` ⚠️ Temporarily removed VIRTUAL field: ${key} from model ${model.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for Notification model: ensure characterName VIRTUAL field is removed
|
||||||
|
// This is a workaround for Sequelize bug where it confuses characterName (VIRTUAL) with character_name (STRING)
|
||||||
|
if (model.name === 'Notification' && model.rawAttributes.characterName) {
|
||||||
|
if (!virtualFields.characterName) {
|
||||||
|
virtualFields.characterName = model.rawAttributes.characterName;
|
||||||
|
delete model.rawAttributes.characterName;
|
||||||
|
console.log(` ⚠️ Explicitly removed VIRTUAL field: characterName from Notification model`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// constraints: false wird von Sequelize ignoriert wenn Associations vorhanden sind
|
||||||
|
// Wir müssen die Associations temporär entfernen, um Foreign Keys zu verhindern
|
||||||
|
const originalAssociations = model.associations ? { ...model.associations } : {};
|
||||||
|
const associationKeys = Object.keys(originalAssociations);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Entferne temporär alle Associations, damit Sequelize keine Foreign Keys erstellt
|
||||||
|
// Dies muss innerhalb des try Blocks sein, damit die Wiederherstellung im finally Block garantiert ist
|
||||||
|
if (associationKeys.length > 0) {
|
||||||
|
console.log(` ⚠️ Temporarily removing ${associationKeys.length} associations from ${model.name} to prevent FK creation`);
|
||||||
|
// Lösche alle Associations temporär
|
||||||
|
for (const key of associationKeys) {
|
||||||
|
delete model.associations[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne bestehende Foreign Keys vor dem Sync, damit Sequelize sie nicht aktualisiert
|
||||||
|
try {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
// Schema kann eine Funktion sein, daher prüfen wir model.options.schema direkt
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
|
||||||
|
console.log(` 🔍 Checking for foreign keys in ${schema}.${tableName}...`);
|
||||||
|
const foreignKeys = await sequelize.query(`
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_name = :tableName
|
||||||
|
AND tc.table_schema = :schema
|
||||||
|
`, {
|
||||||
|
replacements: { tableName, schema },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foreignKeys && foreignKeys.length > 0) {
|
||||||
|
console.log(` ⚠️ Found ${foreignKeys.length} existing foreign keys:`, foreignKeys.map(fk => fk.constraint_name).join(', '));
|
||||||
|
console.log(` ⚠️ Removing ${foreignKeys.length} existing foreign keys from ${model.name} (schema: ${schema}) before sync`);
|
||||||
|
for (const fk of foreignKeys) {
|
||||||
|
console.log(` 🗑️ Dropping constraint: ${fk.constraint_name}`);
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE "${schema}"."${tableName}"
|
||||||
|
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
console.log(` ✅ All foreign keys removed for ${model.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ✅ No foreign keys found for ${model.name}`);
|
||||||
|
}
|
||||||
|
} catch (fkError) {
|
||||||
|
console.warn(` ⚠️ Could not remove foreign keys for ${model.name}:`, fkError.message);
|
||||||
|
console.warn(` ⚠️ Error details:`, fkError);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` 🔄 Syncing model ${model.name} with constraints: false`);
|
||||||
|
try {
|
||||||
|
// Versuche doppelte pg_description Einträge vor dem Sync zu bereinigen
|
||||||
|
// Hinweis: Benötigt Superuser-Rechte oder spezielle Berechtigungen
|
||||||
|
try {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
// Verwende direkte Parameter-Einsetzung, da DO $$ keine Parameterbindung unterstützt
|
||||||
|
// Die Parameter sind sicher, da sie von Sequelize-Modell-Eigenschaften kommen
|
||||||
|
await sequelize.query(`
|
||||||
|
DELETE FROM pg_catalog.pg_description d1
|
||||||
|
WHERE d1.objoid IN (
|
||||||
|
SELECT c.oid
|
||||||
|
FROM pg_catalog.pg_class c
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relname = '${tableName.replace(/'/g, "''")}'
|
||||||
|
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 });
|
||||||
|
} catch (syncError) {
|
||||||
|
// 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
|
||||||
|
if (syncError.message && (syncError.message.includes('mehr als eine Zeile') || syncError.message.includes('more than one row'))) {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
console.error(` ❌ Cannot sync ${model.name} (${schema}.${tableName}) due to Sequelize describeTable error`);
|
||||||
|
console.error(` ❌ This is likely caused by multiple tables with the same name in different schemas`);
|
||||||
|
console.error(` ❌ or duplicate pg_description entries (requires superuser to fix)`);
|
||||||
|
console.error(` ⚠️ Skipping sync for ${model.name} - Schema is likely already correct`);
|
||||||
|
// Überspringe dieses Model und fahre mit dem nächsten fort
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Wenn eine referenzierte Tabelle noch nicht existiert, erstelle die Tabelle ohne Foreign Key
|
||||||
|
else if (syncError.message && (syncError.message.includes('existiert nicht') || syncError.message.includes('does not exist') || syncError.message.includes('Relation'))) {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
console.warn(` ⚠️ Cannot create ${model.name} (${schema}.${tableName}) with Foreign Key - referenced table does not exist yet`);
|
||||||
|
console.warn(` ⚠️ Attempting to create table without Foreign Key constraint...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prüfe, ob die Tabelle bereits existiert
|
||||||
|
const [tableExists] = await sequelize.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = :schema
|
||||||
|
AND table_name = :tableName
|
||||||
|
) as exists
|
||||||
|
`, {
|
||||||
|
replacements: { schema, tableName },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tableExists && tableExists.exists) {
|
||||||
|
console.log(` ℹ️ Table ${schema}.${tableName} already exists, skipping creation`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle die Tabelle manuell ohne Foreign Key
|
||||||
|
// Verwende queryInterface.createTable mit den Attributen, aber ohne Foreign Keys
|
||||||
|
const queryInterface = sequelize.getQueryInterface();
|
||||||
|
const attributes = {};
|
||||||
|
|
||||||
|
// Kopiere alle Attribute aus dem Model, aber entferne references
|
||||||
|
for (const [key, attr] of Object.entries(model.rawAttributes)) {
|
||||||
|
attributes[key] = { ...attr };
|
||||||
|
// Entferne references, damit kein Foreign Key erstellt wird
|
||||||
|
if (attributes[key].references) {
|
||||||
|
delete attributes[key].references;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle die Tabelle mit queryInterface.createTable ohne Foreign Keys
|
||||||
|
await queryInterface.createTable(tableName, attributes, {
|
||||||
|
schema,
|
||||||
|
// Stelle sicher, dass keine Foreign Keys erstellt werden
|
||||||
|
charset: model.options?.charset,
|
||||||
|
collate: model.options?.collate
|
||||||
|
});
|
||||||
|
console.log(` ✅ Table ${schema}.${tableName} created successfully without Foreign Key`);
|
||||||
|
} catch (createError) {
|
||||||
|
console.error(` ❌ Failed to create table ${schema}.${tableName} without Foreign Key:`, createError.message);
|
||||||
|
console.error(` ⚠️ Skipping ${model.name} - will retry after dependencies are created`);
|
||||||
|
// Überspringe dieses Model und fahre mit dem nächsten fort
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wenn Sequelize einen Foreign Key Constraint Fehler hat, entferne verwaiste Einträge oder überspringe das Model
|
||||||
|
else if (syncError.name === 'SequelizeForeignKeyConstraintError' || (syncError.message && (syncError.message.includes('FOREIGN KEY') || syncError.message.includes('Fremdschlüssel')))) {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
console.error(` ❌ Cannot sync ${model.name} (${schema}.${tableName}) due to Foreign Key Constraint Error`);
|
||||||
|
console.error(` ❌ Detail: ${syncError.parent?.detail || syncError.message}`);
|
||||||
|
console.error(` ⚠️ This usually means there are orphaned records. Cleanup should have removed them.`);
|
||||||
|
console.error(` ⚠️ Skipping sync for ${model.name} - please check and fix orphaned records manually`);
|
||||||
|
// Überspringe dieses Model und fahre mit dem nächsten fort
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Wenn Sequelize versucht, Foreign Keys zu erstellen, entferne sie nach dem Fehler
|
||||||
|
else if (syncError.message && syncError.message.includes('REFERENCES')) {
|
||||||
|
console.log(` ⚠️ Sequelize tried to create FK despite constraints: false, removing any created FKs...`);
|
||||||
|
try {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
const foreignKeys = await sequelize.query(`
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_name = :tableName
|
||||||
|
AND tc.table_schema = :schema
|
||||||
|
`, {
|
||||||
|
replacements: { tableName, schema },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foreignKeys && foreignKeys.length > 0) {
|
||||||
|
for (const fk of foreignKeys) {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE "${schema}"."${tableName}"
|
||||||
|
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Versuche Sync erneut ohne Foreign Keys
|
||||||
|
console.log(` 🔄 Retrying sync without foreign keys...`);
|
||||||
|
await model.sync({ alter: true, force: false, constraints: false });
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error(` ❌ Retry failed:`, retryError.message);
|
||||||
|
console.error(` ❌ Original sync error:`, syncError.message);
|
||||||
|
// Kombiniere beide Fehler für besseres Debugging
|
||||||
|
const combinedError = new Error(`Sync failed: ${syncError.message}. Retry also failed: ${retryError.message}`);
|
||||||
|
combinedError.originalError = syncError;
|
||||||
|
combinedError.retryError = retryError;
|
||||||
|
throw combinedError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw syncError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entferne alle Foreign Keys, die Sequelize möglicherweise trotzdem erstellt hat
|
||||||
|
try {
|
||||||
|
const tableName = model.tableName;
|
||||||
|
const schema = model.options?.schema || 'public';
|
||||||
|
const foreignKeys = await sequelize.query(`
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints AS tc
|
||||||
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.table_name = :tableName
|
||||||
|
AND tc.table_schema = :schema
|
||||||
|
`, {
|
||||||
|
replacements: { tableName, schema },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foreignKeys && foreignKeys.length > 0) {
|
||||||
|
console.log(` ⚠️ Sequelize created ${foreignKeys.length} foreign keys despite constraints: false, removing them...`);
|
||||||
|
for (const fk of foreignKeys) {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE "${schema}"."${tableName}"
|
||||||
|
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fkError) {
|
||||||
|
console.warn(` ⚠️ Could not check/remove foreign keys after sync:`, fkError.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Stelle die Associations wieder her (IMMER, auch bei Fehlern)
|
||||||
|
if (associationKeys.length > 0) {
|
||||||
|
console.log(` ✅ Restoring ${associationKeys.length} associations for ${model.name}`);
|
||||||
|
model.associations = originalAssociations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore VIRTUAL fields after sync
|
||||||
|
for (const [key, attr] of Object.entries(virtualFields)) {
|
||||||
|
model.rawAttributes[key] = attr;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log('✅ Schema-Updates für alle Models abgeschlossen');
|
console.log('✅ Schema-Updates für alle Models abgeschlossen');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -33,6 +33,123 @@ const syncDatabase = async () => {
|
|||||||
console.log("Initializing database schemas...");
|
console.log("Initializing database schemas...");
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
|
// Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations)
|
||||||
|
// 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.
|
||||||
|
console.log("Ensuring Vocab-Trainer tables exist...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_language (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
share_code TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_language_owner_fk
|
||||||
|
FOREIGN KEY (owner_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_language_subscription_user_fk
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_language_subscription_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
|
||||||
|
ON community.vocab_language(owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
|
||||||
|
ON community.vocab_language_subscription(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
|
||||||
|
ON community.vocab_language_subscription(language_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_chapter_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chapter_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
|
||||||
|
ON community.vocab_chapter(language_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
language_id INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
normalized TEXT NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_lexeme_language_fk
|
||||||
|
FOREIGN KEY (language_id)
|
||||||
|
REFERENCES community.vocab_language(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_lexeme_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
|
||||||
|
ON community.vocab_lexeme(language_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
chapter_id INTEGER NOT NULL,
|
||||||
|
learning_lexeme_id INTEGER NOT NULL,
|
||||||
|
reference_lexeme_id INTEGER NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT vocab_chlex_chapter_fk
|
||||||
|
FOREIGN KEY (chapter_id)
|
||||||
|
REFERENCES community.vocab_chapter(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_learning_fk
|
||||||
|
FOREIGN KEY (learning_lexeme_id)
|
||||||
|
REFERENCES community.vocab_lexeme(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_reference_fk
|
||||||
|
FOREIGN KEY (reference_lexeme_id)
|
||||||
|
REFERENCES community.vocab_lexeme(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_creator_fk
|
||||||
|
FOREIGN KEY (created_by_user_id)
|
||||||
|
REFERENCES community."user"(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
|
||||||
|
ON community.vocab_chapter_lexeme(chapter_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
|
||||||
|
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
||||||
|
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
||||||
|
`);
|
||||||
|
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', 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 {
|
||||||
@@ -54,6 +171,112 @@ const syncDatabase = async () => {
|
|||||||
console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e);
|
console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup: Entferne verwaiste Einträge vor Schema-Updates (nur wenn Schema-Updates aktiviert)
|
||||||
|
if (currentStage === 'dev') {
|
||||||
|
console.log("Cleaning up orphaned entries...");
|
||||||
|
try {
|
||||||
|
// Cleanup user_param_visibility
|
||||||
|
const result1 = await sequelize.query(`
|
||||||
|
DELETE FROM community.user_param_visibility
|
||||||
|
WHERE param_id NOT IN (
|
||||||
|
SELECT id FROM community.user_param
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount1 = result1[1] || 0;
|
||||||
|
if (deletedCount1 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount1} verwaiste user_param_visibility Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stock mit ungültigen branch_id (0 oder nicht existierend)
|
||||||
|
const result2 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.stock
|
||||||
|
WHERE branch_id = 0 OR branch_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.branch
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount2 = result2[1] || 0;
|
||||||
|
if (deletedCount2 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount2} verwaiste stock Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup knowledge mit ungültigen character_id oder product_id
|
||||||
|
const result3 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.knowledge
|
||||||
|
WHERE character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR product_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.product
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount3 = result3[1] || 0;
|
||||||
|
if (deletedCount3 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount3} verwaiste knowledge Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup notification mit ungültigen user_id
|
||||||
|
const result4 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_log.notification
|
||||||
|
WHERE user_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.falukant_user
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount4 = result4[1] || 0;
|
||||||
|
if (deletedCount4 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount4} verwaiste notification Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup promotional_gift mit ungültigen sender_character_id oder recipient_character_id
|
||||||
|
const result5 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_log.promotional_gift
|
||||||
|
WHERE sender_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR recipient_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount5 = result5[1] || 0;
|
||||||
|
if (deletedCount5 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount5} verwaiste promotional_gift Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup user_house mit ungültigen house_type_id oder user_id
|
||||||
|
const result6 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.user_house
|
||||||
|
WHERE house_type_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.house
|
||||||
|
) OR user_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.falukant_user
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount6 = result6[1] || 0;
|
||||||
|
if (deletedCount6 > 0) {
|
||||||
|
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
|
||||||
|
const result7 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.child_relation
|
||||||
|
WHERE father_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR mother_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR child_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount7 = result7[1] || 0;
|
||||||
|
if (deletedCount7 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount7} verwaiste child_relation Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0) {
|
||||||
|
console.log("✅ Keine verwaisten Einträge gefunden");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte verwaiste Einträge nicht bereinigen:', e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Setting up associations...");
|
console.log("Setting up associations...");
|
||||||
setupAssociations();
|
setupAssociations();
|
||||||
|
|
||||||
@@ -104,6 +327,10 @@ const syncDatabase = async () => {
|
|||||||
// Deployment-Synchronisation (immer Schema-Updates)
|
// Deployment-Synchronisation (immer Schema-Updates)
|
||||||
const syncDatabaseForDeployment = async () => {
|
const syncDatabaseForDeployment = async () => {
|
||||||
try {
|
try {
|
||||||
|
// WICHTIG: Bei Caching-Problemen das Script neu starten
|
||||||
|
// Node.js cached ES-Module, daher müssen Models neu geladen werden
|
||||||
|
console.log('📦 Lade Models neu (Node.js Module-Cache wird verwendet)...');
|
||||||
|
|
||||||
// Zeige den aktuellen Stage an
|
// Zeige den aktuellen Stage an
|
||||||
const currentStage = process.env.STAGE || 'nicht gesetzt';
|
const currentStage = process.env.STAGE || 'nicht gesetzt';
|
||||||
console.log(`🚀 Starte Datenbank-Synchronisation für Deployment (Stage: ${currentStage})`);
|
console.log(`🚀 Starte Datenbank-Synchronisation für Deployment (Stage: ${currentStage})`);
|
||||||
@@ -133,6 +360,185 @@ const syncDatabaseForDeployment = async () => {
|
|||||||
console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e);
|
console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Transport product_id und size nullable machen
|
||||||
|
console.log("Making transport product_id and size nullable...");
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Prüfe ob product_id NOT NULL Constraint existiert
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_data'
|
||||||
|
AND table_name = 'transport'
|
||||||
|
AND column_name = 'product_id'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.transport
|
||||||
|
ALTER COLUMN product_id DROP NOT NULL;
|
||||||
|
RAISE NOTICE 'product_id NOT NULL Constraint entfernt';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Prüfe ob size NOT NULL Constraint existiert
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'falukant_data'
|
||||||
|
AND table_name = 'transport'
|
||||||
|
AND column_name = 'size'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.transport
|
||||||
|
ALTER COLUMN size DROP NOT NULL;
|
||||||
|
RAISE NOTICE 'size NOT NULL Constraint entfernt';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
console.log("✅ Transport product_id und size sind jetzt nullable");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte Transport-Spalten nicht nullable machen:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: Entferne verwaiste Einträge vor Schema-Updates
|
||||||
|
console.log("Cleaning up orphaned entries...");
|
||||||
|
try {
|
||||||
|
// Cleanup user_param_visibility
|
||||||
|
const result1 = await sequelize.query(`
|
||||||
|
DELETE FROM community.user_param_visibility
|
||||||
|
WHERE param_id NOT IN (
|
||||||
|
SELECT id FROM community.user_param
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount1 = result1[1] || 0;
|
||||||
|
if (deletedCount1 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount1} verwaiste user_param_visibility Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stock mit ungültigen branch_id (0 oder nicht existierend)
|
||||||
|
const result2 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.stock
|
||||||
|
WHERE branch_id = 0 OR branch_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.branch
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount2 = result2[1] || 0;
|
||||||
|
if (deletedCount2 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount2} verwaiste stock Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup knowledge mit ungültigen character_id oder product_id
|
||||||
|
const result3 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.knowledge
|
||||||
|
WHERE character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR product_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.product
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount3 = result3[1] || 0;
|
||||||
|
if (deletedCount3 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount3} verwaiste knowledge Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup notification mit ungültigen user_id
|
||||||
|
const result4 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_log.notification
|
||||||
|
WHERE user_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.falukant_user
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount4 = result4[1] || 0;
|
||||||
|
if (deletedCount4 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount4} verwaiste notification Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup promotional_gift mit ungültigen sender_character_id oder recipient_character_id
|
||||||
|
const result5 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_log.promotional_gift
|
||||||
|
WHERE sender_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR recipient_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount5 = result5[1] || 0;
|
||||||
|
if (deletedCount5 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount5} verwaiste promotional_gift Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup user_house mit ungültigen house_type_id oder user_id
|
||||||
|
const result6 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.user_house
|
||||||
|
WHERE house_type_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.house
|
||||||
|
) OR user_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.falukant_user
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount6 = result6[1] || 0;
|
||||||
|
if (deletedCount6 > 0) {
|
||||||
|
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
|
||||||
|
const result7 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.child_relation
|
||||||
|
WHERE father_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR mother_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR child_character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount7 = result7[1] || 0;
|
||||||
|
if (deletedCount7 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount7} verwaiste child_relation Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup political_office mit ungültigen character_id, office_type_id oder region_id
|
||||||
|
const result8 = await sequelize.query(`
|
||||||
|
DELETE FROM falukant_data.political_office
|
||||||
|
WHERE character_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.character
|
||||||
|
) OR office_type_id NOT IN (
|
||||||
|
SELECT id FROM falukant_type.political_office_type
|
||||||
|
) OR region_id NOT IN (
|
||||||
|
SELECT id FROM falukant_data.region
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const deletedCount8 = result8[1] || 0;
|
||||||
|
if (deletedCount8 > 0) {
|
||||||
|
console.log(`✅ ${deletedCount8} verwaiste political_office Einträge entfernt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup vehicle.condition: Legacy-Nulls + Range clamp (UI zeigt sonst "Unbekannt")
|
||||||
|
const result9 = await sequelize.query(`
|
||||||
|
UPDATE falukant_data.vehicle
|
||||||
|
SET condition = 100
|
||||||
|
WHERE condition IS NULL;
|
||||||
|
`);
|
||||||
|
const updatedNullConditions = result9[1] || 0;
|
||||||
|
if (updatedNullConditions > 0) {
|
||||||
|
console.log(`✅ ${updatedNullConditions} vehicle.condition NULL → 100 gesetzt`);
|
||||||
|
}
|
||||||
|
const result10 = await sequelize.query(`
|
||||||
|
UPDATE falukant_data.vehicle
|
||||||
|
SET condition = GREATEST(0, LEAST(100, condition))
|
||||||
|
WHERE condition < 0 OR condition > 100;
|
||||||
|
`);
|
||||||
|
const clampedConditions = result10[1] || 0;
|
||||||
|
if (clampedConditions > 0) {
|
||||||
|
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) {
|
||||||
|
console.log("✅ Keine verwaisten Einträge gefunden");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Konnte verwaiste Einträge nicht bereinigen:', e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Setting up associations...");
|
console.log("Setting up associations...");
|
||||||
setupAssociations();
|
setupAssociations();
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ fi
|
|||||||
# 5. Frontend neu bauen – VITE_* aus Environment übernehmen oder Defaults setzen
|
# 5. Frontend neu bauen – VITE_* aus Environment übernehmen oder Defaults setzen
|
||||||
echo "Baue Frontend neu..."
|
echo "Baue Frontend neu..."
|
||||||
export VITE_API_BASE_URL=${VITE_API_BASE_URL:-https://www.your-part.de}
|
export VITE_API_BASE_URL=${VITE_API_BASE_URL:-https://www.your-part.de}
|
||||||
|
# Standard: Daemon direkt auf Port 4551, nicht über Apache-Proxy
|
||||||
export VITE_DAEMON_SOCKET=${VITE_DAEMON_SOCKET:-wss://www.your-part.de:4551}
|
export VITE_DAEMON_SOCKET=${VITE_DAEMON_SOCKET:-wss://www.your-part.de:4551}
|
||||||
export VITE_CHAT_WS_URL=${VITE_CHAT_WS_URL:-wss://www.your-part.de:1235}
|
export VITE_CHAT_WS_URL=${VITE_CHAT_WS_URL:-wss://www.your-part.de:1235}
|
||||||
|
|
||||||
|
|||||||
142
deploy-with-config.sh
Executable file
142
deploy-with-config.sh
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== YourPart Deployment mit vorhandener Konfiguration ==="
|
||||||
|
|
||||||
|
# Prüfen ob wir im richtigen Verzeichnis sind
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
echo "Error: Bitte führen Sie dieses Skript aus dem YourPart3-Root-Verzeichnis aus"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Frontend bauen
|
||||||
|
echo "Building frontend..."
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Verzeichnisse erstellen
|
||||||
|
echo "Creating directories..."
|
||||||
|
sudo mkdir -p /opt/yourpart/{frontend,backend,images/{tmp,userimages,screenshots}}
|
||||||
|
|
||||||
|
# Frontend kopieren
|
||||||
|
echo "Deploying frontend..."
|
||||||
|
sudo cp -r frontend/dist /opt/yourpart/frontend/
|
||||||
|
sudo chown -R www-data:www-data /opt/yourpart/frontend
|
||||||
|
sudo chmod -R 755 /opt/yourpart/frontend
|
||||||
|
|
||||||
|
# Backend kopieren
|
||||||
|
echo "Deploying backend..."
|
||||||
|
sudo cp -r backend/* /opt/yourpart/backend/
|
||||||
|
sudo chown -R www-data:www-data /opt/yourpart/backend
|
||||||
|
sudo chmod -R 755 /opt/yourpart/backend
|
||||||
|
|
||||||
|
# .env-Datei erstellen
|
||||||
|
echo "Creating .env file..."
|
||||||
|
cat > /opt/yourpart/backend/.env << 'ENVEOF'
|
||||||
|
# Datenbank-Konfiguration (aus alter App)
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_USER=yourpart
|
||||||
|
DB_PASS=hitomisan
|
||||||
|
DB_NAME=yp2
|
||||||
|
DB_PORT=60000
|
||||||
|
|
||||||
|
# Anwendungskonfiguration
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=2020
|
||||||
|
STAGE=production
|
||||||
|
|
||||||
|
# Redis-Konfiguration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASS=your_redis_password
|
||||||
|
|
||||||
|
# Session-Konfiguration (aus alter App)
|
||||||
|
SECRET_KEY=k7e0CCw75PcmEGa
|
||||||
|
SESSION_SECRET=k7e0CCw75PcmEGa
|
||||||
|
|
||||||
|
# E-Mail-Konfiguration (aus alter App)
|
||||||
|
SMTP_HOST=smtp.1blu.de
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=e226079_0-tsschulz
|
||||||
|
SMTP_PASS=hitomisan
|
||||||
|
SMTP_SECURE=true
|
||||||
|
|
||||||
|
# E-Mail-Einstellungen (aus alter App)
|
||||||
|
SENDER_NAME=YourPart
|
||||||
|
SENDER_EMAIL=kontakt@your-part.de
|
||||||
|
|
||||||
|
# AMQP-Konfiguration (aus alter App)
|
||||||
|
AMQP_HOST=tsschulz.de
|
||||||
|
AMQP_PORT=5672
|
||||||
|
AMQP_EXCHANGE=yourpart
|
||||||
|
AMQP_USERNAME=yourpart
|
||||||
|
AMQP_PASSWORD=yourpart
|
||||||
|
|
||||||
|
# API-Keys (aus alter App)
|
||||||
|
WEATHER_API_KEY=d0ddfcbc915f50263274211648a5dab0
|
||||||
|
NEWS_API_KEY=pub_212733602779de7708a7374d67e363bd06af4
|
||||||
|
|
||||||
|
# Pfad-Konfiguration (aus alter App)
|
||||||
|
ROOT_PATH=/opt/yourpart
|
||||||
|
IMAGES_PATH=/images
|
||||||
|
TMP_IMAGES_PATH=/images/tmp
|
||||||
|
USER_IMAGES_PATH=/images/userimages
|
||||||
|
SCREENSHOT_IMAGES_PATH=/images/screenshots
|
||||||
|
|
||||||
|
# URL-Konfiguration
|
||||||
|
BASE_URL=https://www.your-part.de
|
||||||
|
IMAGES_URL=https://www.your-part.de/images/
|
||||||
|
ACTIVATION_URL=https://www.your-part.de/activate?code=
|
||||||
|
PASSWORD_RESET_URL=https://www.your-part.de/setnewpassword?username=
|
||||||
|
|
||||||
|
# Spiel-Konfiguration (aus alter App)
|
||||||
|
START_BUDGET=10
|
||||||
|
TEST_MODE=false
|
||||||
|
PAY_PER_DAY=100
|
||||||
|
|
||||||
|
# Debug-Einstellungen
|
||||||
|
DEBUG_SQL=false
|
||||||
|
DEBUG_MESSAGES=false
|
||||||
|
DEBUG_RECREATE_DB=false
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
sudo chown www-data:www-data /opt/yourpart/backend/.env
|
||||||
|
sudo chmod 600 /opt/yourpart/backend/.env
|
||||||
|
|
||||||
|
# Service installieren
|
||||||
|
echo "Installing service..."
|
||||||
|
sudo cp yourpart.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable yourpart.service
|
||||||
|
|
||||||
|
# Apache-Konfiguration
|
||||||
|
echo "Configuring Apache..."
|
||||||
|
sudo cp yourpart-http.conf /etc/apache2/sites-available/
|
||||||
|
sudo cp yourpart-https.conf /etc/apache2/sites-available/
|
||||||
|
|
||||||
|
# Alte Konfiguration deaktivieren
|
||||||
|
sudo a2dissite yourpart 2>/dev/null || true
|
||||||
|
|
||||||
|
# Neue Konfigurationen aktivieren
|
||||||
|
sudo a2ensite yourpart-http
|
||||||
|
sudo a2ensite yourpart-https
|
||||||
|
|
||||||
|
# Apache-Module aktivieren
|
||||||
|
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl
|
||||||
|
sudo systemctl reload apache2
|
||||||
|
|
||||||
|
# Service starten
|
||||||
|
echo "Starting service..."
|
||||||
|
sudo systemctl start yourpart.service
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployment abgeschlossen! ==="
|
||||||
|
echo "Frontend: /opt/yourpart/frontend/dist/"
|
||||||
|
echo "Backend: /opt/yourpart/backend/"
|
||||||
|
echo "Service: yourpart.service"
|
||||||
|
echo ""
|
||||||
|
echo "Status prüfen:"
|
||||||
|
echo " sudo systemctl status yourpart.service"
|
||||||
|
echo " sudo systemctl status apache2"
|
||||||
|
echo " sudo journalctl -u yourpart.service -f"
|
||||||
143
docs/3D_ANIMATIONS_FALUKANT.md
Normal file
143
docs/3D_ANIMATIONS_FALUKANT.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# 3D-Animationen im Falukant-Bereich
|
||||||
|
|
||||||
|
## Benötigte Dependencies
|
||||||
|
|
||||||
|
### Three.js (Empfohlen)
|
||||||
|
```bash
|
||||||
|
npm install three
|
||||||
|
npm install @types/three --save-dev # Für TypeScript-Support
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative Optionen:**
|
||||||
|
- **Babylon.js**: Mächtiger, aber größer (~500KB vs ~600KB)
|
||||||
|
- **A-Frame**: WebVR-fokussiert, einfacher für VR/AR
|
||||||
|
- **React Three Fiber**: Falls React verwendet wird (hier Vue)
|
||||||
|
|
||||||
|
**Empfehlung: Three.js** - am weitesten verbreitet, beste Dokumentation, große Community
|
||||||
|
|
||||||
|
### Optional: Vue-Three.js Wrapper
|
||||||
|
```bash
|
||||||
|
npm install vue-threejs # Oder troika-three-text für Text-Rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sinnvolle Seiten für 3D-Animationen
|
||||||
|
|
||||||
|
### 1. **OverviewView** (Hauptübersicht)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐⭐
|
||||||
|
- **3D-Charakter-Modell**: Rotierendes 3D-Modell des eigenen Charakters
|
||||||
|
- **Statussymbole**: 3D-Icons für Geld, Gesundheit, Reputation (schwebend/rotierend)
|
||||||
|
- **Hintergrund**: Subtile 3D-Szene (z.B. mittelalterliche Stadt im Hintergrund)
|
||||||
|
|
||||||
|
### 2. **HouseView** (Haus)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐⭐
|
||||||
|
- **3D-Haus-Modell**: Interaktives 3D-Modell des eigenen Hauses
|
||||||
|
- **Upgrade-Visualisierung**: Animation beim Haus-Upgrade
|
||||||
|
- **Zustand-Anzeige**: 3D-Visualisierung von Dach, Wänden, Boden, Fenstern
|
||||||
|
|
||||||
|
### 3. **BranchView** (Niederlassungen)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐
|
||||||
|
- **3D-Fabrik/Gebäude**: 3D-Modell der Niederlassung
|
||||||
|
- **Produktions-Animation**: 3D-Animationen für laufende Produktionen
|
||||||
|
- **Transport-Visualisierung**: 3D-Wagen/Karren für Transporte
|
||||||
|
|
||||||
|
### 4. **FamilyView** (Familie)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐
|
||||||
|
- **3D-Charaktere**: 3D-Modelle von Partner und Kindern
|
||||||
|
- **Beziehungs-Visualisierung**: 3D-Animationen für Beziehungsstatus
|
||||||
|
- **Geschenk-Animation**: 3D-Animation beim Verschenken
|
||||||
|
|
||||||
|
### 5. **HealthView** (Gesundheit)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Körper-Modell**: 3D-Visualisierung des Gesundheitszustands
|
||||||
|
- **Aktivitäts-Animationen**: 3D-Animationen für Gesundheitsaktivitäten
|
||||||
|
|
||||||
|
### 6. **NobilityView** (Sozialstatus)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Wappen**: Rotierendes 3D-Wappen
|
||||||
|
- **Insignien**: 3D-Krone, Schwert, etc. je nach Titel
|
||||||
|
|
||||||
|
### 7. **ChurchView** (Kirche)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Kirche**: 3D-Modell der Kirche
|
||||||
|
- **Taufe-Animation**: 3D-Animation bei der Taufe
|
||||||
|
|
||||||
|
### 8. **BankView** (Bank)
|
||||||
|
**Sinnvoll:** ⭐⭐
|
||||||
|
- **3D-Bankgebäude**: 3D-Modell der Bank
|
||||||
|
- **Geld-Animation**: 3D-Münzen/Geldstapel
|
||||||
|
|
||||||
|
### 9. **UndergroundView** (Untergrund)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐⭐
|
||||||
|
- **3D-Dungeon**: 3D-Untergrund-Visualisierung
|
||||||
|
- **Aktivitäts-Animationen**: 3D-Animationen für Untergrund-Aktivitäten
|
||||||
|
|
||||||
|
### 10. **ReputationView** (Reputation)
|
||||||
|
**Sinnvoll:** ⭐⭐⭐
|
||||||
|
- **3D-Party-Szene**: 3D-Visualisierung von Festen
|
||||||
|
- **Reputation-Visualisierung**: 3D-Effekte für Reputationsänderungen
|
||||||
|
|
||||||
|
## Implementierungs-Strategie
|
||||||
|
|
||||||
|
### Phase 1: Basis-Setup
|
||||||
|
1. Three.js installieren
|
||||||
|
2. Basis-Komponente `ThreeScene.vue` erstellen
|
||||||
|
3. Erste einfache Animation (z.B. rotierender Würfel) auf OverviewView
|
||||||
|
|
||||||
|
### Phase 2: Charakter-Modell
|
||||||
|
1. 3D-Charakter-Modell erstellen/laden (GLTF/GLB)
|
||||||
|
2. Auf OverviewView integrieren
|
||||||
|
3. Interaktionen (Klick, Hover)
|
||||||
|
|
||||||
|
### Phase 3: Gebäude-Modelle
|
||||||
|
1. Haus-Modell für HouseView
|
||||||
|
2. Fabrik-Modell für BranchView
|
||||||
|
3. Kirche-Modell für ChurchView
|
||||||
|
|
||||||
|
### Phase 4: Animationen
|
||||||
|
1. Upgrade-Animationen
|
||||||
|
2. Status-Änderungs-Animationen
|
||||||
|
3. Interaktive Elemente
|
||||||
|
|
||||||
|
## Technische Überlegungen
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Lazy Loading**: 3D-Szenen nur laden, wenn Seite aktiv ist
|
||||||
|
- **Level of Detail (LOD)**: Einfache Modelle für schwächere Geräte
|
||||||
|
- **WebGL-Detection**: Fallback auf 2D, wenn WebGL nicht unterstützt wird
|
||||||
|
|
||||||
|
### Asset-Management
|
||||||
|
- **GLTF/GLB**: Kompaktes Format für 3D-Modelle
|
||||||
|
- **Texturen**: Optimiert für Web (WebP, komprimiert)
|
||||||
|
- **CDN**: Assets über CDN laden für bessere Performance
|
||||||
|
|
||||||
|
### Browser-Kompatibilität
|
||||||
|
- **WebGL 1.0**: Mindestanforderung (95%+ Browser)
|
||||||
|
- **WebGL 2.0**: Optional für bessere Features
|
||||||
|
- **Fallback**: 2D-Versionen für ältere Browser
|
||||||
|
|
||||||
|
## Beispiel-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
components/
|
||||||
|
falukant/
|
||||||
|
ThreeScene.vue # Basis-3D-Szene-Komponente
|
||||||
|
CharacterModel.vue # 3D-Charakter-Komponente
|
||||||
|
BuildingModel.vue # 3D-Gebäude-Komponente
|
||||||
|
assets/
|
||||||
|
3d/
|
||||||
|
models/
|
||||||
|
character.glb
|
||||||
|
house.glb
|
||||||
|
factory.glb
|
||||||
|
textures/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. **Three.js installieren**
|
||||||
|
2. **Basis-Komponente erstellen**
|
||||||
|
3. **Erste Animation auf OverviewView testen**
|
||||||
|
4. **3D-Modelle erstellen/beschaffen** (Blender, Sketchfab, etc.)
|
||||||
|
5. **Schrittweise auf weitere Seiten ausweiten**
|
||||||
171
docs/3D_ASSETS_STRUCTURE.md
Normal file
171
docs/3D_ASSETS_STRUCTURE.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# 3D-Assets Struktur für Falukant
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/public/
|
||||||
|
models/
|
||||||
|
3d/
|
||||||
|
falukant/
|
||||||
|
characters/
|
||||||
|
male.glb # Basis-Modell männlich
|
||||||
|
female.glb # Basis-Modell weiblich
|
||||||
|
male_child.glb # Männlich, Kind (0-9 Jahre)
|
||||||
|
male_teen.glb # Männlich, Teenager (10-17 Jahre)
|
||||||
|
male_adult.glb # Männlich, Erwachsen (18-39 Jahre)
|
||||||
|
male_middle.glb # Männlich, Mittelalter (40-59 Jahre)
|
||||||
|
male_elder.glb # Männlich, Älter (60+ Jahre)
|
||||||
|
female_child.glb # Weiblich, Kind
|
||||||
|
female_teen.glb # Weiblich, Teenager
|
||||||
|
female_adult.glb # Weiblich, Erwachsen
|
||||||
|
female_middle.glb # Weiblich, Mittelalter
|
||||||
|
female_elder.glb # Weiblich, Älter
|
||||||
|
buildings/
|
||||||
|
house/
|
||||||
|
house_small.glb # Kleines Haus
|
||||||
|
house_medium.glb # Mittleres Haus
|
||||||
|
house_large.glb # Großes Haus
|
||||||
|
factory/
|
||||||
|
factory_basic.glb # Basis-Fabrik
|
||||||
|
factory_advanced.glb # Erweiterte Fabrik
|
||||||
|
church/
|
||||||
|
church.glb # Kirche
|
||||||
|
bank/
|
||||||
|
bank.glb # Bank
|
||||||
|
objects/
|
||||||
|
weapons/
|
||||||
|
sword.glb
|
||||||
|
shield.glb
|
||||||
|
items/
|
||||||
|
coin.glb
|
||||||
|
gift.glb
|
||||||
|
effects/
|
||||||
|
particles/
|
||||||
|
money.glb # Geld-Effekt
|
||||||
|
health.glb # Gesundheits-Effekt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Namenskonventionen
|
||||||
|
|
||||||
|
### Charaktere
|
||||||
|
- Format: `{gender}[_{ageRange}].glb`
|
||||||
|
- Beispiele:
|
||||||
|
- `male.glb` - Basis-Modell männlich (Fallback)
|
||||||
|
- `female.glb` - Basis-Modell weiblich (Fallback)
|
||||||
|
- `male_adult.glb` - Männlich, Erwachsen
|
||||||
|
- `female_teen.glb` - Weiblich, Teenager
|
||||||
|
|
||||||
|
### Gebäude
|
||||||
|
- Format: `{buildingType}_{variant}.glb`
|
||||||
|
- Beispiele:
|
||||||
|
- `house_small.glb`
|
||||||
|
- `factory_basic.glb`
|
||||||
|
- `church.glb`
|
||||||
|
|
||||||
|
### Objekte
|
||||||
|
- Format: `{category}/{item}.glb`
|
||||||
|
- Beispiele:
|
||||||
|
- `weapons/sword.glb`
|
||||||
|
- `items/coin.glb`
|
||||||
|
|
||||||
|
## Altersbereiche
|
||||||
|
|
||||||
|
Die Altersbereiche werden automatisch bestimmt:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In CharacterModel3D.vue
|
||||||
|
getAgeRange(age) {
|
||||||
|
if (age < 10) return 'child';
|
||||||
|
if (age < 18) return 'teen';
|
||||||
|
if (age < 40) return 'adult';
|
||||||
|
if (age < 60) return 'middle';
|
||||||
|
return 'elder';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback-Verhalten:**
|
||||||
|
- Wenn kein spezifisches Modell für den Altersbereich existiert, wird das Basis-Modell (`male.glb` / `female.glb`) verwendet
|
||||||
|
- Dies ermöglicht schrittweise Erweiterung ohne Breaking Changes
|
||||||
|
|
||||||
|
## Dateigrößen-Empfehlungen
|
||||||
|
|
||||||
|
- **Charaktere**: 100KB - 500KB (komprimiert)
|
||||||
|
- **Gebäude**: 200KB - 1MB (komprimiert)
|
||||||
|
- **Objekte**: 10KB - 100KB (komprimiert)
|
||||||
|
|
||||||
|
## Optimierung
|
||||||
|
|
||||||
|
### Vor dem Hochladen:
|
||||||
|
1. **Blender** öffnen
|
||||||
|
2. **Decimate Modifier** anwenden (falls nötig)
|
||||||
|
3. **Texturen komprimieren** (WebP, max 1024x1024)
|
||||||
|
4. **GLB Export** mit:
|
||||||
|
- Compression aktiviert
|
||||||
|
- Texturen eingebettet
|
||||||
|
- Unnötige Animationen entfernt
|
||||||
|
|
||||||
|
### Komprimierung:
|
||||||
|
- Verwende `gltf-pipeline` oder `gltf-transform` für weitere Komprimierung
|
||||||
|
- Ziel: < 500KB pro Modell
|
||||||
|
|
||||||
|
## Verwendung im Code
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- CharacterModel3D.vue -->
|
||||||
|
<CharacterModel3D
|
||||||
|
:gender="character.gender"
|
||||||
|
:age="character.age"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Automatisch wird geladen: -->
|
||||||
|
<!-- /models/3d/falukant/characters/male_adult.glb -->
|
||||||
|
<!-- Falls nicht vorhanden: male.glb -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erweiterte Struktur (Optional)
|
||||||
|
|
||||||
|
Für komplexere Szenarien:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/public/
|
||||||
|
models/
|
||||||
|
3d/
|
||||||
|
falukant/
|
||||||
|
characters/
|
||||||
|
{gender}/
|
||||||
|
base/
|
||||||
|
{gender}.glb # Basis-Modell
|
||||||
|
ages/
|
||||||
|
{gender}_{ageRange}.glb
|
||||||
|
variants/
|
||||||
|
{gender}_{variant}.glb # Z.B. verschiedene Outfits
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wartung
|
||||||
|
|
||||||
|
### Neue Modelle hinzufügen:
|
||||||
|
1. GLB-Datei in entsprechendes Verzeichnis kopieren
|
||||||
|
2. Namenskonvention beachten
|
||||||
|
3. Dateigröße prüfen (< 500KB empfohlen)
|
||||||
|
4. Im Browser testen
|
||||||
|
|
||||||
|
### Modelle aktualisieren:
|
||||||
|
1. Alte Datei ersetzen
|
||||||
|
2. Browser-Cache leeren (oder Versionierung verwenden)
|
||||||
|
3. Testen
|
||||||
|
|
||||||
|
### Versionierung (Optional):
|
||||||
|
```
|
||||||
|
characters/
|
||||||
|
v1/
|
||||||
|
male.glb
|
||||||
|
v2/
|
||||||
|
male.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance-Tipps
|
||||||
|
|
||||||
|
1. **Lazy Loading**: Modelle nur laden, wenn benötigt
|
||||||
|
2. **Preloading**: Wichtige Modelle vorladen
|
||||||
|
3. **Caching**: Browser-Cache nutzen
|
||||||
|
4. **CDN**: Für Produktion CDN verwenden
|
||||||
159
docs/3D_MODEL_CREATION_TOOLS.md
Normal file
159
docs/3D_MODEL_CREATION_TOOLS.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# 3D-Modell-Erstellung für Falukant
|
||||||
|
|
||||||
|
## KI-basierte Tools (Empfohlen)
|
||||||
|
|
||||||
|
### 1. **Rodin** ⭐⭐⭐⭐⭐
|
||||||
|
- **URL**: https://rodin.io/
|
||||||
|
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D (z.B. "medieval character", "house")
|
||||||
|
- Sehr gute Qualität
|
||||||
|
- Export als GLB/GLTF
|
||||||
|
- **Gut für**: Charaktere, Gebäude, Objekte
|
||||||
|
|
||||||
|
### 2. **Meshy** ⭐⭐⭐⭐⭐
|
||||||
|
- **URL**: https://www.meshy.ai/
|
||||||
|
- **Preis**: Kostenlos (mit Limits), ab $9/monat
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Bild-zu-3D
|
||||||
|
- Textur-Generierung
|
||||||
|
- Export als GLB/OBJ/FBX
|
||||||
|
- **Gut für**: Alle Arten von Modellen
|
||||||
|
|
||||||
|
### 3. **Luma AI Genie** ⭐⭐⭐⭐
|
||||||
|
- **URL**: https://lumalabs.ai/genie
|
||||||
|
- **Preis**: Kostenlos (Beta)
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Sehr schnell
|
||||||
|
- Export als GLB
|
||||||
|
- **Gut für**: Schnelle Prototypen
|
||||||
|
|
||||||
|
### 4. **CSM (Common Sense Machines)** ⭐⭐⭐⭐
|
||||||
|
- **URL**: https://csm.ai/
|
||||||
|
- **Preis**: Kostenlos (mit Limits)
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Bild-zu-3D
|
||||||
|
- Export als GLB/USD
|
||||||
|
- **Gut für**: Verschiedene Objekte
|
||||||
|
|
||||||
|
### 5. **Tripo AI** ⭐⭐⭐⭐
|
||||||
|
- **URL**: https://www.tripo3d.ai/
|
||||||
|
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- Bild-zu-3D
|
||||||
|
- Export als GLB/FBX/OBJ
|
||||||
|
- **Gut für**: Charaktere und Objekte
|
||||||
|
|
||||||
|
### 6. **Masterpiece Studio** ⭐⭐⭐
|
||||||
|
- **URL**: https://masterpiecestudio.com/
|
||||||
|
- **Preis**: Ab $9/monat
|
||||||
|
- **Features**:
|
||||||
|
- Text-zu-3D
|
||||||
|
- VR-Unterstützung
|
||||||
|
- Export als GLB/FBX
|
||||||
|
- **Gut für**: Professionelle Modelle
|
||||||
|
|
||||||
|
## Traditionelle Tools (Für Nachbearbeitung)
|
||||||
|
|
||||||
|
### 1. **Blender** (Kostenlos) ⭐⭐⭐⭐⭐
|
||||||
|
- **URL**: https://www.blender.org/
|
||||||
|
- **Features**:
|
||||||
|
- Vollständige 3D-Suite
|
||||||
|
- GLB/GLTF Export
|
||||||
|
- Optimierung von KI-generierten Modellen
|
||||||
|
- **Gut für**: Nachbearbeitung, Optimierung, Animationen
|
||||||
|
|
||||||
|
### 2. **Sketchfab** (Modelle kaufen/laden)
|
||||||
|
- **URL**: https://sketchfab.com/
|
||||||
|
- **Preis**: Kostenlos (CC0 Modelle), Premium Modelle kostenpflichtig
|
||||||
|
- **Features**:
|
||||||
|
- Millionen von 3D-Modellen
|
||||||
|
- Viele kostenlose CC0 Modelle
|
||||||
|
- GLB/GLTF Download
|
||||||
|
- **Gut für**: Vorgefertigte Modelle, Inspiration
|
||||||
|
|
||||||
|
## Empfohlener Workflow
|
||||||
|
|
||||||
|
### Für Falukant-Charaktere:
|
||||||
|
1. **Rodin** oder **Meshy** verwenden
|
||||||
|
2. Prompt: "medieval character, male/female, simple style, low poly, game ready"
|
||||||
|
3. Export als GLB
|
||||||
|
4. In **Blender** optimieren (falls nötig)
|
||||||
|
5. Texturen anpassen
|
||||||
|
|
||||||
|
### Für Gebäude:
|
||||||
|
1. **Meshy** oder **Tripo AI** verwenden
|
||||||
|
2. Prompt: "medieval house, simple, low poly, game ready, front view"
|
||||||
|
3. Export als GLB
|
||||||
|
4. In **Blender** optimieren
|
||||||
|
5. Mehrere Varianten erstellen (Haus, Fabrik, Kirche)
|
||||||
|
|
||||||
|
### Für Objekte:
|
||||||
|
1. **Sketchfab** durchsuchen (kostenlose CC0 Modelle)
|
||||||
|
2. Oder **Meshy** für spezifische Objekte
|
||||||
|
3. Export als GLB
|
||||||
|
4. Optimieren falls nötig
|
||||||
|
|
||||||
|
## Prompt-Beispiele für Falukant
|
||||||
|
|
||||||
|
### Charakter:
|
||||||
|
```
|
||||||
|
"medieval character, [male/female], simple low poly style,
|
||||||
|
game ready, neutral pose, front view, no background,
|
||||||
|
GLB format, optimized for web"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Haus:
|
||||||
|
```
|
||||||
|
"medieval house, simple low poly style, game ready,
|
||||||
|
front view, no background, GLB format, optimized for web"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fabrik:
|
||||||
|
```
|
||||||
|
"medieval factory building, simple low poly style,
|
||||||
|
game ready, front view, no background, GLB format"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wappen:
|
||||||
|
```
|
||||||
|
"medieval coat of arms shield, simple low poly style,
|
||||||
|
game ready, front view, no background, GLB format"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimierung für Web
|
||||||
|
|
||||||
|
### Nach der Erstellung:
|
||||||
|
1. **Blender** öffnen
|
||||||
|
2. **Decimate Modifier** anwenden (weniger Polygone)
|
||||||
|
3. **Texture** komprimieren (WebP, 512x512 oder 1024x1024)
|
||||||
|
4. **GLB Export** mit:
|
||||||
|
- Compression aktiviert
|
||||||
|
- Texturen eingebettet
|
||||||
|
- Normals und Tangents berechnet
|
||||||
|
|
||||||
|
### Größen-Richtlinien:
|
||||||
|
- **Charaktere**: 2000-5000 Polygone
|
||||||
|
- **Gebäude**: 1000-3000 Polygone
|
||||||
|
- **Objekte**: 100-1000 Polygone
|
||||||
|
- **Texturen**: 512x512 oder 1024x1024 (nicht größer)
|
||||||
|
|
||||||
|
## Kostenlose Alternativen
|
||||||
|
|
||||||
|
### Wenn KI-Tools Limits haben:
|
||||||
|
1. **Sketchfab** durchsuchen (CC0 Modelle)
|
||||||
|
2. **Poly Haven** (https://polyhaven.com/) - kostenlose Assets
|
||||||
|
3. **Kenney.nl** - kostenlose Game Assets
|
||||||
|
4. **OpenGameArt.org** - kostenlose Game Assets
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. **Rodin** oder **Meshy** testen
|
||||||
|
2. Ersten Charakter erstellen
|
||||||
|
3. Als GLB exportieren
|
||||||
|
4. In Three.js testen
|
||||||
|
5. Bei Bedarf optimieren
|
||||||
334
docs/BLENDER_RIGGING_GUIDE.md
Normal file
334
docs/BLENDER_RIGGING_GUIDE.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Blender Rigging-Anleitung für Falukant-Charaktere
|
||||||
|
|
||||||
|
Diese Anleitung erklärt, wie du Bones/Gelenke zu deinen 3D-Modellen in Blender hinzufügst, damit sie animiert werden können.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Blender (kostenlos, https://www.blender.org/)
|
||||||
|
- GLB-Modell von meshy.ai oder anderen Quellen
|
||||||
|
|
||||||
|
## Schritt-für-Schritt Anleitung
|
||||||
|
|
||||||
|
### 1. Modell in Blender importieren
|
||||||
|
|
||||||
|
1. Öffne Blender
|
||||||
|
2. Gehe zu `File` → `Import` → `glTF 2.0 (.glb/.gltf)`
|
||||||
|
3. Wähle dein Modell aus
|
||||||
|
4. Das Modell sollte jetzt in der Szene erscheinen
|
||||||
|
|
||||||
|
### 2. Modell vorbereiten
|
||||||
|
|
||||||
|
1. Stelle sicher, dass das Modell im **Object Mode** ist (Tab drücken, falls im Edit Mode)
|
||||||
|
2. Wähle das Modell aus (Linksklick)
|
||||||
|
3. Drücke `Alt + G` um die Position auf (0, 0, 0) zu setzen
|
||||||
|
4. Drücke `Alt + R` um die Rotation zurückzusetzen
|
||||||
|
5. Drücke `Alt + S` um die Skalierung auf 1 zu setzen
|
||||||
|
|
||||||
|
### 3. Rigging (Bones hinzufügen)
|
||||||
|
|
||||||
|
#### Option A: Automatisches Rigging mit Rigify (Empfohlen)
|
||||||
|
|
||||||
|
1. **Rigify aktivieren:**
|
||||||
|
- Gehe zu `Edit` → `Preferences` (oder `Blender` → `Preferences` auf Mac)
|
||||||
|
- Klicke auf den Tab **"Add-ons"** (links im Fenster)
|
||||||
|
- Im Suchfeld oben rechts tippe: **"rigify"** (ohne Anführungszeichen)
|
||||||
|
- Du solltest "Rigify: Auto-rigging system" sehen
|
||||||
|
- Aktiviere das **Häkchen** neben "Rigify"
|
||||||
|
- Das Add-on ist jetzt aktiviert
|
||||||
|
- Schließe das Preferences-Fenster
|
||||||
|
|
||||||
|
**Alternative Wege zu Preferences:**
|
||||||
|
- Windows/Linux: `Edit` → `Preferences`
|
||||||
|
- Mac: `Blender` → `Preferences`
|
||||||
|
- Oder: `Ctrl + ,` (Strg + Komma)
|
||||||
|
|
||||||
|
2. **Rigify-Rig hinzufügen:**
|
||||||
|
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken, falls im Edit Mode)
|
||||||
|
- Wähle das Modell aus (oder nichts, das Rig wird separat erstellt)
|
||||||
|
- Drücke `Shift + A` (Add Menu)
|
||||||
|
- Wähle **`Armature`** aus
|
||||||
|
- In der Liste siehst du jetzt **`Human (Meta-Rig)`** - klicke darauf
|
||||||
|
- Ein Basis-Rig wird in der Szene erstellt
|
||||||
|
|
||||||
|
**Falls "Human (Meta-Rig)" nicht erscheint:**
|
||||||
|
- Stelle sicher, dass Rigify aktiviert ist (siehe Schritt 1)
|
||||||
|
- Starte Blender neu, falls nötig
|
||||||
|
- Prüfe, ob du die neueste Blender-Version hast (Rigify ist ab Version 2.8+ verfügbar)
|
||||||
|
|
||||||
|
3. **Rig positionieren und anpassen:**
|
||||||
|
|
||||||
|
**Schritt 1: Rig zum Modell bewegen**
|
||||||
|
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken)
|
||||||
|
- Wähle das **Armature** aus (nicht das Modell)
|
||||||
|
- Drücke `G` (Grab/Move) und bewege das Rig zum Modell
|
||||||
|
- Oder: Drücke `Alt + G` um die Position zurückzusetzen, dann `G` + `X`, `Y` oder `Z` für eine Achse
|
||||||
|
|
||||||
|
**Schritt 2: Rig skalieren (falls zu groß/klein)**
|
||||||
|
- Wähle das Armature aus
|
||||||
|
- Drücke `S` (Scale) und skaliere das Rig
|
||||||
|
- Oder: `S` + `X`, `Y` oder `Z` für eine Achse
|
||||||
|
- Tipp: Drücke `Shift + X` (oder Y/Z) um diese Achse auszuschließen
|
||||||
|
|
||||||
|
**Schritt 3: Einzelne Bones anpassen**
|
||||||
|
- Wähle das Armature aus
|
||||||
|
- Wechsle in den **Edit Mode** (Tab)
|
||||||
|
- Wähle einen Bone aus (Linksklick)
|
||||||
|
- Drücke `G` um ihn zu bewegen
|
||||||
|
- Drücke `E` um einen neuen Bone zu extrudieren
|
||||||
|
- Drücke `R` um einen Bone zu rotieren
|
||||||
|
- Drücke `S` um einen Bone zu skalieren
|
||||||
|
|
||||||
|
**Wichtige Bones zum Anpassen:**
|
||||||
|
- **Root/Spine** - Sollte in der Mitte des Körpers sein (Hüfthöhe)
|
||||||
|
- **Spine1/Spine2** - Entlang der Wirbelsäule
|
||||||
|
- **Neck/Head** - Am Hals und Kopf
|
||||||
|
- **Shoulders** - An den Schultern
|
||||||
|
- **Arms** - Entlang der Arme
|
||||||
|
- **Legs** - Entlang der Beine
|
||||||
|
|
||||||
|
**Tipp:** Nutze die Zahlenansicht (Numpad) um die Positionen genau zu sehen
|
||||||
|
|
||||||
|
4. **Rig generieren:**
|
||||||
|
- Wechsle zurück in den **Object Mode** (Tab drücken)
|
||||||
|
- Wähle das **Meta-Rig (Armature)** aus (nicht das Modell!) - sollte im Outliner blau markiert sein
|
||||||
|
|
||||||
|
**Methode 1: Rigify-Button in der Toolbar (Einfachste Methode)**
|
||||||
|
- Oben in der Toolbar siehst du den Button **"Rigify"** (neben "Object")
|
||||||
|
- Klicke auf **"Rigify"** → **"Generate Rig"**
|
||||||
|
- Ein vollständiges Rig wird erstellt (dies kann einen Moment dauern)
|
||||||
|
|
||||||
|
**Methode 2: Properties-Panel (Alternative)**
|
||||||
|
- Im **Properties-Panel** (rechts):
|
||||||
|
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
|
||||||
|
- Oder: Klicke auf das **Bone-Icon** (Armature Properties)
|
||||||
|
- Scrolle durch die Tabs, bis du **"Rigify"** oder **"Rigify Generation"** siehst
|
||||||
|
- In diesem Tab findest du den Button **"Generate Rig"**
|
||||||
|
- Klicke auf **"Generate Rig"**
|
||||||
|
|
||||||
|
**Wichtig:** Nach dem Generieren kannst du das Rig weiter anpassen, aber du musst es im **Pose Mode** tun (nicht Edit Mode)
|
||||||
|
|
||||||
|
**Die richtigen Tabs im Properties-Panel (von oben nach unten):**
|
||||||
|
- 📐 **Object Properties** (Würfel-Icon) - hier findest du Transform, etc.
|
||||||
|
- 🦴 **Armature Properties** (Bone-Icon) - hier findest du Armature-Einstellungen
|
||||||
|
- 🔧 **Modifier Properties** (Wrench-Icon) - hier sollte der **Rigify-Tab** sein!
|
||||||
|
- 🌍 **World Properties** (Globus-Icon) - NICHT hier suchen!
|
||||||
|
|
||||||
|
**Falls du den Rigify-Tab nicht siehst:**
|
||||||
|
- Stelle sicher, dass das **Meta-Rig** (nicht ein bereits generiertes Rig) ausgewählt ist
|
||||||
|
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
|
||||||
|
- Der Rigify-Tab sollte dort erscheinen
|
||||||
|
|
||||||
|
#### Option B: Manuelles Rigging
|
||||||
|
|
||||||
|
1. **Armature erstellen:**
|
||||||
|
- Drücke `Shift + A` → `Armature`
|
||||||
|
- Ein Bone wird erstellt
|
||||||
|
|
||||||
|
2. **Bones hinzufügen:**
|
||||||
|
- Wechsle in den **Edit Mode** (Tab)
|
||||||
|
- Wähle den Root-Bone aus
|
||||||
|
- Drücke `E` um einen neuen Bone zu extrudieren
|
||||||
|
- Erstelle die wichtigsten Bones:
|
||||||
|
- **Spine/Spine1/Spine2** - Wirbelsäule
|
||||||
|
- **Neck/Head** - Hals und Kopf
|
||||||
|
- **LeftArm/LeftForeArm/LeftHand** - Linker Arm
|
||||||
|
- **RightArm/RightForeArm/RightHand** - Rechter Arm
|
||||||
|
- **LeftUpLeg/LeftLeg/LeftFoot** - Linkes Bein
|
||||||
|
- **RightUpLeg/RightLeg/RightFoot** - Rechtes Bein
|
||||||
|
|
||||||
|
3. **Bone-Namen vergeben:**
|
||||||
|
- Wähle jeden Bone aus
|
||||||
|
- Im Properties-Panel (rechts) unter "Bone" kannst du den Namen ändern
|
||||||
|
- **Wichtig:** Verwende diese Namen für die Animation:
|
||||||
|
- `LeftArm`, `RightArm`
|
||||||
|
- `LeftForeArm`, `RightForeArm`
|
||||||
|
- `LeftHand`, `RightHand`
|
||||||
|
- `LeftUpLeg`, `RightUpLeg`
|
||||||
|
- `LeftLeg`, `RightLeg`
|
||||||
|
- `LeftFoot`, `RightFoot`
|
||||||
|
- `Neck`, `Head`
|
||||||
|
- `Spine`, `Spine1`, `Spine2`
|
||||||
|
|
||||||
|
### 4. Modell an Bones binden (Skinning)
|
||||||
|
|
||||||
|
1. **Beide Objekte auswählen:**
|
||||||
|
- Wähle zuerst das **Mesh** aus
|
||||||
|
- Dann wähle das **Armature** aus (Shift + Linksklick)
|
||||||
|
- Drücke `Ctrl + P` → `With Automatic Weights`
|
||||||
|
- Blender berechnet automatisch, welche Vertices zu welchen Bones gehören
|
||||||
|
|
||||||
|
2. **Weights überprüfen:**
|
||||||
|
- Wähle das Mesh aus
|
||||||
|
- Wechsle in den **Weight Paint Mode** (Dropdown oben)
|
||||||
|
- Wähle einen Bone aus (rechts im Properties-Panel)
|
||||||
|
- Rot = vollständig gebunden, Blau = nicht gebunden
|
||||||
|
- Falls nötig, kannst du die Weights manuell anpassen
|
||||||
|
|
||||||
|
### 5. Test-Animation erstellen (Optional)
|
||||||
|
|
||||||
|
1. **Pose Mode aktivieren:**
|
||||||
|
- Wähle das **generierte Rig** aus (nicht das Meta-Rig!)
|
||||||
|
- Wechsle in den **Pose Mode** (Dropdown oben: "Object Mode" → "Pose Mode")
|
||||||
|
- Oder: `Ctrl + Tab` → "Pose Mode"
|
||||||
|
|
||||||
|
2. **Bone auswählen:**
|
||||||
|
- **Wichtig:** Arbeite im **3D-Viewport** (Hauptfenster), nicht nur im Outliner!
|
||||||
|
- **Rigify-Bone-Namen** (nach dem Generieren):
|
||||||
|
- Für **Knie beugen**: `Leg.L (IK)` oder `Leg.L (FK)` (nicht "Tweak"!)
|
||||||
|
- Für **Hand anheben**: `Arm.L (IK)` oder `Arm.L (FK)`
|
||||||
|
- Für **Fuß bewegen**: `Leg.L (IK)` (der Fuß-Controller)
|
||||||
|
- **IK** = Inverse Kinematics (einfacher, empfohlen für Anfänger)
|
||||||
|
- **FK** = Forward Kinematics (mehr Kontrolle)
|
||||||
|
- **Tweak** = Feinabstimmungen (für später, nicht für Hauptanimationen)
|
||||||
|
- Klicke auf einen **Bone** im **3D-Viewport** (nicht im Outliner!)
|
||||||
|
- Der Bone sollte orange/ausgewählt sein und im Viewport sichtbar sein
|
||||||
|
- **Tipp:** Nutze `X-Ray Mode` (Button oben im Viewport) um Bones besser zu sehen
|
||||||
|
- **Tipp:** Im Outliner kannst du Bones finden, aber die Animation machst du im Viewport
|
||||||
|
|
||||||
|
3. **Bone animieren:**
|
||||||
|
- Wähle z.B. `hand.L` (linke Hand) aus
|
||||||
|
- Drücke `R` (Rotate) und rotiere den Bone
|
||||||
|
- Oder: `R` + `Z` (um Z-Achse rotieren)
|
||||||
|
- Oder: `R` + `X` (um X-Achse rotieren)
|
||||||
|
- Bewege die Maus → Linksklick zum Bestätigen
|
||||||
|
- **Beispiel für Hand anheben:** `hand.L` → `R` → `Z` → nach oben bewegen
|
||||||
|
|
||||||
|
4. **Animation aufnehmen (Timeline):**
|
||||||
|
- Unten siehst du die **Timeline** (falls nicht sichtbar: `Shift + F12` oder `Window` → `Animation` → `Timeline`)
|
||||||
|
- Stelle den Frame auf **1** (Anfang)
|
||||||
|
- Wähle den Bone aus und positioniere ihn in der **Ausgangsposition**
|
||||||
|
- Drücke `I` (Insert Keyframe) → wähle **"Rotation"** (oder "Location" falls bewegt)
|
||||||
|
- Ein Keyframe wird erstellt (gelber Punkt in der Timeline)
|
||||||
|
- Stelle den Frame auf **30** (oder einen anderen Frame)
|
||||||
|
- Rotiere/Bewege den Bone in die **Zielposition** (z.B. Hand nach oben)
|
||||||
|
- Drücke wieder `I` → **"Rotation"** (oder "Location")
|
||||||
|
- Stelle den Frame auf **60** (Rückkehr zur Ausgangsposition)
|
||||||
|
- Rotiere den Bone zurück zur Ausgangsposition
|
||||||
|
- Drücke `I` → **"Rotation"**
|
||||||
|
- Drücke **Play** (Leertaste) um die Animation zu sehen
|
||||||
|
|
||||||
|
5. **Animation testen:**
|
||||||
|
- Die Animation sollte jetzt in einer Schleife abgespielt werden
|
||||||
|
- Du kannst weitere Keyframes hinzufügen (Frame 90, 120, etc.)
|
||||||
|
- **Tipp:** Nutze `Alt + A` um die Animation zu stoppen
|
||||||
|
|
||||||
|
### 6. Modell exportieren
|
||||||
|
|
||||||
|
1. **Beide Objekte auswählen:**
|
||||||
|
- Wähle das **Mesh** aus
|
||||||
|
- Shift + Linksklick auf das **generierte Rig** (nicht das Meta-Rig!)
|
||||||
|
|
||||||
|
2. **Exportieren:**
|
||||||
|
- Gehe zu `File` → `Export` → `glTF 2.0 (.glb/.gltf)`
|
||||||
|
- Wähle `.glb` Format
|
||||||
|
- Stelle sicher, dass folgende Optionen aktiviert sind:
|
||||||
|
- ✅ **Include** → **Selected Objects**
|
||||||
|
- ✅ **Transform** → **+Y Up**
|
||||||
|
- ✅ **Geometry** → **Apply Modifiers**
|
||||||
|
- ✅ **Animation** → **Bake Animation** (wichtig für Animationen!)
|
||||||
|
- ✅ **Animation** → **Always Sample Animations** (falls Animationen nicht korrekt exportiert werden)
|
||||||
|
- Klicke auf "Export glTF 2.0"
|
||||||
|
|
||||||
|
### 7. Modell testen
|
||||||
|
|
||||||
|
1. Kopiere die exportierte `.glb` Datei nach:
|
||||||
|
```
|
||||||
|
frontend/public/models/3d/falukant/characters/
|
||||||
|
```
|
||||||
|
2. Lade die Seite neu
|
||||||
|
3. Die Bones sollten jetzt automatisch erkannt und animiert werden
|
||||||
|
4. **Animationen testen:**
|
||||||
|
- Öffne die Browser-Konsole (F12)
|
||||||
|
- Du solltest sehen: `[ThreeScene] Found X animation(s)`
|
||||||
|
- Die Animationen sollten automatisch abgespielt werden
|
||||||
|
- Falls keine Animationen vorhanden sind, werden die Bones trotzdem mit Idle-Animationen bewegt
|
||||||
|
|
||||||
|
## Rig anpassen - Detaillierte Anleitung
|
||||||
|
|
||||||
|
### Rig nach dem Generieren anpassen
|
||||||
|
|
||||||
|
Wenn das Rigify-Rig generiert wurde, aber nicht perfekt passt:
|
||||||
|
|
||||||
|
1. **Pose Mode verwenden:**
|
||||||
|
- Wähle das generierte Armature aus
|
||||||
|
- Wechsle in den **Pose Mode** (Dropdown oben, oder Strg+Tab → Pose Mode)
|
||||||
|
- Hier kannst du die Bones bewegen, ohne die Struktur zu zerstören
|
||||||
|
|
||||||
|
2. **Rig neu generieren (falls nötig):**
|
||||||
|
- Falls das Rig komplett neu positioniert werden muss:
|
||||||
|
- Lösche das generierte Rig (X → Delete)
|
||||||
|
- Gehe zurück zum Meta-Rig
|
||||||
|
- Passe das Meta-Rig im Edit Mode an
|
||||||
|
- Generiere das Rig erneut
|
||||||
|
|
||||||
|
3. **Snap to Mesh (Hilfsmittel):**
|
||||||
|
- Im Edit Mode: `Shift + Tab` um Snap zu aktivieren
|
||||||
|
- Oder: Rechtsklick auf das Snap-Symbol (Magnet) oben
|
||||||
|
- Wähle "Face" oder "Vertex" als Snap-Target
|
||||||
|
- Jetzt werden Bones automatisch am Mesh ausgerichtet
|
||||||
|
|
||||||
|
### Häufige Probleme und Lösungen
|
||||||
|
|
||||||
|
**Problem: Rig ist zu groß/klein**
|
||||||
|
- Lösung: Im Object Mode das Armature auswählen und mit `S` skalieren
|
||||||
|
|
||||||
|
**Problem: Rig ist an falscher Position**
|
||||||
|
- Lösung: Im Object Mode mit `G` bewegen, oder `Alt + G` zurücksetzen
|
||||||
|
|
||||||
|
**Problem: Einzelne Bones passen nicht**
|
||||||
|
- Lösung: Im Edit Mode die Bones einzeln anpassen (`G` zum Bewegen)
|
||||||
|
|
||||||
|
**Problem: Nach dem Generieren passt es nicht mehr**
|
||||||
|
- Lösung: Passe das Meta-Rig an und generiere neu, oder verwende Pose Mode
|
||||||
|
|
||||||
|
## Tipps und Tricks
|
||||||
|
|
||||||
|
### Bone-Namen für automatische Erkennung
|
||||||
|
|
||||||
|
Die Komponente erkennt Bones anhand ihrer Namen. Verwende diese Keywords:
|
||||||
|
- `arm` - für Arme
|
||||||
|
- `hand` oder `wrist` - für Hände
|
||||||
|
- `leg` oder `knee` - für Beine
|
||||||
|
- `foot` oder `ankle` - für Füße
|
||||||
|
- `shoulder` - für Schultern
|
||||||
|
- `elbow` - für Ellbogen
|
||||||
|
|
||||||
|
### Einfacheres Rigging mit Mixamo
|
||||||
|
|
||||||
|
Alternativ kannst du:
|
||||||
|
1. Dein Modell auf [Mixamo](https://www.mixamo.com/) hochladen
|
||||||
|
2. Automatisches Rigging durchführen lassen
|
||||||
|
3. Das geriggte Modell herunterladen
|
||||||
|
4. In Blender importieren und anpassen
|
||||||
|
|
||||||
|
### Performance-Optimierung
|
||||||
|
|
||||||
|
- Verwende nicht zu viele Bones (max. 50-100 für Charaktere)
|
||||||
|
- Entferne unnötige Bones vor dem Export
|
||||||
|
- Teste die Animation im Browser, bevor du das finale Modell exportierst
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Bones werden nicht erkannt
|
||||||
|
|
||||||
|
- Prüfe die Bone-Namen (müssen `arm`, `hand`, `leg`, etc. enthalten)
|
||||||
|
- Stelle sicher, dass das Modell korrekt an die Bones gebunden ist
|
||||||
|
- Öffne die Browser-Konsole und prüfe die Logs: `[ThreeScene] Found X bones for animation`
|
||||||
|
|
||||||
|
### Modell verformt sich falsch
|
||||||
|
|
||||||
|
- Überprüfe die Weights im Weight Paint Mode
|
||||||
|
- Passe die Bone-Positionen an
|
||||||
|
- Stelle sicher, dass alle Vertices korrekt zugewiesen sind
|
||||||
|
|
||||||
|
### Export schlägt fehl
|
||||||
|
|
||||||
|
- Stelle sicher, dass beide Objekte (Mesh + Armature) ausgewählt sind
|
||||||
|
- Prüfe, ob das Modell im Object Mode ist
|
||||||
|
- Versuche es mit einem anderen Export-Format (.gltf statt .glb)
|
||||||
|
|
||||||
|
## Weitere Ressourcen
|
||||||
|
|
||||||
|
- [Blender Rigging Tutorial](https://www.youtube.com/results?search_query=blender+rigging+tutorial)
|
||||||
|
- [Mixamo Auto-Rigging](https://www.mixamo.com/)
|
||||||
|
- [Three.js GLTF Animation Guide](https://threejs.org/docs/#manual/en/introduction/Animation-system)
|
||||||
26
fix-api-urls.sh
Executable file
26
fix-api-urls.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== YourPart API-URL Fix ==="
|
||||||
|
|
||||||
|
cd ~/yourpart3/frontend
|
||||||
|
|
||||||
|
# 1. Alle localhost:3001 Referenzen finden
|
||||||
|
echo "Suche nach localhost:3001 Referenzen..."
|
||||||
|
grep -r "localhost:3001" src/ || echo "Keine localhost:3001 Referenzen gefunden"
|
||||||
|
|
||||||
|
# 2. Alle localhost Referenzen finden
|
||||||
|
echo ""
|
||||||
|
echo "Suche nach localhost Referenzen..."
|
||||||
|
grep -r "localhost" src/ || echo "Keine localhost Referenzen gefunden"
|
||||||
|
|
||||||
|
# 3. API-Konfigurationsdateien finden
|
||||||
|
echo ""
|
||||||
|
echo "Suche nach API-Konfigurationsdateien..."
|
||||||
|
find src/ -name "*.js" -exec grep -l "axios\|baseURL\|localhost" {} \;
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== API-URL Fix abgeschlossen ==="
|
||||||
|
echo "Bitte überprüfen Sie die gefundenen Dateien und ersetzen Sie:"
|
||||||
|
echo " localhost:3001 → /api"
|
||||||
|
echo " http://localhost:3001 → /api"
|
||||||
|
echo " baseURL: 'http://localhost:3001' → baseURL: '/api'"
|
||||||
22
fix-cors.sh
Executable file
22
fix-cors.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== YourPart CORS-Fix ==="
|
||||||
|
|
||||||
|
# Backup der ursprünglichen app.js erstellen
|
||||||
|
sudo cp /opt/yourpart/backend/app.js /opt/yourpart/backend/app.js.backup
|
||||||
|
|
||||||
|
# CORS-Konfiguration aktualisieren
|
||||||
|
sudo sed -i 's|origin: \[.*\]|origin: [\n "http://localhost:3000", \n "http://localhost:5173", \n "http://127.0.0.1:3000", \n "http://127.0.0.1:5173",\n "https://your-part.de",\n "https://www.your-part.de",\n "http://your-part.de",\n "http://www.your-part.de"\n ]|' /opt/yourpart/backend/app.js
|
||||||
|
|
||||||
|
echo "CORS-Konfiguration aktualisiert!"
|
||||||
|
|
||||||
|
# Service neu starten
|
||||||
|
echo "Starte Backend-Service neu..."
|
||||||
|
sudo systemctl restart yourpart.service
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
echo "Service-Status:"
|
||||||
|
sudo systemctl status yourpart.service
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "CORS-Fix abgeschlossen! Testen Sie jetzt die Anwendung."
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user