Implement church career management features

- Added endpoints for church career functionalities including overview, available positions, application submission, and application decision-making.
- Enhanced the FalukantController to handle church-related requests.
- Updated associations and models to support church office types and requirements.
- Integrated new routes in the falukantRouter for church career operations.
- Implemented service methods for managing church applications and checking church career status.
- Updated frontend components to display current positions, available positions, and manage applications with appropriate UI elements and loading states.
- Localized new church-related strings in both English and German.
This commit is contained in:
Torsten Schulz (local)
2026-01-22 16:46:42 +01:00
parent 8e226615eb
commit 4f786cdcc3
13 changed files with 1424 additions and 2 deletions

View File

@@ -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));

View File

@@ -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'

View File

@@ -0,0 +1,47 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ChurchApplication extends Model {}
ChurchApplication.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
officeTypeId: {
type: DataTypes.INTEGER,
allowNull: false
},
characterId: {
type: DataTypes.INTEGER,
allowNull: false
},
regionId: {
type: DataTypes.INTEGER,
allowNull: false
},
supervisorId: {
type: DataTypes.INTEGER,
allowNull: 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;

View File

@@ -0,0 +1,38 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ChurchOffice extends Model {}
ChurchOffice.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
officeTypeId: {
type: DataTypes.INTEGER,
allowNull: false
},
characterId: {
type: DataTypes.INTEGER,
allowNull: false
},
regionId: {
type: DataTypes.INTEGER,
allowNull: false
},
supervisorId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'ID des Vorgesetzten (höhere Position in der Hierarchie)'
}
}, {
sequelize,
modelName: 'ChurchOffice',
tableName: 'church_office',
schema: 'falukant_data',
timestamps: true,
underscored: true
});
export default ChurchOffice;

View File

@@ -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;

View File

@@ -0,0 +1,38 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ChurchOfficeType extends Model {}
ChurchOfficeType.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
seatsPerRegion: {
type: DataTypes.INTEGER,
allowNull: false
},
regionType: {
type: DataTypes.STRING,
allowNull: false
},
hierarchyLevel: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Höhere Zahl = höhere Position in der Hierarchie'
}
}, {
sequelize,
modelName: 'ChurchOfficeType',
tableName: 'church_office_type',
schema: 'falukant_type',
timestamps: false,
underscored: true
});
export default ChurchOfficeType;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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({