1use crate::{
2 AttachmentUpload, AudioInput, CreateFeedbackInput, CreateFeedbackResult, DeveloperReplyInput,
3 FeedbackAccessToken, FeedbackAiContext, FeedbackAttachment, FeedbackAttachmentKind, FeedbackId,
4 FeedbackListFilter, FeedbackMessage, FeedbackMessageSource, FeedbackMutationResult,
5 FeedbackReleaseBinding, FeedbackStatus, FeedbackStoreError, FeedbackStoreService,
6 FeedbackSummary, FeedbackThread, FeedbackValidationError, FeedbackWarning, Transcript,
7 TranscriptionError, TranscriptionService, TransitionFeedbackInput, hash_access_token,
8};
9use chrono::{TimeDelta, Utc};
10use minco_plugin_audit::{AuditEvent, AuditService};
11use minco_plugin_events::{DomainEvent, EventServices, OutboxRecord};
12use minco_plugin_notifications::{Notification, NotificationChannel, NotificationService};
13use minco_plugin_object_storage::{
14 ObjectKey, ObjectStoreError, ObjectStoreService, PutObject, StoredObject,
15};
16use serde::{Deserialize, Serialize};
17use std::{collections::BTreeMap, sync::Arc};
18use uuid::Uuid;
19
20pub const FEEDBACK_BASE_PATH: &str = "/_minco/feedback";
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum FeedbackWidgetPosition {
25 TopLeft,
26 TopRight,
27 BottomLeft,
28 #[default]
29 BottomRight,
30}
31
32impl FeedbackWidgetPosition {
33 #[must_use]
34 pub const fn as_str(self) -> &'static str {
35 match self {
36 Self::TopLeft => "top_left",
37 Self::TopRight => "top_right",
38 Self::BottomLeft => "bottom_left",
39 Self::BottomRight => "bottom_right",
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum FeedbackWidgetTheme {
47 Light,
48 Dark,
49 #[default]
50 Auto,
51}
52
53impl FeedbackWidgetTheme {
54 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::Light => "light",
58 Self::Dark => "dark",
59 Self::Auto => "auto",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum FeedbackTokenStorage {
72 #[default]
73 Session,
74 Local,
75}
76
77impl FeedbackTokenStorage {
78 #[must_use]
79 pub const fn as_str(self) -> &'static str {
80 match self {
81 Self::Session => "session",
82 Self::Local => "local",
83 }
84 }
85}
86
87#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[allow(clippy::struct_excessive_bools)]
97pub struct FeedbackConfig {
98 pub project_id: String,
99 #[serde(default = "default_widget_label")]
100 pub widget_label: String,
101 #[serde(default)]
102 pub widget_position: FeedbackWidgetPosition,
103 #[serde(default = "default_offset")]
104 pub offset_x_px: u16,
105 #[serde(default = "default_offset")]
106 pub offset_y_px: u16,
107 #[serde(default)]
108 pub theme: FeedbackWidgetTheme,
109 #[serde(default)]
110 pub token_storage: FeedbackTokenStorage,
111 #[serde(default = "default_http_body_limit")]
112 pub max_http_body_bytes: usize,
113 #[serde(default = "default_screenshot_limit")]
114 pub max_screenshot_bytes: usize,
115 #[serde(default = "default_audio_limit")]
116 pub max_audio_bytes: usize,
117 #[serde(default = "default_file_limit")]
118 pub max_file_bytes: usize,
119 #[serde(default = "default_max_attachments")]
120 pub max_attachments: usize,
121 #[serde(default = "default_recording_seconds")]
122 pub max_recording_seconds: u32,
123 #[serde(default)]
124 pub project_key: Option<String>,
125 #[serde(default)]
126 pub allow_anonymous: bool,
127 #[serde(default)]
128 pub developer_token: Option<String>,
129 #[serde(default = "default_developer_recipient")]
130 pub developer_recipient: String,
131 #[serde(default)]
132 pub developer_link_base: Option<String>,
133 #[serde(default = "default_true")]
134 pub notify_client_updates: bool,
135 #[serde(default)]
138 pub publish_events_inline: bool,
139 #[serde(default)]
140 pub transcription_enabled: bool,
141 #[serde(default)]
142 pub auto_transcribe_audio: bool,
143 #[serde(default = "default_true")]
144 pub screenshot_enabled: bool,
145 #[serde(default)]
146 pub voice_enabled: bool,
147 #[serde(default)]
150 pub include_url_query: bool,
151 #[serde(default = "default_redacted_query_parameters")]
152 pub redact_query_parameters: Vec<String>,
153 #[serde(default = "default_poll_interval")]
154 pub poll_interval_ms: u64,
155 #[serde(default)]
156 pub privacy_notice: Option<String>,
157}
158
159impl std::fmt::Debug for FeedbackConfig {
160 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 formatter
162 .debug_struct("FeedbackConfig")
163 .field("project_id", &self.project_id)
164 .field("widget_label", &self.widget_label)
165 .field("widget_position", &self.widget_position)
166 .field("offset_x_px", &self.offset_x_px)
167 .field("offset_y_px", &self.offset_y_px)
168 .field("theme", &self.theme)
169 .field("token_storage", &self.token_storage)
170 .field("max_http_body_bytes", &self.max_http_body_bytes)
171 .field("max_screenshot_bytes", &self.max_screenshot_bytes)
172 .field("max_audio_bytes", &self.max_audio_bytes)
173 .field("max_file_bytes", &self.max_file_bytes)
174 .field("max_attachments", &self.max_attachments)
175 .field("max_recording_seconds", &self.max_recording_seconds)
176 .field(
177 "project_key",
178 &self.project_key.as_ref().map(|_| "[REDACTED]"),
179 )
180 .field("allow_anonymous", &self.allow_anonymous)
181 .field(
182 "developer_token",
183 &self.developer_token.as_ref().map(|_| "[REDACTED]"),
184 )
185 .field("developer_recipient", &self.developer_recipient)
186 .field(
187 "developer_link_base_configured",
188 &self.developer_link_base.is_some(),
189 )
190 .field("notify_client_updates", &self.notify_client_updates)
191 .field("publish_events_inline", &self.publish_events_inline)
192 .field("transcription_enabled", &self.transcription_enabled)
193 .field("auto_transcribe_audio", &self.auto_transcribe_audio)
194 .field("screenshot_enabled", &self.screenshot_enabled)
195 .field("voice_enabled", &self.voice_enabled)
196 .field("include_url_query", &self.include_url_query)
197 .field("redact_query_parameters", &self.redact_query_parameters)
198 .field("poll_interval_ms", &self.poll_interval_ms)
199 .field("privacy_notice", &self.privacy_notice)
200 .finish()
201 }
202}
203
204impl Default for FeedbackConfig {
205 fn default() -> Self {
206 Self {
207 project_id: "default".into(),
208 widget_label: default_widget_label(),
209 widget_position: FeedbackWidgetPosition::BottomRight,
210 offset_x_px: default_offset(),
211 offset_y_px: default_offset(),
212 theme: FeedbackWidgetTheme::Auto,
213 token_storage: FeedbackTokenStorage::Session,
214 max_http_body_bytes: default_http_body_limit(),
215 max_screenshot_bytes: default_screenshot_limit(),
216 max_audio_bytes: default_audio_limit(),
217 max_file_bytes: default_file_limit(),
218 max_attachments: default_max_attachments(),
219 max_recording_seconds: default_recording_seconds(),
220 project_key: None,
221 allow_anonymous: false,
222 developer_token: None,
223 developer_recipient: default_developer_recipient(),
224 developer_link_base: None,
225 notify_client_updates: true,
226 publish_events_inline: false,
227 transcription_enabled: false,
228 auto_transcribe_audio: false,
229 screenshot_enabled: true,
230 voice_enabled: false,
231 include_url_query: false,
232 redact_query_parameters: default_redacted_query_parameters(),
233 poll_interval_ms: default_poll_interval(),
234 privacy_notice: None,
235 }
236 }
237}
238
239impl FeedbackConfig {
240 pub fn validate(&self) -> Result<(), FeedbackServiceError> {
241 validate_config_text("project_id", &self.project_id, 100)?;
242 validate_config_text("widget_label", &self.widget_label, 80)?;
243 validate_config_text("developer_recipient", &self.developer_recipient, 200)?;
244 validate_optional_config_text("project_key", self.project_key.as_deref(), 500)?;
245 validate_optional_config_text(
246 "developer_link_base",
247 self.developer_link_base.as_deref(),
248 2_000,
249 )?;
250 validate_optional_config_text("privacy_notice", self.privacy_notice.as_deref(), 2_000)?;
251 if let Some(token) = self.developer_token.as_deref()
252 && token
253 .chars()
254 .filter(|character| !character.is_whitespace())
255 .count()
256 < 24
257 {
258 return Err(FeedbackServiceError::Configuration(
259 "developer_token must contain at least 24 non-whitespace characters".into(),
260 ));
261 }
262 if self.max_http_body_bytes < 256 * 1024 || self.max_http_body_bytes > 8 * 1024 * 1024 {
263 return Err(FeedbackServiceError::Configuration(
264 "max_http_body_bytes must be between 256 KiB and 8 MiB for the default serverless HTTP profile".into(),
265 ));
266 }
267 if self.max_screenshot_bytes == 0
268 || self.max_audio_bytes == 0
269 || self.max_file_bytes == 0
270 || self.max_screenshot_bytes > self.max_http_body_bytes
271 || self.max_audio_bytes > self.max_http_body_bytes
272 || self.max_file_bytes > self.max_http_body_bytes
273 {
274 return Err(FeedbackServiceError::Configuration(
275 "feedback attachment limits must be greater than zero and no larger than max_http_body_bytes".into(),
276 ));
277 }
278 if self.max_attachments > 8 {
279 return Err(FeedbackServiceError::Configuration(
280 "max_attachments must be between 0 and 8".into(),
281 ));
282 }
283 if !(5..=300).contains(&self.max_recording_seconds) {
284 return Err(FeedbackServiceError::Configuration(
285 "max_recording_seconds must be between 5 and 300".into(),
286 ));
287 }
288 if !(1_000..=300_000).contains(&self.poll_interval_ms) {
289 return Err(FeedbackServiceError::Configuration(
290 "poll_interval_ms must be between 1000 and 300000".into(),
291 ));
292 }
293 if self.auto_transcribe_audio && !self.transcription_enabled {
294 return Err(FeedbackServiceError::Configuration(
295 "auto_transcribe_audio requires transcription_enabled".into(),
296 ));
297 }
298 if self.transcription_enabled && (self.allow_anonymous || self.project_key.is_some()) {
299 return Err(FeedbackServiceError::Configuration(
300 "transcription_enabled requires authenticated feedback.create submissions; it cannot be combined with allow_anonymous or project_key".into(),
301 ));
302 }
303 if self.redact_query_parameters.iter().any(|value| {
304 value.trim().is_empty() || value.len() > 100 || value.chars().any(char::is_control)
305 }) {
306 return Err(FeedbackServiceError::Configuration(
307 "redact_query_parameters entries must contain 1-100 visible characters".into(),
308 ));
309 }
310 Ok(())
311 }
312
313 #[must_use]
314 pub fn widget_config(&self) -> FeedbackWidgetConfig {
315 FeedbackWidgetConfig {
316 enabled: true,
317 project_id: self.project_id.clone(),
318 label: self.widget_label.clone(),
319 position: self.widget_position.as_str().replace('_', "-"),
320 offset_x_px: self.offset_x_px,
321 offset_y_px: self.offset_y_px,
322 theme: self.theme.as_str().into(),
323 token_storage: self.token_storage.as_str().into(),
324 screenshot_enabled: self.screenshot_enabled,
325 voice_enabled: self.voice_enabled,
326 transcription_enabled: self.transcription_enabled,
327 max_http_body_bytes: self.max_http_body_bytes,
328 max_screenshot_bytes: self.max_screenshot_bytes,
329 max_audio_bytes: self.max_audio_bytes,
330 max_file_bytes: self.max_file_bytes,
331 max_attachments: self.max_attachments,
332 max_recording_seconds: self.max_recording_seconds,
333 include_url_query: self.include_url_query,
334 redact_query_parameters: self.redact_query_parameters.clone(),
335 poll_interval_ms: self.poll_interval_ms,
336 privacy_notice: self.privacy_notice.clone(),
337 }
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[allow(clippy::struct_excessive_bools)]
344pub struct FeedbackWidgetConfig {
345 pub enabled: bool,
346 pub project_id: String,
347 pub label: String,
348 pub position: String,
349 pub offset_x_px: u16,
350 pub offset_y_px: u16,
351 pub theme: String,
352 pub token_storage: String,
353 pub screenshot_enabled: bool,
354 pub voice_enabled: bool,
355 pub transcription_enabled: bool,
356 pub max_http_body_bytes: usize,
357 pub max_screenshot_bytes: usize,
358 pub max_audio_bytes: usize,
359 pub max_file_bytes: usize,
360 pub max_attachments: usize,
361 pub max_recording_seconds: u32,
362 pub include_url_query: bool,
363 pub redact_query_parameters: Vec<String>,
364 pub poll_interval_ms: u64,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub privacy_notice: Option<String>,
367}
368
369const fn default_offset() -> u16 {
370 24
371}
372
373const fn default_http_body_limit() -> usize {
374 7 * 1024 * 1024
375}
376
377const fn default_screenshot_limit() -> usize {
378 4 * 1024 * 1024
379}
380
381const fn default_audio_limit() -> usize {
382 5 * 1024 * 1024
383}
384
385const fn default_file_limit() -> usize {
386 5 * 1024 * 1024
387}
388
389const fn default_max_attachments() -> usize {
390 3
391}
392
393const fn default_recording_seconds() -> u32 {
394 90
395}
396
397fn default_redacted_query_parameters() -> Vec<String> {
398 [
399 "access_token",
400 "api_key",
401 "code",
402 "key",
403 "password",
404 "secret",
405 "signature",
406 "token",
407 ]
408 .into_iter()
409 .map(str::to_owned)
410 .collect()
411}
412
413const fn default_poll_interval() -> u64 {
414 15_000
415}
416
417fn default_widget_label() -> String {
418 "Share feedback".into()
419}
420
421fn default_developer_recipient() -> String {
422 "developers".into()
423}
424
425const fn default_true() -> bool {
426 true
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430enum NotificationAudience {
431 None,
432 Developer,
433 Client,
434}
435
436#[derive(Clone)]
437pub struct FeedbackService {
438 store: FeedbackStoreService,
439 objects: ObjectStoreService,
440 notifications: NotificationService,
441 audit: AuditService,
442 events: EventServices,
443 transcription: Option<TranscriptionService>,
444 config: Arc<FeedbackConfig>,
445 release_binding: Option<Arc<FeedbackReleaseBinding>>,
446}
447
448impl std::fmt::Debug for FeedbackService {
449 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 formatter
451 .debug_struct("FeedbackService")
452 .field("config", &self.config)
453 .field("transcription_configured", &self.transcription.is_some())
454 .field("release_bound", &self.release_binding.is_some())
455 .finish_non_exhaustive()
456 }
457}
458
459impl FeedbackService {
460 pub fn new(
461 store: FeedbackStoreService,
462 objects: ObjectStoreService,
463 notifications: NotificationService,
464 audit: AuditService,
465 events: EventServices,
466 transcription: Option<TranscriptionService>,
467 config: FeedbackConfig,
468 ) -> Result<Self, FeedbackServiceError> {
469 config.validate()?;
470 if config.transcription_enabled && transcription.is_none() {
471 return Err(FeedbackServiceError::Configuration(
472 "transcription_enabled requires a TranscriptionService".into(),
473 ));
474 }
475 Ok(Self {
476 store,
477 objects,
478 notifications,
479 audit,
480 events,
481 transcription,
482 config: Arc::new(config),
483 release_binding: None,
484 })
485 }
486
487 pub fn with_release_binding(
488 mut self,
489 binding: FeedbackReleaseBinding,
490 ) -> Result<Self, FeedbackServiceError> {
491 binding.validate()?;
492 self.release_binding = Some(Arc::new(binding));
493 Ok(self)
494 }
495
496 #[must_use]
497 pub fn release_binding(&self) -> Option<&FeedbackReleaseBinding> {
498 self.release_binding.as_deref()
499 }
500
501 #[must_use]
502 pub fn config(&self) -> &FeedbackConfig {
503 &self.config
504 }
505
506 pub async fn ready(&self) -> Result<(), FeedbackServiceError> {
507 self.store.ready().await?;
508 Ok(())
509 }
510
511 pub async fn create(
512 &self,
513 mut input: CreateFeedbackInput,
514 uploads: Vec<AttachmentUpload>,
515 correlation_id: Uuid,
516 ) -> Result<CreateFeedbackResult, FeedbackServiceError> {
517 if input.project_id.trim().is_empty() {
518 input.project_id.clone_from(&self.config.project_id);
519 }
520 if input.project_id != self.config.project_id {
521 return Err(FeedbackValidationError::InvalidField {
522 field: "project_id",
523 detail: "does not match the configured feedback project".into(),
524 }
525 .into());
526 }
527
528 if let Some(binding) = self.release_binding.as_deref() {
529 input.context.release_id = Some(binding.release_id.clone());
530 input.context.environment = Some(binding.environment.clone());
531 }
532
533 if uploads.len() > self.config.max_attachments {
534 return Err(FeedbackServiceError::InvalidAttachment(format!(
535 "feedback contains {} attachments; configured maximum is {}",
536 uploads.len(),
537 self.config.max_attachments
538 )));
539 }
540 let aggregate_bytes = uploads.iter().try_fold(0_usize, |total, upload| {
541 total.checked_add(upload.bytes.len()).ok_or_else(|| {
542 FeedbackServiceError::InvalidAttachment(
543 "aggregate attachment size exceeds the platform address space".into(),
544 )
545 })
546 })?;
547 if aggregate_bytes > self.config.max_http_body_bytes {
548 return Err(FeedbackServiceError::InvalidAttachment(format!(
549 "aggregate attachment payload is {aggregate_bytes} bytes; configured HTTP body ceiling is {} bytes",
550 self.config.max_http_body_bytes
551 )));
552 }
553
554 let mut thread = FeedbackThread::create(input)?;
555 if let Some(binding) = self.release_binding.as_deref() {
556 thread.messages.push(binding.system_message()?);
557 }
558 let client_token = FeedbackAccessToken::generate();
559 let mut stored_keys = Vec::new();
560 let mut warnings = Vec::new();
561
562 for upload in uploads {
563 let (attachment, attachment_warnings) =
564 match self.store_attachment(thread.id, upload).await {
565 Ok(value) => value,
566 Err(error) => {
567 self.cleanup_objects(&stored_keys).await;
568 return Err(error);
569 }
570 };
571 stored_keys.push(ObjectKey::parse(attachment.object_key.clone())?);
572 if let Some(transcript) = attachment.transcript.clone() {
573 let message = match FeedbackMessage::new(
574 crate::FeedbackAuthorRole::Client,
575 None,
576 transcript,
577 FeedbackMessageSource::VoiceTranscript,
578 true,
579 ) {
580 Ok(message) => message,
581 Err(error) => {
582 self.cleanup_objects(&stored_keys).await;
583 return Err(error.into());
584 }
585 };
586 thread.append_message(message);
587 }
588 thread.add_attachment(attachment);
589 warnings.extend(attachment_warnings);
590 }
591
592 if let Err(error) = self
593 .store
594 .create(thread.clone(), hash_access_token(&client_token))
595 .await
596 {
597 self.cleanup_objects(&stored_keys).await;
598 return Err(error.into());
599 }
600
601 warnings.extend(self.record_created(&thread, correlation_id).await);
602 Ok(CreateFeedbackResult {
603 thread,
604 client_token,
605 warnings,
606 })
607 }
608
609 pub async fn get_for_client(
610 &self,
611 id: FeedbackId,
612 token: &FeedbackAccessToken,
613 ) -> Result<FeedbackThread, FeedbackServiceError> {
614 Ok(self.client_thread(id, token).await?.client_view())
615 }
616
617 pub async fn get_for_developer(
618 &self,
619 id: FeedbackId,
620 ) -> Result<FeedbackThread, FeedbackServiceError> {
621 let thread = self
622 .store
623 .get(id)
624 .await?
625 .ok_or(FeedbackServiceError::NotFound(id))?;
626 if thread.project_id != self.config.project_id {
627 return Err(FeedbackServiceError::NotFound(id));
628 }
629 Ok(thread)
630 }
631
632 pub async fn list(
633 &self,
634 mut filter: FeedbackListFilter,
635 ) -> Result<Vec<FeedbackSummary>, FeedbackServiceError> {
636 if filter
637 .project_id
638 .as_deref()
639 .is_some_and(|project_id| project_id != self.config.project_id)
640 {
641 return Err(FeedbackValidationError::InvalidField {
642 field: "project_id",
643 detail: "does not match the configured feedback project".into(),
644 }
645 .into());
646 }
647 filter.project_id = Some(self.config.project_id.clone());
648 Ok(self.store.list(filter).await?)
649 }
650
651 pub async fn reply_as_client(
652 &self,
653 id: FeedbackId,
654 token: &FeedbackAccessToken,
655 body: impl Into<String>,
656 correlation_id: Uuid,
657 ) -> Result<FeedbackMutationResult, FeedbackServiceError> {
658 let mut thread = self.client_thread(id, token).await?;
659 let expected_revision = thread.revision;
660 thread.append_message(FeedbackMessage::client(body)?);
661 if thread.status == FeedbackStatus::NeedsClarification {
662 thread.transition(FeedbackStatus::Acknowledged, None)?;
663 }
664 self.store.save(thread.clone(), expected_revision).await?;
665 let warnings = self
666 .record_mutation(
667 &thread,
668 "feedback.client_replied",
669 "Client replied to feedback",
670 NotificationAudience::Developer,
671 thread.context.client_subject.clone(),
672 correlation_id,
673 )
674 .await;
675 Ok(FeedbackMutationResult {
676 thread: thread.client_view(),
677 warnings,
678 })
679 }
680
681 pub async fn reply_as_developer(
682 &self,
683 id: FeedbackId,
684 input: DeveloperReplyInput,
685 actor_subject: String,
686 correlation_id: Uuid,
687 ) -> Result<FeedbackMutationResult, FeedbackServiceError> {
688 let mut thread = self.get_for_developer(id).await?;
689 let expected_revision = thread.revision;
690 thread.append_message(FeedbackMessage::developer(
691 input.author_display,
692 input.body,
693 input.visible_to_client,
694 )?);
695 if thread.status == FeedbackStatus::New {
696 thread.transition(FeedbackStatus::Acknowledged, None)?;
697 }
698 self.store.save(thread.clone(), expected_revision).await?;
699 let warnings = self
700 .record_mutation(
701 &thread,
702 "feedback.developer_replied",
703 "Developer replied to feedback",
704 if input.visible_to_client && self.config.notify_client_updates {
705 NotificationAudience::Client
706 } else {
707 NotificationAudience::None
708 },
709 Some(actor_subject),
710 correlation_id,
711 )
712 .await;
713 Ok(FeedbackMutationResult { thread, warnings })
714 }
715
716 pub async fn transition(
717 &self,
718 id: FeedbackId,
719 input: TransitionFeedbackInput,
720 actor_subject: String,
721 correlation_id: Uuid,
722 ) -> Result<FeedbackMutationResult, FeedbackServiceError> {
723 let mut thread = self.get_for_developer(id).await?;
724 let expected_revision = thread.revision;
725 let previous = thread.status;
726 thread.transition(input.status, input.resolution)?;
727 thread.append_message(FeedbackMessage::new(
728 crate::FeedbackAuthorRole::System,
729 input.author_display,
730 format!("Status changed from {previous} to {}", input.status),
731 FeedbackMessageSource::StatusChange,
732 true,
733 )?);
734 self.store.save(thread.clone(), expected_revision).await?;
735 let warnings = self
736 .record_mutation(
737 &thread,
738 "feedback.status_changed",
739 &format!("Feedback status changed to {}", input.status),
740 if self.config.notify_client_updates {
741 NotificationAudience::Client
742 } else {
743 NotificationAudience::None
744 },
745 Some(actor_subject),
746 correlation_id,
747 )
748 .await;
749 Ok(FeedbackMutationResult { thread, warnings })
750 }
751
752 pub async fn transcribe(&self, audio: AudioInput) -> Result<Transcript, FeedbackServiceError> {
753 if !self.config.voice_enabled {
754 return Err(FeedbackServiceError::Configuration(
755 "voice feedback is disabled for this deployment".into(),
756 ));
757 }
758 if !self.config.transcription_enabled {
759 return Err(TranscriptionError::NotConfigured.into());
760 }
761 self.validate_upload_size(FeedbackAttachmentKind::Audio, audio.bytes.len())?;
762 let service = self
763 .transcription
764 .as_ref()
765 .ok_or(TranscriptionError::NotConfigured)?;
766 Ok(service.transcribe(audio).await?)
767 }
768
769 pub async fn ai_context(
770 &self,
771 id: FeedbackId,
772 ) -> Result<FeedbackAiContext, FeedbackServiceError> {
773 Ok(FeedbackAiContext::from_thread(
774 self.get_for_developer(id).await?,
775 ))
776 }
777
778 pub async fn attachment_for_developer(
779 &self,
780 id: FeedbackId,
781 attachment_id: Uuid,
782 ) -> Result<StoredObject, FeedbackServiceError> {
783 let thread = self.get_for_developer(id).await?;
784 self.load_attachment(&thread, attachment_id).await
785 }
786
787 pub async fn attachment_for_client(
788 &self,
789 id: FeedbackId,
790 token: &FeedbackAccessToken,
791 attachment_id: Uuid,
792 ) -> Result<StoredObject, FeedbackServiceError> {
793 let thread = self.client_thread(id, token).await?;
794 self.load_attachment(&thread, attachment_id).await
795 }
796
797 async fn client_thread(
798 &self,
799 id: FeedbackId,
800 token: &FeedbackAccessToken,
801 ) -> Result<FeedbackThread, FeedbackServiceError> {
802 let thread = self
803 .store
804 .get_for_client(id, &hash_access_token(token))
805 .await?
806 .ok_or(FeedbackServiceError::ClientAccessDenied)?;
807 if thread.project_id != self.config.project_id {
808 return Err(FeedbackServiceError::ClientAccessDenied);
809 }
810 Ok(thread)
811 }
812
813 async fn load_attachment(
814 &self,
815 thread: &FeedbackThread,
816 attachment_id: Uuid,
817 ) -> Result<StoredObject, FeedbackServiceError> {
818 let attachment = thread
819 .attachments
820 .iter()
821 .find(|attachment| attachment.id == attachment_id)
822 .ok_or(FeedbackServiceError::AttachmentNotFound(attachment_id))?;
823 let key = ObjectKey::parse(attachment.object_key.clone())?;
824 self.objects
825 .get(&key)
826 .await?
827 .ok_or(FeedbackServiceError::AttachmentNotFound(attachment_id))
828 }
829
830 async fn store_attachment(
831 &self,
832 feedback_id: FeedbackId,
833 upload: AttachmentUpload,
834 ) -> Result<(FeedbackAttachment, Vec<FeedbackWarning>), FeedbackServiceError> {
835 self.validate_attachment(&upload)?;
836 let safe_name = safe_file_name(&upload.file_name);
837 let attachment_id = Uuid::now_v7();
838 let object_key = ObjectKey::parse(format!(
839 "feedback/{feedback_id}/{attachment_id}/{safe_name}"
840 ))?;
841 let mut attributes = BTreeMap::from([
842 ("feedback_id".into(), feedback_id.to_string()),
843 ("attachment_id".into(), attachment_id.to_string()),
844 ("file_name".into(), safe_name.clone()),
845 (
846 "kind".into(),
847 format!("{:?}", upload.kind).to_ascii_lowercase(),
848 ),
849 ]);
850 attributes.insert("project_id".into(), self.config.project_id.clone());
851 let metadata = self
852 .objects
853 .put(PutObject {
854 key: object_key.clone(),
855 bytes: upload.bytes.clone(),
856 content_type: upload.content_type.clone(),
857 attributes,
858 })
859 .await?;
860 let mut warnings = Vec::new();
861 let transcript = if upload.kind == FeedbackAttachmentKind::Audio
862 && self.config.transcription_enabled
863 && self.config.auto_transcribe_audio
864 {
865 match self
866 .transcription
867 .as_ref()
868 .ok_or(TranscriptionError::NotConfigured)?
869 .transcribe(AudioInput {
870 bytes: upload.bytes,
871 file_name: safe_name.clone(),
872 content_type: upload.content_type.clone(),
873 language: None,
874 prompt: Some("Transcribe client product feedback accurately.".into()),
875 })
876 .await
877 {
878 Ok(transcript) => Some(transcript.text),
879 Err(error) => {
880 warnings.push(downstream_warning(
881 "feedback_transcription_failed",
882 "Audio was stored, but automatic transcription did not complete.",
883 &error,
884 ));
885 None
886 }
887 }
888 } else {
889 None
890 };
891 Ok((
892 FeedbackAttachment {
893 id: attachment_id,
894 kind: upload.kind,
895 object_key: object_key.as_str().to_owned(),
896 file_name: safe_name,
897 content_type: upload.content_type,
898 size_bytes: metadata.size_bytes,
899 sha256: metadata.sha256,
900 created_at: metadata.created_at,
901 transcript,
902 },
903 warnings,
904 ))
905 }
906
907 fn validate_attachment(&self, upload: &AttachmentUpload) -> Result<(), FeedbackServiceError> {
908 match upload.kind {
909 FeedbackAttachmentKind::Screenshot if !self.config.screenshot_enabled => {
910 return Err(FeedbackServiceError::InvalidAttachment(
911 "screenshot feedback is disabled for this deployment".into(),
912 ));
913 }
914 FeedbackAttachmentKind::Audio if !self.config.voice_enabled => {
915 return Err(FeedbackServiceError::InvalidAttachment(
916 "voice feedback is disabled for this deployment".into(),
917 ));
918 }
919 _ => {}
920 }
921 self.validate_upload_size(upload.kind, upload.bytes.len())?;
922 if upload.file_name.trim().is_empty() || upload.file_name.chars().any(char::is_control) {
923 return Err(FeedbackServiceError::InvalidAttachment(
924 "attachment file name is invalid".into(),
925 ));
926 }
927 let content_type = upload.content_type.trim().to_ascii_lowercase();
928 if content_type.is_empty()
929 || (upload.kind == FeedbackAttachmentKind::Screenshot
930 && !content_type.starts_with("image/"))
931 || (upload.kind == FeedbackAttachmentKind::Audio && !content_type.starts_with("audio/"))
932 {
933 return Err(FeedbackServiceError::InvalidAttachment(format!(
934 "content type {:?} is not valid for {:?}",
935 upload.content_type, upload.kind
936 )));
937 }
938 Ok(())
939 }
940
941 async fn cleanup_objects(&self, keys: &[ObjectKey]) {
942 for key in keys {
943 if let Err(error) = self.objects.delete(key).await {
944 tracing::warn!(
945 object_key = key.as_str(),
946 %error,
947 "failed to clean up feedback attachment after aborted submission"
948 );
949 }
950 }
951 }
952
953 fn validate_upload_size(
954 &self,
955 kind: FeedbackAttachmentKind,
956 actual: usize,
957 ) -> Result<(), FeedbackServiceError> {
958 let maximum = match kind {
959 FeedbackAttachmentKind::Screenshot => self.config.max_screenshot_bytes,
960 FeedbackAttachmentKind::Audio => self.config.max_audio_bytes,
961 FeedbackAttachmentKind::File => self.config.max_file_bytes,
962 };
963 if actual == 0 {
964 return Err(FeedbackServiceError::InvalidAttachment(
965 "attachment must not be empty".into(),
966 ));
967 }
968 if actual > maximum {
969 return Err(FeedbackServiceError::AttachmentTooLarge {
970 kind,
971 actual,
972 maximum,
973 });
974 }
975 Ok(())
976 }
977
978 async fn record_created(
979 &self,
980 thread: &FeedbackThread,
981 correlation_id: Uuid,
982 ) -> Vec<FeedbackWarning> {
983 self.record_mutation(
984 thread,
985 "feedback.created",
986 "New client feedback",
987 NotificationAudience::Developer,
988 thread.context.client_subject.clone(),
989 correlation_id,
990 )
991 .await
992 }
993
994 async fn record_mutation(
995 &self,
996 thread: &FeedbackThread,
997 event_type: &str,
998 title: &str,
999 audience: NotificationAudience,
1000 actor_subject: Option<String>,
1001 correlation_id: Uuid,
1002 ) -> Vec<FeedbackWarning> {
1003 let mut warnings = Vec::new();
1004
1005 let mut audit = AuditEvent::new(
1006 event_type,
1007 "feedback",
1008 thread.id.to_string(),
1009 correlation_id,
1010 );
1011 audit.actor_subject = actor_subject;
1012 audit.metadata.insert(
1013 "project_id".into(),
1014 serde_json::Value::String(thread.project_id.clone()),
1015 );
1016 audit.metadata.insert(
1017 "status".into(),
1018 serde_json::Value::String(thread.status.to_string()),
1019 );
1020 if let Err(error) = self.audit.append(audit).await {
1021 warnings.push(downstream_warning(
1022 "feedback_audit_failed",
1023 "Feedback was saved, but audit recording did not complete.",
1024 &error,
1025 ));
1026 }
1027
1028 warnings.extend(self.record_event(thread, event_type, correlation_id).await);
1029
1030 if let Some(notification) = self.notification(thread, event_type, title, audience)
1031 && let Err(error) = self.notifications.send(notification).await
1032 {
1033 warnings.push(downstream_warning(
1034 "feedback_notification_failed",
1035 "Feedback was saved, but notification delivery did not complete.",
1036 &error,
1037 ));
1038 }
1039
1040 warnings
1041 }
1042
1043 async fn record_event(
1044 &self,
1045 thread: &FeedbackThread,
1046 event_type: &str,
1047 correlation_id: Uuid,
1048 ) -> Vec<FeedbackWarning> {
1049 let payload = match serde_json::to_value(thread) {
1050 Ok(value) => value,
1051 Err(error) => {
1052 return vec![downstream_warning(
1053 "feedback_event_serialization_failed",
1054 "Feedback was saved, but event preparation did not complete.",
1055 &error,
1056 )];
1057 }
1058 };
1059 let event = DomainEvent::new(
1060 event_type,
1061 "feedback",
1062 thread.id.to_string(),
1063 correlation_id,
1064 payload,
1065 );
1066 let record = OutboxRecord::pending(event.clone());
1067 if let Err(error) = self.events.outbox.enqueue(record).await {
1068 return vec![downstream_warning(
1069 "feedback_event_enqueue_failed",
1070 "Feedback was saved, but event queuing did not complete.",
1071 &error,
1072 )];
1073 }
1074 if !self.config.publish_events_inline {
1075 return Vec::new();
1076 }
1077 let worker_id = format!("feedback-request-{correlation_id}");
1078 let claimed = match self
1079 .events
1080 .outbox
1081 .claim_event(event.id, &worker_id, Utc::now() + TimeDelta::minutes(1))
1082 .await
1083 {
1084 Ok(Some(record)) => record,
1085 Ok(None) => {
1086 return vec![FeedbackWarning::new(
1087 "feedback_event_claim_unavailable",
1088 "the feedback event was durably queued for later publication",
1089 )];
1090 }
1091 Err(error) => {
1092 return vec![downstream_warning(
1093 "feedback_event_claim_failed",
1094 "The feedback event was queued, but immediate publication did not start.",
1095 &error,
1096 )];
1097 }
1098 };
1099 match self.events.publisher.publish(&claimed.event).await {
1100 Ok(()) => {
1101 if let Err(error) = self
1102 .events
1103 .outbox
1104 .mark_published(event.id, &worker_id)
1105 .await
1106 {
1107 vec![downstream_warning(
1108 "feedback_event_mark_published_failed",
1109 "The feedback event was published, but its delivery state was not finalized.",
1110 &error,
1111 )]
1112 } else {
1113 Vec::new()
1114 }
1115 }
1116 Err(error) => {
1117 let detail = error.to_string();
1118 let _ = self
1119 .events
1120 .outbox
1121 .mark_failed(
1122 event.id,
1123 &worker_id,
1124 detail.clone(),
1125 Utc::now() + TimeDelta::seconds(30),
1126 )
1127 .await;
1128 vec![downstream_warning(
1129 "feedback_event_publish_failed",
1130 "The feedback event was queued, but immediate publication did not complete.",
1131 &error,
1132 )]
1133 }
1134 }
1135 }
1136
1137 fn notification(
1138 &self,
1139 thread: &FeedbackThread,
1140 event_type: &str,
1141 title: &str,
1142 audience: NotificationAudience,
1143 ) -> Option<Notification> {
1144 let (channel, recipient) = match audience {
1145 NotificationAudience::None => return None,
1146 NotificationAudience::Developer => (
1147 NotificationChannel::DeveloperInbox,
1148 self.config.developer_recipient.clone(),
1149 ),
1150 NotificationAudience::Client => (
1151 NotificationChannel::InApp,
1152 thread.context.client_subject.clone()?,
1153 ),
1154 };
1155 let mut notification = Notification::new(
1156 event_type,
1157 channel,
1158 recipient,
1159 title,
1160 format!(
1161 "{} [{}] — {}",
1162 thread.title, thread.status, thread.context.page_url
1163 ),
1164 );
1165 notification.link = self
1166 .config
1167 .developer_link_base
1168 .as_ref()
1169 .map(|base| format!("{}/{}", base.trim_end_matches('/'), thread.id));
1170 notification.metadata.insert(
1171 "feedback_id".into(),
1172 serde_json::Value::String(thread.id.to_string()),
1173 );
1174 notification.metadata.insert(
1175 "project_id".into(),
1176 serde_json::Value::String(thread.project_id.clone()),
1177 );
1178 Some(notification)
1179 }
1180}
1181
1182fn safe_file_name(value: &str) -> String {
1183 let safe = value
1184 .chars()
1185 .map(|character| {
1186 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1187 character
1188 } else {
1189 '-'
1190 }
1191 })
1192 .take(160)
1193 .collect::<String>();
1194 if safe.trim_matches('-').is_empty() {
1195 "attachment.bin".into()
1196 } else {
1197 safe
1198 }
1199}
1200
1201fn downstream_warning(
1202 code: &'static str,
1203 public_detail: &'static str,
1204 error: &impl std::fmt::Display,
1205) -> FeedbackWarning {
1206 tracing::warn!(warning_code = code, %error, "feedback downstream action failed");
1207 FeedbackWarning::new(code, public_detail)
1208}
1209
1210fn validate_config_text(
1211 field: &'static str,
1212 value: &str,
1213 maximum: usize,
1214) -> Result<(), FeedbackServiceError> {
1215 if value.trim().is_empty()
1216 || value.chars().count() > maximum
1217 || value.chars().any(char::is_control)
1218 {
1219 return Err(FeedbackServiceError::Configuration(format!(
1220 "{field} must contain 1-{maximum} visible characters"
1221 )));
1222 }
1223 Ok(())
1224}
1225
1226fn validate_optional_config_text(
1227 field: &'static str,
1228 value: Option<&str>,
1229 maximum: usize,
1230) -> Result<(), FeedbackServiceError> {
1231 if let Some(value) = value {
1232 validate_config_text(field, value, maximum)?;
1233 }
1234 Ok(())
1235}
1236
1237#[derive(Debug, thiserror::Error)]
1238pub enum FeedbackServiceError {
1239 #[error(transparent)]
1240 Validation(#[from] FeedbackValidationError),
1241 #[error("feedback was not found: {0}")]
1242 NotFound(FeedbackId),
1243 #[error("feedback client access was denied")]
1244 ClientAccessDenied,
1245 #[error("feedback attachment was not found: {0}")]
1246 AttachmentNotFound(Uuid),
1247 #[error("invalid feedback attachment: {0}")]
1248 InvalidAttachment(String),
1249 #[error("feedback attachment {kind:?} is {actual} bytes; limit is {maximum} bytes")]
1250 AttachmentTooLarge {
1251 kind: FeedbackAttachmentKind,
1252 actual: usize,
1253 maximum: usize,
1254 },
1255 #[error("invalid feedback configuration: {0}")]
1256 Configuration(String),
1257 #[error(transparent)]
1258 Store(#[from] FeedbackStoreError),
1259 #[error(transparent)]
1260 ObjectStore(#[from] ObjectStoreError),
1261 #[error(transparent)]
1262 Transcription(#[from] TranscriptionError),
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268 use crate::{
1269 FeedbackContext, FeedbackKind, FeedbackPriority, FeedbackStore, MemoryFeedbackStore,
1270 };
1271 use async_trait::async_trait;
1272 use minco_plugin_audit::MemoryAuditSink;
1273 use minco_plugin_events::MemoryEventBus;
1274 use minco_plugin_notifications::MemoryNotificationSink;
1275 use minco_plugin_object_storage::MemoryObjectStore;
1276 use std::collections::BTreeSet;
1277
1278 struct Harness {
1279 service: FeedbackService,
1280 notifications: Arc<MemoryNotificationSink>,
1281 audit: Arc<MemoryAuditSink>,
1282 events: Arc<MemoryEventBus>,
1283 objects: Arc<MemoryObjectStore>,
1284 }
1285
1286 fn harness() -> Harness {
1287 harness_with_config(FeedbackConfig {
1288 project_id: "example".into(),
1289 publish_events_inline: true,
1290 ..FeedbackConfig::default()
1291 })
1292 }
1293
1294 fn harness_with_config(config: FeedbackConfig) -> Harness {
1295 harness_with_store(
1296 FeedbackStoreService::new(Arc::new(MemoryFeedbackStore::default())),
1297 config,
1298 )
1299 }
1300
1301 fn harness_with_store(store: FeedbackStoreService, config: FeedbackConfig) -> Harness {
1302 let notifications = Arc::new(MemoryNotificationSink::default());
1303 let audit = Arc::new(MemoryAuditSink::default());
1304 let events = Arc::new(MemoryEventBus::default());
1305 let objects = Arc::new(MemoryObjectStore::default());
1306 let service = FeedbackService::new(
1307 store,
1308 ObjectStoreService::new(objects.clone()),
1309 NotificationService::new(notifications.clone()),
1310 AuditService::new(audit.clone()),
1311 EventServices {
1312 publisher: events.clone(),
1313 outbox: events.clone(),
1314 },
1315 None,
1316 config,
1317 )
1318 .unwrap();
1319 Harness {
1320 service,
1321 notifications,
1322 audit,
1323 events,
1324 objects,
1325 }
1326 }
1327
1328 #[derive(Debug)]
1329 struct RejectingFeedbackStore;
1330
1331 #[async_trait]
1332 impl FeedbackStore for RejectingFeedbackStore {
1333 async fn create(
1334 &self,
1335 _thread: FeedbackThread,
1336 _client_token_hash: String,
1337 ) -> Result<(), FeedbackStoreError> {
1338 Err(FeedbackStoreError::Infrastructure(
1339 "injected persistence failure".into(),
1340 ))
1341 }
1342
1343 async fn get(&self, _id: FeedbackId) -> Result<Option<FeedbackThread>, FeedbackStoreError> {
1344 Err(FeedbackStoreError::Infrastructure(
1345 "injected persistence failure".into(),
1346 ))
1347 }
1348
1349 async fn get_for_client(
1350 &self,
1351 _id: FeedbackId,
1352 _client_token_hash: &str,
1353 ) -> Result<Option<FeedbackThread>, FeedbackStoreError> {
1354 Err(FeedbackStoreError::Infrastructure(
1355 "injected persistence failure".into(),
1356 ))
1357 }
1358
1359 async fn list(
1360 &self,
1361 _filter: FeedbackListFilter,
1362 ) -> Result<Vec<FeedbackSummary>, FeedbackStoreError> {
1363 Err(FeedbackStoreError::Infrastructure(
1364 "injected persistence failure".into(),
1365 ))
1366 }
1367
1368 async fn save(
1369 &self,
1370 _thread: FeedbackThread,
1371 _expected_revision: u64,
1372 ) -> Result<(), FeedbackStoreError> {
1373 Err(FeedbackStoreError::Infrastructure(
1374 "injected persistence failure".into(),
1375 ))
1376 }
1377 }
1378
1379 #[test]
1380 fn browser_tokens_are_tab_scoped_by_default() {
1381 let config = FeedbackConfig::default();
1382 assert_eq!(config.token_storage, FeedbackTokenStorage::Session);
1383 assert_eq!(config.widget_config().token_storage, "session");
1384 assert!(!config.allow_anonymous);
1385
1386 let deserialized: FeedbackConfig =
1387 serde_json::from_value(serde_json::json!({"project_id": "example"})).unwrap();
1388 assert!(!deserialized.allow_anonymous);
1389 }
1390
1391 #[test]
1392 fn text_only_profile_can_disable_all_attachments() {
1393 let config = FeedbackConfig {
1394 project_id: "text-only".into(),
1395 max_attachments: 0,
1396 screenshot_enabled: false,
1397 voice_enabled: false,
1398 ..FeedbackConfig::default()
1399 };
1400
1401 config.validate().expect("zero-attachment profile");
1402 assert_eq!(config.widget_config().max_attachments, 0);
1403 }
1404
1405 #[test]
1406 fn public_submission_modes_cannot_enable_transcription() {
1407 for config in [
1408 FeedbackConfig {
1409 project_id: "example".into(),
1410 transcription_enabled: true,
1411 allow_anonymous: true,
1412 ..FeedbackConfig::default()
1413 },
1414 FeedbackConfig {
1415 project_id: "example".into(),
1416 transcription_enabled: true,
1417 project_key: Some("browser-visible-key".into()),
1418 ..FeedbackConfig::default()
1419 },
1420 ] {
1421 assert!(matches!(
1422 config.validate(),
1423 Err(FeedbackServiceError::Configuration(_))
1424 ));
1425 }
1426 }
1427
1428 #[test]
1429 fn downstream_warning_details_do_not_expose_provider_diagnostics() {
1430 let warning = downstream_warning(
1431 "feedback_transcription_failed",
1432 "Audio transcription did not complete.",
1433 &TranscriptionError::Provider(
1434 "provider response containing sensitive diagnostics".into(),
1435 ),
1436 );
1437
1438 assert_eq!(warning.detail, "Audio transcription did not complete.");
1439 assert!(!warning.detail.contains("sensitive diagnostics"));
1440 }
1441
1442 fn input() -> CreateFeedbackInput {
1443 CreateFeedbackInput {
1444 project_id: "example".into(),
1445 kind: FeedbackKind::Bug,
1446 priority: FeedbackPriority::High,
1447 title: "Save does not work".into(),
1448 description: "The save button leaves the form open.".into(),
1449 context: FeedbackContext {
1450 page_url: "https://example.test/orders/one".into(),
1451 route_name: Some("order-edit".into()),
1452 release_id: Some("release-1".into()),
1453 environment: Some("review".into()),
1454 request_id: Some("request-1".into()),
1455 user_agent: None,
1456 viewport: None,
1457 client_subject: Some("client-1".into()),
1458 },
1459 tags: BTreeSet::new(),
1460 }
1461 }
1462
1463 fn release_binding() -> FeedbackReleaseBinding {
1464 let release_digest = "a".repeat(64);
1465 FeedbackReleaseBinding {
1466 release_id: format!("minco.{}", &release_digest[..24]),
1467 release_digest,
1468 environment: "review".into(),
1469 deployment_attempt_id: "review-20260807".into(),
1470 deployment_receipt_digest: "b".repeat(64),
1471 ui_build_id: None,
1472 ui_build_digest: None,
1473 }
1474 }
1475
1476 #[tokio::test]
1477 async fn create_stamps_server_authoritative_release_identity() {
1478 let identity = release_binding();
1479 let harness = harness();
1480 let service = harness
1481 .service
1482 .clone()
1483 .with_release_binding(identity.clone())
1484 .expect("valid release binding");
1485 let mut input = input();
1486 input.context.release_id = Some("client-controlled".into());
1487 input.context.environment = Some("client-controlled".into());
1488
1489 let result = service
1490 .create(input, Vec::new(), Uuid::now_v7())
1491 .await
1492 .expect("release-bound feedback");
1493
1494 assert_eq!(
1495 result.thread.context.release_id,
1496 Some(identity.release_id.clone())
1497 );
1498 assert_eq!(
1499 result.thread.context.environment,
1500 Some(identity.environment.clone())
1501 );
1502 assert_eq!(
1503 FeedbackReleaseBinding::from_thread(&result.thread),
1504 Some(identity)
1505 );
1506 assert!(result.thread.client_view().messages.is_empty());
1507 }
1508
1509 #[tokio::test]
1510 async fn create_persists_notifies_audits_and_publishes() {
1511 let harness = harness();
1512 let created = harness
1513 .service
1514 .create(input(), Vec::new(), Uuid::now_v7())
1515 .await
1516 .unwrap();
1517 assert_eq!(created.thread.status, FeedbackStatus::New);
1518 assert_eq!(harness.notifications.all().await.len(), 1);
1519 assert_eq!(harness.audit.all().await.len(), 1);
1520 assert_eq!(harness.events.published().await.len(), 1);
1521 }
1522
1523 #[tokio::test]
1524 async fn default_fast_path_queues_events_without_waiting_for_publication() {
1525 let harness = harness_with_config(FeedbackConfig {
1526 project_id: "example".into(),
1527 ..FeedbackConfig::default()
1528 });
1529 harness
1530 .service
1531 .create(input(), Vec::new(), Uuid::now_v7())
1532 .await
1533 .unwrap();
1534 assert!(harness.events.published().await.is_empty());
1535 let records = harness.events.outbox_records().await;
1536 assert_eq!(records.len(), 1);
1537 assert_eq!(
1538 records[0].status,
1539 minco_plugin_events::OutboxStatus::Pending
1540 );
1541 }
1542
1543 #[tokio::test]
1544 async fn developer_question_requires_an_explicit_clarification_transition() {
1545 let harness = harness();
1546 let created = harness
1547 .service
1548 .create(input(), Vec::new(), Uuid::now_v7())
1549 .await
1550 .unwrap();
1551 let result = harness
1552 .service
1553 .reply_as_developer(
1554 created.thread.id,
1555 DeveloperReplyInput {
1556 body: "Does this still happen after refreshing?".into(),
1557 visible_to_client: true,
1558 author_display: Some("developer".into()),
1559 },
1560 "developer-1".into(),
1561 Uuid::now_v7(),
1562 )
1563 .await
1564 .unwrap();
1565 assert_eq!(result.thread.status, FeedbackStatus::Acknowledged);
1566 assert_eq!(
1567 harness
1568 .audit
1569 .all()
1570 .await
1571 .last()
1572 .unwrap()
1573 .actor_subject
1574 .as_deref(),
1575 Some("developer-1")
1576 );
1577 }
1578
1579 #[tokio::test]
1580 async fn client_token_is_required_for_client_thread_access() {
1581 let harness = harness();
1582 let created = harness
1583 .service
1584 .create(input(), Vec::new(), Uuid::now_v7())
1585 .await
1586 .unwrap();
1587 assert!(
1588 harness
1589 .service
1590 .get_for_client(created.thread.id, &FeedbackAccessToken::generate())
1591 .await
1592 .is_err()
1593 );
1594 assert!(
1595 harness
1596 .service
1597 .get_for_client(created.thread.id, &created.client_token)
1598 .await
1599 .is_ok()
1600 );
1601 }
1602
1603 #[tokio::test]
1604 async fn developer_access_is_scoped_to_the_configured_project() {
1605 let store = Arc::new(MemoryFeedbackStore::default());
1606 let foreign_token = FeedbackAccessToken::generate();
1607 let foreign_thread = FeedbackThread::create(CreateFeedbackInput {
1608 project_id: "foreign".into(),
1609 ..input()
1610 })
1611 .unwrap();
1612 let foreign_id = foreign_thread.id;
1613 store
1614 .create(foreign_thread, hash_access_token(&foreign_token))
1615 .await
1616 .unwrap();
1617 let harness = harness_with_store(
1618 FeedbackStoreService::new(store),
1619 FeedbackConfig {
1620 project_id: "example".into(),
1621 ..FeedbackConfig::default()
1622 },
1623 );
1624
1625 assert!(matches!(
1626 harness.service.get_for_developer(foreign_id).await,
1627 Err(FeedbackServiceError::NotFound(id)) if id == foreign_id
1628 ));
1629 assert!(matches!(
1630 harness
1631 .service
1632 .get_for_client(foreign_id, &foreign_token)
1633 .await,
1634 Err(FeedbackServiceError::ClientAccessDenied)
1635 ));
1636 assert!(matches!(
1637 harness
1638 .service
1639 .list(FeedbackListFilter {
1640 project_id: Some("foreign".into()),
1641 ..FeedbackListFilter::default()
1642 })
1643 .await,
1644 Err(FeedbackServiceError::Validation(
1645 FeedbackValidationError::InvalidField {
1646 field: "project_id",
1647 ..
1648 }
1649 ))
1650 ));
1651 }
1652
1653 #[tokio::test]
1654 async fn service_enforces_attachment_count_without_relying_on_http_extractors() {
1655 let harness = harness_with_config(FeedbackConfig {
1656 project_id: "example".into(),
1657 max_attachments: 1,
1658 ..FeedbackConfig::default()
1659 });
1660 let upload = AttachmentUpload {
1661 kind: FeedbackAttachmentKind::Screenshot,
1662 file_name: "screen.png".into(),
1663 content_type: "image/png".into(),
1664 bytes: vec![1, 2, 3],
1665 };
1666 let result = harness
1667 .service
1668 .create(input(), vec![upload.clone(), upload], Uuid::now_v7())
1669 .await;
1670 assert!(matches!(
1671 result,
1672 Err(FeedbackServiceError::InvalidAttachment(_))
1673 ));
1674 assert!(harness.objects.is_empty().await);
1675 }
1676
1677 #[tokio::test]
1678 async fn partially_uploaded_objects_are_removed_when_a_later_attachment_is_invalid() {
1679 let harness = harness();
1680 let result = harness
1681 .service
1682 .create(
1683 input(),
1684 vec![
1685 AttachmentUpload {
1686 kind: FeedbackAttachmentKind::Screenshot,
1687 file_name: "screen.png".into(),
1688 content_type: "image/png".into(),
1689 bytes: vec![1, 2, 3],
1690 },
1691 AttachmentUpload {
1692 kind: FeedbackAttachmentKind::Screenshot,
1693 file_name: "not-an-image.txt".into(),
1694 content_type: "text/plain".into(),
1695 bytes: vec![4, 5, 6],
1696 },
1697 ],
1698 Uuid::now_v7(),
1699 )
1700 .await;
1701 assert!(matches!(
1702 result,
1703 Err(FeedbackServiceError::InvalidAttachment(_))
1704 ));
1705 assert!(harness.objects.is_empty().await);
1706 }
1707
1708 #[tokio::test]
1709 async fn uploaded_objects_are_removed_when_authoritative_persistence_fails() {
1710 let harness = harness_with_store(
1711 FeedbackStoreService::new(Arc::new(RejectingFeedbackStore)),
1712 FeedbackConfig {
1713 project_id: "example".into(),
1714 ..FeedbackConfig::default()
1715 },
1716 );
1717 let result = harness
1718 .service
1719 .create(
1720 input(),
1721 vec![AttachmentUpload {
1722 kind: FeedbackAttachmentKind::Screenshot,
1723 file_name: "screen.png".into(),
1724 content_type: "image/png".into(),
1725 bytes: vec![1, 2, 3],
1726 }],
1727 Uuid::now_v7(),
1728 )
1729 .await;
1730 assert!(matches!(
1731 result,
1732 Err(FeedbackServiceError::Store(
1733 FeedbackStoreError::Infrastructure(_)
1734 ))
1735 ));
1736 assert!(harness.objects.is_empty().await);
1737 }
1738
1739 #[tokio::test]
1740 async fn disabled_media_features_fail_closed_below_the_http_layer() {
1741 let harness = harness_with_config(FeedbackConfig {
1742 project_id: "example".into(),
1743 screenshot_enabled: false,
1744 voice_enabled: false,
1745 ..FeedbackConfig::default()
1746 });
1747 for upload in [
1748 AttachmentUpload {
1749 kind: FeedbackAttachmentKind::Screenshot,
1750 file_name: "screen.png".into(),
1751 content_type: "image/png".into(),
1752 bytes: vec![1],
1753 },
1754 AttachmentUpload {
1755 kind: FeedbackAttachmentKind::Audio,
1756 file_name: "voice.webm".into(),
1757 content_type: "audio/webm".into(),
1758 bytes: vec![1],
1759 },
1760 ] {
1761 let result = harness
1762 .service
1763 .create(input(), vec![upload], Uuid::now_v7())
1764 .await;
1765 assert!(matches!(
1766 result,
1767 Err(FeedbackServiceError::InvalidAttachment(_))
1768 ));
1769 }
1770 assert!(harness.objects.is_empty().await);
1771 }
1772}