dogy_backend_api/service/assistant/daily_challenges/
handlers.rs1use 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, 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 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}