diff --git a/backend/migrations/20260126000000-add-relationship-change-log.cjs b/backend/migrations/20260126000000-add-relationship-change-log.cjs new file mode 100644 index 0000000..20bb204 --- /dev/null +++ b/backend/migrations/20260126000000-add-relationship-change-log.cjs @@ -0,0 +1,105 @@ +/* eslint-disable */ +'use strict'; + +/** Log-Tabelle für alle Änderungen an relationship und marriage_proposals (keine Einträge werden gelöscht). + * Hilft zu analysieren, warum z.B. Werbungen um einen Partner über Nacht verschwinden. */ + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS falukant_log.relationship_change_log ( + id serial PRIMARY KEY, + changed_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + table_name character varying(64) NOT NULL, + operation character varying(16) NOT NULL, + record_id integer, + payload_old jsonb, + payload_new jsonb + ); + `); + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS relationship_change_log_changed_at_idx + ON falukant_log.relationship_change_log (changed_at); + `); + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS relationship_change_log_table_operation_idx + ON falukant_log.relationship_change_log (table_name, operation); + `); + + const triggerFunction = ` + CREATE OR REPLACE FUNCTION falukant_log.log_relationship_change() + RETURNS TRIGGER AS $$ + DECLARE + v_record_id INTEGER; + v_payload_old JSONB; + v_payload_new JSONB; + BEGIN + IF TG_OP = 'INSERT' THEN + v_record_id := NEW.id; + v_payload_old := NULL; + v_payload_new := to_jsonb(NEW); + ELSIF TG_OP = 'UPDATE' THEN + v_record_id := NEW.id; + v_payload_old := to_jsonb(OLD); + v_payload_new := to_jsonb(NEW); + ELSIF TG_OP = 'DELETE' THEN + v_record_id := OLD.id; + v_payload_old := to_jsonb(OLD); + v_payload_new := NULL; + END IF; + + INSERT INTO falukant_log.relationship_change_log ( + table_name, + operation, + record_id, + payload_old, + payload_new + ) VALUES ( + TG_TABLE_NAME, + TG_OP, + v_record_id, + v_payload_old, + v_payload_new + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + ELSE + RETURN NEW; + END IF; + END; + $$ LANGUAGE plpgsql; + `; + await queryInterface.sequelize.query(triggerFunction); + + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS trg_log_relationship_change ON falukant_data.relationship; + CREATE TRIGGER trg_log_relationship_change + AFTER INSERT OR UPDATE OR DELETE ON falukant_data.relationship + FOR EACH ROW + EXECUTE FUNCTION falukant_log.log_relationship_change(); + `); + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS trg_log_relationship_change ON falukant_data.marriage_proposals; + CREATE TRIGGER trg_log_relationship_change + AFTER INSERT OR UPDATE OR DELETE ON falukant_data.marriage_proposals + FOR EACH ROW + EXECUTE FUNCTION falukant_log.log_relationship_change(); + `); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS trg_log_relationship_change ON falukant_data.relationship; + `); + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS trg_log_relationship_change ON falukant_data.marriage_proposals; + `); + await queryInterface.sequelize.query(` + DROP FUNCTION IF EXISTS falukant_log.log_relationship_change(); + `); + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS falukant_log.relationship_change_log; + `); + }, +}; diff --git a/backend/models/falukant/log/relationship_change_log.js b/backend/models/falukant/log/relationship_change_log.js new file mode 100644 index 0000000..0ad7eed --- /dev/null +++ b/backend/models/falukant/log/relationship_change_log.js @@ -0,0 +1,49 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; + +/** + * Log aller Änderungen an relationship und marriage_proposals. + * Einträge werden ausschließlich durch DB-Trigger geschrieben und nicht gelöscht. + * Hilft zu analysieren, warum z.B. Werbungen um einen Partner verschwinden. + */ +class RelationshipChangeLog extends Model {} + +RelationshipChangeLog.init( + { + changedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + tableName: { + type: DataTypes.STRING(64), + allowNull: false + }, + operation: { + type: DataTypes.STRING(16), + allowNull: false + }, + recordId: { + type: DataTypes.INTEGER, + allowNull: true + }, + payloadOld: { + type: DataTypes.JSONB, + allowNull: true + }, + payloadNew: { + type: DataTypes.JSONB, + allowNull: true + } + }, + { + sequelize, + modelName: 'RelationshipChangeLog', + tableName: 'relationship_change_log', + schema: 'falukant_log', + timestamps: false, + underscored: true + } +); + +export default RelationshipChangeLog; diff --git a/backend/models/index.js b/backend/models/index.js index 5fed70c..6bd96f4 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -113,6 +113,7 @@ import Vote from './falukant/data/vote.js'; import ElectionResult from './falukant/data/election_result.js'; import PoliticalOfficeHistory from './falukant/log/political_office_history.js'; import ElectionHistory from './falukant/log/election_history.js'; +import RelationshipChangeLog from './falukant/log/relationship_change_log.js'; // — Kirchliche Ämter (Church) — import ChurchOfficeType from './falukant/type/church_office_type.js'; @@ -248,6 +249,7 @@ const models = { ElectionResult, PoliticalOfficeHistory, ElectionHistory, + RelationshipChangeLog, ChurchOfficeType, ChurchOfficeRequirement, ChurchOffice, diff --git a/frontend/public/models/3d/falukant/characters/README.md b/frontend/public/models/3d/falukant/characters/README.md index 7857b34..e5dcd5d 100644 --- a/frontend/public/models/3d/falukant/characters/README.md +++ b/frontend/public/models/3d/falukant/characters/README.md @@ -11,6 +11,8 @@ Dieses Verzeichnis enthält die 3D-Modelle für Falukant-Charaktere. - `female.glb` - Basis-Modell weiblich ### Altersspezifische Modelle + +#### Altersbereich-Modelle (Fallback für Altersgruppen) - `male_toddler.glb` - Männlich, Kleinkind (0-3 Jahre) - `male_child.glb` - Männlich, Kind (4-7 Jahre) - `male_preteen.glb` - Männlich, Vor-Teenager (8-12 Jahre) @@ -22,9 +24,19 @@ Dieses Verzeichnis enthält die 3D-Modelle für Falukant-Charaktere. - `female_teen.glb` - Weiblich, Teenager (13-17 Jahre) - `female_adult.glb` - Weiblich, Erwachsen (18+ Jahre) +#### Genaue Alters-Modelle (optional, für spezifische Altersstufen) +- `male_1y.glb`, `male_2y.glb`, `male_3y.glb`, etc. - Männlich, genaues Alter in Jahren +- `female_1y.glb`, `female_2y.glb`, `female_5y.glb`, etc. - Weiblich, genaues Alter in Jahren + +**Hinweis:** Genaue Alters-Modelle haben Vorrang vor Altersbereich-Modellen. Wenn z.B. für Alter 1 sowohl `female_1y.glb` als auch `female_toddler.glb` existieren, wird `female_1y.glb` verwendet. + ## Fallback-Verhalten -Wenn kein spezifisches Modell für den Altersbereich existiert, wird automatisch das Basis-Modell (`male.glb` / `female.glb`) verwendet. +Die Komponente verwendet eine dreistufige Fallback-Hierarchie: + +1. **Genaues Alter:** Zuerst wird nach einem Modell für das genaue Alter gesucht (z.B. `female_1y.glb` für Alter 1) +2. **Altersbereich:** Falls kein genaues Alters-Modell existiert, wird das Altersbereich-Modell verwendet (z.B. `female_toddler.glb` für Alter 1-3) +3. **Basis-Modell:** Falls auch kein Altersbereich-Modell existiert, wird das Basis-Modell verwendet (`male.glb` / `female.glb`) ## Dateigröße diff --git a/frontend/src/components/Character3D.vue b/frontend/src/components/Character3D.vue index 8fc9054..deda777 100644 --- a/frontend/src/components/Character3D.vue +++ b/frontend/src/components/Character3D.vue @@ -82,6 +82,13 @@ export default { const base = getApiBaseURL(); const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH; return `${prefix}/${this.actualGender}_${this.ageGroup}.glb`; + }, + exactAgeModelPath() { + // Pfad für genaues Alter (z.B. female_1y.glb für Alter 1) + const age = this.actualAge; + const base = getApiBaseURL(); + const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH; + return `${prefix}/${this.actualGender}_${age}y.glb`; } }, watch: { @@ -207,15 +214,32 @@ export default { const base = getApiBaseURL(); const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH; - const modelPath = this.modelPath; + + // Fallback-Hierarchie: + // 1. Zuerst versuchen, Modell für genaues Alter zu laden (z.B. female_1y.glb) + // 2. Falls nicht vorhanden, Altersbereich verwenden (z.B. female_toddler.glb) + // 3. Falls auch nicht vorhanden, Basis-Modell verwenden (z.B. female.glb) + const exactAgePath = this.exactAgeModelPath; + const ageGroupPath = this.modelPath; const fallbackPath = `${prefix}/${this.actualGender}.glb`; let gltf; try { - gltf = await loader.loadAsync(modelPath); - } catch (error) { - console.warn(`Could not load ${modelPath}, trying fallback model`); - gltf = await loader.loadAsync(fallbackPath); + // Versuche zuerst genaues Alter + try { + gltf = await loader.loadAsync(exactAgePath); + console.log(`Loaded exact age model: ${exactAgePath}`); + } catch (exactAgeError) { + // Falls genaues Alter nicht existiert, versuche Altersbereich + try { + gltf = await loader.loadAsync(ageGroupPath); + console.log(`Loaded age group model: ${ageGroupPath}`); + } catch (ageGroupError) { + // Falls Altersbereich nicht existiert, verwende Basis-Modell + console.warn(`Could not load ${ageGroupPath}, trying fallback model`); + gltf = await loader.loadAsync(fallbackPath); + } + } } finally { dracoLoader.dispose(); }