Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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
@@ -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
@@ -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 path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
import chatRouter from './routers/chatRouter.js';
|
||||
import authRouter from './routers/authRouter.js';
|
||||
import navigationRouter from './routers/navigationRouter.js';
|
||||
@@ -16,6 +17,7 @@ import match3Router from './routers/match3Router.js';
|
||||
import taxiRouter from './routers/taxiRouter.js';
|
||||
import taxiMapRouter from './routers/taxiMapRouter.js';
|
||||
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
||||
import termineRouter from './routers/termineRouter.js';
|
||||
import cors from 'cors';
|
||||
import './jobs/sessionCleanup.js';
|
||||
|
||||
@@ -24,6 +26,25 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
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 = {
|
||||
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'],
|
||||
@@ -52,6 +73,7 @@ app.use('/api/forum', forumRouter);
|
||||
app.use('/api/falukant', falukantRouter);
|
||||
app.use('/api/friendships', friendshipRouter);
|
||||
app.use('/api/blog', blogRouter);
|
||||
app.use('/api/termine', termineRouter);
|
||||
|
||||
// Serve frontend SPA for non-API routes to support history mode clean URLs
|
||||
const frontendDir = path.join(__dirname, '../frontend');
|
||||
|
||||
@@ -27,6 +27,7 @@ class AdminController {
|
||||
// User administration
|
||||
this.searchUsers = this.searchUsers.bind(this);
|
||||
this.getUser = this.getUser.bind(this);
|
||||
this.getUsers = this.getUsers.bind(this);
|
||||
this.updateUser = this.updateUser.bind(this);
|
||||
|
||||
// Rights
|
||||
@@ -37,6 +38,11 @@ class AdminController {
|
||||
|
||||
// Statistics
|
||||
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);
|
||||
}
|
||||
|
||||
async getOpenInterests(req, res) {
|
||||
@@ -74,6 +80,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) {
|
||||
try {
|
||||
const { userid: requester } = req.headers;
|
||||
@@ -290,6 +320,69 @@ 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 getRoomTypes(req, res) {
|
||||
try {
|
||||
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.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
|
||||
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) => {
|
||||
const { branchId, productId, quantity } = req.body;
|
||||
return this.service.createProduction(userId, branchId, productId, quantity);
|
||||
@@ -91,6 +92,7 @@ class FalukantController {
|
||||
if (!result) throw { status: 404, message: 'No family data found' };
|
||||
return result;
|
||||
});
|
||||
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
|
||||
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
|
||||
this.getGifts = this._wrapWithUser((userId) => {
|
||||
console.log('🔍 getGifts called with userId:', userId);
|
||||
@@ -114,6 +116,12 @@ class FalukantController {
|
||||
}, { successStatus: 201 });
|
||||
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.baptise = this._wrapWithUser((userId, req) => {
|
||||
const { characterId: childId, firstName } = req.body;
|
||||
@@ -143,6 +151,24 @@ class FalukantController {
|
||||
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
|
||||
|
||||
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.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
|
||||
|
||||
@@ -181,6 +207,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 }
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -117,10 +117,6 @@ const menuStructure = {
|
||||
visible: ["hasfalukantaccount"],
|
||||
path: "/falukant/branch"
|
||||
},
|
||||
directors: {
|
||||
visible: ["hasfalukantaccount"],
|
||||
path: "/falukant/directors"
|
||||
},
|
||||
family: {
|
||||
visible: ["hasfalukantaccount"],
|
||||
path: "/falukant/family"
|
||||
@@ -251,10 +247,14 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "chatrooms"],
|
||||
path: "/admin/chatrooms"
|
||||
},
|
||||
servicesStatus: {
|
||||
visible: ["mainadmin"],
|
||||
path: "/admin/services/status"
|
||||
},
|
||||
interests: {
|
||||
visible: ["mainadmin", "interests"],
|
||||
path: "/admin/interests"
|
||||
},
|
||||
},
|
||||
falukant: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
children: {
|
||||
@@ -270,6 +270,10 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/database"
|
||||
},
|
||||
mapEditor: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/map"
|
||||
},
|
||||
}
|
||||
},
|
||||
minigames: {
|
||||
|
||||
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();
|
||||
|
||||
98
backend/daemonServer.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
const PORT = 4551;
|
||||
|
||||
// Einfache In-Memory-Struktur für Verbindungen (für spätere Erweiterungen)
|
||||
const connections = new Set();
|
||||
|
||||
function createServer() {
|
||||
const wss = new WebSocketServer({ port: PORT });
|
||||
|
||||
console.log(`[Daemon] WebSocket-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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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 = {
|
||||
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(`
|
||||
CREATE OR REPLACE FUNCTION community.update_hashed_id() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
|
||||
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 Underground from './falukant/data/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 BlogPost from './community/blog_post.js';
|
||||
import Campaign from './match3/campaign.js';
|
||||
@@ -284,6 +291,21 @@ export default function setupAssociations() {
|
||||
RegionData.belongsTo(RegionType, { foreignKey: 'regionTypeId', as: 'regionType' });
|
||||
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' });
|
||||
RegionData.hasMany(FalukantUser, { foreignKey: 'mainBranchRegionId', as: 'users' });
|
||||
|
||||
@@ -421,6 +443,89 @@ export default function setupAssociations() {
|
||||
PromotionalGiftLog.belongsTo(FalukantCharacter, { foreignKey: 'recipientCharacterId', as: 'recipient' });
|
||||
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(PromotionalGiftMood, { foreignKey: 'gift_id', as: 'promotionalgiftmoods' });
|
||||
|
||||
@@ -493,44 +598,52 @@ export default function setupAssociations() {
|
||||
|
||||
Learning.belongsTo(LearnRecipient, {
|
||||
foreignKey: 'learningRecipientId',
|
||||
as: 'recipient'
|
||||
as: 'recipient',
|
||||
constraints: false
|
||||
}
|
||||
);
|
||||
|
||||
LearnRecipient.hasMany(Learning, {
|
||||
foreignKey: 'learningRecipientId',
|
||||
as: 'learnings'
|
||||
as: 'learnings',
|
||||
constraints: false
|
||||
});
|
||||
|
||||
Learning.belongsTo(FalukantUser, {
|
||||
foreignKey: 'associatedFalukantUserId',
|
||||
as: 'learner'
|
||||
as: 'learner',
|
||||
constraints: false
|
||||
}
|
||||
);
|
||||
|
||||
FalukantUser.hasMany(Learning, {
|
||||
foreignKey: 'associatedFalukantUserId',
|
||||
as: 'learnings'
|
||||
as: 'learnings',
|
||||
constraints: false
|
||||
});
|
||||
|
||||
Learning.belongsTo(ProductType, {
|
||||
foreignKey: 'productId',
|
||||
as: 'productType'
|
||||
as: 'productType',
|
||||
constraints: false
|
||||
});
|
||||
|
||||
ProductType.hasMany(Learning, {
|
||||
foreignKey: 'productId',
|
||||
as: 'learnings'
|
||||
as: 'learnings',
|
||||
constraints: false
|
||||
});
|
||||
|
||||
Learning.belongsTo(FalukantCharacter, {
|
||||
foreignKey: 'associatedLearningCharacterId',
|
||||
as: 'learningCharacter'
|
||||
as: 'learningCharacter',
|
||||
constraints: false
|
||||
});
|
||||
|
||||
FalukantCharacter.hasMany(Learning, {
|
||||
foreignKey: 'associatedLearningCharacterId',
|
||||
as: 'learningsCharacter'
|
||||
as: 'learningsCharacter',
|
||||
constraints: false
|
||||
});
|
||||
|
||||
FalukantUser.hasMany(Credit, {
|
||||
|
||||
@@ -8,16 +8,12 @@ const Folder = sequelize.define('folder', {
|
||||
allowNull: false},
|
||||
parentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'folder',
|
||||
key: 'id'}},
|
||||
allowNull: true
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'}}}, {
|
||||
allowNull: false
|
||||
}}, {
|
||||
tableName: 'folder',
|
||||
schema: 'community',
|
||||
underscored: true,
|
||||
|
||||
@@ -10,22 +10,11 @@ const FolderImageVisibility = sequelize.define('folder_image_visibility', {
|
||||
},
|
||||
folderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'folder',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
visibilityTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: {
|
||||
schema: 'type',
|
||||
tableName: 'image_visibility_type'
|
||||
},
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'folder_image_visibility',
|
||||
|
||||
@@ -10,19 +10,11 @@ const FolderVisibilityUser = sequelize.define('folder_visibility_user', {
|
||||
},
|
||||
folderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'folder',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
visibilityUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'image_visibility_user',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'folder_visibility_user',
|
||||
|
||||
@@ -10,19 +10,11 @@ const GuestbookEntry = sequelize.define('guestbook_entry', {
|
||||
allowNull: false},
|
||||
recipientId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
senderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: true
|
||||
},
|
||||
senderUsername: {
|
||||
type: DataTypes.STRING,
|
||||
|
||||
@@ -18,16 +18,12 @@ const Image = sequelize.define('image', {
|
||||
unique: true},
|
||||
folderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'folder',
|
||||
key: 'id'}},
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'}}}, {
|
||||
allowNull: false
|
||||
}}, {
|
||||
tableName: 'image',
|
||||
schema: 'community',
|
||||
underscored: true,
|
||||
|
||||
@@ -10,22 +10,11 @@ const ImageImageVisibility = sequelize.define('image_image_visibility', {
|
||||
},
|
||||
imageId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'image',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
visibilityTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: {
|
||||
schema: 'type',
|
||||
tableName: 'image_visibility_type'
|
||||
},
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'image_image_visibility',
|
||||
|
||||
@@ -7,19 +7,11 @@ import { encrypt, decrypt } from '../../utils/encryption.js';
|
||||
const UserParam = sequelize.define('user_param', {
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id',
|
||||
},
|
||||
allowNull: false
|
||||
},
|
||||
paramTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: UserParamType,
|
||||
key: 'id',
|
||||
},
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.STRING,
|
||||
|
||||
@@ -6,19 +6,11 @@ import UserRightType from '../type/user_right.js';
|
||||
const UserRight = sequelize.define('user_right', {
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
rightTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: UserRightType,
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
}}, {
|
||||
tableName: 'user_right',
|
||||
schema: 'community',
|
||||
|
||||
@@ -34,6 +34,18 @@ FalukantCharacter.init(
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Model, DataTypes } from 'sequelize';
|
||||
import { sequelize } from '../../../utils/sequelize.js';
|
||||
import WeatherType from '../type/weather.js';
|
||||
|
||||
class Production extends Model { }
|
||||
|
||||
@@ -13,6 +14,11 @@ Production.init({
|
||||
quantity: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false},
|
||||
weatherTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Wetter zum Zeitpunkt der Produktionserstellung'
|
||||
},
|
||||
startTimestamp: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
@@ -10,26 +10,24 @@ RegionData.init({
|
||||
allowNull: false},
|
||||
regionTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: RegionType,
|
||||
key: 'id',
|
||||
schema: 'falukant_type'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
parentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'region',
|
||||
key: 'id',
|
||||
schema: 'falukant_data'}
|
||||
allowNull: true
|
||||
},
|
||||
map: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: {}
|
||||
}
|
||||
,
|
||||
taxPercent: {
|
||||
type: DataTypes.DECIMAL,
|
||||
allowNull: false,
|
||||
defaultValue: 7,
|
||||
field: 'tax_percent'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'RegionData',
|
||||
|
||||
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: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: FalukantCharacter,
|
||||
key: 'id'},
|
||||
onDelete: 'CASCADE'},
|
||||
allowNull: false},
|
||||
character2Id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: FalukantCharacter,
|
||||
key: 'id'},
|
||||
onDelete: 'CASCADE'},
|
||||
allowNull: false},
|
||||
relationshipTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
|
||||
@@ -6,15 +6,20 @@ class FalukantStock extends Model { }
|
||||
FalukantStock.init({
|
||||
branchId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
allowNull: false
|
||||
},
|
||||
stockTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false},
|
||||
quantity: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false}}, {
|
||||
allowNull: false},
|
||||
productQuality: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Quality of the stored product (0-100)'
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'StockData',
|
||||
tableName: 'stock',
|
||||
|
||||
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: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'user',
|
||||
schema: 'community'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
unique: true},
|
||||
money: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
@@ -38,12 +31,11 @@ FalukantUser.init({
|
||||
defaultValue: 1},
|
||||
mainBranchRegionId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: RegionData,
|
||||
key: 'id',
|
||||
schema: 'falukant_data'
|
||||
}
|
||||
allowNull: true
|
||||
},
|
||||
lastNobilityAdvanceAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
@@ -26,13 +26,11 @@ UserHouse.init({
|
||||
},
|
||||
houseTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
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
@@ -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 { 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({
|
||||
userId: {
|
||||
@@ -10,6 +15,11 @@ Notification.init({
|
||||
tr: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false},
|
||||
character_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'character_name'
|
||||
},
|
||||
shown: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
||||
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: {
|
||||
type: DataTypes.INTEGER,
|
||||
field: 'gift_id',
|
||||
references: { model: PromotionalGift, key: 'id' },
|
||||
allowNull: false
|
||||
},
|
||||
traitId: {
|
||||
type: DataTypes.INTEGER,
|
||||
field: 'trait_id',
|
||||
references: { model: CharacterTrait, key: 'id' },
|
||||
allowNull: false
|
||||
},
|
||||
suitability: {
|
||||
|
||||
@@ -10,19 +10,11 @@ PromotionalGiftMood.init(
|
||||
giftId: {
|
||||
type: DataTypes.INTEGER,
|
||||
field: 'gift_id',
|
||||
references: {
|
||||
model: PromotionalGift,
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false
|
||||
},
|
||||
moodId: {
|
||||
type: DataTypes.INTEGER,
|
||||
field: 'mood_id',
|
||||
references: {
|
||||
model: Mood,
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false
|
||||
},
|
||||
suitability: {
|
||||
|
||||
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},
|
||||
parentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'region',
|
||||
key: 'id',
|
||||
schema: 'falukant_type'}
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
|
||||
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
@@ -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
@@ -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', {
|
||||
forumId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'forum',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
permissionId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'forum_permission',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'forum_forum_permission',
|
||||
|
||||
@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
|
||||
import MusicType from './falukant/type/music.js';
|
||||
import BanquetteType from './falukant/type/banquette.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 LearnRecipient from './falukant/type/learn_recipient.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 UndergroundType from './falukant/type/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 ChatUser from './chat/user.js';
|
||||
@@ -201,12 +210,18 @@ const models = {
|
||||
BanquetteType,
|
||||
Party,
|
||||
PartyInvitedNobility,
|
||||
ReputationActionType,
|
||||
ReputationActionLog,
|
||||
ChildRelation,
|
||||
LearnRecipient,
|
||||
Learning,
|
||||
Credit,
|
||||
DebtorsPrism,
|
||||
HealthActivity,
|
||||
RegionDistance,
|
||||
VehicleType,
|
||||
Vehicle,
|
||||
Transport,
|
||||
PoliticalOfficeType,
|
||||
PoliticalOfficeRequirement,
|
||||
PoliticalOfficeBenefitType,
|
||||
@@ -220,6 +235,9 @@ const models = {
|
||||
ElectionHistory,
|
||||
UndergroundType,
|
||||
Underground,
|
||||
WeatherType,
|
||||
Weather,
|
||||
ProductWeatherEffect,
|
||||
Room,
|
||||
ChatUser,
|
||||
ChatRight,
|
||||
|
||||
@@ -9,11 +9,7 @@ const Match3Level = sequelize.define('Match3Level', {
|
||||
},
|
||||
campaignId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'match3_campaigns',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
|
||||
@@ -10,19 +10,11 @@ const Match3LevelTileType = sequelize.define('Match3LevelTileType', {
|
||||
levelId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'match3_levels',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'Referenz auf den Level'
|
||||
},
|
||||
tileTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'match3_tile_types',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'Referenz auf den Tile-Typ'
|
||||
},
|
||||
weight: {
|
||||
|
||||
@@ -9,14 +9,7 @@ const TaxiHighscore = sequelize.define('TaxiHighscore', {
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true, // Kann null sein, falls User gelöscht wird
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'user',
|
||||
schema: 'community'
|
||||
},
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: true // Kann null sein, falls User gelöscht wird
|
||||
},
|
||||
nickname: {
|
||||
type: DataTypes.STRING(100),
|
||||
@@ -44,13 +37,6 @@ const TaxiHighscore = sequelize.define('TaxiHighscore', {
|
||||
mapId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'taxi_map',
|
||||
schema: 'taxi'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'ID der gespielten Map'
|
||||
},
|
||||
mapName: {
|
||||
|
||||
@@ -378,16 +378,19 @@ export async function createTriggers() {
|
||||
tp.election_id,
|
||||
tp.tp_office_type_id,
|
||||
tp.tp_election_date,
|
||||
(
|
||||
SELECT json_agg(vr)
|
||||
FROM votes vr
|
||||
WHERE vr.election_id = tp.election_id
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(vr)
|
||||
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 updated_at
|
||||
FROM to_process tp
|
||||
),
|
||||
|
||||
|
||||
-- 10) Cleanup: Stimmen, Kandidaten und Wahlen löschen
|
||||
_del_votes AS (
|
||||
DELETE FROM falukant_data.vote
|
||||
|
||||
@@ -13,13 +13,7 @@ const interestTranslation = sequelize.define('interest_translation_type', {
|
||||
},
|
||||
interestsId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Interest,
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
allowNull: false
|
||||
}}, {
|
||||
tableName: 'interest_translation',
|
||||
schema: 'type',
|
||||
|
||||
@@ -21,11 +21,7 @@ const UserParamType = sequelize.define('user_param_type', {
|
||||
},
|
||||
settingsId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'settings',
|
||||
key: 'id'
|
||||
}
|
||||
allowNull: false
|
||||
},
|
||||
orderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
||||
@@ -7,7 +7,7 @@ const UserRightType = sequelize.define('user_right_type', {
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'user_right_type',
|
||||
tableName: 'user_right',
|
||||
schema: 'type',
|
||||
underscored: true
|
||||
});
|
||||
|
||||
1590
backend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "NODE_ENV=development node server.js",
|
||||
"start-daemon": "node daemonServer.js",
|
||||
"sync-db": "node sync-database.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
@@ -15,7 +16,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"amqplib": "^0.10.4",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-redis": "^7.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -26,9 +27,9 @@
|
||||
"i18n": "^0.15.1",
|
||||
"joi": "^17.13.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.0",
|
||||
"mysql2": "^3.10.3",
|
||||
"nodemailer": "^6.9.14",
|
||||
"nodemailer": "^7.0.11",
|
||||
"pg": "^8.12.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"redis": "^4.7.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom);
|
||||
// --- Users Admin ---
|
||||
router.get('/users/search', authenticate, adminController.searchUsers);
|
||||
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
|
||||
router.get('/users/batch', authenticate, adminController.getUsers);
|
||||
router.get('/users/:id', authenticate, adminController.getUser);
|
||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||
|
||||
@@ -40,6 +41,11 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
|
||||
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
||||
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
||||
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);
|
||||
|
||||
// --- Minigames Admin ---
|
||||
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', falukantController.getBranches);
|
||||
router.post('/branches', falukantController.createBranch);
|
||||
router.post('/branches/upgrade', falukantController.upgradeBranch);
|
||||
router.get('/productions', falukantController.getAllProductions);
|
||||
router.post('/production', falukantController.createProduction);
|
||||
router.get('/production/:branchId', falukantController.getProduction);
|
||||
@@ -37,6 +38,7 @@ router.get('/director/:branchId', falukantController.getDirectorForBranch);
|
||||
router.get('/directors', falukantController.getAllDirectors);
|
||||
router.post('/directors', falukantController.updateDirector);
|
||||
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
||||
router.post('/family/set-heir', falukantController.setHeir);
|
||||
router.get('/family/gifts', falukantController.getGifts);
|
||||
router.get('/family/children', falukantController.getChildren);
|
||||
router.post('/family/gift', falukantController.sendGift);
|
||||
@@ -51,6 +53,8 @@ router.post('/houses', falukantController.buyUserHouse);
|
||||
router.get('/party/types', falukantController.getPartyTypes);
|
||||
router.post('/party', falukantController.createParty);
|
||||
router.get('/party', falukantController.getParties);
|
||||
router.get('/reputation/actions', falukantController.getReputationActions);
|
||||
router.post('/reputation/actions', falukantController.executeReputationAction);
|
||||
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
|
||||
router.post('/church/baptise', falukantController.baptise);
|
||||
router.get('/education', falukantController.getEducation);
|
||||
@@ -69,6 +73,17 @@ router.post('/politics/elections', falukantController.vote);
|
||||
router.get('/politics/open', falukantController.getOpenPolitics);
|
||||
router.post('/politics/open', falukantController.applyForElections);
|
||||
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('/notifications', falukantController.getNotifications);
|
||||
router.get('/notifications/all', falukantController.getAllNotifications);
|
||||
|
||||
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;
|
||||
|
||||
@@ -19,7 +19,9 @@ import Branch from "../models/falukant/data/branch.js";
|
||||
import FalukantStock from "../models/falukant/data/stock.js";
|
||||
import FalukantStockType from "../models/falukant/type/stock.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 RegionDistance from "../models/falukant/data/region_distance.js";
|
||||
import Room from '../models/chat/room.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
|
||||
@@ -298,6 +300,104 @@ 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 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) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
@@ -441,6 +541,30 @@ class AdminService {
|
||||
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) {
|
||||
if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import User from '../models/community/user.js';
|
||||
|
||||
@@ -328,7 +328,7 @@ class SettingsService extends BaseService{
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
const bcrypt = await import('bcrypt');
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const match = await bcrypt.compare(settings.oldpassword, user.password);
|
||||
if (!match) {
|
||||
throw new Error('Old password is incorrect');
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
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: '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: 'meat', 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: 'horse', category: 5, productionTime: 5, sellCost: 60 },
|
||||
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
||||
], {
|
||||
];
|
||||
|
||||
const productsToInsert = baseProducts;
|
||||
|
||||
await ProductType.bulkCreate(productsToInsert, {
|
||||
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 MusicType from "../../models/falukant/type/music.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 PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
|
||||
import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js";
|
||||
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.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.
|
||||
const falukantDebug = process.env.DEBUG_FALUKANT === '1';
|
||||
@@ -36,13 +42,67 @@ export const initializeFalukantTypes = async () => {
|
||||
await initializeFalukantPartyTypes();
|
||||
await initializeFalukantMusicTypes();
|
||||
await initializeFalukantBanquetteTypes();
|
||||
await initializeFalukantReputationActions();
|
||||
await initializeLearnerTypes();
|
||||
await initializePoliticalOfficeBenefitTypes();
|
||||
await initializePoliticalOfficeTypes();
|
||||
await initializePoliticalOfficePrerequisites();
|
||||
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 regionTypeTrs = [
|
||||
"country",
|
||||
@@ -273,6 +333,17 @@ const learnerTypes = [
|
||||
{ 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 = [
|
||||
{ tr: 'salary' },
|
||||
{ tr: 'reputation' },
|
||||
@@ -282,6 +353,7 @@ const politicalOfficeBenefitTypes = [
|
||||
{ tr: 'tax_exemption' },
|
||||
{ tr: 'guard_protection' },
|
||||
{ tr: 'court_immunity' },
|
||||
{ tr: 'set_regionl_tax' },
|
||||
];
|
||||
|
||||
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 () => {
|
||||
for (const benefitType of politicalOfficeBenefitTypes) {
|
||||
await PoliticalOfficeBenefitType.findOrCreate({
|
||||
@@ -972,3 +1069,445 @@ export const initializeFalukantTitlesOfNobility = async () => {
|
||||
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();
|
||||
|
||||
// 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
|
||||
const dbName = process.env.DB_NAME;
|
||||
const dbUser = process.env.DB_USER;
|
||||
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||
timestamps: false,
|
||||
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
|
||||
},
|
||||
benchmark: SQL_BENCHMARK,
|
||||
logging: sqlLogger,
|
||||
});
|
||||
|
||||
const createSchemas = async () => {
|
||||
@@ -45,6 +66,16 @@ const createSchemas = async () => {
|
||||
|
||||
const initializeDatabase = async () => {
|
||||
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.
|
||||
// Das Syncing (inkl. alter: true bei Bedarf) wird anschließend zentral
|
||||
// über syncModelsWithUpdates()/syncModelsAlways gesteuert.
|
||||
@@ -95,7 +126,8 @@ const syncModelsWithUpdates = async (models) => {
|
||||
if (needsUpdate) {
|
||||
console.log('🔄 Schema-Updates nötig - verwende alter: true');
|
||||
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');
|
||||
} else {
|
||||
@@ -363,12 +395,13 @@ const getExpectedDefaultValue = (defaultValue) => {
|
||||
const updateSchema = async (models) => {
|
||||
console.log('🔄 Aktualisiere Datenbankschema...');
|
||||
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');
|
||||
};
|
||||
|
||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) {
|
||||
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
|
||||
try {
|
||||
const result = await sequelize.query(
|
||||
`SELECT falukant_data.update_money(
|
||||
@@ -385,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
|
||||
changedBy,
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
transaction: transaction || undefined,
|
||||
}
|
||||
);
|
||||
return {
|
||||
@@ -406,8 +440,311 @@ const syncModelsAlways = async (models) => {
|
||||
console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...');
|
||||
|
||||
try {
|
||||
for (const model of Object.values(models)) {
|
||||
await model.sync({ alter: true, force: false });
|
||||
for (const model of Object.values(models)) {
|
||||
// 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');
|
||||
} catch (error) {
|
||||
|
||||
@@ -54,6 +54,112 @@ const syncDatabase = async () => {
|
||||
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...");
|
||||
setupAssociations();
|
||||
|
||||
@@ -104,6 +210,10 @@ const syncDatabase = async () => {
|
||||
// Deployment-Synchronisation (immer Schema-Updates)
|
||||
const syncDatabaseForDeployment = async () => {
|
||||
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
|
||||
const currentStage = process.env.STAGE || 'nicht gesetzt';
|
||||
console.log(`🚀 Starte Datenbank-Synchronisation für Deployment (Stage: ${currentStage})`);
|
||||
@@ -133,6 +243,165 @@ const syncDatabaseForDeployment = async () => {
|
||||
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`);
|
||||
}
|
||||
|
||||
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0 && deletedCount8 === 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...");
|
||||
setupAssociations();
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ fi
|
||||
# 5. Frontend neu bauen – VITE_* aus Environment übernehmen oder Defaults setzen
|
||||
echo "Baue Frontend neu..."
|
||||
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_CHAT_WS_URL=${VITE_CHAT_WS_URL:-wss://www.your-part.de:1235}
|
||||
|
||||
|
||||
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"
|
||||
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
@@ -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."
|
||||
5
frontend/.env.production
Normal file
@@ -0,0 +1,5 @@
|
||||
VITE_API_BASE_URL=https://www.your-part.de
|
||||
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
|
||||
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
|
||||
VITE_CHAT_WS_URL=wss://www.your-part.de:1235
|
||||
VITE_SOCKET_IO_URL=https://www.your-part.de:4443
|
||||
206
frontend/package-lock.json
generated
@@ -530,13 +530,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "10.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.7.tgz",
|
||||
"integrity": "sha512-mE71aUH5baH0me8duB4FY5qevUJizypHsYw3eCvmOx07QvmKppgOONx3dYINxuA89Z2qkAGb/K6Nrpi7aAMwew==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.8.tgz",
|
||||
"integrity": "sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "10.0.7",
|
||||
"@intlify/shared": "10.0.7"
|
||||
"@intlify/message-compiler": "10.0.8",
|
||||
"@intlify/shared": "10.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -546,12 +546,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "10.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.7.tgz",
|
||||
"integrity": "sha512-nrC4cDL/UHZSUqd8sRbVz+DPukzZ8NnG5OK+EB/nlxsH35deyzyVkXP/QuR8mFZrISJ+4hCd6VtCQCcT+RO+5g==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.8.tgz",
|
||||
"integrity": "sha512-DV+sYXIkHVd5yVb2mL7br/NEUwzUoLBsMkV3H0InefWgmYa34NLZUvMCGi5oWX+Hqr2Y2qUxnVrnOWF4aBlgWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "10.0.7",
|
||||
"@intlify/shared": "10.0.8",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -562,9 +562,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "10.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.7.tgz",
|
||||
"integrity": "sha512-oeoq0L5+5P4ShXa6jBQcx+BT+USe3MjX0xJexZO1y7rfDJdwZ9+QP3jO4tcS1nxhBYYdjvFTqe4bmnLijV0GxQ==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.8.tgz",
|
||||
"integrity": "sha512-BcmHpb5bQyeVNrptC3UhzpBZB/YHHDoEREOUERrmF2BRxsyOEuRrq+Z96C/D4+2KJb8kuHiouzAei7BXlG0YYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -1494,7 +1494,8 @@
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
@@ -1512,13 +1513,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1565,6 +1566,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1593,6 +1607,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -1674,6 +1689,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -1698,6 +1714,20 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.2",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
|
||||
@@ -1730,13 +1760,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
|
||||
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"get-intrinsic": "^1.2.4"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -1745,7 +1772,33 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -1848,12 +1901,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1878,22 +1934,26 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
|
||||
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
|
||||
"dev": true,
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"has-proto": "^1.0.1",
|
||||
"has-symbols": "^1.0.3",
|
||||
"hasown": "^2.0.0"
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1902,6 +1962,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -1915,12 +1988,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
|
||||
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"get-intrinsic": "^1.1.3"
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -1938,23 +2011,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-proto": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
|
||||
"integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
|
||||
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
|
||||
"dev": true,
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -1966,7 +2027,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
@@ -1981,7 +2041,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -2151,6 +2210,15 @@
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
@@ -2161,6 +2229,7 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -2169,6 +2238,7 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -2864,9 +2934,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2987,13 +3057,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "10.0.7",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.7.tgz",
|
||||
"integrity": "sha512-bKsk0PYwP9gdYF4nqSAT0kDpnLu1gZzlxFl885VH4mHVhEnqP16+/mAU05r1U6NIrc0fGDWP89tZ8GzeJZpe+w==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.8.tgz",
|
||||
"integrity": "sha512-mIjy4utxMz9lMMo6G9vYePv7gUFt4ztOMhY9/4czDJxZ26xPeJ49MAGa9wBAE3XuXbYCrtVPmPxNjej7JJJkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "10.0.7",
|
||||
"@intlify/shared": "10.0.7",
|
||||
"@intlify/core-base": "10.0.8",
|
||||
"@intlify/shared": "10.0.8",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/images/falukant/map_old.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 8.5 KiB |