1use 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum RecurrenceFrequency {
21 Weekly,
23 Biweekly,
25 Monthly,
27 Quarterly,
29}
30
31impl RecurrenceFrequency {
32 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 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 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 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 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 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#[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 pub fn parsed_frequency(&self) -> Option<RecurrenceFrequency> {
138 RecurrenceFrequency::from_db_string(&self.frequency)
139 }
140
141 pub fn fulfillment_rate(&self) -> Decimal {
143 let total = self.instances_fulfilled + self.instances_failed;
144 if total == 0 {
145 dec!(1) } else {
147 Decimal::from(self.instances_fulfilled) / Decimal::from(total)
148 }
149 }
150}
151
152#[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#[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 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#[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#[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
213pub struct RecurringCommitmentService {
215 pool: PgPool,
216}
217
218impl RecurringCommitmentService {
219 pub fn new(pool: PgPool) -> Self {
220 Self { pool }
221 }
222
223 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 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 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 if recurring.next_deadline > now {
290 return Ok(None);
291 }
292
293 let instance_number = recurring.instances_created + 1;
294
295 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 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 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 pub async fn generate_all_due(&self) -> Result<Vec<GeneratedCommitment>> {
359 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 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 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 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 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 pub async fn resume(&self, recurring_id: Uuid, user_id: Uuid) -> Result<bool> {
458 let mut tx = self.pool.begin().await?;
459
460 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 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 pub async fn get_summary(&self, user_id: Uuid) -> Result<RecurringSummary> {
504 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 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 pub async fn delete(&self, recurring_id: Uuid, user_id: Uuid) -> Result<bool> {
570 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 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}