# Phase 7 — Missions, CDLC, Discord, Jobs **Status**: PENDING **Depends on**: Phase 3 (ProcessService, ServerConfigStorage); Phase 4 (DifficultyScanJob stub); Phase 5 (ModService, mod jobs stubs); Phase 6 (SteamService, run_task_loop) **Next phase**: `phase-08-middleware-polish.md` --- ## Goal Mission CRUD + file upload, CDLC management, Discord webhook notifications, and all APScheduler jobs registered. After this phase every domain feature is complete and the scheduler drives automated background work. --- ## Java Source Files to Read `` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\` | File | What to extract | |------|----------------| | `domain/server/mission/MissionServiceImpl.java` | getMissions(), saveEnabledMissionList(), addMission(), deleteMission(), updateMission(), save(MultipartFile), checkMissionFileExists() | | `domain/server/mission/VanillaMissionsImporter.java` | Import built-in missions on startup | | `domain/server/mission/dto/Mission.java` | Fields: id, name, template, enabled, difficulty | | `domain/server/mission/dto/Missions.java` | Fields: disabledMissions, enabledMissions | | `domain/server/mission/job/MissionScannerJob.java` | Filesystem scan + DB sync logic | | `domain/server/cdlc/CdlcService.java` | findAll(), toggleCdlc(id) | | `domain/server/cdlc/CdlcFileStorageService.java` | Filesystem existence check | | `domain/server/cdlc/dto/Cdlc.java` | Fields: id, name, enabled, fileExists | | `domain/discord/DiscordIntegration.java` | sendMessage(MessageKind) — async, checks enabled flag | | `domain/discord/DiscordWebhookHandler.java` | HTTP POST to webhook URL | | `domain/discord/message/MessageKind.java` | Enum: SERVER_STARTED, SERVER_STARTING, SERVER_STOPPED, SERVER_UPDATING, PLAYER_JOINED | | `domain/discord/message/ServerStartedMessageCreator.java` | Message text | | `domain/discord/message/ServerStoppedMessageCreator.java` | Message text | | `domain/discord/model/DiscordMessage.java` | Fields: username, content, embeds:[{title,description,color}] | | `application/scheduling/AswgJob.java` | Base job interface: getName(), run() | | `application/scheduling/AswgTaskScheduler.java` | schedule(job, cron), runNow(job), cancel(name) | | `application/scheduling/JobExecutionInfoService.java` | saveJobExecution(), getJobHistory() | | `application/scheduling/dto/JobExecution.java` | Fields: jobName, startDate, finishDate, status, message | | `domain/server/difficulty/DifficultyScanJob.java` | Scan Users/*.Arma3Profile + DB sync | | `domain/server/mod/job/InstallDeleteModsFromFilesystemJob.java` | Scan mods/ + DB sync | | `domain/server/mod/job/ModSettingsScanJob.java` | Scan mod dirs for settings files | | `domain/server/mod/job/ModUpdateJob.java` | Check Steam API lastUpdated per mod | | `application/security/jwt/cleaner/InvalidJwtCleaner.java` | Delete expired JWT rows from DB | | `web/MissionRestController.java` | Exact paths and JSON shapes | | `web/MissionFilesRestController.java` | Mission file upload | | `web/CdlcRestController.java` | GET + POST toggle | --- ## Output Files to Create ``` src/domain/server/mission/__init__.py src/domain/server/mission/mission_service.py src/domain/server/mission/mission_file_storage.py (scan MPMissions/ for .pbo files) src/domain/server/mission/vanilla_missions_importer.py src/domain/server/mission/jobs.py (MissionScannerJob) src/domain/server/mission/models.py (Mission, Missions dataclasses) src/domain/server/cdlc/__init__.py src/domain/server/cdlc/cdlc_service.py src/domain/server/cdlc/cdlc_file_storage.py src/domain/server/cdlc/models.py (Cdlc dataclass) src/domain/discord/__init__.py src/domain/discord/discord_integration.py src/domain/discord/discord_webhook_handler.py src/domain/discord/models.py (DiscordMessage, MessageKind enum) src/application/scheduler/__init__.py src/application/scheduler/scheduler.py (APScheduler setup + job registry) src/application/scheduler/job_execution_service.py src/web/schemas/missions.py src/web/schemas/cdlc.py src/web/mission_router.py src/web/mission_files_router.py src/web/cdlc_router.py ``` Update `src/main.py`: - Register `mission_router`, `mission_files_router`, `cdlc_router` - In `lifespan`: `scheduler.start()`, register all 6 jobs, call `vanilla_missions_importer.run()` on first startup - In `lifespan` teardown: `scheduler.shutdown()` --- ## Implementation Notes ### APScheduler setup ```python # src/application/scheduler/scheduler.py from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger scheduler = AsyncIOScheduler() def schedule_job(job_fn, cron: str, job_id: str) -> None: try: scheduler.remove_job(job_id) except Exception: pass scheduler.add_job(job_fn, CronTrigger.from_crontab(cron), id=job_id, misfire_grace_time=60) ``` Register in lifespan (cron strings come from `settings.*_cron` properties): ```python scheduler.start() schedule_job(difficulty_scan_job, settings.difficulty_scan_cron, "difficulty_scan") schedule_job(mission_scanner_job, settings.mission_scan_cron, "mission_scan") schedule_job(mod_filesystem_job, settings.mod_scan_cron, "mod_filesystem_scan") schedule_job(mod_settings_scan_job,settings.mod_settings_scan_cron, "mod_settings_scan") schedule_job(mod_update_job, settings.mod_update_cron, "mod_update") schedule_job(jwt_cleaner_job, "0 * * * *", "jwt_cleaner") ``` Read the cron expressions from `aswg-default-config.properties` (Java file) and add them as `settings` fields. ### Job execution tracking Each job records execution in the `job_execution` table: ```python # src/application/scheduler/job_execution_service.py async def wrap_job(job_name: str, job_fn) -> None: start = datetime.utcnow() status, message = "SUCCESS", None try: await job_fn() except Exception as e: status, message = "FAILED", str(e) log.error("Job %s failed: %s", job_name, e) finally: await job_execution_repo.save(JobExecutionEntity( job_name=job_name, start_date=start, finish_date=datetime.utcnow(), status=status, message=message, )) ``` ### MissionService Missions are `.pbo` files in `/MPMissions/`. ```python async def get_missions(self) -> Missions: db_missions = await self.mission_repo.find_all() return Missions( disabled_missions=[m for m in db_missions if not m.enabled], enabled_missions=[m for m in db_missions if m.enabled], ) async def save_enabled_mission_list(self, missions: list[Mission]) -> None: await self.mission_repo.disable_all() for m in missions: await self.mission_repo.enable_by_template(m.template) await self.mission_repo.update_difficulty(m.template, m.difficulty) # Write missions block to server.cfg via ServerConfigStorage cfg = self.config_storage.read() cfg.missions = [CfgMission(template=m.template, difficulty=m.difficulty) for m in missions] self.config_storage.write(cfg) async def add_mission(self, name: str, template: str) -> None: await self.mission_repo.save(MissionEntity(name=name, template=template, enabled=False, difficulty="Regular")) ``` `template` format: `"filename.MapName"` e.g. `"tdm_stratis.Stratis"` (`.pbo` extension excluded). ### MissionScannerJob Scans `/MPMissions/` for `*.pbo` files. Derives template name by stripping `.pbo`. Inserts missing DB entries, removes DB entries where file is gone. ### VanillaMissionsImporter Reads a bundled JSON/text list of vanilla Arma 3 mission templates. On first startup (`@app.on_event` / lifespan), insert any missing. Check existence with `mission_repo.find_by_template()`. ### CDLC service ```python async def find_all(self) -> list[Cdlc]: entities = await self.cdlc_repo.find_all() return [ Cdlc( id=e.id, name=e.name, enabled=e.enabled, file_exists=(Path(settings.server_directory_path) / e.name).exists() ) for e in entities ] async def toggle_cdlc(self, id: int) -> None: entity = await self.cdlc_repo.find_by_id(id) entity.enabled = not entity.enabled await self.cdlc_repo.save(entity) ``` ### Discord Integration ```python # src/domain/discord/models.py from enum import StrEnum from dataclasses import dataclass class MessageKind(StrEnum): SERVER_STARTED = "SERVER_STARTED" SERVER_STARTING = "SERVER_STARTING" SERVER_STOPPED = "SERVER_STOPPED" SERVER_UPDATING = "SERVER_UPDATING" PLAYER_JOINED = "PLAYER_JOINED" @dataclass class DiscordMessage: username: str content: str embeds: list[dict] def to_dict(self) -> dict: return {"username": self.username, "content": self.content, "embeds": self.embeds} ``` ```python # src/domain/discord/discord_integration.py import asyncio _MESSAGE_TEXTS = { MessageKind.SERVER_STARTED: "Server has started.", MessageKind.SERVER_STARTING: "Server is starting...", MessageKind.SERVER_STOPPED: "Server has stopped.", MessageKind.SERVER_UPDATING: "Server is updating...", } class DiscordIntegration: async def send_message(self, kind: MessageKind) -> None: if not settings.discord_enabled: return text = _MESSAGE_TEXTS.get(kind, kind.value) msg = DiscordMessage(username="ASWG", content=text, embeds=[]) asyncio.create_task(self._webhook.send(msg)) # fire-and-forget class DiscordWebhookHandler: async def send(self, message: DiscordMessage) -> None: try: async with httpx.AsyncClient() as client: await client.post(settings.discord_webhook_url, json=message.to_dict(), timeout=10.0) except Exception as e: log.warning("Discord webhook failed: %s", e) ``` Wire into `ProcessService` (edit Phase 3's `process_service.py`): - After `start_server()` completes: `await discord.send_message(MessageKind.SERVER_STARTED)` - After `stop_server()`: `await discord.send_message(MessageKind.SERVER_STOPPED)` ### REST Endpoints **Missions** (`/api/v1/missions`) ``` GET /api/v1/missions → {disabledMissions, enabledMissions} MISSION_VIEW POST /api/v1/missions/enabled body:{missions:[{name,template,difficulty,enabled}]} MISSION_UPDATE → 200 POST /api/v1/missions/template body:{name,template} MISSION_ADD → 200 DELETE /api/v1/missions/template body:{template} MISSION_DELETE → 200 PUT /api/v1/missions/id/{id} body:Mission MISSION_UPDATE → 200 ``` **Mission Files** (`/api/v1/missions-files`) ``` POST /api/v1/missions-files multipart: file(s) + overwrite? MISSION_UPLOAD → 200 GET /api/v1/missions-files/{name}/exists → {exists: bool} MISSION_VIEW ``` **CDLC** (`/api/v1/cdlc`) ``` GET /api/v1/cdlc → {cdlcs:[{id,name,enabled,fileExists}]} CDLC_VIEW POST /api/v1/cdlc/{id}/toggle → 200 CDLC_UPDATE ``` ### JSON shapes Mission: ```json {"id": 1, "name": "TDM Stratis", "template": "tdm_stratis.Stratis", "enabled": true, "difficulty": "Regular"} ``` CDLC: ```json {"id": 1, "name": "gm", "enabled": false, "fileExists": true} ``` --- ## Completion Checklist - [ ] `GET /api/v1/missions` returns `{disabledMissions, enabledMissions}` - [ ] `POST /api/v1/missions/enabled` persists and writes mission block to server.cfg - [ ] `POST /api/v1/missions/template` inserts mission row with `enabled=false` - [ ] `POST /api/v1/missions-files` saves .pbo and inserts DB record - [ ] `GET /api/v1/cdlc` computes `fileExists` from filesystem at request time - [ ] `POST /api/v1/cdlc/{id}/toggle` flips enabled flag - [ ] APScheduler starts in lifespan; all 6 jobs registered with cron from settings - [ ] Each job records execution row in `job_execution` table - [ ] Discord fires on server start/stop when `discord.enabled=true` - [ ] `InvalidJwtCleaner` hourly job deletes rows where `expiration_datetime < now()` - [ ] `VanillaMissionsImporter` inserts default missions on first startup ## Contract for Phase 8 Phase 8 adds no new service classes. It wraps everything built in Phases 1–7 with: - Global exception handler (FastAPI `@app.exception_handler`) - SPA redirect middleware (non-API paths → `index.html`) - Optional rate limiting middleware - Structured logging via `structlog`