diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index c9ff440..81ab7da 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -148,6 +148,20 @@ class FalukantController { this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId)); this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId)); + + // Church career endpoints + this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId)); + this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId)); + this.applyForChurchPosition = this._wrapWithUser((userId, req) => { + const { officeTypeId, regionId } = req.body; + return this.service.applyForChurchPosition(userId, officeTypeId, regionId); + }, { successStatus: 201 }); + this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId)); + this.decideOnChurchApplication = this._wrapWithUser((userId, req) => { + const { applicationId, decision } = req.body; + return this.service.decideOnChurchApplication(userId, applicationId, decision); + }); + this.hasChurchCareer = this._wrapWithUser((userId) => this.service.hasChurchCareer(userId)); this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId)); this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes)); this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds)); diff --git a/backend/models/associations.js b/backend/models/associations.js index d6351ad..4a240cf 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -93,6 +93,10 @@ import PoliticalOfficeRequirement from './falukant/predefine/political_office_pr import PoliticalOfficePrerequisite from './falukant/predefine/political_office_prerequisite.js'; import PoliticalOfficeHistory from './falukant/log/political_office_history.js'; import ElectionHistory from './falukant/log/election_history.js'; +import ChurchOfficeType from './falukant/type/church_office_type.js'; +import ChurchOfficeRequirement from './falukant/predefine/church_office_requirement.js'; +import ChurchOffice from './falukant/data/church_office.js'; +import ChurchApplication from './falukant/data/church_application.js'; import Underground from './falukant/data/underground.js'; import UndergroundType from './falukant/type/underground.js'; import VehicleType from './falukant/type/vehicle.js'; @@ -866,6 +870,86 @@ export default function setupAssociations() { } ); + // — Church Offices — + + // Requirements for church office + ChurchOfficeRequirement.belongsTo(ChurchOfficeType, { + foreignKey: 'officeTypeId', + as: 'officeType' + }); + ChurchOfficeType.hasMany(ChurchOfficeRequirement, { + foreignKey: 'officeTypeId', + as: 'requirements' + }); + + // Prerequisite office type + ChurchOfficeRequirement.belongsTo(ChurchOfficeType, { + foreignKey: 'prerequisiteOfficeTypeId', + as: 'prerequisiteOfficeType' + }); + + // Actual church office holdings + ChurchOffice.belongsTo(ChurchOfficeType, { + foreignKey: 'officeTypeId', + as: 'type' + }); + ChurchOfficeType.hasMany(ChurchOffice, { + foreignKey: 'officeTypeId', + as: 'offices' + }); + + ChurchOffice.belongsTo(FalukantCharacter, { + foreignKey: 'characterId', + as: 'holder' + }); + FalukantCharacter.hasOne(ChurchOffice, { + foreignKey: 'characterId', + as: 'heldChurchOffice' + }); + + // Supervisor relationship + ChurchOffice.belongsTo(FalukantCharacter, { + foreignKey: 'supervisorId', + as: 'supervisor' + }); + + // Applications for church office + ChurchApplication.belongsTo(ChurchOfficeType, { + foreignKey: 'officeTypeId', + as: 'officeType' + }); + ChurchOfficeType.hasMany(ChurchApplication, { + foreignKey: 'officeTypeId', + as: 'applications' + }); + + ChurchApplication.belongsTo(FalukantCharacter, { + foreignKey: 'characterId', + as: 'applicant' + }); + FalukantCharacter.hasMany(ChurchApplication, { + foreignKey: 'characterId', + as: 'churchApplications' + }); + + ChurchApplication.belongsTo(FalukantCharacter, { + foreignKey: 'supervisorId', + as: 'supervisor' + }); + FalukantCharacter.hasMany(ChurchApplication, { + foreignKey: 'supervisorId', + as: 'supervisedApplications' + }); + + ChurchApplication.belongsTo(RegionData, { + foreignKey: 'regionId', + as: 'region' + }); + RegionData.hasMany(ChurchApplication, { + foreignKey: 'regionId', + as: 'churchApplications' + }); + Underground.belongsTo(UndergroundType, { foreignKey: 'undergroundTypeId', as: 'undergroundType' diff --git a/backend/models/falukant/data/church_application.js b/backend/models/falukant/data/church_application.js new file mode 100644 index 0000000..2515c09 --- /dev/null +++ b/backend/models/falukant/data/church_application.js @@ -0,0 +1,47 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; + +class ChurchApplication extends Model {} + +ChurchApplication.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + officeTypeId: { + type: DataTypes.INTEGER, + allowNull: false + }, + characterId: { + type: DataTypes.INTEGER, + allowNull: false + }, + regionId: { + type: DataTypes.INTEGER, + allowNull: false + }, + supervisorId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'ID des Vorgesetzten, der über die Bewerbung entscheidet' + }, + status: { + type: DataTypes.ENUM('pending', 'approved', 'rejected'), + allowNull: false, + defaultValue: 'pending' + }, + decisionDate: { + type: DataTypes.DATE, + allowNull: true + } +}, { + sequelize, + modelName: 'ChurchApplication', + tableName: 'church_application', + schema: 'falukant_data', + timestamps: true, + underscored: true +}); + +export default ChurchApplication; diff --git a/backend/models/falukant/data/church_office.js b/backend/models/falukant/data/church_office.js new file mode 100644 index 0000000..a92dfda --- /dev/null +++ b/backend/models/falukant/data/church_office.js @@ -0,0 +1,38 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; + +class ChurchOffice extends Model {} + +ChurchOffice.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + officeTypeId: { + type: DataTypes.INTEGER, + allowNull: false + }, + characterId: { + type: DataTypes.INTEGER, + allowNull: false + }, + regionId: { + type: DataTypes.INTEGER, + allowNull: false + }, + supervisorId: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'ID des Vorgesetzten (höhere Position in der Hierarchie)' + } +}, { + sequelize, + modelName: 'ChurchOffice', + tableName: 'church_office', + schema: 'falukant_data', + timestamps: true, + underscored: true +}); + +export default ChurchOffice; diff --git a/backend/models/falukant/predefine/church_office_requirement.js b/backend/models/falukant/predefine/church_office_requirement.js new file mode 100644 index 0000000..da323da --- /dev/null +++ b/backend/models/falukant/predefine/church_office_requirement.js @@ -0,0 +1,35 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; + +class ChurchOfficeRequirement extends Model {} + +ChurchOfficeRequirement.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + officeTypeId: { + type: DataTypes.INTEGER, + allowNull: false + }, + prerequisiteOfficeTypeId: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'Erforderliche niedrigere Position in der Hierarchie' + }, + minTitleLevel: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'Mindest-Titel-Level (optional)' + } +}, { + sequelize, + modelName: 'ChurchOfficeRequirement', + tableName: 'church_office_requirement', + schema: 'falukant_predefine', + timestamps: false, + underscored: true +}); + +export default ChurchOfficeRequirement; diff --git a/backend/models/falukant/type/church_office_type.js b/backend/models/falukant/type/church_office_type.js new file mode 100644 index 0000000..d5bc710 --- /dev/null +++ b/backend/models/falukant/type/church_office_type.js @@ -0,0 +1,38 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; + +class ChurchOfficeType extends Model {} + +ChurchOfficeType.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + seatsPerRegion: { + type: DataTypes.INTEGER, + allowNull: false + }, + regionType: { + type: DataTypes.STRING, + allowNull: false + }, + hierarchyLevel: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Höhere Zahl = höhere Position in der Hierarchie' + } +}, { + sequelize, + modelName: 'ChurchOfficeType', + tableName: 'church_office_type', + schema: 'falukant_type', + timestamps: false, + underscored: true +}); + +export default ChurchOfficeType; diff --git a/backend/models/index.js b/backend/models/index.js index 65b5be4..5fed70c 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -113,6 +113,12 @@ 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'; + +// — Kirchliche Ämter (Church) — +import ChurchOfficeType from './falukant/type/church_office_type.js'; +import ChurchOfficeRequirement from './falukant/predefine/church_office_requirement.js'; +import ChurchOffice from './falukant/data/church_office.js'; +import ChurchApplication from './falukant/data/church_application.js'; import UndergroundType from './falukant/type/underground.js'; import Underground from './falukant/data/underground.js'; import VehicleType from './falukant/type/vehicle.js'; @@ -242,6 +248,10 @@ const models = { ElectionResult, PoliticalOfficeHistory, ElectionHistory, + ChurchOfficeType, + ChurchOfficeRequirement, + ChurchOffice, + ChurchApplication, UndergroundType, Underground, WeatherType, diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index d484674..e3b02bf 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -59,6 +59,12 @@ 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('/church/overview', falukantController.getChurchOverview); +router.get('/church/positions/available', falukantController.getAvailableChurchPositions); +router.post('/church/positions/apply', falukantController.applyForChurchPosition); +router.get('/church/applications/supervised', falukantController.getSupervisedApplications); +router.post('/church/applications/decide', falukantController.decideOnChurchApplication); +router.get('/church/career/check', falukantController.hasChurchCareer); router.get('/education', falukantController.getEducation); router.post('/education', falukantController.sendToSchool); router.get('/bank/overview', falukantController.getBankOverview); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 08d0466..af78960 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -57,6 +57,10 @@ import PoliticalOfficeHistory from '../models/falukant/log/political_office_hist import UndergroundType from '../models/falukant/type/underground.js'; import Notification from '../models/falukant/log/notification.js'; import PoliticalOffice from '../models/falukant/data/political_office.js'; +import ChurchOfficeType from '../models/falukant/type/church_office_type.js'; +import ChurchOfficeRequirement from '../models/falukant/predefine/church_office_requirement.js'; +import ChurchOffice from '../models/falukant/data/church_office.js'; +import ChurchApplication from '../models/falukant/data/church_application.js'; import Underground from '../models/falukant/data/underground.js'; import VehicleType from '../models/falukant/type/vehicle.js'; import Vehicle from '../models/falukant/data/vehicle.js'; @@ -1017,6 +1021,11 @@ class FalukantService extends BaseService { async createTransport(hashedUserId, { branchId, vehicleTypeId, vehicleIds, productId, quantity, targetBranchId }) { const user = await getFalukantUserOrFail(hashedUserId); + + // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können transportieren) + if (await this.hasChurchCareer(hashedUserId)) { + throw new Error('churchCareerNoDirectTransactions'); + } const sourceBranch = await Branch.findOne({ where: { id: branchId, falukantUserId: user.id }, @@ -1604,6 +1613,12 @@ class FalukantService extends BaseService { async createProduction(hashedUserId, branchId, productId, quantity) { const u = await getFalukantUserOrFail(hashedUserId); + + // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können produzieren) + if (await this.hasChurchCareer(hashedUserId)) { + throw new Error('churchCareerNoDirectTransactions'); + } + const b = await getBranchOrFail(u.id, branchId); const p = await ProductType.findOne({ where: { id: productId } }); const runningProductions = await Production.findAll({ where: { branchId: b.id } }); @@ -1753,6 +1768,16 @@ class FalukantService extends BaseService { // Konsistenz wie sellAll: nur aus Stocks dieses Branches verkaufen und alles atomar ausführen return await sequelize.transaction(async (t) => { const user = await getFalukantUserOrFail(hashedUserId, { transaction: t }); + + // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können verkaufen) + const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t }); + if (character) { + const churchOffice = await ChurchOffice.findOne({ where: { characterId: character.id }, transaction: t }); + if (churchOffice) { + throw new Error('churchCareerNoDirectTransactions'); + } + } + const branch = await getBranchOrFail(user.id, branchId); const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t }); @@ -1854,6 +1879,16 @@ class FalukantService extends BaseService { // Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen. return await sequelize.transaction(async (t) => { const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t }); + + // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können verkaufen) + const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t }); + if (character) { + const churchOffice = await ChurchOffice.findOne({ where: { characterId: character.id }, transaction: t }); + if (churchOffice) { + throw new Error('churchCareerNoDirectTransactions'); + } + } + const branch = await Branch.findOne({ where: { id: branchId, falukantUserId: falukantUser.id }, include: [{ model: FalukantStock, as: 'stocks' }], @@ -2191,6 +2226,12 @@ class FalukantService extends BaseService { async buyStorage(hashedUserId, branchId, amount, stockTypeId) { const user = await getFalukantUserOrFail(hashedUserId); + + // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können Lager kaufen) + if (await this.hasChurchCareer(hashedUserId)) { + throw new Error('churchCareerNoDirectTransactions'); + } + const branch = await getBranchOrFail(user.id, branchId); const buyableStocks = await BuyableStock.findAll({ where: { regionId: branch.regionId, stockTypeId }, @@ -2260,6 +2301,12 @@ class FalukantService extends BaseService { async sellStorage(hashedUserId, branchId, amount, stockTypeId) { const user = await getFalukantUserOrFail(hashedUserId); + + // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können Lager verkaufen) + if (await this.hasChurchCareer(hashedUserId)) { + throw new Error('churchCareerNoDirectTransactions'); + } + const branch = await getBranchOrFail(user.id, branchId); const stock = await FalukantStock.findOne({ where: { branchId: branch.id, stockTypeId }, @@ -6239,4 +6286,558 @@ async function enrichNotificationsWithCharacterNames(notifications) { n.character_name = resolved; } } + + // ==================== Church Career Methods ==================== + + async getChurchOverview(hashedUserId) { + // Liefert alle aktuell besetzten kirchlichen Ämter im eigenen Gebiet + const user = await getFalukantUserOrFail(hashedUserId); + const character = await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id', 'regionId'] + }); + if (!character) { + return []; + } + + const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); + + const offices = await ChurchOffice.findAll({ + where: { + regionId: { + [Op.in]: relevantRegionIds + } + }, + include: [ + { + model: ChurchOfficeType, + as: 'type', + attributes: ['name', 'hierarchyLevel'] + }, + { + model: RegionData, + as: 'region', + attributes: ['name'] + }, + { + model: FalukantCharacter, + as: 'holder', + attributes: ['id', 'gender'], + include: [ + { + model: FalukantPredefineFirstname, + as: 'definedFirstName', + attributes: ['name'] + }, + { + model: FalukantPredefineLastname, + as: 'definedLastName', + attributes: ['name'] + }, + { + model: TitleOfNobility, + as: 'nobleTitle', + attributes: ['labelTr'] + } + ] + }, + { + model: FalukantCharacter, + as: 'supervisor', + attributes: ['id'], + include: [ + { + model: FalukantPredefineFirstname, + as: 'definedFirstName', + attributes: ['name'] + }, + { + model: FalukantPredefineLastname, + as: 'definedLastName', + attributes: ['name'] + } + ], + required: false + } + ], + order: [ + [{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC'], + [{ model: RegionData, as: 'region' }, 'name', 'ASC'] + ] + }); + + return offices.map(office => { + const o = office.get({ plain: true }); + return { + id: o.id, + officeType: { + name: o.type.name, + hierarchyLevel: o.type.hierarchyLevel + }, + region: { + name: o.region.name + }, + character: o.holder ? { + id: o.holder.id, + name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(), + gender: o.holder.gender, + title: o.holder.nobleTitle?.labelTr + } : null, + supervisor: o.supervisor ? { + id: o.supervisor.id, + name: `${o.supervisor.definedFirstName?.name || ''} ${o.supervisor.definedLastName?.name || ''}`.trim() + } : null + }; + }); + } + + async getAvailableChurchPositions(hashedUserId) { + // Liefert verfügbare kirchliche Positionen, für die sich der User bewerben kann + const user = await getFalukantUserOrFail(hashedUserId); + const character = await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id', 'regionId'], + include: [ + { + model: TitleOfNobility, + as: 'nobleTitle', + attributes: ['labelTr', 'level'] + } + ] + }); + if (!character) { + return []; + } + + // Prüfe, ob User bereits ein kirchliches Amt hat + const existingOffice = await ChurchOffice.findOne({ + where: { characterId: character.id } + }); + if (existingOffice) { + // User hat bereits ein Amt, kann sich nur auf höhere Positionen bewerben + const currentOffice = await ChurchOffice.findOne({ + where: { characterId: character.id }, + include: [{ + model: ChurchOfficeType, + as: 'type', + attributes: ['hierarchyLevel'] + }] + }); + const currentLevel = currentOffice?.type?.hierarchyLevel || 0; + + // Finde höhere Positionen + const availableOffices = await ChurchOfficeType.findAll({ + where: { + hierarchyLevel: { + [Op.gt]: currentLevel + } + }, + include: [ + { + model: ChurchOfficeRequirement, + as: 'requirements' + } + ] + }); + + return this.filterAvailablePositions(availableOffices, character, existingOffice); + } else { + // User hat noch kein Amt, kann sich auf Einstiegspositionen bewerben + const entryLevelOffices = await ChurchOfficeType.findAll({ + where: { + hierarchyLevel: 1 // Dorfgeistlicher ist Einstieg + }, + include: [ + { + model: ChurchOfficeRequirement, + as: 'requirements' + } + ] + }); + + return this.filterAvailablePositions(entryLevelOffices, character, null); + } + } + + async filterAvailablePositions(officeTypes, character, existingOffice) { + const available = []; + const relevantRegionIds = await this.getRegionAndParentIds(character.regionId); + + for (const officeType of officeTypes) { + // Prüfe Voraussetzungen + const requirements = officeType.requirements || []; + let canApply = true; + + // Prüfe, ob Voraussetzung erfüllt ist (niedrigere Position) + if (requirements.length > 0 && requirements[0].prerequisiteOfficeTypeId) { + if (!existingOffice) { + canApply = false; // Benötigt niedrigere Position, aber User hat keine + } else { + const currentOfficeType = await ChurchOfficeType.findByPk(existingOffice.officeTypeId); + if (currentOfficeType?.hierarchyLevel >= officeType.hierarchyLevel) { + canApply = false; // Aktuelle Position ist nicht niedrig genug + } + } + } + + if (!canApply) continue; + + // Finde verfügbare Positionen in relevanten Regionen + const filledPositions = await ChurchOffice.count({ + where: { + officeTypeId: officeType.id, + regionId: { + [Op.in]: relevantRegionIds + } + } + }); + + // Prüfe, ob noch Plätze frei sind + if (filledPositions < officeType.seatsPerRegion) { + // Finde Vorgesetzten für diese Position + const supervisor = await this.findSupervisorForPosition(officeType, character.regionId); + + // Finde passende Region für diese Position + const region = await RegionData.findOne({ + where: { + id: { + [Op.in]: relevantRegionIds + } + }, + include: [{ + model: RegionType, + as: 'regionType', + attributes: ['labelTr'] + }], + order: [['id', 'ASC']] // Nimm die erste passende Region + }); + + available.push({ + id: officeType.id, + officeType: { + name: officeType.name, + hierarchyLevel: officeType.hierarchyLevel, + seatsPerRegion: officeType.seatsPerRegion, + regionType: officeType.regionType + }, + region: region ? { + id: region.id, + name: region.name + } : null, + regionId: region?.id || character.regionId, + availableSeats: officeType.seatsPerRegion - filledPositions, + supervisor: supervisor ? { + id: supervisor.id, + name: `${supervisor.definedFirstName?.name || ''} ${supervisor.definedLastName?.name || ''}`.trim() + } : null + }); + } + } + + return available; + } + + async findSupervisorForPosition(officeType, regionId) { + // Finde den Vorgesetzten (höhere Position in der Hierarchie) + const supervisorOfficeType = await ChurchOfficeType.findOne({ + where: { + hierarchyLevel: { + [Op.gt]: officeType.hierarchyLevel + } + }, + order: [['hierarchyLevel', 'ASC']] // Nimm die nächsthöhere Position + }); + + if (!supervisorOfficeType) { + return null; // Kein Vorgesetzter (z.B. Papst) + } + + // Finde den Vorgesetzten in der Region oder übergeordneten Regionen + const relevantRegionIds = await this.getRegionAndParentIds(regionId); + const supervisorOffice = await ChurchOffice.findOne({ + where: { + officeTypeId: supervisorOfficeType.id, + regionId: { + [Op.in]: relevantRegionIds + } + }, + include: [ + { + model: FalukantCharacter, + as: 'holder', + attributes: ['id'], + include: [ + { + model: FalukantPredefineFirstname, + as: 'definedFirstName', + attributes: ['name'] + }, + { + model: FalukantPredefineLastname, + as: 'definedLastName', + attributes: ['name'] + } + ] + } + ] + }); + + return supervisorOffice?.holder || null; + } + + async applyForChurchPosition(hashedUserId, officeTypeId, regionId) { + // Bewerbung für eine kirchliche Position + const user = await getFalukantUserOrFail(hashedUserId); + const character = await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id', 'regionId'] + }); + if (!character) { + throw new Error('Character not found'); + } + + // Prüfe, ob bereits eine Bewerbung für diese Position existiert + const existingApplication = await ChurchApplication.findOne({ + where: { + characterId: character.id, + officeTypeId: officeTypeId, + status: 'pending' + } + }); + if (existingApplication) { + throw new Error('Application already exists'); + } + + // Finde Vorgesetzten + const officeType = await ChurchOfficeType.findByPk(officeTypeId); + if (!officeType) { + throw new Error('Office type not found'); + } + + const supervisor = await this.findSupervisorForPosition(officeType, regionId); + if (!supervisor) { + throw new Error('Supervisor not found'); + } + + // Erstelle Bewerbung + const application = await ChurchApplication.create({ + officeTypeId: officeTypeId, + characterId: character.id, + regionId: regionId, + supervisorId: supervisor.id, + status: 'pending' + }); + + // Benachrichtige Vorgesetzten + const supervisorUser = await FalukantUser.findOne({ + where: { id: supervisor.userId }, + attributes: ['id'] + }); + if (supervisorUser) { + await notifyUser(supervisorUser.id, { + tr: 'falukant.church.application.received', + characterId: character.id, + officeTypeId: officeTypeId + }); + } + + return application; + } + + async getSupervisedApplications(hashedUserId) { + // Liefert alle Bewerbungen, über die der User als Vorgesetzter entscheiden kann + const user = await getFalukantUserOrFail(hashedUserId); + const character = await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id'] + }); + if (!character) { + return []; + } + + const applications = await ChurchApplication.findAll({ + where: { + supervisorId: character.id, + status: 'pending' + }, + include: [ + { + model: ChurchOfficeType, + as: 'officeType', + attributes: ['name', 'hierarchyLevel'] + }, + { + model: RegionData, + as: 'region', + attributes: ['name'] + }, + { + model: FalukantCharacter, + as: 'applicant', + attributes: ['id', 'gender', 'age'], + include: [ + { + model: FalukantPredefineFirstname, + as: 'definedFirstName', + attributes: ['name'] + }, + { + model: FalukantPredefineLastname, + as: 'definedLastName', + attributes: ['name'] + }, + { + model: TitleOfNobility, + as: 'nobleTitle', + attributes: ['labelTr'] + } + ] + } + ], + order: [['createdAt', 'DESC']] + }); + + return applications.map(app => { + const a = app.get({ plain: true }); + return { + id: a.id, + officeType: { + name: a.officeType.name, + hierarchyLevel: a.officeType.hierarchyLevel + }, + region: { + name: a.region.name + }, + applicant: { + id: a.applicant.id, + name: `${a.applicant.definedFirstName?.name || ''} ${a.applicant.definedLastName?.name || ''}`.trim(), + gender: a.applicant.gender, + age: a.applicant.age, + title: a.applicant.nobleTitle?.labelTr + }, + createdAt: a.createdAt + }; + }); + } + + async decideOnChurchApplication(hashedUserId, applicationId, decision) { + // Entscheidung über eine Bewerbung (approve/reject) + const user = await getFalukantUserOrFail(hashedUserId); + const character = await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id'] + }); + if (!character) { + throw new Error('Character not found'); + } + + const application = await ChurchApplication.findOne({ + where: { + id: applicationId, + supervisorId: character.id, + status: 'pending' + }, + include: [ + { + model: ChurchOfficeType, + as: 'officeType' + } + ] + }); + + if (!application) { + throw new Error('Application not found or already processed'); + } + + if (decision === 'approve') { + // Prüfe, ob noch Platz verfügbar ist + const filledPositions = await ChurchOffice.count({ + where: { + officeTypeId: application.officeTypeId, + regionId: application.regionId + } + }); + + if (filledPositions >= application.officeType.seatsPerRegion) { + throw new Error('No available seats'); + } + + // Erstelle kirchliches Amt + await ChurchOffice.create({ + officeTypeId: application.officeTypeId, + characterId: application.characterId, + regionId: application.regionId, + supervisorId: character.id + }); + + // Aktualisiere Bewerbung + application.status = 'approved'; + application.decisionDate = new Date(); + await application.save(); + + // Benachrichtige Bewerber + const applicantCharacter = await FalukantCharacter.findByPk(application.characterId); + if (applicantCharacter && applicantCharacter.userId) { + await notifyUser(applicantCharacter.userId, { + tr: 'falukant.church.application.approved', + officeTypeId: application.officeTypeId + }); + } + + // Wenn User bereits ein niedrigeres Amt hatte, entferne es + const lowerOffice = await ChurchOffice.findOne({ + where: { + characterId: application.characterId, + officeTypeId: { + [Op.ne]: application.officeTypeId + } + }, + include: [{ + model: ChurchOfficeType, + as: 'type', + attributes: ['hierarchyLevel'] + }] + }); + + if (lowerOffice && lowerOffice.type.hierarchyLevel < application.officeType.hierarchyLevel) { + await lowerOffice.destroy(); + } + + } else if (decision === 'reject') { + application.status = 'rejected'; + application.decisionDate = new Date(); + await application.save(); + + // Benachrichtige Bewerber + const applicantCharacter = await FalukantCharacter.findByPk(application.characterId); + if (applicantCharacter && applicantCharacter.userId) { + await notifyUser(applicantCharacter.userId, { + tr: 'falukant.church.application.rejected', + officeTypeId: application.officeTypeId + }); + } + } else { + throw new Error('Invalid decision'); + } + + return application; + } + + async hasChurchCareer(hashedUserId) { + // Prüft, ob der User eine kirchliche Karriere hat + const user = await getFalukantUserOrFail(hashedUserId); + const character = await FalukantCharacter.findOne({ + where: { userId: user.id }, + attributes: ['id'] + }); + if (!character) { + return false; + } + + const churchOffice = await ChurchOffice.findOne({ + where: { characterId: character.id } + }); + + return !!churchOffice; + } } diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index c86278b..8de4c5f 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -16,6 +16,8 @@ import ReputationActionType from "../../models/falukant/type/reputation_action.j 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 ChurchOfficeType from "../../models/falukant/type/church_office_type.js"; +import ChurchOfficeRequirement from "../../models/falukant/predefine/church_office_requirement.js"; import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js"; import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js"; import UndergroundType from "../../models/falukant/type/underground.js"; @@ -47,6 +49,8 @@ export const initializeFalukantTypes = async () => { await initializePoliticalOfficeBenefitTypes(); await initializePoliticalOfficeTypes(); await initializePoliticalOfficePrerequisites(); + await initializeChurchOfficeTypes(); + await initializeChurchOfficePrerequisites(); await initializeUndergroundTypes(); await initializeVehicleTypes(); await initializeFalukantWeatherTypes(); @@ -1024,6 +1028,119 @@ export const initializePoliticalOfficePrerequisites = async () => { console.log(`[Falukant] OfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`); }; +// — Church Offices — + +const churchOffices = [ + { tr: "village-priest", seatsPerRegion: 1, regionType: "city", hierarchyLevel: 1 }, + { tr: "parish-priest", seatsPerRegion: 1, regionType: "city", hierarchyLevel: 2 }, + { tr: "dean", seatsPerRegion: 1, regionType: "county", hierarchyLevel: 3 }, + { tr: "archdeacon", seatsPerRegion: 1, regionType: "shire", hierarchyLevel: 4 }, + { tr: "bishop", seatsPerRegion: 1, regionType: "markgravate", hierarchyLevel: 5 }, + { tr: "archbishop", seatsPerRegion: 1, regionType: "duchy", hierarchyLevel: 6 }, + { tr: "cardinal", seatsPerRegion: 3, regionType: "country", hierarchyLevel: 7 }, + { tr: "pope", seatsPerRegion: 1, regionType: "country", hierarchyLevel: 8 } +]; + +const churchOfficePrerequisites = [ + { + officeTr: "village-priest", + prerequisite: { + prerequisiteOfficeTypeId: null // Einstiegsposition, keine Voraussetzung + } + }, + { + officeTr: "parish-priest", + prerequisite: { + prerequisiteOfficeTypeId: "village-priest" + } + }, + { + officeTr: "dean", + prerequisite: { + prerequisiteOfficeTypeId: "parish-priest" + } + }, + { + officeTr: "archdeacon", + prerequisite: { + prerequisiteOfficeTypeId: "dean" + } + }, + { + officeTr: "bishop", + prerequisite: { + prerequisiteOfficeTypeId: "archdeacon" + } + }, + { + officeTr: "archbishop", + prerequisite: { + prerequisiteOfficeTypeId: "bishop" + } + }, + { + officeTr: "cardinal", + prerequisite: { + prerequisiteOfficeTypeId: "archbishop" + } + }, + { + officeTr: "pope", + prerequisite: { + prerequisiteOfficeTypeId: "cardinal" + } + } +]; + +export const initializeChurchOfficeTypes = async () => { + for (const co of churchOffices) { + await ChurchOfficeType.findOrCreate({ + where: { name: co.tr }, + defaults: { + seatsPerRegion: co.seatsPerRegion, + regionType: co.regionType, + hierarchyLevel: co.hierarchyLevel + } + }); + } + console.log(`[Falukant] ChurchOfficeTypes initialized`); +}; + +export const initializeChurchOfficePrerequisites = async () => { + let created = 0; + let existing = 0; + let skipped = 0; + for (const prereq of churchOfficePrerequisites) { + const office = await ChurchOfficeType.findOne({ where: { name: prereq.officeTr } }); + if (!office) { skipped++; continue; } + + let prerequisiteOfficeTypeId = null; + if (prereq.prerequisite.prerequisiteOfficeTypeId) { + const prerequisiteOffice = await ChurchOfficeType.findOne({ + where: { name: prereq.prerequisite.prerequisiteOfficeTypeId } + }); + if (prerequisiteOffice) { + prerequisiteOfficeTypeId = prerequisiteOffice.id; + } + } + + try { + const [record, wasCreated] = await ChurchOfficeRequirement.findOrCreate({ + where: { officeTypeId: office.id }, + defaults: { + officeTypeId: office.id, + prerequisiteOfficeTypeId: prerequisiteOfficeTypeId + } + }); + if (wasCreated) created++; else existing++; + } catch (e) { + if (falukantDebug) console.error('[Falukant] ChurchOfficePrereq Fehler', { officeId: office?.id, error: e.message }); + throw e; + } + } + console.log(`[Falukant] ChurchOfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`); +}; + export const initializeUndergroundTypes = async () => { for (const underground of undergroundTypes) { await UndergroundType.findOrCreate({ diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index b2634ce..29db5b5 100644 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -837,6 +837,58 @@ } }, "church": { + "title": "Kirche", + "tabs": { + "current": "Aktuelle Positionen", + "available": "Verfügbare Positionen", + "applications": "Bewerbungen" + }, + "current": { + "office": "Amt", + "region": "Region", + "holder": "Inhaber", + "supervisor": "Vorgesetzter", + "none": "Keine aktuellen Positionen vorhanden." + }, + "available": { + "office": "Amt", + "region": "Region", + "supervisor": "Vorgesetzter", + "seats": "Verfügbare Plätze", + "action": "Aktion", + "apply": "Bewerben", + "applySuccess": "Bewerbung erfolgreich eingereicht.", + "applyError": "Fehler beim Einreichen der Bewerbung.", + "none": "Keine verfügbaren Positionen." + }, + "applications": { + "office": "Amt", + "region": "Region", + "applicant": "Bewerber", + "date": "Datum", + "action": "Aktion", + "approve": "Annehmen", + "reject": "Ablehnen", + "approveSuccess": "Bewerbung angenommen.", + "rejectSuccess": "Bewerbung abgelehnt.", + "decideError": "Fehler bei der Entscheidung.", + "none": "Keine Bewerbungen vorhanden." + }, + "offices": { + "village-priest": "Dorfgeistlicher", + "parish-priest": "Pfarrer", + "dean": "Dekan", + "archdeacon": "Erzdiakon", + "bishop": "Bischof", + "archbishop": "Erzbischof", + "cardinal": "Kardinal", + "pope": "Papst" + }, + "application": { + "received": "Neue Bewerbung erhalten", + "approved": "Bewerbung angenommen", + "rejected": "Bewerbung abgelehnt" + }, "title": "Kirche", "baptism": { "title": "Taufen", diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json index 92caa9b..082fbe4 100644 --- a/frontend/src/i18n/locales/en/falukant.json +++ b/frontend/src/i18n/locales/en/falukant.json @@ -376,6 +376,76 @@ "assessor": "Assessor" } }, + "church": { + "title": "Church", + "tabs": { + "current": "Current Positions", + "available": "Available Positions", + "applications": "Applications" + }, + "current": { + "office": "Office", + "region": "Region", + "holder": "Holder", + "supervisor": "Supervisor", + "none": "No current positions available." + }, + "available": { + "office": "Office", + "region": "Region", + "supervisor": "Supervisor", + "seats": "Available Seats", + "action": "Action", + "apply": "Apply", + "applySuccess": "Application submitted successfully.", + "applyError": "Error submitting application.", + "none": "No available positions." + }, + "applications": { + "office": "Office", + "region": "Region", + "applicant": "Applicant", + "date": "Date", + "action": "Action", + "approve": "Approve", + "reject": "Reject", + "approveSuccess": "Application approved.", + "rejectSuccess": "Application rejected.", + "decideError": "Error making decision.", + "none": "No applications available." + }, + "offices": { + "village-priest": "Village Priest", + "parish-priest": "Parish Priest", + "dean": "Dean", + "archdeacon": "Archdeacon", + "bishop": "Bishop", + "archbishop": "Archbishop", + "cardinal": "Cardinal", + "pope": "Pope" + }, + "application": { + "received": "New application received", + "approved": "Application approved", + "rejected": "Application rejected" + }, + "baptism": { + "title": "Baptism", + "table": { + "name": "First Name", + "gender": "Gender", + "age": "Age", + "baptise": "Baptize (50)", + "newName": "Suggest Name" + }, + "gender": { + "male": "Boy", + "female": "Girl" + }, + "success": "The child has been baptized.", + "error": "The child could not be baptized." + } + }, "family": { "children": { "title": "Children", diff --git a/frontend/src/views/falukant/ChurchView.vue b/frontend/src/views/falukant/ChurchView.vue index b1651d5..109ac46 100644 --- a/frontend/src/views/falukant/ChurchView.vue +++ b/frontend/src/views/falukant/ChurchView.vue @@ -6,6 +6,7 @@
+

