forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1use anyhow::Result;
2use axum::{
3 extract::{Path, State},
4 response::IntoResponse,
5};
6use axum_extra::extract::Cached;
7use axum_htmx::{HxBoosted, HxRequest};
8use minijinja::context as template_context;
9use std::collections::HashMap;
10
11use crate::{
12 atproto::{
13 auth::SimpleOAuthSessionProvider,
14 client::{OAuthPdsClient, PutRecordRequest},
15 lexicon::{
16 community::lexicon::calendar::event::{
17 Event as CommunityEvent, EventLink, EventLocation as CommunityLocation, Mode,
18 Status, NSID as COMMUNITY_NSID,
19 },
20 community::lexicon::location,
21 events::smokesignal::calendar::event::{
22 Event as SmokeSignalEvent, Location as SmokeSignalLocation, PlaceLocation,
23 NSID as SMOKESIGNAL_NSID,
24 },
25 },
26 },
27 create_renderer,
28 http::{
29 context::WebContext,
30 errors::{MigrateEventError, WebError},
31 middleware_auth::Auth,
32 middleware_i18n::Language,
33 utils::url_from_aturi,
34 },
35 resolve::{parse_input, InputType},
36 storage::{
37 event::{event_get, event_insert_with_metadata},
38 handle::{handle_for_did, handle_for_handle, model::Handle},
39 },
40};
41
42pub async fn handle_migrate_event(
43 State(web_context): State<WebContext>,
44 HxBoosted(hx_boosted): HxBoosted,
45 Language(language): Language,
46 Cached(auth): Cached<Auth>,
47 HxRequest(hx_request): HxRequest,
48 Path((handle_slug, event_rkey)): Path<(String, String)>,
49) -> Result<impl IntoResponse, WebError> {
50 let current_handle = auth.require(&web_context.config.destination_key, "/")?;
51
52 // Create the template renderer with enhanced context
53 let renderer = create_renderer!(web_context.clone(), Language(language), hx_boosted, hx_request);
54 let canonical_url = format!("https://{}/{}/{}/migrate", renderer.web_context.config.external_base, handle_slug, event_rkey);
55
56 // Lookup the user handle/profile
57 let profile: Result<Handle> = match parse_input(&handle_slug) {
58 Ok(InputType::Handle(handle)) => handle_for_handle(&web_context.pool, &handle)
59 .await
60 .map_err(|err| err.into()),
61 Ok(InputType::Plc(did) | InputType::Web(did)) => handle_for_did(&web_context.pool, &did)
62 .await
63 .map_err(|err| err.into()),
64 Err(err) => Err(err.into()),
65 };
66
67 if let Err(err) = profile {
68 return Ok(renderer.render_error(
69 err,
70 template_context! {
71 current_handle => current_handle.handle.as_str(),
72 canonical_url => canonical_url,
73 },
74 ));
75 }
76 let profile = profile.unwrap();
77
78 // Construct AT URI for the source event
79 let source_aturi = format!("at://{}/{}/{}", profile.did, SMOKESIGNAL_NSID, event_rkey);
80
81 // Check if the user is authorized to migrate this event (must be the event creator/organizer)
82 if profile.did != current_handle.did {
83 return Ok(renderer.render_error(
84 MigrateEventError::NotAuthorized,
85 template_context! {
86 current_handle => current_handle.handle.as_str(),
87 canonical_url => canonical_url,
88 },
89 ));
90 }
91
92 // Fetch the event from the database
93 let event = event_get(&web_context.pool, &source_aturi).await;
94 if let Err(err) = event {
95 return Ok(renderer.render_error(
96 err,
97 template_context! {
98 current_handle => current_handle.handle.as_str(),
99 canonical_url => canonical_url,
100 },
101 ));
102 }
103 let event = event.unwrap();
104
105 // Check that this is a smokesignal event (we only migrate those)
106 if event.lexicon != SMOKESIGNAL_NSID {
107 // If it's already the community event type, we don't need to migrate
108 if event.lexicon == COMMUNITY_NSID {
109 return Ok(renderer.render_error(
110 MigrateEventError::AlreadyMigrated,
111 template_context! {
112 current_handle => current_handle.handle.as_str(),
113 canonical_url => canonical_url,
114 },
115 ));
116 }
117
118 return Ok(renderer.render_error(
119 MigrateEventError::UnsupportedEventType,
120 template_context! {
121 current_handle => current_handle.handle.as_str(),
122 canonical_url => canonical_url,
123 },
124 ));
125 }
126
127 // Parse the legacy event
128 let legacy_event = match serde_json::from_value::<SmokeSignalEvent>(event.record.0.clone()) {
129 Ok(event) => event,
130 Err(err) => {
131 return Ok(renderer.render_error(
132 err,
133 template_context! {
134 current_handle => current_handle.handle.as_str(),
135 canonical_url => canonical_url,
136 },
137 ));
138 }
139 };
140
141 // Extract data from the legacy event
142 let (name, text, created_at, starts_at, extra) = match legacy_event {
143 SmokeSignalEvent::Current {
144 name,
145 text,
146 created_at,
147 starts_at,
148 extra,
149 } => (name, text, created_at, starts_at, extra),
150 };
151
152 // Extract optional fields from the extra map
153 let ends_at = extra
154 .get("endsAt")
155 .and_then(|v| v.as_str())
156 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
157 .map(|dt| dt.with_timezone(&chrono::Utc));
158
159 // Extract legacy mode/status
160 let legacy_mode = extra.get("mode").and_then(|v| v.as_str());
161 let legacy_status = extra.get("status").and_then(|v| v.as_str());
162
163 // Convert mode to the community format
164 let mode = match legacy_mode {
165 Some("events.smokesignal.calendar.event#inperson") => Some(Mode::InPerson),
166 Some("events.smokesignal.calendar.event#virtual") => Some(Mode::Virtual),
167 _ => None,
168 };
169
170 // Convert status to the community format
171 let status = match legacy_status {
172 Some("events.smokesignal.calendar.event#scheduled") => Some(Status::Scheduled),
173 Some("events.smokesignal.calendar.event#cancelled") => Some(Status::Cancelled),
174 Some("events.smokesignal.calendar.event#postponed") => Some(Status::Postponed),
175 Some("events.smokesignal.calendar.event#rescheduled") => Some(Status::Rescheduled),
176 _ => Some(Status::Scheduled), // Default to scheduled if not specified
177 };
178
179 // Helper function to convert PlaceLocation to community Address
180 fn convert_place_to_address(place: &PlaceLocation) -> location::Address {
181 location::Address::Current {
182 country: place.country.clone().unwrap_or_default(),
183 postal_code: place.postal_code.clone(),
184 region: place.region.clone(),
185 locality: place.locality.clone(),
186 street: place.street.clone(),
187 name: Some(place.name.clone()),
188 }
189 }
190
191 // Extract locations and links from the legacy event
192 let mut locations = Vec::new();
193 let mut uris = Vec::new();
194
195 if let Some(location_values) = extra.get("location") {
196 if let Some(location_array) = location_values.as_array() {
197 for location_value in location_array {
198 // Parse the location
199 if let Ok(location) =
200 serde_json::from_value::<SmokeSignalLocation>(location_value.clone())
201 {
202 match location {
203 SmokeSignalLocation::Place(place) => {
204 // Convert place location to community address
205 let address = convert_place_to_address(&place);
206 locations.push(CommunityLocation::Address(address));
207 }
208 SmokeSignalLocation::Virtual(virtual_loc) => {
209 // Convert virtual locations to EventLink elements
210 if let Some(url) = &virtual_loc.url {
211 uris.push(EventLink::Current {
212 uri: url.clone(),
213 name: Some(virtual_loc.name.clone()),
214 });
215 }
216 }
217 }
218 }
219 }
220 }
221 }
222
223 // Create a new community event
224 let new_event = CommunityEvent::Current {
225 name: name.clone(),
226 description: text.unwrap_or_default(),
227 created_at: created_at.unwrap_or_else(chrono::Utc::now),
228 starts_at,
229 ends_at,
230 mode,
231 status,
232 locations,
233 uris,
234 extra: HashMap::default(),
235 };
236
237 // Construct the target AT-URI for the new community event
238 let migrated_aturi = format!("at://{}/{}/{}", profile.did, COMMUNITY_NSID, event_rkey);
239
240 // Check if a record already exists at the target AT-URI
241 let existing_event = event_get(&web_context.pool, &migrated_aturi).await;
242 if existing_event.is_ok() {
243 return Ok(renderer.render_error(
244 MigrateEventError::DestinationExists,
245 template_context! {
246 current_handle => current_handle.handle.as_str(),
247 canonical_url => canonical_url,
248 },
249 ));
250 }
251
252 // Set up XRPC client
253 // Error if we don't have auth data
254 let auth_data = auth.1.ok_or(MigrateEventError::NotAuthorized)?;
255 let client_auth: SimpleOAuthSessionProvider = SimpleOAuthSessionProvider::try_from(auth_data)?;
256
257 let client = OAuthPdsClient {
258 http_client: &web_context.http_client,
259 pds: ¤t_handle.pds,
260 };
261
262 // Create the community event record in the user's PDS using putRecord to retain the same rkey
263 let update_record_request = PutRecordRequest {
264 repo: current_handle.did.clone(),
265 collection: COMMUNITY_NSID.to_string(),
266 record_key: event_rkey.clone(),
267 record: new_event.clone(),
268 validate: false,
269 swap_commit: None,
270 swap_record: None, // We're creating a new record, not replacing
271 };
272
273 // Write to the PDS
274 let update_record_result = client.put_record(&client_auth, update_record_request).await;
275 if let Err(err) = update_record_result {
276 return Ok(renderer.render_error(
277 err,
278 template_context! {
279 current_handle => current_handle.handle.as_str(),
280 canonical_url => canonical_url,
281 },
282 ));
283 }
284 // update_record_result is guaranteed to be Ok at this point since we checked for Err above
285 let update_record_result = update_record_result.unwrap();
286
287 // We already have the migrated AT-URI defined above
288
289 // Insert the migrated event into the database
290 let migrated_event_insert_result = event_insert_with_metadata(
291 &web_context.pool,
292 &migrated_aturi,
293 &update_record_result.cid,
294 ¤t_handle.did,
295 COMMUNITY_NSID,
296 &new_event,
297 &name,
298 )
299 .await;
300
301 if let Err(err) = migrated_event_insert_result {
302 return Ok(renderer.render_error(
303 err,
304 template_context! {
305 current_handle => current_handle.handle.as_str(),
306 canonical_url => canonical_url,
307 },
308 ));
309 }
310
311 // Generate URL for the migrated event
312 let migrated_event_url = url_from_aturi(&web_context.config.external_base, &migrated_aturi)?;
313
314 // Return success with migration complete template
315 Ok(renderer.render_template(
316 "migrate_event",
317 template_context! {
318 migrated_event_url => migrated_event_url,
319 source_aturi => source_aturi,
320 migrated_aturi => migrated_aturi,
321 event_name => name,
322 source_lexicon => SMOKESIGNAL_NSID,
323 target_lexicon => COMMUNITY_NSID,
324 },
325 Some(¤t_handle),
326 &canonical_url,
327 ))
328}