Skip to main content

minco_plugin_feedback/
service.rs

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/// Browser storage used for the opaque client conversation token.
65///
66/// `session` is the privacy-preserving default: the thread remains available
67/// across navigation in the current tab but is not retained after the tab is
68/// closed. `local` is an explicit opt-in for longer-lived review environments.
69#[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/// Runtime configuration for a feedback deployment.
88///
89/// Browser-visible project keys are an abuse-control mechanism rather than an
90/// authentication secret. Developer bearer tokens are intended only as a
91/// local/operator fallback; production applications should inject a
92/// `minco_http::Principal` with the `feedback.manage` permission.
93#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
94// These independent deployment controls intentionally map one-to-one to the
95// public plugin configuration schema and are not mutually exclusive states.
96#[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    /// Publish newly enqueued domain events on the request path. Disabled by default so feedback
136    /// submission latency is not coupled to SQS, `EventBridge`, or another external broker.
137    #[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    /// Include URL query parameters in captured page context. Disabled by default because query
148    /// strings commonly contain personal data or temporary credentials.
149    #[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// This is the browser-safe projection of independent widget capabilities.
343#[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}