1use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use sqlx::{FromRow, PgPool};
8use std::fmt;
9use uuid::Uuid;
10
11use crate::error::{ReputationError, Result};
12
13#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
14pub struct OutputCommitment {
15 pub commitment_id: Uuid,
16 pub user_id: Uuid,
17 pub token_id: Uuid,
18 pub title: String,
19 pub description: Option<String>,
20 pub deadline: DateTime<Utc>,
21 pub status: CommitmentStatus,
22 pub evidence_url: Option<String>,
23 pub evidence_description: Option<String>,
24 pub verified_at: Option<DateTime<Utc>>,
25 pub verified_by: Option<Uuid>,
26 pub created_at: DateTime<Utc>,
27}
28
29impl fmt::Display for OutputCommitment {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(
32 f,
33 "Commitment({}, {}, status={:?})",
34 self.commitment_id, self.title, self.status
35 )
36 }
37}
38
39#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq, Eq)]
40#[sqlx(type_name = "varchar", rename_all = "lowercase")]
41#[derive(Default)]
42pub enum CommitmentStatus {
43 #[default]
44 Pending,
45 Completed,
46 Verified,
47 Failed,
48 Expired,
49}
50
51impl CommitmentStatus {
52 pub fn is_terminal(&self) -> bool {
54 matches!(
55 self,
56 CommitmentStatus::Verified | CommitmentStatus::Failed | CommitmentStatus::Expired
57 )
58 }
59
60 pub fn is_active(&self) -> bool {
62 matches!(
63 self,
64 CommitmentStatus::Pending | CommitmentStatus::Completed
65 )
66 }
67
68 pub fn is_successful(&self) -> bool {
70 matches!(self, CommitmentStatus::Verified)
71 }
72
73 pub fn is_failed(&self) -> bool {
75 matches!(self, CommitmentStatus::Failed | CommitmentStatus::Expired)
76 }
77
78 pub fn can_transition_to(&self, target: CommitmentStatus) -> bool {
80 match self {
81 CommitmentStatus::Pending => matches!(
82 target,
83 CommitmentStatus::Completed | CommitmentStatus::Expired | CommitmentStatus::Failed
84 ),
85 CommitmentStatus::Completed => matches!(
86 target,
87 CommitmentStatus::Verified | CommitmentStatus::Failed
88 ),
89 CommitmentStatus::Verified | CommitmentStatus::Failed | CommitmentStatus::Expired => {
91 false
92 }
93 }
94 }
95
96 pub fn possible_next_states(&self) -> Vec<CommitmentStatus> {
98 match self {
99 CommitmentStatus::Pending => vec![
100 CommitmentStatus::Completed,
101 CommitmentStatus::Expired,
102 CommitmentStatus::Failed,
103 ],
104 CommitmentStatus::Completed => {
105 vec![CommitmentStatus::Verified, CommitmentStatus::Failed]
106 }
107 CommitmentStatus::Verified | CommitmentStatus::Failed | CommitmentStatus::Expired => {
109 vec![]
110 }
111 }
112 }
113
114 pub fn all_variants() -> [Self; 5] {
116 [
117 Self::Pending,
118 Self::Completed,
119 Self::Verified,
120 Self::Failed,
121 Self::Expired,
122 ]
123 }
124
125 pub fn as_str(&self) -> &'static str {
127 match self {
128 Self::Pending => "pending",
129 Self::Completed => "completed",
130 Self::Verified => "verified",
131 Self::Failed => "failed",
132 Self::Expired => "expired",
133 }
134 }
135}
136
137impl fmt::Display for CommitmentStatus {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self {
140 CommitmentStatus::Pending => write!(f, "pending"),
141 CommitmentStatus::Completed => write!(f, "completed"),
142 CommitmentStatus::Verified => write!(f, "verified"),
143 CommitmentStatus::Failed => write!(f, "failed"),
144 CommitmentStatus::Expired => write!(f, "expired"),
145 }
146 }
147}
148
149impl std::str::FromStr for CommitmentStatus {
150 type Err = ReputationError;
151
152 fn from_str(s: &str) -> Result<Self> {
153 match s.to_lowercase().as_str() {
154 "pending" => Ok(CommitmentStatus::Pending),
155 "completed" => Ok(CommitmentStatus::Completed),
156 "verified" => Ok(CommitmentStatus::Verified),
157 "failed" => Ok(CommitmentStatus::Failed),
158 "expired" => Ok(CommitmentStatus::Expired),
159 _ => Err(ReputationError::Validation(format!(
160 "Invalid commitment status: {}. Valid values: pending, completed, verified, failed, expired",
161 s
162 ))),
163 }
164 }
165}
166
167#[derive(Debug, Deserialize)]
168pub struct CreateCommitmentRequest {
169 pub token_id: Uuid,
170 pub title: String,
171 pub description: Option<String>,
172 pub deadline: DateTime<Utc>,
173}
174
175impl CreateCommitmentRequest {
176 pub fn validate(&self) -> Result<()> {
177 if self.title.is_empty() {
178 return Err(ReputationError::Validation("Title is required".to_string()));
179 }
180 if self.title.len() > 200 {
181 return Err(ReputationError::Validation(
182 "Title must be at most 200 characters".to_string(),
183 ));
184 }
185 if let Some(ref desc) = self.description {
186 if desc.len() > 2000 {
187 return Err(ReputationError::Validation(
188 "Description must be at most 2000 characters".to_string(),
189 ));
190 }
191 }
192 if self.deadline < Utc::now() {
193 return Err(ReputationError::Validation(
194 "Deadline must be in the future".to_string(),
195 ));
196 }
197 Ok(())
198 }
199
200 pub fn builder(token_id: Uuid, title: impl Into<String>) -> CreateCommitmentRequestBuilder {
202 CreateCommitmentRequestBuilder::new(token_id, title)
203 }
204}
205
206pub struct CreateCommitmentRequestBuilder {
208 token_id: Uuid,
209 title: String,
210 description: Option<String>,
211 deadline: Option<DateTime<Utc>>,
212}
213
214impl CreateCommitmentRequestBuilder {
215 pub fn new(token_id: Uuid, title: impl Into<String>) -> Self {
217 Self {
218 token_id,
219 title: title.into(),
220 description: None,
221 deadline: None,
222 }
223 }
224
225 pub fn description(mut self, description: impl Into<String>) -> Self {
227 self.description = Some(description.into());
228 self
229 }
230
231 pub fn deadline(mut self, deadline: DateTime<Utc>) -> Self {
233 self.deadline = Some(deadline);
234 self
235 }
236
237 pub fn deadline_days_from_now(mut self, days: i64) -> Self {
239 self.deadline = Some(Utc::now() + chrono::Duration::days(days));
240 self
241 }
242
243 pub fn build(self) -> Result<CreateCommitmentRequest> {
245 let deadline = self
246 .deadline
247 .ok_or_else(|| ReputationError::Validation("Deadline is required".to_string()))?;
248
249 let request = CreateCommitmentRequest {
250 token_id: self.token_id,
251 title: self.title,
252 description: self.description,
253 deadline,
254 };
255
256 request.validate()?;
257 Ok(request)
258 }
259}
260
261#[derive(Debug, Deserialize)]
262pub struct SubmitEvidenceRequest {
263 pub evidence_url: String,
264 pub evidence_description: Option<String>,
265}
266
267impl SubmitEvidenceRequest {
268 pub fn validate(&self) -> Result<()> {
269 if self.evidence_url.is_empty() {
270 return Err(ReputationError::Validation(
271 "Evidence URL is required".to_string(),
272 ));
273 }
274 if !self.evidence_url.starts_with("https://") {
275 return Err(ReputationError::Validation(
276 "Evidence URL must be HTTPS".to_string(),
277 ));
278 }
279 Ok(())
280 }
281}
282
283#[derive(Debug, Deserialize)]
284pub struct VerifyCommitmentRequest {
285 pub fulfilled: bool,
286 pub quality_score: Option<Decimal>,
287 pub notes: Option<String>,
288}
289
290pub struct CommitmentService {
292 pool: PgPool,
293}
294
295impl CommitmentService {
296 pub fn new(pool: PgPool) -> Self {
297 Self { pool }
298 }
299
300 pub async fn create(
302 &self,
303 user_id: Uuid,
304 request: &CreateCommitmentRequest,
305 ) -> Result<OutputCommitment> {
306 request.validate()?;
307
308 let commitment = sqlx::query_as::<_, OutputCommitment>(
309 r#"
310 INSERT INTO output_commitments (user_id, token_id, title, description, deadline)
311 VALUES ($1, $2, $3, $4, $5)
312 RETURNING *
313 "#,
314 )
315 .bind(user_id)
316 .bind(request.token_id)
317 .bind(&request.title)
318 .bind(&request.description)
319 .bind(request.deadline)
320 .fetch_one(&self.pool)
321 .await?;
322
323 tracing::info!(commitment_id = %commitment.commitment_id, "Commitment created");
324 Ok(commitment)
325 }
326
327 pub async fn submit_evidence(
329 &self,
330 commitment_id: Uuid,
331 user_id: Uuid,
332 request: &SubmitEvidenceRequest,
333 ) -> Result<OutputCommitment> {
334 request.validate()?;
335
336 let commitment = sqlx::query_as::<_, OutputCommitment>(
337 r#"
338 UPDATE output_commitments
339 SET evidence_url = $3, evidence_description = $4, status = 'completed'
340 WHERE commitment_id = $1 AND user_id = $2 AND status = 'pending'
341 RETURNING *
342 "#,
343 )
344 .bind(commitment_id)
345 .bind(user_id)
346 .bind(&request.evidence_url)
347 .bind(&request.evidence_description)
348 .fetch_optional(&self.pool)
349 .await?
350 .ok_or_else(|| ReputationError::CommitmentNotFound(commitment_id.to_string()))?;
351
352 tracing::info!(commitment_id = %commitment_id, "Evidence submitted");
353 Ok(commitment)
354 }
355
356 pub async fn verify(
358 &self,
359 commitment_id: Uuid,
360 admin_id: Uuid,
361 request: &VerifyCommitmentRequest,
362 ) -> Result<(OutputCommitment, Decimal)> {
363 let mut tx = self.pool.begin().await?;
364
365 let commitment: OutputCommitment = sqlx::query_as(
367 r#"
368 SELECT * FROM output_commitments
369 WHERE commitment_id = $1 AND status = 'completed'
370 FOR UPDATE
371 "#,
372 )
373 .bind(commitment_id)
374 .fetch_optional(&mut *tx)
375 .await?
376 .ok_or_else(|| ReputationError::CommitmentNotFound(commitment_id.to_string()))?;
377
378 let new_status = if request.fulfilled {
379 CommitmentStatus::Verified
380 } else {
381 CommitmentStatus::Failed
382 };
383
384 let updated: OutputCommitment = sqlx::query_as(
386 r#"
387 UPDATE output_commitments
388 SET status = $2, verified_at = NOW(), verified_by = $3
389 WHERE commitment_id = $1
390 RETURNING *
391 "#,
392 )
393 .bind(commitment_id)
394 .bind(new_status.to_string())
395 .bind(admin_id)
396 .fetch_one(&mut *tx)
397 .await?;
398
399 let base_delta = if request.fulfilled {
401 dec!(10) } else {
403 dec!(-20) };
405
406 let quality_bonus = if request.fulfilled {
408 request
409 .quality_score
410 .map(|q| (q - dec!(50)) / dec!(10)) .unwrap_or(dec!(0))
412 } else {
413 dec!(0)
414 };
415
416 let total_delta = base_delta + quality_bonus;
417
418 sqlx::query(
420 r#"
421 UPDATE users
422 SET reputation_score = GREATEST(0, LEAST(1000, reputation_score + $2))
423 WHERE user_id = $1
424 "#,
425 )
426 .bind(commitment.user_id)
427 .bind(total_delta)
428 .execute(&mut *tx)
429 .await?;
430
431 sqlx::query(
433 r#"
434 INSERT INTO reputation_events (user_id, event_type, delta, reason)
435 VALUES ($1, $2, $3, $4)
436 "#,
437 )
438 .bind(commitment.user_id)
439 .bind(if request.fulfilled {
440 "commitment_fulfilled"
441 } else {
442 "commitment_failed"
443 })
444 .bind(total_delta)
445 .bind(&request.notes)
446 .execute(&mut *tx)
447 .await?;
448
449 tx.commit().await?;
450
451 tracing::info!(
452 commitment_id = %commitment_id,
453 fulfilled = request.fulfilled,
454 delta = %total_delta,
455 "Commitment verified"
456 );
457
458 Ok((updated, total_delta))
459 }
460
461 pub async fn get_user_commitments(&self, user_id: Uuid) -> Result<Vec<OutputCommitment>> {
463 let commitments = sqlx::query_as::<_, OutputCommitment>(
464 r#"
465 SELECT * FROM output_commitments
466 WHERE user_id = $1
467 ORDER BY created_at DESC
468 "#,
469 )
470 .bind(user_id)
471 .fetch_all(&self.pool)
472 .await?;
473
474 Ok(commitments)
475 }
476
477 pub async fn get_token_commitments(&self, token_id: Uuid) -> Result<Vec<OutputCommitment>> {
479 let commitments = sqlx::query_as::<_, OutputCommitment>(
480 r#"
481 SELECT * FROM output_commitments
482 WHERE token_id = $1
483 ORDER BY created_at DESC
484 "#,
485 )
486 .bind(token_id)
487 .fetch_all(&self.pool)
488 .await?;
489
490 Ok(commitments)
491 }
492
493 pub async fn get_pending_verification(&self, limit: i64) -> Result<Vec<OutputCommitment>> {
495 let commitments = sqlx::query_as::<_, OutputCommitment>(
496 r#"
497 SELECT * FROM output_commitments
498 WHERE status = 'completed'
499 ORDER BY created_at ASC
500 LIMIT $1
501 "#,
502 )
503 .bind(limit)
504 .fetch_all(&self.pool)
505 .await?;
506
507 Ok(commitments)
508 }
509
510 pub async fn expire_overdue(&self) -> Result<u64> {
512 let mut tx = self.pool.begin().await?;
513
514 let overdue: Vec<OutputCommitment> = sqlx::query_as(
516 r#"
517 SELECT * FROM output_commitments
518 WHERE status = 'pending' AND deadline < NOW()
519 FOR UPDATE
520 "#,
521 )
522 .fetch_all(&mut *tx)
523 .await?;
524
525 let count = overdue.len() as u64;
526
527 for commitment in &overdue {
528 sqlx::query(
530 r#"
531 UPDATE output_commitments
532 SET status = 'expired'
533 WHERE commitment_id = $1
534 "#,
535 )
536 .bind(commitment.commitment_id)
537 .execute(&mut *tx)
538 .await?;
539
540 let penalty = dec!(-15);
542 sqlx::query(
543 r#"
544 UPDATE users
545 SET reputation_score = GREATEST(0, reputation_score + $2)
546 WHERE user_id = $1
547 "#,
548 )
549 .bind(commitment.user_id)
550 .bind(penalty)
551 .execute(&mut *tx)
552 .await?;
553
554 sqlx::query(
556 r#"
557 INSERT INTO reputation_events (user_id, event_type, delta, reason)
558 VALUES ($1, 'commitment_expired', $2, $3)
559 "#,
560 )
561 .bind(commitment.user_id)
562 .bind(penalty)
563 .bind(format!("Commitment expired: {}", commitment.title))
564 .execute(&mut *tx)
565 .await?;
566 }
567
568 tx.commit().await?;
569
570 if count > 0 {
571 tracing::info!(count = count, "Expired overdue commitments");
572 }
573
574 Ok(count)
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn test_commitment_status_is_terminal() {
584 assert!(CommitmentStatus::Verified.is_terminal());
585 assert!(CommitmentStatus::Failed.is_terminal());
586 assert!(CommitmentStatus::Expired.is_terminal());
587 assert!(!CommitmentStatus::Pending.is_terminal());
588 assert!(!CommitmentStatus::Completed.is_terminal());
589 }
590
591 #[test]
592 fn test_commitment_status_is_active() {
593 assert!(CommitmentStatus::Pending.is_active());
594 assert!(CommitmentStatus::Completed.is_active());
595 assert!(!CommitmentStatus::Verified.is_active());
596 assert!(!CommitmentStatus::Failed.is_active());
597 assert!(!CommitmentStatus::Expired.is_active());
598 }
599
600 #[test]
601 fn test_commitment_status_is_successful() {
602 assert!(CommitmentStatus::Verified.is_successful());
603 assert!(!CommitmentStatus::Pending.is_successful());
604 assert!(!CommitmentStatus::Completed.is_successful());
605 assert!(!CommitmentStatus::Failed.is_successful());
606 assert!(!CommitmentStatus::Expired.is_successful());
607 }
608
609 #[test]
610 fn test_commitment_status_is_failed() {
611 assert!(CommitmentStatus::Failed.is_failed());
612 assert!(CommitmentStatus::Expired.is_failed());
613 assert!(!CommitmentStatus::Pending.is_failed());
614 assert!(!CommitmentStatus::Completed.is_failed());
615 assert!(!CommitmentStatus::Verified.is_failed());
616 }
617
618 #[test]
619 fn test_commitment_status_can_transition() {
620 assert!(CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Completed));
622 assert!(CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Expired));
623 assert!(CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Failed));
624 assert!(!CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Verified));
625 assert!(!CommitmentStatus::Pending.can_transition_to(CommitmentStatus::Pending));
626
627 assert!(CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Verified));
629 assert!(CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Failed));
630 assert!(!CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Pending));
631 assert!(!CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Expired));
632 assert!(!CommitmentStatus::Completed.can_transition_to(CommitmentStatus::Completed));
633
634 assert!(!CommitmentStatus::Verified.can_transition_to(CommitmentStatus::Failed));
636 assert!(!CommitmentStatus::Failed.can_transition_to(CommitmentStatus::Verified));
637 assert!(!CommitmentStatus::Expired.can_transition_to(CommitmentStatus::Pending));
638 }
639
640 #[test]
641 fn test_commitment_status_possible_next_states() {
642 let pending_next = CommitmentStatus::Pending.possible_next_states();
643 assert_eq!(pending_next.len(), 3);
644 assert!(pending_next.contains(&CommitmentStatus::Completed));
645 assert!(pending_next.contains(&CommitmentStatus::Expired));
646 assert!(pending_next.contains(&CommitmentStatus::Failed));
647
648 let completed_next = CommitmentStatus::Completed.possible_next_states();
649 assert_eq!(completed_next.len(), 2);
650 assert!(completed_next.contains(&CommitmentStatus::Verified));
651 assert!(completed_next.contains(&CommitmentStatus::Failed));
652
653 assert_eq!(CommitmentStatus::Verified.possible_next_states().len(), 0);
655 assert_eq!(CommitmentStatus::Failed.possible_next_states().len(), 0);
656 assert_eq!(CommitmentStatus::Expired.possible_next_states().len(), 0);
657 }
658
659 #[test]
660 fn test_commitment_status_display() {
661 assert_eq!(CommitmentStatus::Pending.to_string(), "pending");
662 assert_eq!(CommitmentStatus::Completed.to_string(), "completed");
663 assert_eq!(CommitmentStatus::Verified.to_string(), "verified");
664 assert_eq!(CommitmentStatus::Failed.to_string(), "failed");
665 assert_eq!(CommitmentStatus::Expired.to_string(), "expired");
666 }
667
668 #[test]
669 fn test_commitment_status_default() {
670 assert_eq!(CommitmentStatus::default(), CommitmentStatus::Pending);
671 }
672
673 #[test]
674 fn test_commitment_status_from_str_valid() {
675 assert_eq!(
676 "pending".parse::<CommitmentStatus>().unwrap(),
677 CommitmentStatus::Pending
678 );
679 assert_eq!(
680 "completed".parse::<CommitmentStatus>().unwrap(),
681 CommitmentStatus::Completed
682 );
683 assert_eq!(
684 "verified".parse::<CommitmentStatus>().unwrap(),
685 CommitmentStatus::Verified
686 );
687 assert_eq!(
688 "failed".parse::<CommitmentStatus>().unwrap(),
689 CommitmentStatus::Failed
690 );
691 assert_eq!(
692 "expired".parse::<CommitmentStatus>().unwrap(),
693 CommitmentStatus::Expired
694 );
695 }
696
697 #[test]
698 fn test_commitment_status_from_str_case_insensitive() {
699 assert_eq!(
700 "PENDING".parse::<CommitmentStatus>().unwrap(),
701 CommitmentStatus::Pending
702 );
703 assert_eq!(
704 "Completed".parse::<CommitmentStatus>().unwrap(),
705 CommitmentStatus::Completed
706 );
707 assert_eq!(
708 "VERIFIED".parse::<CommitmentStatus>().unwrap(),
709 CommitmentStatus::Verified
710 );
711 assert_eq!(
712 "Failed".parse::<CommitmentStatus>().unwrap(),
713 CommitmentStatus::Failed
714 );
715 assert_eq!(
716 "EXPIRED".parse::<CommitmentStatus>().unwrap(),
717 CommitmentStatus::Expired
718 );
719 }
720
721 #[test]
722 fn test_commitment_status_from_str_invalid() {
723 assert!("invalid".parse::<CommitmentStatus>().is_err());
724 assert!("unknown".parse::<CommitmentStatus>().is_err());
725 assert!("".parse::<CommitmentStatus>().is_err());
726 }
727
728 #[test]
730 fn test_commitment_status_all_variants() {
731 let variants = CommitmentStatus::all_variants();
732 assert_eq!(variants.len(), 5);
733 assert_eq!(variants[0], CommitmentStatus::Pending);
734 assert_eq!(variants[1], CommitmentStatus::Completed);
735 assert_eq!(variants[2], CommitmentStatus::Verified);
736 assert_eq!(variants[3], CommitmentStatus::Failed);
737 assert_eq!(variants[4], CommitmentStatus::Expired);
738 }
739
740 #[test]
741 fn test_commitment_status_as_str() {
742 assert_eq!(CommitmentStatus::Pending.as_str(), "pending");
743 assert_eq!(CommitmentStatus::Completed.as_str(), "completed");
744 assert_eq!(CommitmentStatus::Verified.as_str(), "verified");
745 assert_eq!(CommitmentStatus::Failed.as_str(), "failed");
746 assert_eq!(CommitmentStatus::Expired.as_str(), "expired");
747 }
748
749 #[test]
750 fn test_create_commitment_request_builder() {
751 let token_id = Uuid::new_v4();
752 let request = CreateCommitmentRequest::builder(token_id, "Test Commitment")
753 .description("Test description")
754 .deadline_days_from_now(7)
755 .build();
756
757 assert!(request.is_ok());
758 let req = request.unwrap();
759 assert_eq!(req.token_id, token_id);
760 assert_eq!(req.title, "Test Commitment");
761 assert_eq!(req.description, Some("Test description".to_string()));
762 }
763
764 #[test]
765 fn test_create_commitment_request_builder_no_deadline() {
766 let token_id = Uuid::new_v4();
767 let request = CreateCommitmentRequest::builder(token_id, "Test")
768 .description("Desc")
769 .build();
770
771 assert!(request.is_err());
772 }
773
774 #[test]
775 fn test_create_commitment_request_builder_validation() {
776 let token_id = Uuid::new_v4();
777 let long_title = "a".repeat(201);
779 let request = CreateCommitmentRequest::builder(token_id, long_title)
780 .deadline_days_from_now(7)
781 .build();
782
783 assert!(request.is_err());
784 }
785}