//! 运行时写入命令。 //! //! 用于表达保存快照、更新设置、写入浏览历史、调整钱包和保存存档等输入。 use std::collections::HashSet; use serde_json::Value; use shared_kernel::{ normalize_optional_string, normalize_required_string, parse_rfc3339 as parse_shared_rfc3339, }; use crate::domain::*; use crate::errors::*; use crate::{format_utc_micros, runtime_profile_recharge_product_by_id}; pub const PROFILE_USER_TAG_MAX_COUNT: usize = 8; pub const PROFILE_USER_TAG_MAX_CHARS: usize = 16; // 统一把共享必填字符串归一化映射到 runtime 各自的字段错误,避免输入构造函数重复 trim + 判空。 fn normalize_runtime_settings_user_id( user_id: String, ) -> Result { normalize_required_string(user_id).ok_or(RuntimeSettingsFieldError::MissingUserId) } fn normalize_runtime_browse_history_user_id( user_id: String, ) -> Result { normalize_required_string(user_id).ok_or(RuntimeBrowseHistoryFieldError::MissingUserId) } fn normalize_runtime_profile_user_id(user_id: String) -> Result { normalize_required_string(user_id).ok_or(RuntimeProfileFieldError::MissingUserId) } pub fn build_runtime_setting_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_settings_user_id(user_id)?; Ok(RuntimeSettingGetInput { user_id }) } pub fn build_runtime_setting_upsert_input( user_id: String, music_volume: f32, platform_theme: RuntimePlatformTheme, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_settings_user_id(user_id)?; let normalized = RuntimeSettings::normalized(music_volume, platform_theme); Ok(RuntimeSettingUpsertInput { user_id, music_volume: normalized.music_volume, platform_theme: normalized.platform_theme, updated_at_micros, }) } pub fn build_runtime_browse_history_list_input( user_id: String, ) -> Result { let user_id = normalize_runtime_browse_history_user_id(user_id)?; Ok(RuntimeBrowseHistoryListInput { user_id }) } pub fn build_runtime_profile_dashboard_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileDashboardGetInput { user_id }) } pub fn build_runtime_profile_wallet_ledger_list_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileWalletLedgerListInput { user_id }) } pub fn build_runtime_tracking_event_input( event_id: String, event_key: String, scope_kind: RuntimeTrackingScopeKind, scope_id: String, user_id: Option, owner_user_id: Option, profile_id: Option, module_key: Option, metadata_json: String, occurred_at_micros: i64, ) -> Result { let event_id = normalize_required_string(event_id) .ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?; let event_key = normalize_required_string(event_key) .ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?; let scope_id = normalize_required_string(scope_id) .ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?; let metadata_json = normalize_tracking_metadata_json(metadata_json)?; Ok(RuntimeTrackingEventInput { event_id, event_key, scope_kind, scope_id, user_id: normalize_optional_string(user_id), owner_user_id: normalize_optional_string(owner_user_id), profile_id: normalize_optional_string(profile_id), module_key: normalize_optional_string(module_key), metadata_json, occurred_at_micros, }) } pub fn build_runtime_profile_task_center_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileTaskCenterGetInput { user_id }) } pub fn build_analytics_metric_query_input( event_key: String, scope_kind: RuntimeTrackingScopeKind, scope_id: String, granularity: AnalyticsGranularity, ) -> Result { let event_key = normalize_required_string(event_key) .ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?; let scope_id = normalize_required_string(scope_id) .ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?; Ok(AnalyticsMetricQueryInput { event_key, scope_kind, scope_id, granularity, }) } pub fn build_runtime_profile_task_claim_input( user_id: String, task_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let task_id = normalize_profile_task_id(task_id)?; Ok(RuntimeProfileTaskClaimInput { user_id, task_id }) } pub fn build_runtime_profile_task_config_admin_list_input( admin_user_id: String, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; Ok(RuntimeProfileTaskConfigAdminListInput { admin_user_id }) } #[allow(clippy::too_many_arguments)] pub fn build_runtime_profile_task_config_admin_upsert_input( admin_user_id: String, task_id: String, title: String, description: String, event_key: String, cycle: RuntimeProfileTaskCycle, scope_kind: RuntimeTrackingScopeKind, threshold: u32, reward_points: u64, enabled: bool, sort_order: i32, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let task_id = normalize_profile_task_id(task_id)?; let title = normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?; let event_key = normalize_required_string(event_key) .ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?; // 中文注释:个人任务首版只按用户维度累计,避免 site/work/module 误复用用户桶。 if scope_kind != RuntimeTrackingScopeKind::User { return Err(RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind); } if threshold == 0 { return Err(RuntimeProfileFieldError::InvalidTaskThreshold); } if reward_points == 0 || reward_points > i64::MAX as u64 { return Err(RuntimeProfileFieldError::InvalidTaskReward); } Ok(RuntimeProfileTaskConfigAdminUpsertInput { admin_user_id, task_id, title, description: normalize_optional_string(Some(description)).unwrap_or_default(), event_key, cycle, scope_kind, threshold, reward_points, enabled, sort_order, updated_at_micros, }) } pub fn build_runtime_profile_task_config_admin_disable_input( admin_user_id: String, task_id: String, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let task_id = normalize_profile_task_id(task_id)?; Ok(RuntimeProfileTaskConfigAdminDisableInput { admin_user_id, task_id, updated_at_micros, }) } pub fn build_runtime_profile_wallet_adjustment_input( user_id: String, amount: u64, ledger_id: String, created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let ledger_id = normalize_required_string(ledger_id).ok_or(RuntimeProfileFieldError::MissingLedgerId)?; if amount == 0 || amount > i64::MAX as u64 { return Err(RuntimeProfileFieldError::InvalidWalletAmount); } Ok(RuntimeProfileWalletAdjustmentInput { user_id, amount, ledger_id, created_at_micros, }) } pub fn build_runtime_profile_recharge_center_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileRechargeCenterGetInput { user_id }) } pub fn build_runtime_profile_recharge_order_create_input( user_id: String, product_id: String, payment_channel: String, created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let product_id = normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?; if runtime_profile_recharge_product_by_id(&product_id).is_none() { return Err(RuntimeProfileFieldError::UnknownRechargeProduct); } let payment_channel = normalize_required_string(payment_channel) .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); Ok(RuntimeProfileRechargeOrderCreateInput { user_id, product_id, payment_channel, created_at_micros, }) } pub fn build_runtime_profile_feedback_submission_input( user_id: String, description: String, contact_phone: Option, evidence_items: Vec, created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let description = normalize_required_string(description) .ok_or(RuntimeProfileFieldError::MissingFeedbackDescription)?; let description_chars = description.chars().count(); if description_chars < PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS { return Err(RuntimeProfileFieldError::FeedbackDescriptionTooShort); } if description_chars > PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS { return Err(RuntimeProfileFieldError::FeedbackDescriptionTooLong); } let contact_phone = normalize_optional_string(contact_phone); if contact_phone .as_deref() .map(|value| value.chars().count() > PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS) .unwrap_or(false) { return Err(RuntimeProfileFieldError::FeedbackContactPhoneTooLong); } if evidence_items.len() > PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT { return Err(RuntimeProfileFieldError::TooManyFeedbackEvidenceItems); } let feedback_id = build_runtime_profile_feedback_submission_id(&user_id, created_at_micros); let mut total_size_bytes = 0u64; let mut normalized_evidence_items = Vec::with_capacity(evidence_items.len()); for (index, item) in evidence_items.into_iter().enumerate() { let content_type = normalize_required_string(item.content_type) .map(|value| value.to_ascii_lowercase()) .ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType)?; if !is_profile_feedback_image_content_type(&content_type) { return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType); } if item.size_bytes == 0 || item.size_bytes > PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES { return Err(RuntimeProfileFieldError::FeedbackEvidenceTooLarge); } total_size_bytes = total_size_bytes .checked_add(item.size_bytes) .ok_or(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge)?; if total_size_bytes > PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES { return Err(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge); } let data_url = normalize_required_string(item.data_url) .ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)?; if !has_profile_feedback_data_url_prefix(&data_url, &content_type) { return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl); } let file_name = normalize_optional_string(Some(item.file_name)) .unwrap_or_else(|| format!("feedback-image-{}", index + 1)); normalized_evidence_items.push(RuntimeProfileFeedbackEvidenceSnapshot { evidence_id: build_runtime_profile_feedback_evidence_id(&feedback_id, index), file_name, content_type, size_bytes: item.size_bytes, data_url, }); } Ok(RuntimeProfileFeedbackSubmissionInput { user_id, description, contact_phone, evidence_items: normalized_evidence_items, created_at_micros, }) } pub fn build_runtime_referral_invite_center_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeReferralInviteCenterGetInput { user_id }) } pub fn build_runtime_referral_redeem_input( user_id: String, invite_code: String, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let invite_code = normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?; Ok(RuntimeReferralRedeemInput { user_id, invite_code, updated_at_micros, }) } pub fn build_runtime_profile_reward_code_redeem_input( user_id: String, code: String, redeemed_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; Ok(RuntimeProfileRewardCodeRedeemInput { user_id, code, redeemed_at_micros, }) } pub fn build_runtime_profile_redeem_code_admin_upsert_input( admin_user_id: String, code: String, mode: RuntimeProfileRedeemCodeMode, reward_points: u64, max_uses: u32, enabled: bool, allowed_user_ids: Vec, allowed_public_user_codes: Vec, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; if reward_points == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); } if max_uses == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); } Ok(RuntimeProfileRedeemCodeAdminUpsertInput { admin_user_id, code, mode, reward_points, max_uses, enabled, allowed_user_ids: allowed_user_ids .into_iter() .filter_map(|value| normalize_optional_string(Some(value))) .collect(), allowed_public_user_codes: allowed_public_user_codes .into_iter() .filter_map(|value| normalize_optional_string(Some(value))) .collect(), updated_at_micros, }) } pub fn build_runtime_profile_redeem_code_admin_list_input( admin_user_id: String, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; Ok(RuntimeProfileRedeemCodeAdminListInput { admin_user_id }) } pub fn build_runtime_profile_invite_code_admin_upsert_input( admin_user_id: String, invite_code: String, metadata_json: String, granted_user_tags: Vec, starts_at_micros: Option, expires_at_micros: Option, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let invite_code = normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?; let metadata_json = normalize_invite_code_metadata_json(metadata_json)?; let granted_user_tags = normalize_profile_user_tags(granted_user_tags)?; crate::commands::validate_runtime_profile_invite_code_validity_window( starts_at_micros, expires_at_micros, )?; Ok(RuntimeProfileInviteCodeAdminUpsertInput { admin_user_id, invite_code, metadata_json, granted_user_tags, starts_at_micros, expires_at_micros, updated_at_micros, }) } pub fn build_runtime_profile_invite_code_admin_list_input( admin_user_id: String, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; Ok(RuntimeProfileInviteCodeAdminListInput { admin_user_id }) } pub fn build_runtime_profile_redeem_code_admin_disable_input( admin_user_id: String, code: String, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; let code = normalize_redeem_code(code).ok_or(RuntimeProfileFieldError::MissingRedeemCode)?; Ok(RuntimeProfileRedeemCodeAdminDisableInput { admin_user_id, code, updated_at_micros, }) } pub fn build_runtime_profile_play_stats_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfilePlayStatsGetInput { user_id }) } pub fn build_runtime_snapshot_get_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeSnapshotGetInput { user_id }) } pub fn build_runtime_snapshot_delete_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeSnapshotDeleteInput { user_id }) } pub fn build_runtime_profile_save_archive_list_input( user_id: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; Ok(RuntimeProfileSaveArchiveListInput { user_id }) } pub fn build_runtime_profile_save_archive_resume_input( user_id: String, world_key: String, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let world_key = normalize_required_string(world_key).ok_or(RuntimeProfileFieldError::MissingWorldKey)?; Ok(RuntimeProfileSaveArchiveResumeInput { user_id, world_key }) } pub fn build_runtime_save_checkpoint_input( session_id: String, bottom_tab: String, saved_at_micros: i64, updated_at_micros: i64, ) -> Result { let session_id = normalize_required_string(session_id) .ok_or(RuntimeProfileFieldError::MissingCheckpointSessionId)?; let bottom_tab = normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?; Ok(RuntimeSaveCheckpointInput { session_id, bottom_tab, saved_at_micros, updated_at_micros, }) } pub fn build_runtime_browse_history_clear_input( user_id: String, ) -> Result { let user_id = normalize_runtime_browse_history_user_id(user_id)?; Ok(RuntimeBrowseHistoryClearInput { user_id }) } pub fn build_runtime_snapshot_upsert_input( user_id: String, saved_at_micros: i64, bottom_tab: String, game_state: Value, current_story: Option, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let bottom_tab = normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?; let game_state_json = serde_json::to_string(&game_state) .map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?; let current_story_json = normalize_current_story_json(current_story)?; Ok(RuntimeSnapshotUpsertInput { user_id, saved_at_micros, bottom_tab, game_state_json, current_story_json, updated_at_micros, }) } pub fn build_runtime_browse_history_sync_input( user_id: String, entries: Vec, updated_at_micros: i64, ) -> Result { let user_id = normalize_runtime_browse_history_user_id(user_id)?; if entries.len() > MAX_BROWSE_HISTORY_BATCH_SIZE { return Err(RuntimeBrowseHistoryFieldError::TooManyEntries); } let mut normalized_entries = Vec::with_capacity(entries.len()); for entry in entries { let Some(owner_user_id) = normalize_required_string(entry.owner_user_id) else { continue; }; let Some(profile_id) = normalize_required_string(entry.profile_id) else { continue; }; let Some(world_name) = normalize_required_string(entry.world_name) else { continue; }; // 与旧 Node 仓储保持一致:单条缺少关键字段时静默过滤,不让整批请求失败。 let visited_at_micros = entry .visited_at .as_deref() .and_then(parse_utc_rfc3339_to_micros) .unwrap_or(updated_at_micros); normalized_entries.push(RuntimeBrowseHistoryWriteInput { owner_user_id, profile_id, world_name, subtitle: normalize_optional_string(entry.subtitle), summary_text: normalize_optional_string(entry.summary_text), cover_image_src: normalize_optional_string(entry.cover_image_src), theme_mode: normalize_optional_string(entry.theme_mode), author_display_name: normalize_optional_string(entry.author_display_name), // 统一把 visitedAt 收口成 RFC3339,避免后续排序与回包格式继续漂移。 visited_at: Some(format_utc_micros(visited_at_micros)), }); } Ok(RuntimeBrowseHistorySyncInput { user_id, entries: normalized_entries, updated_at_micros, }) } pub fn prepare_runtime_browse_history_entries( input: RuntimeBrowseHistorySyncInput, ) -> Result, RuntimeBrowseHistoryFieldError> { let validated_input = build_runtime_browse_history_sync_input( input.user_id, input.entries, input.updated_at_micros, )?; let mut prepared_entries = validated_input .entries .into_iter() .map(|entry| { let visited_at_micros = entry .visited_at .as_deref() .and_then(parse_utc_rfc3339_to_micros) .unwrap_or(validated_input.updated_at_micros); RuntimeBrowseHistoryPreparedEntry { browse_history_id: build_runtime_browse_history_id( &validated_input.user_id, &entry.owner_user_id, &entry.profile_id, ), user_id: validated_input.user_id.clone(), owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, world_name: entry.world_name, subtitle: entry.subtitle.unwrap_or_default(), summary_text: entry.summary_text.unwrap_or_default(), cover_image_src: entry.cover_image_src, theme_mode: RuntimeBrowseHistoryThemeMode::from_client_str( entry.theme_mode.as_deref().unwrap_or("mythic"), ), author_display_name: entry .author_display_name .unwrap_or_else(|| DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME.to_string()), visited_at_micros, updated_at_micros: validated_input.updated_at_micros, } }) .collect::>(); // 与旧 Node 仓储保持一致:先按 visitedAt 倒序,再按 owner/profile 去重,只保留最近一次访问。 prepared_entries.sort_by(|left, right| { right .visited_at_micros .cmp(&left.visited_at_micros) .then_with(|| left.browse_history_id.cmp(&right.browse_history_id)) }); let mut seen_ids = HashSet::new(); prepared_entries.retain(|entry| seen_ids.insert(entry.browse_history_id.clone())); Ok(prepared_entries) } pub fn build_runtime_browse_history_id( user_id: &str, owner_user_id: &str, profile_id: &str, ) -> String { format!("{user_id}:{owner_user_id}:{profile_id}") } pub fn build_runtime_profile_feedback_submission_id( user_id: &str, created_at_micros: i64, ) -> String { format!("feedback:{}:{}", user_id.trim(), created_at_micros) } pub fn build_runtime_profile_feedback_evidence_id(feedback_id: &str, index: usize) -> String { format!("{}:evidence:{:02}", feedback_id.trim(), index + 1) } fn parse_utc_rfc3339_to_micros(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { return None; } let nanos = parse_shared_rfc3339(trimmed).ok()?.unix_timestamp_nanos(); i64::try_from(nanos / 1_000).ok() } fn normalize_bottom_tab(value: String) -> Option { let trimmed = normalize_required_string(value)?; let normalized = match trimmed.as_str() { "character" | "inventory" => trimmed, _ => "adventure".to_string(), }; Some(normalized) } fn normalize_current_story_json( current_story: Option, ) -> Result, RuntimeProfileFieldError> { let Some(current_story) = current_story else { return Ok(None); }; if !current_story.is_object() { return Ok(None); } serde_json::to_string(¤t_story) .map(Some) .map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson) } fn is_profile_feedback_image_content_type(value: &str) -> bool { matches!( value, "image/png" | "image/jpeg" | "image/jpg" | "image/webp" | "image/gif" ) } fn has_profile_feedback_data_url_prefix(data_url: &str, content_type: &str) -> bool { data_url .to_ascii_lowercase() .starts_with(&format!("data:{content_type};base64,")) } pub fn normalize_invite_code(value: String) -> Option { let normalized = value .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .map(|character| character.to_ascii_uppercase()) .collect::(); if normalized.is_empty() { None } else { Some(normalized) } } pub fn normalize_redeem_code(value: String) -> Option { normalize_invite_code(value) } pub fn normalize_invite_code_metadata_json( value: String, ) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()); } if trimmed.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES { return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); } let parsed = serde_json::from_str::(trimmed) .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?; if !parsed.is_object() { return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); } serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata) } pub fn normalize_profile_user_tags( values: Vec, ) -> Result, RuntimeProfileFieldError> { let mut tags = Vec::new(); for value in values { let Some(tag) = normalize_optional_string(Some(value)) else { continue; }; if tag.chars().count() > PROFILE_USER_TAG_MAX_CHARS { return Err(RuntimeProfileFieldError::InvalidUserTag); } if !tags.iter().any(|existing| existing == &tag) { tags.push(tag); } if tags.len() > PROFILE_USER_TAG_MAX_COUNT { return Err(RuntimeProfileFieldError::InvalidUserTag); } } Ok(tags) } pub fn validate_runtime_profile_invite_code_validity_window( starts_at_micros: Option, expires_at_micros: Option, ) -> Result<(), RuntimeProfileFieldError> { if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at) { return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow); } Ok(()) } pub fn resolve_runtime_profile_invite_code_status( starts_at_micros: Option, expires_at_micros: Option, now_micros: i64, ) -> RuntimeProfileInviteCodeStatus { if starts_at_micros .map(|starts_at| now_micros < starts_at) .unwrap_or(false) { return RuntimeProfileInviteCodeStatus::Pending; } if expires_at_micros .map(|expires_at| now_micros >= expires_at) .unwrap_or(false) { return RuntimeProfileInviteCodeStatus::Expired; } RuntimeProfileInviteCodeStatus::Active } fn normalize_tracking_metadata_json(value: String) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()); } let parsed = serde_json::from_str::(trimmed) .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?; if !parsed.is_object() { return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata); } serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata) } fn normalize_profile_task_id(value: String) -> Result { normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId) }