{{ $t('falukant.church.baptism.title') }}

@@ -36,6 +37,124 @@
+ + +
+
{{ $t('loading') }}
+
+ + + + + + + + + + + + + + + + + + + + +
{{ $t('falukant.church.current.office') }}{{ $t('falukant.church.current.region') }}{{ $t('falukant.church.current.holder') }}{{ $t('falukant.church.current.supervisor') }}
{{ $t(`falukant.church.offices.${pos.officeType.name}`) }}{{ pos.region.name }} + + {{ $t(`falukant.titles.${pos.character.gender}.${pos.character.title || 'noncivil'}`) }} + {{ pos.character.name }} + + + + + {{ pos.supervisor.name }} + + +
{{ $t('falukant.church.current.none') }}
+
+
+ + +
+
{{ $t('loading') }}
+
+ + + + + + + + + + + + + + + + + + + + + + +
{{ $t('falukant.church.available.office') }}{{ $t('falukant.church.available.region') }}{{ $t('falukant.church.available.supervisor') }}{{ $t('falukant.church.available.seats') }}{{ $t('falukant.church.available.action') }}
{{ $t(`falukant.church.offices.${pos.officeType.name}`) }}{{ pos.region?.name || '—' }} + + {{ pos.supervisor.name }} + + + {{ pos.availableSeats }} + +
{{ $t('falukant.church.available.none') }}
+
+
+ + +
+
{{ $t('loading') }}
+
+ + + + + + + + + + + + + + + + + + + + + + +
{{ $t('falukant.church.applications.office') }}{{ $t('falukant.church.applications.region') }}{{ $t('falukant.church.applications.applicant') }}{{ $t('falukant.church.applications.date') }}{{ $t('falukant.church.applications.action') }}
{{ $t(`falukant.church.offices.${app.officeType.name}`) }}{{ app.region.name }} + {{ $t(`falukant.titles.${app.applicant.gender}.${app.applicant.title || 'noncivil'}`) }} + {{ app.applicant.name }} ({{ app.applicant.age }}) + {{ formatDate(app.createdAt) }} + + +
{{ $t('falukant.church.applications.none') }}
+
+
@@ -61,12 +180,36 @@ export default { activeTab: 'baptism', tabs: [ { value: 'baptism', label: 'falukant.church.baptism.title' }, + { value: 'current', label: 'falukant.church.tabs.current' }, + { value: 'available', label: 'falukant.church.tabs.available' }, + { value: 'applications', label: 'falukant.church.tabs.applications' }, ], - baptismList: [] + baptismList: [], + currentPositions: [], + availablePositions: [], + supervisedApplications: [], + ownCharacterId: null, + loading: { + current: false, + available: false, + applications: false + } } }, async mounted() { - await this.loadNotBaptisedChildren() + await this.loadNotBaptisedChildren(); + await this.loadOwnCharacterId(); + }, + watch: { + activeTab(newTab) { + if (newTab === 'current') { + this.loadCurrentPositions(); + } else if (newTab === 'available') { + this.loadAvailablePositions(); + } else if (newTab === 'applications') { + this.loadSupervisedApplications(); + } + } }, methods: { async loadNotBaptisedChildren() { @@ -99,6 +242,102 @@ export default { console.error(err) this.$root.$refs.errorDialog.open('tr:falukant.church.baptism.error') } + }, + async loadCurrentPositions() { + this.loading.current = true; + try { + const { data } = await apiClient.get('/api/falukant/church/overview'); + this.currentPositions = data; + } catch (err) { + console.error('Error loading current positions', err); + } finally { + this.loading.current = false; + } + }, + async loadAvailablePositions() { + this.loading.available = true; + try { + const { data } = await apiClient.get('/api/falukant/church/positions/available'); + this.availablePositions = data; + } catch (err) { + console.error('Error loading available positions', err); + } finally { + this.loading.available = false; + } + }, + async loadSupervisedApplications() { + this.loading.applications = true; + try { + const { data } = await apiClient.get('/api/falukant/church/applications/supervised'); + this.supervisedApplications = data; + } catch (err) { + console.error('Error loading supervised applications', err); + } finally { + this.loading.applications = false; + } + }, + async loadOwnCharacterId() { + try { + const { data } = await apiClient.get('/api/falukant/info'); + if (data.character && data.character.id) { + this.ownCharacterId = data.character.id; + } + } catch (err) { + console.error('Error loading own character ID', err); + } + }, + isOwnPosition(pos) { + if (!this.ownCharacterId || !pos.character) { + return false; + } + return pos.character.id === this.ownCharacterId; + }, + async applyForPosition(position) { + try { + const regionId = position.regionId || position.region?.id; + + if (!regionId) { + throw new Error('Region not found'); + } + + await apiClient.post('/api/falukant/church/positions/apply', { + officeTypeId: position.id, + regionId: regionId + }); + + this.$root.$refs.messageDialog?.open('tr:falukant.church.available.applySuccess'); + await this.loadAvailablePositions(); + } catch (err) { + console.error('Error applying for position', err); + const errorMsg = err.response?.data?.message || 'falukant.church.available.applyError'; + this.$root.$refs.errorDialog?.open(`tr:${errorMsg}`); + } + }, + async decideOnApplication(applicationId, decision) { + try { + await apiClient.post('/api/falukant/church/applications/decide', { + applicationId: applicationId, + decision: decision + }); + + const msgKey = decision === 'approve' + ? 'falukant.church.applications.approveSuccess' + : 'falukant.church.applications.rejectSuccess'; + this.$root.$refs.messageDialog?.open(`tr:${msgKey}`); + await this.loadSupervisedApplications(); + await this.loadCurrentPositions(); + } catch (err) { + console.error('Error deciding on application', err); + const errorMsg = err.response?.data?.message || 'falukant.church.applications.decideError'; + this.$root.$refs.errorDialog?.open(`tr:${errorMsg}`); + } + }, + formatDate(date) { + return new Date(date).toLocaleDateString(this.$i18n.locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); } } } @@ -140,4 +379,75 @@ input[type="text"] { th { text-align: left; } + +.tab-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.table-scroll { + flex: 1; + overflow-y: auto; + border: 1px solid #ddd; +} + +.church-table { + border-collapse: collapse; + width: 100%; +} + +.church-table thead th { + position: sticky; + top: 0; + background: #FFF; + z-index: 1; + padding: 8px; + border: 1px solid #ddd; + text-align: left; +} + +.church-table tbody td { + padding: 8px; + border: 1px solid #ddd; +} + +.church-table tbody tr.own-position { + background-color: #e0e0e0; + font-weight: bold; +} + +.loading { + text-align: center; + font-style: italic; + margin: 20px 0; +} + +.approve-button { + background-color: #28a745; + color: white; + border: none; + padding: 4px 8px; + margin-right: 4px; + cursor: pointer; + border-radius: 4px; +} + +.approve-button:hover { + background-color: #218838; +} + +.reject-button { + background-color: #dc3545; + color: white; + border: none; + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; +} + +.reject-button:hover { + background-color: #c82333; +} \ No newline at end of file