1use crate::{
2 AttachmentUpload, AudioInput, CreateFeedbackInput, CreateFeedbackResult, DeveloperReplyInput,
3 FeedbackAccessToken, FeedbackAiContext, FeedbackAttachment, FeedbackAttachmentKind, FeedbackId,
4 FeedbackListFilter, FeedbackMessage, FeedbackMessageSource, FeedbackMutationResult,
5 FeedbackStatus, FeedbackStoreError, FeedbackStoreService, FeedbackSummary, FeedbackThread,
6 FeedbackValidationError, FeedbackWarning, Transcript, TranscriptionError, TranscriptionService,
7 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 !(1..=8).contains(&self.max_attachments) {
279 return Err(FeedbackServiceError::Configuration(
280 "max_attachments must be between 1 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}
446
447impl std::fmt::Debug for FeedbackService {
448 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 formatter
450 .debug_struct("FeedbackService")
451 .field("config", &self.config)
452 .field("transcription_configured", &self.transcription.is_some())
453 .finish_non_exhaustive()
454 }
455}
456
457impl FeedbackService {
458 pub fn new(
459 store: FeedbackStoreService,
460 objects: ObjectStoreService,
461 notifications: NotificationService,
462 audit: AuditService,
463 events: EventServices,
464 transcription: Option<TranscriptionService>,
465 config: FeedbackConfig,
466 ) -> Result<Self, FeedbackServiceError> {
467 config.validate()?;
468 if config.transcription_enabled && transcription.is_none() {
469 return Err(FeedbackServiceError::Configuration(
470 "transcription_enabled requires a TranscriptionService".into(),
471 ));
472 }
473 Ok(Self {
474 store,
475 objects,
476 notifications,
477 audit,
478 events,
479 transcription,
480 config: Arc::new(config),
481 })
482 }
483
484 #[must_use]
485 pub fn config(&self) -> &FeedbackConfig {
486 &self.config
487 }
488
489 pub async fn ready(&self) -> Result<(), FeedbackServiceError> {
490 self.store.ready().await?;
491 Ok(())
492 }
493
494 pub async fn create(
495 &self,
496 mut input: CreateFeedbackInput,
497 uploads: Vec<AttachmentUpload>,
498 correlation_id: Uuid,
499 ) -> Result<CreateFeedbackResult, FeedbackServiceError> {
500 if input.project_id.trim().is_empty() {
501 input.project_id.clone_from(&self.config.project_id);
502 }
503 if input.project_id != self.config.project_id {
504 return Err(FeedbackValidationError::InvalidField {
505 field: "project_id",
506 detail: "does not match the configured feedback project".into(),
507 }
508 .into());
509 }
510
511 if uploads.len() > self.config.max_attachments {
512 return Err(FeedbackServiceError::InvalidAttachment(format!(
513 "feedback contains {} attachments; configured maximum is {}",
514 uploads.len(),
515 self.config.max_attachments
516 )));
517 }
518 let aggregate_bytes = uploads.iter().try_fold(0_usize, |total, upload| {
519 total.checked_add(upload.bytes.len()).ok_or_else(|| {
520 FeedbackServiceError::InvalidAttachment(
521 "aggregate attachment size exceeds the platform address space".into(),
522 )
523 })
524 })?;
525 if aggregate_bytes > self.config.max_http_body_bytes {
526 return Err(FeedbackServiceError::InvalidAttachment(format!(
527 "aggregate attachment payload is {aggregate_bytes} bytes; configured HTTP body ceiling is {} bytes",
528 self.config.max_http_body_bytes
529 )));
530 }
531
532 let mut thread = FeedbackThread::create(input)?;
533 let client_token = FeedbackAccessToken::generate();
534 let mut stored_keys = Vec::new();
535 let mut warnings = Vec::new();
536
537 for upload in uploads {
538 let (attachment, attachment_warnings) =
539 match self.store_attachment(thread.id, upload).await {
540 Ok(value) => value,
541 Err(error) => {
542 self.cleanup_objects(&stored_keys).await;
543 return Err(error);
544 }
545 };
546 stored_keys.push(ObjectKey::parse(attachment.object_key.clone())?);
547 if let Some(transcript) = attachment.transcript.clone() {
548 let message = match FeedbackMessage::new(
549 crate::FeedbackAuthorRole::Client,
550 None,
551 transcript,
552 FeedbackMessageSource::VoiceTranscript,
553 true,
554 ) {
555 Ok(message) => message,
556 Err(error) => {
557 self.cleanup_objects(&stored_keys).await;
558 return Err(error.into());
559 }
560 };
561 thread.append_message(message);
562 }
563 thread.add_attachment(attachment);
564 warnings.extend(attachment_warnings);
565 }
566
567 if let Err(error) = self
568 .store
569 .create(thread.clone(), hash_access_token(&client_token))
570 .await
571 {
572 self.cleanup_objects(&stored_keys).await;
573 return Err(error.into());
574 }
575
576 warnings.extend(self.record_created(&thread, correlation_id).await);
577 Ok(CreateFeedbackResult {
578 thread,
579 client_token,
580 warnings,
581 })
582 }
583
584 pub async fn get_for_client(
585 &self,
586 id: FeedbackId,
587 token: &FeedbackAccessToken,
588 ) -> Result<FeedbackThread, FeedbackServiceError> {
589 Ok(self.client_thread(id, token).await?.client_view())
590 }
591
592 pub async fn get_for_developer(
593 &self,
594 id: FeedbackId,
595 ) -> Result<FeedbackThread, FeedbackServiceError> {
596 let thread = self
597 .store
598 .get(id)
599 .await?
600 .ok_or(FeedbackServiceError::NotFound(id))?;
601 if thread.project_id != self.config.project_id {
602 return Err(FeedbackServiceError::NotFound(id));
603 }
604 Ok(thread)
605 }
606
607 pub async fn list(
608 &self,
609 mut filter: FeedbackListFilter,
610 ) -> Result<Vec<FeedbackSummary>, FeedbackServiceError> {
611 if filter
612 .project_id
613 .as_deref()
614 .is_some_and(|project_id| project_id != self.config.project_id)
615 {
616 return Err(FeedbackValidationError::InvalidField {
617 field: "project_id",
618 detail: "does not match the configured feedback project".into(),
619 }
620 .into());
621 }
622 filter.project_id = Some(self.config.project_id.clone());
623 Ok(self.store.list(filter).await?)
624 }
625
626 pub async fn reply_as_client(
627 &self,
628 id: FeedbackId,
629 token: &FeedbackAccessToken,
630 body: impl Into<String>,
631 correlation_id: Uuid,
632 ) -> Result<FeedbackMutationResult, FeedbackServiceError> {
633 let mut thread = self.client_thread(id, token).await?;
634 let expected_revision = thread.revision;
635 thread.append_message(FeedbackMessage::client(body)?);
636 if thread.status == FeedbackStatus::NeedsClarification {
637 thread.transition(FeedbackStatus::Acknowledged, None)?;
638 }
639 self.store.save(thread.clone(), expected_revision).await?;
640 let warnings = self
641 .record_mutation(
642 &thread,
643 "feedback.client_replied",
644 "Client replied to feedback",
645 NotificationAudience::Developer,
646 thread.context.client_subject.clone(),
647 correlation_id,
648 )
649 .await;
650 Ok(FeedbackMutationResult {
651 thread: thread.client_view(),
652 warnings,
653 })
654 }
655
656 pub async fn reply_as_developer(
657 &self,
658 id: FeedbackId,
659 input: DeveloperReplyInput,
660 actor_subject: String,
661 correlation_id: Uuid,
662 ) -> Result<FeedbackMutationResult, FeedbackServiceError> {
663 let mut thread = self.get_for_developer(id).await?;
664 let expected_revision = thread.revision;
665 let asks_question = input.visible_to_client && input.body.trim_end().ends_with('?');
666 thread.append_message(FeedbackMessage::developer(
667 input.author_display,
668 input.body,
669 input.visible_to_client,
670 )?);
671 if asks_question
672 && thread
673 .status
674 .can_transition_to(FeedbackStatus::NeedsClarification)
675 {
676 thread.transition(FeedbackStatus::NeedsClarification, None)?;
677 } else if thread.status == FeedbackStatus::New {
678 thread.transition(FeedbackStatus::Acknowledged, None)?;
679 }
680 self.store.save(thread.clone(), expected_revision).await?;
681 let warnings = self
682 .record_mutation(
683 &thread,
684 "feedback.developer_replied",
685 "Developer replied to feedback",
686 if input.visible_to_client && self.config.notify_client_updates {
687 NotificationAudience::Client
688 } else {
689 NotificationAudience::None
690 },
691 Some(actor_subject),
692 correlation_id,
693 )
694 .await;
695 Ok(FeedbackMutationResult { thread, warnings })
696 }
697
698 pub async fn transition(
699 &self,
700 id: FeedbackId,
701 input: TransitionFeedbackInput,
702 actor_subject: String,
703 correlation_id: Uuid,
704 ) -> Result<FeedbackMutationResult, FeedbackServiceError> {
705 let mut thread = self.get_for_developer(id).await?;
706 let expected_revision = thread.revision;
707 let previous = thread.status;
708 thread.transition(input.status, input.resolution)?;
709 thread.append_message(FeedbackMessage::new(
710 crate::FeedbackAuthorRole::System,
711 input.author_display,
712 format!("Status changed from {previous} to {}", input.status),
713 FeedbackMessageSource::StatusChange,
714 true,
715 )?);
716 self.store.save(thread.clone(), expected_revision).await?;
717 let warnings = self
718 .record_mutation(
719 &thread,
720 "feedback.status_changed",
721 &format!("Feedback status changed to {}", input.status),
722 if self.config.notify_client_updates {
723 NotificationAudience::Client
724 } else {
725 NotificationAudience::None
726 },
727 Some(actor_subject),
728 correlation_id,
729 )
730 .await;
731 Ok(FeedbackMutationResult { thread, warnings })
732 }
733
734 pub async fn transcribe(&self, audio: AudioInput) -> Result<Transcript, FeedbackServiceError> {
735 if !self.config.voice_enabled {
736 return Err(FeedbackServiceError::Configuration(
737 "voice feedback is disabled for this deployment".into(),
738 ));
739 }
740 if !self.config.transcription_enabled {
741 return Err(TranscriptionError::NotConfigured.into());
742 }
743 self.validate_upload_size(FeedbackAttachmentKind::Audio, audio.bytes.len())?;
744 let service = self
745 .transcription
746 .as_ref()
747 .ok_or(TranscriptionError::NotConfigured)?;
748 Ok(service.transcribe(audio).await?)
749 }
750
751 pub async fn ai_context(
752 &self,
753 id: FeedbackId,
754 ) -> Result<FeedbackAiContext, FeedbackServiceError> {
755 Ok(FeedbackAiContext::from_thread(
756 self.get_for_developer(id).await?,
757 ))
758 }
759
760 pub async fn attachment_for_developer(
761 &self,
762 id: FeedbackId,
763 attachment_id: Uuid,
764 ) -> Result<StoredObject, FeedbackServiceError> {
765 let thread = self.get_for_developer(id).await?;
766 self.load_attachment(&thread, attachment_id).await
767 }
768
769 pub async fn attachment_for_client(
770 &self,
771 id: FeedbackId,
772 token: &FeedbackAccessToken,
773 attachment_id: Uuid,
774 ) -> Result<StoredObject, FeedbackServiceError> {
775 let thread = self.client_thread(id, token).await?;
776 self.load_attachment(&thread, attachment_id).await
777 }
778
779 async fn client_thread(
780 &self,
781 id: FeedbackId,
782 token: &FeedbackAccessToken,
783 ) -> Result<FeedbackThread, FeedbackServiceError> {
784 let thread = self
785 .store
786 .get_for_client(id, &hash_access_token(token))
787 .await?
788 .ok_or(FeedbackServiceError::ClientAccessDenied)?;
789 if thread.project_id != self.config.project_id {
790 return Err(FeedbackServiceError::ClientAccessDenied);
791 }
792 Ok(thread)
793 }
794
795 async fn load_attachment(
796 &self,
797 thread: &FeedbackThread,
798 attachment_id: Uuid,
799 ) -> Result<StoredObject, FeedbackServiceError> {
800 let attachment = thread
801 .attachments
802 .iter()
803 .find(|attachment| attachment.id == attachment_id)
804 .ok_or(FeedbackServiceError::AttachmentNotFound(attachment_id))?;
805 let key = ObjectKey::parse(attachment.object_key.clone())?;
806 self.objects
807 .get(&key)
808 .await?
809 .ok_or(FeedbackServiceError::AttachmentNotFound(attachment_id))
810 }
811
812 async fn store_attachment(
813 &self,
814 feedback_id: FeedbackId,
815 upload: AttachmentUpload,
816 ) -> Result<(FeedbackAttachment, Vec<FeedbackWarning>), FeedbackServiceError> {
817 self.validate_attachment(&upload)?;
818 let safe_name = safe_file_name(&upload.file_name);
819 let attachment_id = Uuid::now_v7();
820 let object_key = ObjectKey::parse(format!(
821 "feedback/{feedback_id}/{attachment_id}/{safe_name}"
822 ))?;
823 let mut attributes = BTreeMap::from([
824 ("feedback_id".into(), feedback_id.to_string()),
825 ("attachment_id".into(), attachment_id.to_string()),
826 ("file_name".into(), safe_name.clone()),
827 (
828 "kind".into(),
829 format!("{:?}", upload.kind).to_ascii_lowercase(),
830 ),
831 ]);
832 attributes.insert("project_id".into(), self.config.project_id.clone());
833 let metadata = self
834 .objects
835 .put(PutObject {
836 key: object_key.clone(),
837 bytes: upload.bytes.clone(),
838 content_type: upload.content_type.clone(),
839 attributes,
840 })
841 .await?;
842 let mut warnings = Vec::new();
843 let transcript = if upload.kind == FeedbackAttachmentKind::Audio
844 && self.config.transcription_enabled
845 && self.config.auto_transcribe_audio
846 {
847 match self
848 .transcription
849 .as_ref()
850 .ok_or(TranscriptionError::NotConfigured)?
851 .transcribe(AudioInput {
852 bytes: upload.bytes,
853 file_name: safe_name.clone(),
854 content_type: upload.content_type.clone(),
855 language: None,
856 prompt: Some("Transcribe client product feedback accurately.".into()),
857 })
858 .await
859 {
860 Ok(transcript) => Some(transcript.text),
861 Err(error) => {
862 warnings.push(downstream_warning(
863 "feedback_transcription_failed",
864 "Audio was stored, but automatic transcription did not complete.",
865 &error,
866 ));
867 None
868 }
869 }
870 } else {
871 None
872 };
873 Ok((
874 FeedbackAttachment {
875 id: attachment_id,
876 kind: upload.kind,
877 object_key: object_key.as_str().to_owned(),
878 file_name: safe_name,
879 content_type: upload.content_type,
880 size_bytes: metadata.size_bytes,
881 sha256: metadata.sha256,
882 created_at: metadata.created_at,
883 transcript,
884 },
885 warnings,
886 ))
887 }
888
889 fn validate_attachment(&self, upload: &AttachmentUpload) -> Result<(), FeedbackServiceError> {
890 match upload.kind {
891 FeedbackAttachmentKind::Screenshot if !self.config.screenshot_enabled => {
892 return Err(FeedbackServiceError::InvalidAttachment(
893 "screenshot feedback is disabled for this deployment".into(),
894 ));
895 }
896 FeedbackAttachmentKind::Audio if !self.config.voice_enabled => {
897 return Err(FeedbackServiceError::InvalidAttachment(
898 "voice feedback is disabled for this deployment".into(),
899 ));
900 }
901 _ => {}
902 }
903 self.validate_upload_size(upload.kind, upload.bytes.len())?;
904 if upload.file_name.trim().is_empty() || upload.file_name.chars().any(char::is_control) {
905 return Err(FeedbackServiceError::InvalidAttachment(
906 "attachment file name is invalid".into(),
907 ));
908 }
909 let content_type = upload.content_type.trim().to_ascii_lowercase();
910 if content_type.is_empty()
911 || (upload.kind == FeedbackAttachmentKind::Screenshot
912 && !content_type.starts_with("image/"))
913 || (upload.kind == FeedbackAttachmentKind::Audio && !content_type.starts_with("audio/"))
914 {
915 return Err(FeedbackServiceError::InvalidAttachment(format!(
916 "content type {:?} is not valid for {:?}",
917 upload.content_type, upload.kind
918 )));
919 }
920 Ok(())
921 }
922
923 async fn cleanup_objects(&self, keys: &[ObjectKey]) {
924 for key in keys {
925 if let Err(error) = self.objects.delete(key).await {
926 tracing::warn!(
927 object_key = key.as_str(),
928 %error,
929 "failed to clean up feedback attachment after aborted submission"
930 );
931 }
932 }
933 }
934
935 fn validate_upload_size(
936 &self,
937 kind: FeedbackAttachmentKind,
938 actual: usize,
939 ) -> Result<(), FeedbackServiceError> {
940 let maximum = match kind {
941 FeedbackAttachmentKind::Screenshot => self.config.max_screenshot_bytes,
942 FeedbackAttachmentKind::Audio => self.config.max_audio_bytes,
943 FeedbackAttachmentKind::File => self.config.max_file_bytes,
944 };
945 if actual == 0 {
946 return Err(FeedbackServiceError::InvalidAttachment(
947 "attachment must not be empty".into(),
948 ));
949 }
950 if actual > maximum {
951 return Err(FeedbackServiceError::AttachmentTooLarge {
952 kind,
953 actual,
954 maximum,
955 });
956 }
957 Ok(())
958 }
959
960 async fn record_created(
961 &self,
962 thread: &FeedbackThread,
963 correlation_id: Uuid,
964 ) -> Vec<FeedbackWarning> {
965 self.record_mutation(
966 thread,
967 "feedback.created",
968 "New client feedback",
969 NotificationAudience::Developer,
970 thread.context.client_subject.clone(),
971 correlation_id,
972 )
973 .await
974 }
975
976 async fn record_mutation(
977 &self,
978 thread: &FeedbackThread,
979 event_type: &str,
980 title: &str,
981 audience: NotificationAudience,
982 actor_subject: Option<String>,
983 correlation_id: Uuid,
984 ) -> Vec<FeedbackWarning> {
985 let mut warnings = Vec::new();
986
987 let mut audit = AuditEvent::new(
988 event_type,
989 "feedback",
990 thread.id.to_string(),
991 correlation_id,
992 );
993 audit.actor_subject = actor_subject;
994 audit.metadata.insert(
995 "project_id".into(),
996 serde_json::Value::String(thread.project_id.clone()),
997 );
998 audit.metadata.insert(
999 "status".into(),
1000 serde_json::Value::String(thread.status.to_string()),
1001 );
1002 if let Err(error) = self.audit.append(audit).await {
1003 warnings.push(downstream_warning(
1004 "feedback_audit_failed",
1005 "Feedback was saved, but audit recording did not complete.",
1006 &error,
1007 ));
1008 }
1009
1010 warnings.extend(self.record_event(thread, event_type, correlation_id).await);
1011
1012 if let Some(notification) = self.notification(thread, event_type, title, audience)
1013 && let Err(error) = self.notifications.send(notification).await
1014 {
1015 warnings.push(downstream_warning(
1016 "feedback_notification_failed",
1017 "Feedback was saved, but notification delivery did not complete.",
1018 &error,
1019 ));
1020 }
1021
1022 warnings
1023 }
1024
1025 async fn record_event(
1026 &self,
1027 thread: &FeedbackThread,
1028 event_type: &str,
1029 correlation_id: Uuid,
1030 ) -> Vec<FeedbackWarning> {
1031 let payload = match serde_json::to_value(thread) {
1032 Ok(value) => value,
1033 Err(error) => {
1034 return vec![downstream_warning(
1035 "feedback_event_serialization_failed",
1036 "Feedback was saved, but event preparation did not complete.",
1037 &error,
1038 )];
1039 }
1040 };
1041 let event = DomainEvent::new(
1042 event_type,
1043 "feedback",
1044 thread.id.to_string(),
1045 correlation_id,
1046 payload,
1047 );
1048 let record = OutboxRecord::pending(event.clone());
1049 if let Err(error) = self.events.outbox.enqueue(record).await {
1050 return vec![downstream_warning(
1051 "feedback_event_enqueue_failed",
1052 "Feedback was saved, but event queuing did not complete.",
1053 &error,
1054 )];
1055 }
1056 if !self.config.publish_events_inline {
1057 return Vec::new();
1058 }
1059 let worker_id = format!("feedback-request-{correlation_id}");
1060 let claimed = match self
1061 .events
1062 .outbox
1063 .claim_event(event.id, &worker_id, Utc::now() + TimeDelta::minutes(1))
1064 .await
1065 {
1066 Ok(Some(record)) => record,
1067 Ok(None) => {
1068 return vec![FeedbackWarning::new(
1069 "feedback_event_claim_unavailable",
1070 "the feedback event was durably queued for later publication",
1071 )];
1072 }
1073 Err(error) => {
1074 return vec![downstream_warning(
1075 "feedback_event_claim_failed",
1076 "The feedback event was queued, but immediate publication did not start.",
1077 &error,
1078 )];
1079 }
1080 };
1081 match self.events.publisher.publish(&claimed.event).await {
1082 Ok(()) => {
1083 if let Err(error) = self
1084 .events
1085 .outbox
1086 .mark_published(event.id, &worker_id)
1087 .await
1088 {
1089 vec![downstream_warning(
1090 "feedback_event_mark_published_failed",
1091 "The feedback event was published, but its delivery state was not finalized.",
1092 &error,
1093 )]
1094 } else {
1095 Vec::new()
1096 }
1097 }
1098 Err(error) => {
1099 let detail = error.to_string();
1100 let _ = self
1101 .events
1102 .outbox
1103 .mark_failed(
1104 event.id,
1105 &worker_id,
1106 detail.clone(),
1107 Utc::now() + TimeDelta::seconds(30),
1108 )
1109 .await;
1110 vec![downstream_warning(
1111 "feedback_event_publish_failed",
1112 "The feedback event was queued, but immediate publication did not complete.",
1113 &error,
1114 )]
1115 }
1116 }
1117 }
1118
1119 fn notification(
1120 &self,
1121 thread: &FeedbackThread,
1122 event_type: &str,
1123 title: &str,
1124 audience: NotificationAudience,
1125 ) -> Option<Notification> {
1126 let (channel, recipient) = match audience {
1127 NotificationAudience::None => return None,
1128 NotificationAudience::Developer => (
1129 NotificationChannel::DeveloperInbox,
1130 self.config.developer_recipient.clone(),
1131 ),
1132 NotificationAudience::Client => (
1133 NotificationChannel::InApp,
1134 thread.context.client_subject.clone()?,
1135 ),
1136 };
1137 let mut notification = Notification::new(
1138 event_type,
1139 channel,
1140 recipient,
1141 title,
1142 format!(
1143 "{} [{}] — {}",
1144 thread.title, thread.status, thread.context.page_url
1145 ),
1146 );
1147 notification.link = self
1148 .config
1149 .developer_link_base
1150 .as_ref()
1151 .map(|base| format!("{}/{}", base.trim_end_matches('/'), thread.id));
1152 notification.metadata.insert(
1153 "feedback_id".into(),
1154 serde_json::Value::String(thread.id.to_string()),
1155 );
1156 notification.metadata.insert(
1157 "project_id".into(),
1158 serde_json::Value::String(thread.project_id.clone()),
1159 );
1160 Some(notification)
1161 }
1162}
1163
1164fn safe_file_name(value: &str) -> String {
1165 let safe = value
1166 .chars()
1167 .map(|character| {
1168 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1169 character
1170 } else {
1171 '-'
1172 }
1173 })
1174 .take(160)
1175 .collect::<String>();
1176 if safe.trim_matches('-').is_empty() {
1177 "attachment.bin".into()
1178 } else {
1179 safe
1180 }
1181}
1182
1183fn downstream_warning(
1184 code: &'static str,
1185 public_detail: &'static str,
1186 error: &impl std::fmt::Display,
1187) -> FeedbackWarning {
1188 tracing::warn!(warning_code = code, %error, "feedback downstream action failed");
1189 FeedbackWarning::new(code, public_detail)
1190}
1191
1192fn validate_config_text(
1193 field: &'static str,
1194 value: &str,
1195 maximum: usize,
1196) -> Result<(), FeedbackServiceError> {
1197 if value.trim().is_empty()
1198 || value.chars().count() > maximum
1199 || value.chars().any(char::is_control)
1200 {
1201 return Err(FeedbackServiceError::Configuration(format!(
1202 "{field} must contain 1-{maximum} visible characters"
1203 )));
1204 }
1205 Ok(())
1206}
1207
1208fn validate_optional_config_text(
1209 field: &'static str,
1210 value: Option<&str>,
1211 maximum: usize,
1212) -> Result<(), FeedbackServiceError> {
1213 if let Some(value) = value {
1214 validate_config_text(field, value, maximum)?;
1215 }
1216 Ok(())
1217}
1218
1219#[derive(Debug, thiserror::Error)]
1220pub enum FeedbackServiceError {
1221 #[error(transparent)]
1222 Validation(#[from] FeedbackValidationError),
1223 #[error("feedback was not found: {0}")]
1224 NotFound(FeedbackId),
1225 #[error("feedback client access was denied")]
1226 ClientAccessDenied,
1227 #[error("feedback attachment was not found: {0}")]
1228 AttachmentNotFound(Uuid),
1229 #[error("invalid feedback attachment: {0}")]
1230 InvalidAttachment(String),
1231 #[error("feedback attachment {kind:?} is {actual} bytes; limit is {maximum} bytes")]
1232 AttachmentTooLarge {
1233 kind: FeedbackAttachmentKind,
1234 actual: usize,
1235 maximum: usize,
1236 },
1237 #[error("invalid feedback configuration: {0}")]
1238 Configuration(String),
1239 #[error(transparent)]
1240 Store(#[from] FeedbackStoreError),
1241 #[error(transparent)]
1242 ObjectStore(#[from] ObjectStoreError),
1243 #[error(transparent)]
1244 Transcription(#[from] TranscriptionError),
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249 use super::*;
1250 use crate::{
1251 FeedbackContext, FeedbackKind, FeedbackPriority, FeedbackStore, MemoryFeedbackStore,
1252 };
1253 use async_trait::async_trait;
1254 use minco_plugin_audit::MemoryAuditSink;
1255 use minco_plugin_events::MemoryEventBus;
1256 use minco_plugin_notifications::MemoryNotificationSink;
1257 use minco_plugin_object_storage::MemoryObjectStore;
1258 use std::collections::BTreeSet;
1259
1260 struct Harness {
1261 service: FeedbackService,
1262 notifications: Arc<MemoryNotificationSink>,
1263 audit: Arc<MemoryAuditSink>,
1264 events: Arc<MemoryEventBus>,
1265 objects: Arc<MemoryObjectStore>,
1266 }
1267
1268 fn harness() -> Harness {
1269 harness_with_config(FeedbackConfig {
1270 project_id: "example".into(),
1271 publish_events_inline: true,
1272 ..FeedbackConfig::default()
1273 })
1274 }
1275
1276 fn harness_with_config(config: FeedbackConfig) -> Harness {
1277 harness_with_store(
1278 FeedbackStoreService::new(Arc::new(MemoryFeedbackStore::default())),
1279 config,
1280 )
1281 }
1282
1283 fn harness_with_store(store: FeedbackStoreService, config: FeedbackConfig) -> Harness {
1284 let notifications = Arc::new(MemoryNotificationSink::default());
1285 let audit = Arc::new(MemoryAuditSink::default());
1286 let events = Arc::new(MemoryEventBus::default());
1287 let objects = Arc::new(MemoryObjectStore::default());
1288 let service = FeedbackService::new(
1289 store,
1290 ObjectStoreService::new(objects.clone()),
1291 NotificationService::new(notifications.clone()),
1292 AuditService::new(audit.clone()),
1293 EventServices {
1294 publisher: events.clone(),
1295 outbox: events.clone(),
1296 },
1297 None,
1298 config,
1299 )
1300 .unwrap();
1301 Harness {
1302 service,
1303 notifications,
1304 audit,
1305 events,
1306 objects,
1307 }
1308 }
1309
1310 #[derive(Debug)]
1311 struct RejectingFeedbackStore;
1312
1313 #[async_trait]
1314 impl FeedbackStore for RejectingFeedbackStore {
1315 async fn create(
1316 &self,
1317 _thread: FeedbackThread,
1318 _client_token_hash: String,
1319 ) -> Result<(), FeedbackStoreError> {
1320 Err(FeedbackStoreError::Infrastructure(
1321 "injected persistence failure".into(),
1322 ))
1323 }
1324
1325 async fn get(&self, _id: FeedbackId) -> Result<Option<FeedbackThread>, FeedbackStoreError> {
1326 Err(FeedbackStoreError::Infrastructure(
1327 "injected persistence failure".into(),
1328 ))
1329 }
1330
1331 async fn get_for_client(
1332 &self,
1333 _id: FeedbackId,
1334 _client_token_hash: &str,
1335 ) -> Result<Option<FeedbackThread>, FeedbackStoreError> {
1336 Err(FeedbackStoreError::Infrastructure(
1337 "injected persistence failure".into(),
1338 ))
1339 }
1340
1341 async fn list(
1342 &self,
1343 _filter: FeedbackListFilter,
1344 ) -> Result<Vec<FeedbackSummary>, FeedbackStoreError> {
1345 Err(FeedbackStoreError::Infrastructure(
1346 "injected persistence failure".into(),
1347 ))
1348 }
1349
1350 async fn save(
1351 &self,
1352 _thread: FeedbackThread,
1353 _expected_revision: u64,
1354 ) -> Result<(), FeedbackStoreError> {
1355 Err(FeedbackStoreError::Infrastructure(
1356 "injected persistence failure".into(),
1357 ))
1358 }
1359 }
1360
1361 #[test]
1362 fn browser_tokens_are_tab_scoped_by_default() {
1363 let config = FeedbackConfig::default();
1364 assert_eq!(config.token_storage, FeedbackTokenStorage::Session);
1365 assert_eq!(config.widget_config().token_storage, "session");
1366 assert!(!config.allow_anonymous);
1367
1368 let deserialized: FeedbackConfig =
1369 serde_json::from_value(serde_json::json!({"project_id": "example"})).unwrap();
1370 assert!(!deserialized.allow_anonymous);
1371 }
1372
1373 #[test]
1374 fn public_submission_modes_cannot_enable_transcription() {
1375 for config in [
1376 FeedbackConfig {
1377 project_id: "example".into(),
1378 transcription_enabled: true,
1379 allow_anonymous: true,
1380 ..FeedbackConfig::default()
1381 },
1382 FeedbackConfig {
1383 project_id: "example".into(),
1384 transcription_enabled: true,
1385 project_key: Some("browser-visible-key".into()),
1386 ..FeedbackConfig::default()
1387 },
1388 ] {
1389 assert!(matches!(
1390 config.validate(),
1391 Err(FeedbackServiceError::Configuration(_))
1392 ));
1393 }
1394 }
1395
1396 #[test]
1397 fn downstream_warning_details_do_not_expose_provider_diagnostics() {
1398 let warning = downstream_warning(
1399 "feedback_transcription_failed",
1400 "Audio transcription did not complete.",
1401 &TranscriptionError::Provider(
1402 "provider response containing sensitive diagnostics".into(),
1403 ),
1404 );
1405
1406 assert_eq!(warning.detail, "Audio transcription did not complete.");
1407 assert!(!warning.detail.contains("sensitive diagnostics"));
1408 }
1409
1410 fn input() -> CreateFeedbackInput {
1411 CreateFeedbackInput {
1412 project_id: "example".into(),
1413 kind: FeedbackKind::Bug,
1414 priority: FeedbackPriority::High,
1415 title: "Save does not work".into(),
1416 description: "The save button leaves the form open.".into(),
1417 context: FeedbackContext {
1418 page_url: "https://example.test/orders/one".into(),
1419 route_name: Some("order-edit".into()),
1420 release_id: Some("release-1".into()),
1421 environment: Some("review".into()),
1422 request_id: Some("request-1".into()),
1423 user_agent: None,
1424 viewport: None,
1425 client_subject: Some("client-1".into()),
1426 },
1427 tags: BTreeSet::new(),
1428 }
1429 }
1430
1431 #[tokio::test]
1432 async fn create_persists_notifies_audits_and_publishes() {
1433 let harness = harness();
1434 let created = harness
1435 .service
1436 .create(input(), Vec::new(), Uuid::now_v7())
1437 .await
1438 .unwrap();
1439 assert_eq!(created.thread.status, FeedbackStatus::New);
1440 assert_eq!(harness.notifications.all().await.len(), 1);
1441 assert_eq!(harness.audit.all().await.len(), 1);
1442 assert_eq!(harness.events.published().await.len(), 1);
1443 }
1444
1445 #[tokio::test]
1446 async fn default_fast_path_queues_events_without_waiting_for_publication() {
1447 let harness = harness_with_config(FeedbackConfig {
1448 project_id: "example".into(),
1449 ..FeedbackConfig::default()
1450 });
1451 harness
1452 .service
1453 .create(input(), Vec::new(), Uuid::now_v7())
1454 .await
1455 .unwrap();
1456 assert!(harness.events.published().await.is_empty());
1457 let records = harness.events.outbox_records().await;
1458 assert_eq!(records.len(), 1);
1459 assert_eq!(
1460 records[0].status,
1461 minco_plugin_events::OutboxStatus::Pending
1462 );
1463 }
1464
1465 #[tokio::test]
1466 async fn developer_question_enters_clarification_loop() {
1467 let harness = harness();
1468 let created = harness
1469 .service
1470 .create(input(), Vec::new(), Uuid::now_v7())
1471 .await
1472 .unwrap();
1473 let result = harness
1474 .service
1475 .reply_as_developer(
1476 created.thread.id,
1477 DeveloperReplyInput {
1478 body: "Does this still happen after refreshing?".into(),
1479 visible_to_client: true,
1480 author_display: Some("developer".into()),
1481 },
1482 "developer-1".into(),
1483 Uuid::now_v7(),
1484 )
1485 .await
1486 .unwrap();
1487 assert_eq!(result.thread.status, FeedbackStatus::NeedsClarification);
1488 assert_eq!(
1489 harness
1490 .audit
1491 .all()
1492 .await
1493 .last()
1494 .unwrap()
1495 .actor_subject
1496 .as_deref(),
1497 Some("developer-1")
1498 );
1499 }
1500
1501 #[tokio::test]
1502 async fn client_token_is_required_for_client_thread_access() {
1503 let harness = harness();
1504 let created = harness
1505 .service
1506 .create(input(), Vec::new(), Uuid::now_v7())
1507 .await
1508 .unwrap();
1509 assert!(
1510 harness
1511 .service
1512 .get_for_client(created.thread.id, &FeedbackAccessToken::generate())
1513 .await
1514 .is_err()
1515 );
1516 assert!(
1517 harness
1518 .service
1519 .get_for_client(created.thread.id, &created.client_token)
1520 .await
1521 .is_ok()
1522 );
1523 }
1524
1525 #[tokio::test]
1526 async fn developer_access_is_scoped_to_the_configured_project() {
1527 let store = Arc::new(MemoryFeedbackStore::default());
1528 let foreign_token = FeedbackAccessToken::generate();
1529 let foreign_thread = FeedbackThread::create(CreateFeedbackInput {
1530 project_id: "foreign".into(),
1531 ..input()
1532 })
1533 .unwrap();
1534 let foreign_id = foreign_thread.id;
1535 store
1536 .create(foreign_thread, hash_access_token(&foreign_token))
1537 .await
1538 .unwrap();
1539 let harness = harness_with_store(
1540 FeedbackStoreService::new(store),
1541 FeedbackConfig {
1542 project_id: "example".into(),
1543 ..FeedbackConfig::default()
1544 },
1545 );
1546
1547 assert!(matches!(
1548 harness.service.get_for_developer(foreign_id).await,
1549 Err(FeedbackServiceError::NotFound(id)) if id == foreign_id
1550 ));
1551 assert!(matches!(
1552 harness
1553 .service
1554 .get_for_client(foreign_id, &foreign_token)
1555 .await,
1556 Err(FeedbackServiceError::ClientAccessDenied)
1557 ));
1558 assert!(matches!(
1559 harness
1560 .service
1561 .list(FeedbackListFilter {
1562 project_id: Some("foreign".into()),
1563 ..FeedbackListFilter::default()
1564 })
1565 .await,
1566 Err(FeedbackServiceError::Validation(
1567 FeedbackValidationError::InvalidField {
1568 field: "project_id",
1569 ..
1570 }
1571 ))
1572 ));
1573 }
1574
1575 #[tokio::test]
1576 async fn service_enforces_attachment_count_without_relying_on_http_extractors() {
1577 let harness = harness_with_config(FeedbackConfig {
1578 project_id: "example".into(),
1579 max_attachments: 1,
1580 ..FeedbackConfig::default()
1581 });
1582 let upload = AttachmentUpload {
1583 kind: FeedbackAttachmentKind::Screenshot,
1584 file_name: "screen.png".into(),
1585 content_type: "image/png".into(),
1586 bytes: vec![1, 2, 3],
1587 };
1588 let result = harness
1589 .service
1590 .create(input(), vec![upload.clone(), upload], Uuid::now_v7())
1591 .await;
1592 assert!(matches!(
1593 result,
1594 Err(FeedbackServiceError::InvalidAttachment(_))
1595 ));
1596 assert!(harness.objects.is_empty().await);
1597 }
1598
1599 #[tokio::test]
1600 async fn partially_uploaded_objects_are_removed_when_a_later_attachment_is_invalid() {
1601 let harness = harness();
1602 let result = harness
1603 .service
1604 .create(
1605 input(),
1606 vec![
1607 AttachmentUpload {
1608 kind: FeedbackAttachmentKind::Screenshot,
1609 file_name: "screen.png".into(),
1610 content_type: "image/png".into(),
1611 bytes: vec![1, 2, 3],
1612 },
1613 AttachmentUpload {
1614 kind: FeedbackAttachmentKind::Screenshot,
1615 file_name: "not-an-image.txt".into(),
1616 content_type: "text/plain".into(),
1617 bytes: vec![4, 5, 6],
1618 },
1619 ],
1620 Uuid::now_v7(),
1621 )
1622 .await;
1623 assert!(matches!(
1624 result,
1625 Err(FeedbackServiceError::InvalidAttachment(_))
1626 ));
1627 assert!(harness.objects.is_empty().await);
1628 }
1629
1630 #[tokio::test]
1631 async fn uploaded_objects_are_removed_when_authoritative_persistence_fails() {
1632 let harness = harness_with_store(
1633 FeedbackStoreService::new(Arc::new(RejectingFeedbackStore)),
1634 FeedbackConfig {
1635 project_id: "example".into(),
1636 ..FeedbackConfig::default()
1637 },
1638 );
1639 let result = harness
1640 .service
1641 .create(
1642 input(),
1643 vec![AttachmentUpload {
1644 kind: FeedbackAttachmentKind::Screenshot,
1645 file_name: "screen.png".into(),
1646 content_type: "image/png".into(),
1647 bytes: vec![1, 2, 3],
1648 }],
1649 Uuid::now_v7(),
1650 )
1651 .await;
1652 assert!(matches!(
1653 result,
1654 Err(FeedbackServiceError::Store(
1655 FeedbackStoreError::Infrastructure(_)
1656 ))
1657 ));
1658 assert!(harness.objects.is_empty().await);
1659 }
1660
1661 #[tokio::test]
1662 async fn disabled_media_features_fail_closed_below_the_http_layer() {
1663 let harness = harness_with_config(FeedbackConfig {
1664 project_id: "example".into(),
1665 screenshot_enabled: false,
1666 voice_enabled: false,
1667 ..FeedbackConfig::default()
1668 });
1669 for upload in [
1670 AttachmentUpload {
1671 kind: FeedbackAttachmentKind::Screenshot,
1672 file_name: "screen.png".into(),
1673 content_type: "image/png".into(),
1674 bytes: vec![1],
1675 },
1676 AttachmentUpload {
1677 kind: FeedbackAttachmentKind::Audio,
1678 file_name: "voice.webm".into(),
1679 content_type: "audio/webm".into(),
1680 bytes: vec![1],
1681 },
1682 ] {
1683 let result = harness
1684 .service
1685 .create(input(), vec![upload], Uuid::now_v7())
1686 .await;
1687 assert!(matches!(
1688 result,
1689 Err(FeedbackServiceError::InvalidAttachment(_))
1690 ));
1691 }
1692 assert!(harness.objects.is_empty().await);
1693 }
1694}