kaccy_reputation/
recurring.rs

1//! Recurring commitments module
2//!
3//! This module provides:
4//! - Recurring commitment definitions (monthly, weekly, etc.)
5//! - Automatic commitment generation
6//! - Recurring commitment tracking
7
8use chrono::{DateTime, Datelike, Duration, Utc};
9use rust_decimal::Decimal;
10use rust_decimal_macros::dec;
11use serde::{Deserialize, Serialize};
12use sqlx::{FromRow, PgPool};
13use uuid::Uuid;
14
15use crate::error::{ReputationError, Result};
16
17/// Recurrence frequency
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum RecurrenceFrequency {
21    /// Every week
22    Weekly,
23    /// Every two weeks
24    Biweekly,
25    /// Every month
26    Monthly,
27    /// Every quarter (3 months)
28    Quarterly,
29}
30
31impl RecurrenceFrequency {
32    /// Get the database string representation
33    pub fn to_db_string(&self) -> &'static str {
34        match self {
35            RecurrenceFrequency::Weekly => "weekly",
36            RecurrenceFrequency::Biweekly => "biweekly",
37            RecurrenceFrequency::Monthly => "monthly",
38            RecurrenceFrequency::Quarterly => "quarterly",
39        }
40    }
41
42    /// Parse from database string
43    pub fn from_db_string(s: &str) -> Option<Self> {
44        match s {
45            "weekly" => Some(RecurrenceFrequency::Weekly),
46            "biweekly" => Some(RecurrenceFrequency::Biweekly),
47            "monthly" => Some(RecurrenceFrequency::Monthly),
48            "quarterly" => Some(RecurrenceFrequency::Quarterly),
49            _ => None,
50        }
51    }
52
53    /// Calculate next deadline from current date
54    pub fn next_deadline(&self, from: DateTime<Utc>) -> DateTime<Utc> {
55        match self {
56            RecurrenceFrequency::Weekly => from + Duration::days(7),
57            RecurrenceFrequency::Biweekly => from + Duration::days(14),
58            RecurrenceFrequency::Monthly => {
59                // Add one month
60                let next_month = if from.month() == 12 {
61                    from.with_year(from.year() + 1)
62                        .and_then(|d| d.with_month(1))
63                } else {
64                    from.with_month(from.month() + 1)
65                };
66                next_month.unwrap_or(from + Duration::days(30))
67            }
68            RecurrenceFrequency::Quarterly => {
69                // Add three months
70                let target_month = from.month() + 3;
71                let (year, month) = if target_month > 12 {
72                    (from.year() + 1, target_month - 12)
73                } else {
74                    (from.year(), target_month)
75                };
76                from.with_year(year)
77                    .and_then(|d| d.with_month(month))
78                    .unwrap_or(from + Duration::days(90))
79            }
80        }
81    }
82
83    /// Get human-readable description
84    pub fn description(&self) -> &'static str {
85        match self {
86            RecurrenceFrequency::Weekly => "every week",
87            RecurrenceFrequency::Biweekly => "every two weeks",
88            RecurrenceFrequency::Monthly => "every month",
89            RecurrenceFrequency::Quarterly => "every quarter",
90        }
91    }
92}
93
94impl std::fmt::Display for RecurrenceFrequency {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "{}", self.to_db_string())
97    }
98}
99
100impl std::str::FromStr for RecurrenceFrequency {
101    type Err = crate::error::ReputationError;
102
103    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
104        match s.to_lowercase().as_str() {
105            "weekly" => Ok(RecurrenceFrequency::Weekly),
106            "biweekly" | "bi-weekly" | "bi_weekly" => Ok(RecurrenceFrequency::Biweekly),
107            "monthly" => Ok(RecurrenceFrequency::Monthly),
108            "quarterly" => Ok(RecurrenceFrequency::Quarterly),
109            _ => Err(crate::error::ReputationError::Validation(format!(
110                "Invalid recurrence frequency: {}. Valid values: weekly, biweekly, monthly, quarterly",
111                s
112            ))),
113        }
114    }
115}
116
117/// A recurring commitment definition
118#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
119pub struct RecurringCommitment {
120    pub recurring_id: Uuid,
121    pub user_id: Uuid,
122    pub token_id: Uuid,
123    pub title: String,
124    pub description: Option<String>,
125    pub frequency: String,
126    pub is_active: bool,
127    pub instances_created: i32,
128    pub instances_fulfilled: i32,
129    pub instances_failed: i32,
130    pub last_generated_at: Option<DateTime<Utc>>,
131    pub next_deadline: DateTime<Utc>,
132    pub created_at: DateTime<Utc>,
133}
134
135impl RecurringCommitment {
136    /// Get parsed frequency
137    pub fn parsed_frequency(&self) -> Option<RecurrenceFrequency> {
138        RecurrenceFrequency::from_db_string(&self.frequency)
139    }
140
141    /// Calculate fulfillment rate
142    pub fn fulfillment_rate(&self) -> Decimal {
143        let total = self.instances_fulfilled + self.instances_failed;
144        if total == 0 {
145            dec!(1) // 100% for no instances yet
146        } else {
147            Decimal::from(self.instances_fulfilled) / Decimal::from(total)
148        }
149    }
150}
151
152/// Generated commitment instance from a recurring commitment
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct GeneratedCommitment {
155    pub commitment_id: Uuid,
156    pub recurring_id: Uuid,
157    pub instance_number: i32,
158    pub deadline: DateTime<Utc>,
159}
160
161/// Request to create a recurring commitment
162#[derive(Debug, Clone, Deserialize)]
163pub struct CreateRecurringRequest {
164    pub token_id: Uuid,
165    pub title: String,
166    pub description: Option<String>,
167    pub frequency: RecurrenceFrequency,
168    /// When to start the first commitment
169    pub start_date: Option<DateTime<Utc>>,
170}
171
172impl CreateRecurringRequest {
173    pub fn validate(&self) -> Result<()> {
174        if self.title.is_empty() {
175            return Err(ReputationError::Validation("Title is required".to_string()));
176        }
177        if self.title.len() > 200 {
178            return Err(ReputationError::Validation(
179                "Title must be at most 200 characters".to_string(),
180            ));
181        }
182        if let Some(ref desc) = self.description {
183            if desc.len() > 2000 {
184                return Err(ReputationError::Validation(
185                    "Description must be at most 2000 characters".to_string(),
186                ));
187            }
188        }
189        Ok(())
190    }
191}
192
193/// Summary of recurring commitments for a user
194#[derive(Debug, Clone, Serialize, Default)]
195pub struct RecurringSummary {
196    pub total_active: i64,
197    pub total_instances: i64,
198    pub fulfilled_instances: i64,
199    pub failed_instances: i64,
200    pub average_fulfillment_rate: Decimal,
201    pub upcoming_deadlines: Vec<UpcomingDeadline>,
202}
203
204/// Upcoming deadline info
205#[derive(Debug, Clone, Serialize)]
206pub struct UpcomingDeadline {
207    pub recurring_id: Uuid,
208    pub title: String,
209    pub deadline: DateTime<Utc>,
210    pub days_until: i64,
211}
212
213/// Service for managing recurring commitments
214pub struct RecurringCommitmentService {
215    pool: PgPool,
216}
217
218impl RecurringCommitmentService {
219    pub fn new(pool: PgPool) -> Self {
220        Self { pool }
221    }
222
223    /// Create a new recurring commitment
224    pub async fn create(
225        &self,
226        user_id: Uuid,
227        request: &CreateRecurringRequest,
228    ) -> Result<RecurringCommitment> {
229        request.validate()?;
230
231        let start = request.start_date.unwrap_or_else(Utc::now);
232        let next_deadline = request.frequency.next_deadline(start);
233
234        let recurring: RecurringCommitment = sqlx::query_as(
235            r#"
236            INSERT INTO recurring_commitments
237                (user_id, token_id, title, description, frequency, next_deadline)
238            VALUES ($1, $2, $3, $4, $5, $6)
239            RETURNING *
240            "#,
241        )
242        .bind(user_id)
243        .bind(request.token_id)
244        .bind(&request.title)
245        .bind(&request.description)
246        .bind(request.frequency.to_db_string())
247        .bind(next_deadline)
248        .fetch_one(&self.pool)
249        .await?;
250
251        tracing::info!(
252            recurring_id = %recurring.recurring_id,
253            frequency = %request.frequency,
254            "Recurring commitment created"
255        );
256
257        Ok(recurring)
258    }
259
260    /// Generate the next commitment instance
261    pub async fn generate_instance(
262        &self,
263        recurring_id: Uuid,
264    ) -> Result<Option<GeneratedCommitment>> {
265        let mut tx = self.pool.begin().await?;
266
267        // Get recurring commitment
268        let recurring: Option<RecurringCommitment> = sqlx::query_as(
269            r#"
270            SELECT * FROM recurring_commitments
271            WHERE recurring_id = $1 AND is_active = true
272            FOR UPDATE
273            "#,
274        )
275        .bind(recurring_id)
276        .fetch_optional(&mut *tx)
277        .await?;
278
279        let Some(recurring) = recurring else {
280            return Ok(None);
281        };
282
283        let now = Utc::now();
284        let frequency = recurring.parsed_frequency().ok_or_else(|| {
285            ReputationError::Validation(format!("Invalid frequency: {}", recurring.frequency))
286        })?;
287
288        // Check if it's time to generate
289        if recurring.next_deadline > now {
290            return Ok(None);
291        }
292
293        let instance_number = recurring.instances_created + 1;
294
295        // Create the actual commitment
296        let (commitment_id,): (Uuid,) = sqlx::query_as(
297            r#"
298            INSERT INTO output_commitments (user_id, token_id, title, description, deadline)
299            VALUES ($1, $2, $3, $4, $5)
300            RETURNING commitment_id
301            "#,
302        )
303        .bind(recurring.user_id)
304        .bind(recurring.token_id)
305        .bind(format!("{} #{}", recurring.title, instance_number))
306        .bind(&recurring.description)
307        .bind(recurring.next_deadline)
308        .fetch_one(&mut *tx)
309        .await?;
310
311        // Link to recurring
312        sqlx::query(
313            r#"
314            INSERT INTO recurring_instances (recurring_id, commitment_id, instance_number)
315            VALUES ($1, $2, $3)
316            "#,
317        )
318        .bind(recurring_id)
319        .bind(commitment_id)
320        .bind(instance_number)
321        .execute(&mut *tx)
322        .await?;
323
324        // Update recurring commitment
325        let next_deadline = frequency.next_deadline(recurring.next_deadline);
326        sqlx::query(
327            r#"
328            UPDATE recurring_commitments
329            SET instances_created = instances_created + 1,
330                last_generated_at = NOW(),
331                next_deadline = $2
332            WHERE recurring_id = $1
333            "#,
334        )
335        .bind(recurring_id)
336        .bind(next_deadline)
337        .execute(&mut *tx)
338        .await?;
339
340        tx.commit().await?;
341
342        tracing::info!(
343            recurring_id = %recurring_id,
344            commitment_id = %commitment_id,
345            instance = instance_number,
346            "Generated recurring commitment instance"
347        );
348
349        Ok(Some(GeneratedCommitment {
350            commitment_id,
351            recurring_id,
352            instance_number,
353            deadline: recurring.next_deadline,
354        }))
355    }
356
357    /// Generate all due commitment instances (batch job)
358    pub async fn generate_all_due(&self) -> Result<Vec<GeneratedCommitment>> {
359        // Get all active recurring commitments that are due
360        let due: Vec<(Uuid,)> = sqlx::query_as(
361            r#"
362            SELECT recurring_id FROM recurring_commitments
363            WHERE is_active = true AND next_deadline <= NOW()
364            "#,
365        )
366        .fetch_all(&self.pool)
367        .await?;
368
369        let mut generated = Vec::new();
370        for (recurring_id,) in due {
371            if let Some(instance) = self.generate_instance(recurring_id).await? {
372                generated.push(instance);
373            }
374        }
375
376        if !generated.is_empty() {
377            tracing::info!(
378                count = generated.len(),
379                "Generated recurring commitment instances"
380            );
381        }
382
383        Ok(generated)
384    }
385
386    /// Update recurring stats when a commitment is verified
387    pub async fn update_instance_result(&self, commitment_id: Uuid, fulfilled: bool) -> Result<()> {
388        let column = if fulfilled {
389            "instances_fulfilled"
390        } else {
391            "instances_failed"
392        };
393
394        sqlx::query(&format!(
395            r#"
396            UPDATE recurring_commitments rc
397            SET {} = {} + 1
398            FROM recurring_instances ri
399            WHERE ri.recurring_id = rc.recurring_id
400            AND ri.commitment_id = $1
401            "#,
402            column, column
403        ))
404        .bind(commitment_id)
405        .execute(&self.pool)
406        .await?;
407
408        Ok(())
409    }
410
411    /// Get user's recurring commitments
412    pub async fn get_user_recurring(&self, user_id: Uuid) -> Result<Vec<RecurringCommitment>> {
413        let recurring = sqlx::query_as::<_, RecurringCommitment>(
414            r#"
415            SELECT * FROM recurring_commitments
416            WHERE user_id = $1
417            ORDER BY created_at DESC
418            "#,
419        )
420        .bind(user_id)
421        .fetch_all(&self.pool)
422        .await?;
423
424        Ok(recurring)
425    }
426
427    /// Get recurring commitment by ID
428    pub async fn get_by_id(&self, recurring_id: Uuid) -> Result<Option<RecurringCommitment>> {
429        let recurring = sqlx::query_as::<_, RecurringCommitment>(
430            "SELECT * FROM recurring_commitments WHERE recurring_id = $1",
431        )
432        .bind(recurring_id)
433        .fetch_optional(&self.pool)
434        .await?;
435
436        Ok(recurring)
437    }
438
439    /// Pause a recurring commitment
440    pub async fn pause(&self, recurring_id: Uuid, user_id: Uuid) -> Result<bool> {
441        let result = sqlx::query(
442            r#"
443            UPDATE recurring_commitments
444            SET is_active = false
445            WHERE recurring_id = $1 AND user_id = $2
446            "#,
447        )
448        .bind(recurring_id)
449        .bind(user_id)
450        .execute(&self.pool)
451        .await?;
452
453        Ok(result.rows_affected() > 0)
454    }
455
456    /// Resume a recurring commitment
457    pub async fn resume(&self, recurring_id: Uuid, user_id: Uuid) -> Result<bool> {
458        let mut tx = self.pool.begin().await?;
459
460        // Get current recurring
461        let recurring: Option<RecurringCommitment> = sqlx::query_as(
462            "SELECT * FROM recurring_commitments WHERE recurring_id = $1 AND user_id = $2",
463        )
464        .bind(recurring_id)
465        .bind(user_id)
466        .fetch_optional(&mut *tx)
467        .await?;
468
469        let Some(recurring) = recurring else {
470            return Ok(false);
471        };
472
473        // Calculate next deadline from now if it's in the past
474        let now = Utc::now();
475        let next_deadline = if recurring.next_deadline < now {
476            let frequency = recurring.parsed_frequency().ok_or_else(|| {
477                ReputationError::Validation(format!("Invalid frequency: {}", recurring.frequency))
478            })?;
479            frequency.next_deadline(now)
480        } else {
481            recurring.next_deadline
482        };
483
484        sqlx::query(
485            r#"
486            UPDATE recurring_commitments
487            SET is_active = true, next_deadline = $3
488            WHERE recurring_id = $1 AND user_id = $2
489            "#,
490        )
491        .bind(recurring_id)
492        .bind(user_id)
493        .bind(next_deadline)
494        .execute(&mut *tx)
495        .await?;
496
497        tx.commit().await?;
498
499        Ok(true)
500    }
501
502    /// Get summary for a user
503    pub async fn get_summary(&self, user_id: Uuid) -> Result<RecurringSummary> {
504        // Get aggregate stats
505        let stats: Option<(i64, i64, i64, i64)> = sqlx::query_as(
506            r#"
507            SELECT
508                COUNT(*) FILTER (WHERE is_active) as active,
509                COALESCE(SUM(instances_created), 0) as total,
510                COALESCE(SUM(instances_fulfilled), 0) as fulfilled,
511                COALESCE(SUM(instances_failed), 0) as failed
512            FROM recurring_commitments
513            WHERE user_id = $1
514            "#,
515        )
516        .bind(user_id)
517        .fetch_optional(&self.pool)
518        .await?;
519
520        let (total_active, total_instances, fulfilled_instances, failed_instances) =
521            stats.unwrap_or((0, 0, 0, 0));
522
523        let average_fulfillment_rate = if fulfilled_instances + failed_instances > 0 {
524            Decimal::from(fulfilled_instances)
525                / Decimal::from(fulfilled_instances + failed_instances)
526        } else {
527            dec!(1)
528        };
529
530        // Get upcoming deadlines
531        let upcoming: Vec<(Uuid, String, DateTime<Utc>)> = sqlx::query_as(
532            r#"
533            SELECT recurring_id, title, next_deadline
534            FROM recurring_commitments
535            WHERE user_id = $1 AND is_active = true
536            ORDER BY next_deadline ASC
537            LIMIT 5
538            "#,
539        )
540        .bind(user_id)
541        .fetch_all(&self.pool)
542        .await?;
543
544        let now = Utc::now();
545        let upcoming_deadlines = upcoming
546            .into_iter()
547            .map(|(recurring_id, title, deadline)| {
548                let days_until = (deadline - now).num_days();
549                UpcomingDeadline {
550                    recurring_id,
551                    title,
552                    deadline,
553                    days_until,
554                }
555            })
556            .collect();
557
558        Ok(RecurringSummary {
559            total_active,
560            total_instances,
561            fulfilled_instances,
562            failed_instances,
563            average_fulfillment_rate,
564            upcoming_deadlines,
565        })
566    }
567
568    /// Delete a recurring commitment (only if no instances)
569    pub async fn delete(&self, recurring_id: Uuid, user_id: Uuid) -> Result<bool> {
570        // Check if there are any instances
571        let instance_count: Option<(i64,)> = sqlx::query_as(
572            r#"
573            SELECT COUNT(*) FROM recurring_instances
574            WHERE recurring_id = $1
575            "#,
576        )
577        .bind(recurring_id)
578        .fetch_optional(&self.pool)
579        .await?;
580
581        if instance_count.map(|(c,)| c).unwrap_or(0) > 0 {
582            // Can't delete, just deactivate
583            return self.pause(recurring_id, user_id).await;
584        }
585
586        let result = sqlx::query(
587            r#"
588            DELETE FROM recurring_commitments
589            WHERE recurring_id = $1 AND user_id = $2
590            "#,
591        )
592        .bind(recurring_id)
593        .bind(user_id)
594        .execute(&self.pool)
595        .await?;
596
597        Ok(result.rows_affected() > 0)
598    }
599}