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    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/// 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 !(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// 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}
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}