dogy_backend_api/service/assistant/daily_challenges/
handlers.rs

1use axum::{
2    extract::{Path, State},
3    Extension, Json,
4};
5use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
6use chrono_tz::Tz;
7use reqwest::Client;
8use serde::Serialize;
9use uuid::Uuid;
10
11use crate::{
12    config::load_config,
13    middleware::auth::layer::CurrentUser,
14    service::{
15        assistant::daily_challenges::store::{EXCLUDE_PAST_CHALLENGES_PROMPT, PET_INFO_PROMPT},
16        pets::{models::FullPet, store::retrieve_full_pet},
17    },
18    AppState,
19};
20
21use crate::Result;
22
23use super::store::{
24    retrieve_daily_challenge_streaks, retrieve_past_challenges, retrieve_timezone_from_user,
25    save_daily_challenge, verify_daily_challenge_existence, DAILY_CHALLENGE_PROMPT,
26};
27
28#[derive(Serialize)]
29pub struct DailyChallengeResponse {
30    pub id: Uuid,
31    pub challenge: String,
32}
33
34#[derive(Serialize)]
35pub struct DailyActivity {
36    pub day: String,
37    pub completed: bool,
38}
39
40#[derive(Serialize)]
41pub struct DailyChallengeStreaks {
42    pub current_streak: u16, // Maximum of 65535 days. Dont think anyone will live for more than 179 years.
43    pub longest_streak: u16,
44    pub total_streak_days: u16,
45    pub weekly_activity: Vec<DailyActivity>,
46}
47
48async fn create_challenge_from_ai(pet: FullPet, past_challenges: Option<&Vec<String>>) -> String {
49    let pet_as_str = serde_json::to_string(&pet).unwrap();
50    let prompt = match past_challenges {
51        Some(_) => {
52            format!(
53                "{}\n\n{}\n{}\n\n{}",
54                DAILY_CHALLENGE_PROMPT, PET_INFO_PROMPT, pet_as_str, EXCLUDE_PAST_CHALLENGES_PROMPT,
55            )
56        }
57        None => {
58            format!(
59                "{}\n\n{}\n{}",
60                DAILY_CHALLENGE_PROMPT, PET_INFO_PROMPT, pet_as_str
61            )
62        }
63    };
64
65    let past_challenges_as_str = past_challenges
66        .map(|challenges| challenges.join("\n"))
67        .unwrap_or_else(|| "I have no past daily challenges.".to_string());
68
69    let config = load_config();
70
71    // TODO: Turn this into a shared client instead of creating a new client every time.
72    let client = Client::new();
73    let response = client
74        .post(&config.AZURE_OPENAI_ENDPOINT)
75        .header("api-key", &config.AZURE_OPENAI_KEY)
76        .header("Content-Type", "application/json")
77        .body(
78            serde_json::json!({
79                "messages": [
80                    {
81                        "role": "system",
82                        "content": [{
83                            "type": "text",
84                            "text": prompt
85                        }]
86                    },
87                    {
88                        "role": "user",
89                        "content": [{
90                            "type": "text",
91                            "text": past_challenges_as_str
92                        }]
93                    }
94                ],
95                "temperature": 0.7,
96            })
97            .to_string(),
98        )
99        .send()
100        .await
101        .unwrap();
102
103    response
104        .json::<serde_json::Value>()
105        .await
106        .unwrap()
107        .get("choices")
108        .and_then(|choices| choices.get(0))
109        .and_then(|choice| choice.get("message"))
110        .and_then(|message| message.get("content"))
111        .and_then(|content| content.as_str())
112        .map(|content| content.to_string())
113        .unwrap_or_else(|| "No challenge generated".to_string())
114}
115
116pub async fn create_daily_challenge(
117    Extension(current_user): Extension<CurrentUser>,
118    State(state): State<AppState>,
119    Path(pet_id): Path<Uuid>,
120) -> Result<Json<DailyChallengeResponse>> {
121    let internal_user_id = current_user.internal_id.unwrap();
122    let conn = &*state.db;
123    let timezone = retrieve_timezone_from_user(conn, internal_user_id).await?;
124
125    let mut txn1 = conn.begin().await.unwrap();
126    verify_daily_challenge_existence(&mut txn1, internal_user_id, &timezone).await?;
127    txn1.commit().await.unwrap();
128
129    let pet = retrieve_full_pet(conn, pet_id).await;
130    let past_challenges = retrieve_past_challenges(conn, internal_user_id).await?;
131    let past_challenges_as_opt = (!past_challenges.is_empty()).then_some(&past_challenges);
132
133    let generated_challenge = create_challenge_from_ai(pet, past_challenges_as_opt).await;
134
135    let mut txn = conn.begin().await.unwrap();
136    let challenge_id = save_daily_challenge(
137        &mut txn,
138        internal_user_id,
139        timezone,
140        generated_challenge.as_ref(),
141    )
142    .await?;
143    txn.commit().await.unwrap();
144
145    Ok(Json(DailyChallengeResponse {
146        id: challenge_id,
147        challenge: generated_challenge,
148    }))
149}
150
151pub async fn get_daily_challenge_streak(
152    Extension(current_user): Extension<CurrentUser>,
153    State(state): State<AppState>,
154) -> Result<Json<DailyChallengeStreaks>> {
155    let conn = &*state.db;
156    let internal_user_id = current_user.internal_id.unwrap();
157    let mut txn = conn.begin().await.unwrap();
158
159    let timezone = retrieve_timezone_from_user(&mut *txn, internal_user_id).await?;
160
161    let daily_streak_dates =
162        retrieve_daily_challenge_streaks(&mut txn, internal_user_id, &timezone).await?;
163
164    txn.commit().await.unwrap();
165
166    let total_streak_days = daily_streak_dates.len() as u16;
167    let tz: Tz = timezone.parse().unwrap();
168    let today = Utc::now().with_timezone(&tz);
169    let (current_streak, longest_streak) =
170        retrieve_current_longest_streak(&daily_streak_dates, today);
171    let weekly_activity = retrieve_weekly_activity(&daily_streak_dates, today);
172
173    Ok(Json(DailyChallengeStreaks {
174        weekly_activity,
175        current_streak,
176        longest_streak,
177        total_streak_days,
178    }))
179}
180
181fn retrieve_current_longest_streak(dates: &Vec<NaiveDate>, today: DateTime<Tz>) -> (u16, u16) {
182    let (mut current_streak, mut longest_streak) = (0_u16, 0_u16);
183    let mut streak = 0;
184    let mut prev_day: Option<NaiveDate> = None;
185
186    for date in dates {
187        if let Some(prev) = prev_day {
188            if *date == prev + Duration::days(1) {
189                streak += 1;
190            } else {
191                streak = 1;
192            }
193        } else {
194            streak = 1;
195        }
196
197        if *date == today.date_naive() {
198            current_streak = streak;
199        }
200
201        if streak > longest_streak {
202            longest_streak = streak;
203        }
204
205        prev_day = Some(*date);
206    }
207
208    (current_streak, longest_streak)
209}
210
211fn retrieve_weekly_activity(dates: &[NaiveDate], today: DateTime<Tz>) -> Vec<DailyActivity> {
212    let mut weekly_activity: Vec<DailyActivity> = Vec::new();
213    for i in 0..7 {
214        let date = today - Duration::days(6 - i);
215        let weekday = date.weekday();
216        let short_day = match weekday {
217            Weekday::Mon => "M",
218            Weekday::Tue => "T",
219            Weekday::Wed => "W",
220            Weekday::Thu => "Th",
221            Weekday::Fri => "F",
222            Weekday::Sat => "Sat",
223            Weekday::Sun => "Sun",
224        };
225
226        let completed = dates.contains(&date.date_naive());
227        weekly_activity.push(DailyActivity {
228            day: short_day.to_string(),
229            completed,
230        });
231    }
232
233    weekly_activity
234}