From 2d51ccf83f82f13207a820b963fad0e2ecc94b08 Mon Sep 17 00:00:00 2001 From: Yuze Fu Date: Thu, 10 Sep 2026 19:35:24 +0900 Subject: [PATCH] feat(events): publish and synchronize Discord forum posts (T-222) --- README.md | 30 ++ assets/config/settings.default.toml | 3 + .../20260908000000_add_event_discord_post.sql | 7 + src/discord.rs | 342 ++++++++++++++++++ src/discord/models.rs | 48 +++ src/discord/service.rs | 117 ++++++ src/error.rs | 17 + src/modules/event/dto.rs | 2 + src/modules/event/models.rs | 2 + src/modules/event/repository/event.rs | 18 +- src/modules/event/routes/events.rs | 25 +- src/modules/event/service.rs | 19 +- src/services.rs | 13 +- src/settings.rs | 4 + 14 files changed, 637 insertions(+), 10 deletions(-) create mode 100644 migrations/20260908000000_add_event_discord_post.sql create mode 100644 src/discord/models.rs create mode 100644 src/discord/service.rs diff --git a/README.md b/README.md index d9419cb..a4d283c 100644 --- a/README.md +++ b/README.md @@ -120,3 +120,33 @@ If some common data setup is required for the API endpoint (e.g. `/api/events` r to the repository, the contributors are additionally granting VATPRC staffs an unrevokeable right to use the code freely for any purposes related to VATSIM or VATPRC. + +### Discord event posts + +Set `discord.event_forum_channel_id` to a Discord forum channel ID, in addition +to enabling the bot and configuring its token. If the forum requires tags, set +`discord.event_forum_tag_ids` to the permitted tag IDs. The bot needs View Channel, +Send Messages, Embed Links, Read Message History, and Send Messages in Threads +permissions there. Locked posts additionally require Manage Threads to reopen. + +Event coordinators can publish a saved website event from its edit dialog. The +service stores the forum thread and guild IDs and returns `discord_message` in the +event API. `PUT /api/events/{id}/discord` creates the post once, or synchronizes +its existing title and starter message. Event updates automatically synchronize +linked posts. A failed sync preserves the website changes and exposes +`discord_message.status = OutOfSync`; the editor offers a retry. Unpublished events are never +automatically published. Long descriptions are shortened to Discord's embed +limit, with a link to the full website event. Mentions are disabled. + +Run database migrations before deploying this version. Discord associations live +in `event_discord_message`, keyed by `event_id`. Its `status` column is a text enum +restricted to `Sync` and `OutOfSync`; `synced_at` records the last successful sync. +Failed updates retain that timestamp. First publication failures create no +association and return an API error. + +Discord requests run after website changes are committed, without holding a +transaction or row lock. Concurrent first publications can create duplicate posts; +there is no cross-system atomicity. Local tests use a mock Discord HTTP server. + +The API exposes `discord_message` with string `guild_id` and `message_id` +snowflakes, `status`, and `synced_at`. diff --git a/assets/config/settings.default.toml b/assets/config/settings.default.toml index 99e1f3a..d484f24 100644 --- a/assets/config/settings.default.toml +++ b/assets/config/settings.default.toml @@ -52,6 +52,9 @@ endpoint = "http://localhost:4318/v1/traces" headers = {} [discord] +# Target Discord forum channel for website event posts (optional). +# event_forum_channel_id = 123456789012345678 +# event_forum_tag_ids = [123456789012345678] enabled = false token = "" diff --git a/migrations/20260908000000_add_event_discord_post.sql b/migrations/20260908000000_add_event_discord_post.sql new file mode 100644 index 0000000..aaf2d88 --- /dev/null +++ b/migrations/20260908000000_add_event_discord_post.sql @@ -0,0 +1,7 @@ +CREATE TABLE public.event_discord_message ( + event_id uuid PRIMARY KEY REFERENCES public.event(id) ON DELETE CASCADE, + guild_id text NOT NULL, + message_id text NOT NULL, + status text NOT NULL CHECK (status IN ('Sync', 'OutOfSync')), + synced_at timestamptz NOT NULL +); diff --git a/src/discord.rs b/src/discord.rs index 3e8216d..72af4d1 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,3 +1,6 @@ +pub mod models; +pub mod service; + use serenity::all::{ Command, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, EditInteractionResponse, @@ -163,3 +166,342 @@ async fn respond_with_message(ctx: &Context, command: &CommandInteraction, conte tracing::error!(%error, "failed to respond to discord command"); } } + +#[derive(Debug, thiserror::Error)] +pub enum DiscordPublishError { + #[error("Discord event publishing is not configured")] + NotConfigured, + #[error("invalid Discord message ID")] + InvalidMessageId, + #[error(transparent)] + Request(#[from] serenity::Error), +} + +/// Publishes website events as forum posts; the starter message has the thread's ID. +#[derive(Clone)] +pub struct DiscordEventPublisher { + http: std::sync::Arc, + forum: serenity::all::ChannelId, + tags: Vec, +} + +impl DiscordEventPublisher { + pub fn from_settings(settings: &Discord) -> Option { + if !settings.enabled || settings.token.trim().is_empty() { + return None; + } + Some(Self { + http: std::sync::Arc::new(serenity::http::Http::new(settings.token.trim())), + forum: serenity::all::ChannelId::new( + settings.event_forum_channel_id.filter(|id| *id != 0)?, + ), + tags: settings + .event_forum_tag_ids + .iter() + .copied() + .filter(|id| *id != 0) + .map(serenity::all::ForumTagId::new) + .collect(), + }) + } + + pub async fn sync( + &self, + event: &mut crate::modules::event::models::Event, + ) -> Result<(), DiscordPublishError> { + use serenity::all::{ + ChannelId, CreateAllowedMentions, CreateForumPost, CreateMessage, EditMessage, + EditThread, MessageId, + }; + let title = forum_title(&event.title); + let embed = event_embed(event); + if let Some(message) = &event.discord_message { + let id = message + .message_id + .parse::() + .ok() + .filter(|id| *id != 0) + .ok_or(DiscordPublishError::InvalidMessageId)?; + let channel = ChannelId::new(id); + // Archived forum posts must be reopened before editing their starter message. + channel + .edit_thread(&self.http, EditThread::new().archived(false).name(title)) + .await?; + channel + .edit_message( + &self.http, + MessageId::new(id), + EditMessage::new() + .embed(embed) + .allowed_mentions(CreateAllowedMentions::new()), + ) + .await?; + } else { + let thread = self + .forum + .create_forum_post( + &self.http, + CreateForumPost::new( + title, + CreateMessage::new() + .embed(embed) + .allowed_mentions(CreateAllowedMentions::new()), + ) + .set_applied_tags(self.tags.clone()), + ) + .await?; + event.discord_message = Some(models::DiscordMessage { + message_id: thread.id.to_string(), + guild_id: thread.guild_id.to_string(), + status: models::DiscordSyncStatus::Sync, + synced_at: chrono::Utc::now(), + }); + } + Ok(()) + } +} + +// Discord limits strings by UTF-16 code units. Keep astral characters intact. +fn discord_text(value: &str, limit: usize) -> String { + if value.encode_utf16().count() <= limit { + return value.to_owned(); + } + let mut result = String::new(); + let mut remaining = limit.saturating_sub(1); + for c in value.chars() { + if c.len_utf16() > remaining { + break; + } + result.push(c); + remaining -= c.len_utf16(); + } + result.push('…'); + result +} + +fn forum_title(title: &str) -> String { + let title = title.trim(); + if title.chars().count() < 2 { + format!("活动 {title}") + } else { + discord_text(title, 100) + } +} + +fn event_embed(event: &crate::modules::event::models::Event) -> serenity::all::CreateEmbed { + use serenity::all::CreateEmbed; + let url = format!( + "https://www.vatprc.net/events/{}", + ulid::Ulid::from(event.id) + ); + let mut embed = CreateEmbed::new() + .title(discord_text(&event.title, 256)) + .url(&url) + .description(discord_text(&event.description, 4096)) + .field( + "活动时间", + format!( + "", + event.start_at.timestamp(), + event.end_at.timestamp() + ), + false, + ) + .field("活动详情 / 报名", format!("[在官网查看]({url})"), false); + if let Some(title) = event.title_en.as_deref().filter(|s| !s.is_empty()) { + embed = embed.field("English title", discord_text(title, 256), false); + } + if let Some(image) = event.image_url.as_deref().filter(|s| valid_public_url(s)) { + embed = embed.image(image); + } + for (name, link) in [ + ("论坛", &event.community_link), + ("VATSIM", &event.vatsim_link), + ] { + if let Some(link) = link + .as_deref() + .filter(|s| valid_public_url(s) && s.encode_utf16().count() <= 512) + { + embed = embed.field(name, link, false); + } + } + embed +} + +fn valid_public_url(value: &str) -> bool { + url::Url::parse(value) + .is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some()) +} + +#[cfg(test)] +mod event_post_tests { + use super::*; + + #[test] + fn truncation_respects_discord_utf16_limits() { + assert_eq!(discord_text("中文标题", 4), "中文标题"); + assert_eq!(discord_text("🚀🚀🚀", 4), "🚀…"); + assert_eq!(discord_text("abcde", 4), "abc…"); + assert!(forum_title(&"🚀".repeat(101)).encode_utf16().count() <= 100); + assert!(forum_title(" ").chars().count() >= 2); + } + + #[test] + fn event_embed_includes_details_and_clears_removed_image() { + let event = example_event(); + let value = serde_json::to_value(event_embed(&event)).unwrap(); + assert!( + value["description"] + .as_str() + .unwrap() + .encode_utf16() + .count() + <= 4096 + ); + assert_eq!(value["fields"][0]["value"], ""); + assert!(value.get("image").is_none()); + assert!( + value["url"] + .as_str() + .unwrap() + .ends_with("/events/00000000000000000000000000") + ); + } + + pub(super) fn example_event() -> crate::modules::event::models::Event { + crate::modules::event::models::Event { + id: uuid::Uuid::nil(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + title: "测试活动".into(), + title_en: None, + start_at: chrono::DateTime::from_timestamp(1000, 0).unwrap(), + end_at: chrono::DateTime::from_timestamp(2000, 0).unwrap(), + start_booking_at: None, + end_booking_at: None, + start_atc_booking_at: None, + image_url: None, + community_link: None, + vatsim_link: None, + description: "🚀".repeat(3000), + discord_message: None, + } + } + + #[tokio::test] + async fn publishes_once_then_updates_the_same_post_without_mentions() { + use axum::{extract::Request, response::IntoResponse}; + use serenity::all::{ChannelId, GuildChannel, GuildId, Message}; + use std::sync::{Arc, Mutex}; + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = requests.clone(); + let app = axum::Router::new().fallback(move |request: Request| { + let captured = captured.clone(); + async move { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let body = axum::body::to_bytes(request.into_body(), 16384) + .await + .unwrap(); + captured.lock().unwrap().push(( + method, + path.clone(), + serde_json::from_slice::(&body).unwrap_or_else(|_| { + // Forum creation uses multipart/form-data even without attachments. + let body = std::str::from_utf8(&body).unwrap(); + let payload = body + .split("\r\n\r\n") + .nth(1) + .unwrap() + .split("\r\n--") + .next() + .unwrap(); + serde_json::from_str(payload).unwrap() + }), + )); + if captured.lock().unwrap().last().unwrap().2["name"] == "Forbidden" { + return ( + axum::http::StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "code": 50013, "message": "Missing Permissions" + })), + ) + .into_response(); + } + if path.ends_with("/messages/123") { + axum::Json(serde_json::to_value(Message::default()).unwrap()).into_response() + } else { + let mut channel = GuildChannel::default(); + channel.id = ChannelId::new(123); + channel.guild_id = GuildId::new(456); + axum::Json(serde_json::to_value(channel).unwrap()).into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let publisher = DiscordEventPublisher { + http: Arc::new( + serenity::http::HttpBuilder::new("test-token") + .proxy(format!("http://{address}")) + .ratelimiter_disabled(true) + .build(), + ), + forum: ChannelId::new(789), + tags: vec![], + }; + let mut event = example_event(); + publisher.sync(&mut event).await.unwrap(); + assert_eq!( + event + .discord_message + .as_ref() + .map(|message| message.message_id.as_str()), + Some("123") + ); + assert_eq!( + event + .discord_message + .as_ref() + .map(|message| message.guild_id.as_str()), + Some("456") + ); + event.title = "Updated event".into(); + publisher.sync(&mut event).await.unwrap(); + let requests = requests.lock().unwrap().clone(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].0, "POST"); + assert!(requests[0].1.ends_with("/channels/789/threads")); + assert_eq!( + requests[0].2["message"]["allowed_mentions"]["parse"], + serde_json::json!([]) + ); + assert_eq!(requests[1].0, "PATCH"); + assert!(requests[1].1.ends_with("/channels/123")); + assert_eq!(requests[1].2["name"], "Updated event"); + assert_eq!(requests[1].2["archived"], false); + assert!(requests[2].1.ends_with("/channels/123/messages/123")); + assert_eq!(requests[2].2["embeds"][0]["title"], "Updated event"); + assert_eq!( + requests[2].2["allowed_mentions"]["parse"], + serde_json::json!([]) + ); + event.title = "Forbidden".into(); + let failure = publisher.sync(&mut event).await; + assert!(failure.is_err()); + assert!(matches!(failure, Err(DiscordPublishError::Request(_)))); + assert_eq!( + event + .discord_message + .as_ref() + .map(|message| message.message_id.as_str()), + Some("123") + ); + let mut unpublished = example_event(); + unpublished.title = "Forbidden".into(); + assert!(publisher.sync(&mut unpublished).await.is_err()); + assert!(unpublished.discord_message.is_none()); + server.abort(); + } +} diff --git a/src/discord/models.rs b/src/discord/models.rs new file mode 100644 index 0000000..5fd302d --- /dev/null +++ b/src/discord/models.rs @@ -0,0 +1,48 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct DiscordMessage { + /// Discord snowflake serialized as a string to preserve precision in JavaScript. + pub guild_id: String, + /// The forum starter message and thread share this ID. + pub message_id: String, + pub status: DiscordSyncStatus, + /// Time of the most recent successful synchronization. + pub synced_at: DateTime, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, sqlx::Type, utoipa::ToSchema, +)] +#[sqlx(type_name = "text")] +pub enum DiscordSyncStatus { + OutOfSync, + Sync, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stored_message_decodes_text_status_and_snowflakes() { + let value = serde_json::json!({ + "event_id": "00000000-0000-0000-0000-000000000000", + "guild_id": "123456789012345678", + "message_id": "987654321098765432", + "status": "OutOfSync", + "synced_at": "2026-09-08T00:00:00+00:00" + }); + let message: DiscordMessage = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(message.status, DiscordSyncStatus::OutOfSync); + assert_eq!(message.message_id, "987654321098765432"); + assert_eq!( + serde_json::to_value(message).unwrap()["status"], + "OutOfSync" + ); + let mut invalid = value; + invalid["status"] = serde_json::json!(200); + assert!(serde_json::from_value::(invalid).is_err()); + } +} diff --git a/src/discord/service.rs b/src/discord/service.rs new file mode 100644 index 0000000..d2b637e --- /dev/null +++ b/src/discord/service.rs @@ -0,0 +1,117 @@ +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; + +use super::models::DiscordSyncStatus; +use super::{DiscordEventPublisher, DiscordPublishError}; +use crate::modules::audit_log::models::AuditLogEntity; +use crate::modules::audit_log::service::{AuditLogService, AuditLogServiceError}; +use crate::modules::event::models::Event; +use crate::modules::event::repository::event::EventRepository; +use crate::settings::Discord; + +#[derive(Clone)] +pub struct DiscordService { + db: PgPool, + audit_log: AuditLogService, + publisher: Option, +} + +impl DiscordService { + pub fn new(db: PgPool, audit_log: AuditLogService, settings: &Discord) -> Self { + Self { + db, + audit_log, + publisher: DiscordEventPublisher::from_settings(settings), + } + } + + pub async fn publish_event( + &self, + id: Uuid, + operated_by: Uuid, + ) -> Result { + let mut event = self + .db + .find_event_by_id(id) + .await? + .ok_or(DiscordServiceError::EventNotFound(id))?; + let before = event.clone(); + let result = self.sync_event(&mut event).await; + self.audit_log + .record( + AuditLogEntity::Event(id), + operated_by, + Some(&before), + Some(&event), + ) + .await?; + result?; + Ok(event) + } + + /// Website changes are already saved; no transaction is held during Discord requests. + pub(crate) async fn sync_event(&self, event: &mut Event) -> Result<(), DiscordServiceError> { + let result = match &self.publisher { + Some(publisher) => publisher.sync(event).await, + None => Err(DiscordPublishError::NotConfigured), + }; + if let Err(error) = &result { + tracing::error!(%error, id = %event.id, "failed to synchronize Discord event"); + } + if let Some(message) = &mut event.discord_message { + message.status = if result.is_ok() { + DiscordSyncStatus::Sync + } else { + DiscordSyncStatus::OutOfSync + }; + if result.is_ok() { + message.synced_at = Utc::now(); + } + sqlx::query("INSERT INTO public.event_discord_message (event_id, guild_id, message_id, status, synced_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (event_id) DO UPDATE SET guild_id = EXCLUDED.guild_id, + message_id = EXCLUDED.message_id, status = EXCLUDED.status, synced_at = EXCLUDED.synced_at") + .bind(event.id).bind(&message.guild_id).bind(&message.message_id) + .bind(message.status).bind(message.synced_at).execute(&self.db).await?; + } + result.map_err(DiscordServiceError::Publish) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DiscordServiceError { + #[error("event {0} not found")] + EventNotFound(Uuid), + #[error(transparent)] + Publish(#[from] DiscordPublishError), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + AuditLog(#[from] AuditLogServiceError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn unconfigured_publisher_returns_typed_error_without_creating_a_message() { + let db = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/unused") + .unwrap(); + let service = DiscordService { + audit_log: AuditLogService::new(db.clone()), + db, + publisher: None, + }; + let mut event = crate::discord::event_post_tests::example_event(); + assert!(matches!( + service.sync_event(&mut event).await, + Err(DiscordServiceError::Publish( + DiscordPublishError::NotConfigured + )) + )); + assert!(event.discord_message.is_none()); + } +} diff --git a/src/error.rs b/src/error.rs index b26eb72..293011c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -280,6 +280,7 @@ impl From for ApiError { impl From for ApiError { fn from(error: EventServiceError) -> Self { match error { + EventServiceError::Discord(error) => error.into(), EventServiceError::EventNotFound(id) => { ApiError::not_found("event", ulid::Ulid::from(id).to_string()) } @@ -428,3 +429,19 @@ impl IntoResponse for ApiError { .into_response() } } + +impl From for ApiError { + fn from(error: crate::discord::service::DiscordServiceError) -> Self { + use crate::discord::service::DiscordServiceError; + match error { + DiscordServiceError::EventNotFound(id) => { + Self::not_found("event", ulid::Ulid::from(id).to_string()) + } + DiscordServiceError::Publish(source) => { + Self::bad_request("discord", source.to_string()) + } + DiscordServiceError::Database(source) => Self::Database { source }, + DiscordServiceError::AuditLog(source) => Self::AuditLog { source }, + } + } +} diff --git a/src/modules/event/dto.rs b/src/modules/event/dto.rs index 9f7ff0c..a186031 100644 --- a/src/modules/event/dto.rs +++ b/src/modules/event/dto.rs @@ -61,6 +61,7 @@ impl TryFrom for EventSave { #[derive(Serialize, utoipa::ToSchema)] pub struct EventDto { + pub discord_message: Option, pub id: String, pub created_at: DateTime, pub updated_at: DateTime, @@ -80,6 +81,7 @@ pub struct EventDto { impl From for EventDto { fn from(event: Event) -> Self { Self { + discord_message: event.discord_message, id: Ulid::from(event.id).to_string(), created_at: event.created_at, updated_at: event.updated_at, diff --git a/src/modules/event/models.rs b/src/modules/event/models.rs index db50399..00b3b75 100644 --- a/src/modules/event/models.rs +++ b/src/modules/event/models.rs @@ -5,6 +5,8 @@ use uuid::Uuid; #[derive(Debug, Clone, FromRow, Serialize)] pub struct Event { + #[sqlx(json(nullable))] + pub discord_message: Option, pub id: Uuid, pub created_at: DateTime, pub updated_at: DateTime, diff --git a/src/modules/event/repository/event.rs b/src/modules/event/repository/event.rs index 7075b86..e116158 100644 --- a/src/modules/event/repository/event.rs +++ b/src/modules/event/repository/event.rs @@ -33,7 +33,8 @@ where r#" SELECT id, created_at, updated_at, title, title_en, start_at, end_at, start_booking_at, end_booking_at, start_atc_booking_at, image_url, - community_link, vatsim_link, description + community_link, vatsim_link, description, (SELECT to_jsonb(message) FROM public.event_discord_message message + WHERE message.event_id = event.id) AS discord_message FROM public.event WHERE (is_approved = TRUE OR is_approved IS NULL) AND now() < end_at @@ -52,7 +53,8 @@ where r#" SELECT id, created_at, updated_at, title, title_en, start_at, end_at, start_booking_at, end_booking_at, start_atc_booking_at, image_url, - community_link, vatsim_link, description + community_link, vatsim_link, description, (SELECT to_jsonb(message) FROM public.event_discord_message message + WHERE message.event_id = event.id) AS discord_message FROM public.event WHERE (is_approved = TRUE OR is_approved IS NULL) AND start_at < now() @@ -71,7 +73,8 @@ where r#" SELECT id, created_at, updated_at, title, title_en, start_at, end_at, start_booking_at, end_booking_at, start_atc_booking_at, image_url, - community_link, vatsim_link, description + community_link, vatsim_link, description, (SELECT to_jsonb(message) FROM public.event_discord_message message + WHERE message.event_id = event.id) AS discord_message FROM public.event WHERE id = $1 "#, @@ -110,7 +113,8 @@ where VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, title, title_en, start_at, end_at, start_booking_at, end_booking_at, start_atc_booking_at, image_url, - community_link, vatsim_link, description + community_link, vatsim_link, description, (SELECT to_jsonb(message) FROM public.event_discord_message message + WHERE message.event_id = event.id) AS discord_message "#, ) .bind(Uuid::from(Ulid::new())) @@ -133,7 +137,8 @@ where r#" SELECT id, created_at, updated_at, title, title_en, start_at, end_at, start_booking_at, end_booking_at, start_atc_booking_at, image_url, - community_link, vatsim_link, description + community_link, vatsim_link, description, (SELECT to_jsonb(message) FROM public.event_discord_message message + WHERE message.event_id = event.id) AS discord_message FROM public.event WHERE id = $1 FOR UPDATE @@ -168,7 +173,8 @@ where WHERE id = $1 RETURNING id, created_at, updated_at, title, title_en, start_at, end_at, start_booking_at, end_booking_at, start_atc_booking_at, image_url, - community_link, vatsim_link, description + community_link, vatsim_link, description, (SELECT to_jsonb(message) FROM public.event_discord_message message + WHERE message.event_id = event.id) AS discord_message "#, ) .bind(id) diff --git a/src/modules/event/routes/events.rs b/src/modules/event/routes/events.rs index 845bb8f..ae5fff2 100644 --- a/src/modules/event/routes/events.rs +++ b/src/modules/event/routes/events.rs @@ -10,7 +10,14 @@ use crate::modules::user::models::UserRole; use crate::services::Services; #[derive(utoipa::OpenApi)] -#[openapi(paths(list_events, list_past_events, create_event, get_event, update_event))] +#[openapi(paths( + list_events, + list_past_events, + create_event, + get_event, + update_event, + publish_discord +))] pub(crate) struct ApiDoc; pub fn build_event_routes() -> Router { @@ -20,6 +27,7 @@ pub fn build_event_routes() -> Router { .route("/{eid}", get(get_event)) .route("/", post(create_event)) .route("/{eid}", put(update_event)) + .route("/{eid}/discord", put(publish_discord)) } #[utoipa::path(get, path = "api/events", tag = "Events", responses((status = 200, description = "Successful response", body = Vec)))] @@ -104,3 +112,18 @@ async fn update_event( Ok(Json(EventDto::from(event))) } + +#[utoipa::path(put, path = "api/events/{id}/discord", tag = "Events", security(("oauth2" = [])), params(("id" = String, Path, description = "Event ULID")), responses((status = 200, description = "Published or synchronized Discord post", body = EventDto)))] +async fn publish_discord( + State(services): State, + current_user: CurrentUser, + Path(eid): Path, +) -> Result, ApiError> { + current_user.require_role(UserRole::EventCoordinator)?; + let operated_by = current_user.user_id.ok_or(ApiError::Unauthorized)?; + let event = services + .discord() + .publish_event(eid.parse::()?.into(), operated_by) + .await?; + Ok(Json(event.into())) +} diff --git a/src/modules/event/service.rs b/src/modules/event/service.rs index a32f99c..f3ab3cf 100644 --- a/src/modules/event/service.rs +++ b/src/modules/event/service.rs @@ -25,14 +25,21 @@ pub struct EventService { db: PgPool, audit_log: AuditLogService, user: UserService, + discord: crate::discord::service::DiscordService, } impl EventService { - pub fn new(db: PgPool, audit_log: AuditLogService, user: UserService) -> Self { + pub fn new( + db: PgPool, + audit_log: AuditLogService, + user: UserService, + discord: crate::discord::service::DiscordService, + ) -> Self { Self { db, audit_log, user, + discord, } } @@ -85,11 +92,17 @@ impl EventService { .find_event_by_id_for_update(id) .await? .ok_or(EventServiceError::EventNotFound(id))?; - let event = (&mut *transaction) + let mut event = (&mut *transaction) .update_event(id, event) .await? .ok_or(EventServiceError::EventNotFound(id))?; transaction.commit().await?; + if event.discord_message.is_some() { + match self.discord.sync_event(&mut event).await { + Err(crate::discord::service::DiscordServiceError::Publish(_)) => {} + result => result?, + } + } self.audit_log .record( AuditLogEntity::Event(event.id), @@ -535,6 +548,8 @@ fn permission_satisfies(permission: &UserAtcPermissionRecord, minimum_state: i32 #[derive(Debug, thiserror::Error)] pub enum EventServiceError { + #[error(transparent)] + Discord(#[from] crate::discord::service::DiscordServiceError), #[error("event {0} not found")] EventNotFound(Uuid), #[error("event slot {0} not found")] diff --git a/src/services.rs b/src/services.rs index 22c69a5..d50de0e 100644 --- a/src/services.rs +++ b/src/services.rs @@ -41,6 +41,7 @@ pub struct Services { atc_booking: AtcBookingService, atc_position: AtcPositionService, event: EventService, + discord: crate::discord::service::DiscordService, flight: FlightService, sheet: SheetService, training: TrainingService, @@ -76,7 +77,12 @@ impl Services { AtcApplicationService::new(db.clone(), audit_log.clone(), user.clone(), sheet.clone()); let atc_booking = AtcBookingService::new(db.clone(), user.clone()); let atc_position = AtcPositionService::new(db.clone(), audit_log.clone()); - let event = EventService::new(db.clone(), audit_log.clone(), user.clone()); + let discord = crate::discord::service::DiscordService::new( + db.clone(), + audit_log.clone(), + &settings.discord, + ); + let event = EventService::new(db.clone(), audit_log.clone(), user.clone(), discord.clone()); let access_token = AccessTokenService::new(&settings.authentication.jwt); let refresh_token = RefreshTokenService::new(db.clone(), settings.authentication.jwt.refresh_expires_days); @@ -101,6 +107,7 @@ impl Services { atc_booking, atc_position, event, + discord, flight, sheet, training, @@ -177,6 +184,10 @@ impl Services { &self.atc_position } + pub fn discord(&self) -> &crate::discord::service::DiscordService { + &self.discord + } + pub fn event(&self) -> &EventService { &self.event } diff --git a/src/settings.rs b/src/settings.rs index 4a281e5..5e3e298 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -79,6 +79,10 @@ pub struct Database { #[derive(Debug, Deserialize, Clone)] pub struct Discord { + #[serde(default)] + pub event_forum_channel_id: Option, + #[serde(default)] + pub event_forum_tag_ids: Vec, #[serde(default)] pub enabled: bool, #[serde(default)]