Skip to content

Commit 1b6ce4f

Browse files
authored
Merge pull request #559 from solomon35-stack/fix/solomon35-stack-issues-440-441-442-443
fix: resolve issues #440 #441 #442 #443 (solomon35-stack)
2 parents ec75718 + d1cca7f commit 1b6ce4f

9 files changed

Lines changed: 1150 additions & 9 deletions

File tree

contracts/analytics/src/errors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,6 @@ pub enum AnalyticsError {
5151
InvalidInsightData = 22,
5252
/// No insight record was found with the given ID.
5353
InsightNotFound = 23,
54+
/// Timestamp is not a valid UTC Unix epoch second (Issue #442: DST fix).
55+
InvalidTimestamp = 24,
5456
}

contracts/analytics/src/lib.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use shared::event_schema::{
2727
SessionRecordedEvent,
2828
};
2929
use shared::monitoring::{ContractHealthReport, Monitor};
30+
use shared::timestamp_utils::{utc_day_index, validate_utc_timestamp};
3031
use shared::{emit_access_control_event, emit_analytics_event};
3132
use soroban_sdk::{
3233
contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec,
@@ -120,11 +121,12 @@ fn update_progress_analytics(
120121
});
121122
}
122123

123-
// Streak calculation
124-
let current_day = end_time / 86400;
124+
// Streak calculation — use UTC day index to avoid DST off-by-one (Issue #442).
125+
// utc_day_index() divides by SECS_PER_DAY after normalising to UTC midnight,
126+
// so a DST transition never shifts the day boundary.
127+
let current_day = utc_day_index(end_time);
125128
let prev_day = if analytics.total_sessions > 1 {
126-
let prev_last = analytics.last_activity.saturating_sub(time_spent);
127-
prev_last / 86400
129+
utc_day_index(analytics.last_activity)
128130
} else {
129131
current_day
130132
};
@@ -421,6 +423,11 @@ impl Analytics {
421423

422424
session.student.require_auth();
423425

426+
// Issue #442: validate that end_time is a plausible UTC epoch second.
427+
// This rejects millisecond-precision values and local-time offsets that
428+
// would cause DST off-by-one errors in streak / day-range calculations.
429+
validate_utc_timestamp(end_time).map_err(|_| AnalyticsError::InvalidTimestamp)?;
430+
424431
let time_spent = end_time.saturating_sub(session.start_time);
425432

426433
session.end_time = end_time;
@@ -865,13 +872,13 @@ impl Analytics {
865872
if start_date >= end_date {
866873
return result;
867874
}
868-
let mut current = (start_date / 86400) * 86400;
869-
let end_day = (end_date / 86400) * 86400;
875+
let mut current = utc_day_index(start_date) * shared::timestamp_utils::SECS_PER_DAY;
876+
let end_day = utc_day_index(end_date) * shared::timestamp_utils::SECS_PER_DAY;
870877
while current <= end_day {
871878
if let Some(metrics) = AnalyticsStorage::get_daily_metrics(&env, &course_id, current) {
872879
result.push_back(metrics);
873880
}
874-
current += 86400;
881+
current += shared::timestamp_utils::SECS_PER_DAY;
875882
}
876883
result
877884
}

contracts/mobile-optimizer/src/pwa_manager.rs

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
use soroban_sdk::{contracttype, Address, Env, String, Vec};
1+
use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Vec};
22

33
use crate::types::*;
44

5+
// ─── Storage keys for new PWA features ────────────────────────────────────────
6+
7+
#[contracttype]
8+
#[derive(Clone, Debug, Eq, PartialEq)]
9+
enum PwaExtKey {
10+
/// Pending push notifications for a user.
11+
PushQueue(Address),
12+
/// Install prompt history for a user.
13+
InstallHistory(Address),
14+
/// Global PWA metrics.
15+
Metrics,
16+
}
17+
518
pub struct PwaManager;
619

720
impl PwaManager {
@@ -231,6 +244,233 @@ impl PwaManager {
231244
start_url: String::from_str(env, "/dashboard"),
232245
}
233246
}
247+
248+
// ── Push Notifications (Issue #440) ───────────────────────────────────────
249+
250+
/// Queue a push notification for `recipient`.
251+
///
252+
/// The notification is stored on-chain so the off-chain push service can
253+
/// poll and deliver it. Returns `Err` if the recipient has no active push
254+
/// subscription.
255+
pub fn send_push_notification(
256+
env: &Env,
257+
notification: PushNotification,
258+
) -> Result<(), MobileOptimizerError> {
259+
// Require an active push subscription.
260+
let config = Self::get_or_create_config(env, &notification.recipient);
261+
if !config.push_subscription_active {
262+
return Err(MobileOptimizerError::PwaError);
263+
}
264+
265+
let key = PwaExtKey::PushQueue(notification.recipient.clone());
266+
let mut queue: Vec<PushNotification> =
267+
env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
268+
269+
queue.push_back(notification.clone());
270+
env.storage().persistent().set(&key, &queue);
271+
272+
// Update metrics.
273+
let mut metrics = Self::get_metrics(env);
274+
metrics.notifications_sent += 1;
275+
env.storage().persistent().set(&PwaExtKey::Metrics, &metrics);
276+
277+
env.events().publish(
278+
(symbol_short!("push_sent"), notification.recipient.clone()),
279+
notification.notification_id,
280+
);
281+
282+
Ok(())
283+
}
284+
285+
/// Mark a notification as delivered (called by the push service).
286+
pub fn mark_notification_delivered(
287+
env: &Env,
288+
recipient: &Address,
289+
notification_id: &String,
290+
) -> Result<(), MobileOptimizerError> {
291+
let key = PwaExtKey::PushQueue(recipient.clone());
292+
let mut queue: Vec<PushNotification> = env
293+
.storage()
294+
.persistent()
295+
.get(&key)
296+
.ok_or(MobileOptimizerError::PwaError)?;
297+
298+
let mut found = false;
299+
let mut updated: Vec<PushNotification> = Vec::new(env);
300+
for mut n in queue.iter() {
301+
if n.notification_id == *notification_id {
302+
n.delivered = true;
303+
found = true;
304+
}
305+
updated.push_back(n);
306+
}
307+
if !found {
308+
return Err(MobileOptimizerError::PwaError);
309+
}
310+
env.storage().persistent().set(&key, &updated);
311+
312+
let mut metrics = Self::get_metrics(env);
313+
metrics.notifications_delivered += 1;
314+
env.storage().persistent().set(&PwaExtKey::Metrics, &metrics);
315+
316+
Ok(())
317+
}
318+
319+
/// Mark a notification as read by the user.
320+
pub fn mark_notification_read(
321+
env: &Env,
322+
recipient: &Address,
323+
notification_id: &String,
324+
) -> Result<(), MobileOptimizerError> {
325+
let key = PwaExtKey::PushQueue(recipient.clone());
326+
let mut queue: Vec<PushNotification> = env
327+
.storage()
328+
.persistent()
329+
.get(&key)
330+
.ok_or(MobileOptimizerError::PwaError)?;
331+
332+
let mut found = false;
333+
let mut updated: Vec<PushNotification> = Vec::new(env);
334+
for mut n in queue.iter() {
335+
if n.notification_id == *notification_id {
336+
n.read = true;
337+
found = true;
338+
}
339+
updated.push_back(n);
340+
}
341+
if !found {
342+
return Err(MobileOptimizerError::PwaError);
343+
}
344+
env.storage().persistent().set(&key, &updated);
345+
346+
let mut metrics = Self::get_metrics(env);
347+
metrics.notifications_read += 1;
348+
env.storage().persistent().set(&PwaExtKey::Metrics, &metrics);
349+
350+
Ok(())
351+
}
352+
353+
/// Return all pending (unread) notifications for `recipient`.
354+
pub fn get_pending_notifications(
355+
env: &Env,
356+
recipient: &Address,
357+
) -> Vec<PushNotification> {
358+
let key = PwaExtKey::PushQueue(recipient.clone());
359+
let queue: Vec<PushNotification> =
360+
env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
361+
362+
let now = env.ledger().timestamp();
363+
let mut pending: Vec<PushNotification> = Vec::new(env);
364+
for n in queue.iter() {
365+
let not_expired = n.expires_at.map_or(true, |exp| now < exp);
366+
if !n.read && not_expired {
367+
pending.push_back(n);
368+
}
369+
}
370+
pending
371+
}
372+
373+
// ── Install-to-Home-Screen (Issue #440) ───────────────────────────────────
374+
375+
/// Record that the install prompt was shown to `user`.
376+
pub fn record_install_prompt_shown(
377+
env: &Env,
378+
user: &Address,
379+
platform: String,
380+
) -> Result<(), MobileOptimizerError> {
381+
let record = InstallPromptRecord {
382+
user: user.clone(),
383+
shown_at: env.ledger().timestamp(),
384+
outcome: InstallPromptOutcome::Pending,
385+
platform,
386+
};
387+
388+
let key = PwaExtKey::InstallHistory(user.clone());
389+
let mut history: Vec<InstallPromptRecord> =
390+
env.storage().persistent().get(&key).unwrap_or_else(|| Vec::new(env));
391+
history.push_back(record);
392+
env.storage().persistent().set(&key, &history);
393+
394+
let mut metrics = Self::get_metrics(env);
395+
metrics.prompts_shown += 1;
396+
env.storage().persistent().set(&PwaExtKey::Metrics, &metrics);
397+
398+
Ok(())
399+
}
400+
401+
/// Record the user's response to the install prompt.
402+
pub fn record_install_prompt_outcome(
403+
env: &Env,
404+
user: &Address,
405+
outcome: InstallPromptOutcome,
406+
) -> Result<(), MobileOptimizerError> {
407+
let key = PwaExtKey::InstallHistory(user.clone());
408+
let mut history: Vec<InstallPromptRecord> = env
409+
.storage()
410+
.persistent()
411+
.get(&key)
412+
.ok_or(MobileOptimizerError::PwaError)?;
413+
414+
// Update the most recent pending record.
415+
let len = history.len();
416+
if len == 0 {
417+
return Err(MobileOptimizerError::PwaError);
418+
}
419+
let mut updated: Vec<InstallPromptRecord> = Vec::new(env);
420+
for (i, mut r) in history.iter().enumerate() {
421+
if i as u32 == len - 1 && r.outcome == InstallPromptOutcome::Pending {
422+
r.outcome = outcome.clone();
423+
}
424+
updated.push_back(r);
425+
}
426+
env.storage().persistent().set(&key, &updated);
427+
428+
// Update metrics and install status.
429+
let mut metrics = Self::get_metrics(env);
430+
match outcome {
431+
InstallPromptOutcome::Accepted => {
432+
metrics.installs_accepted += 1;
433+
metrics.active_push_subscribers += 1;
434+
Self::update_install_status(env, user, PwaInstallStatus::Installed)?;
435+
}
436+
InstallPromptOutcome::Dismissed => {
437+
metrics.installs_dismissed += 1;
438+
}
439+
InstallPromptOutcome::Pending => {}
440+
}
441+
env.storage().persistent().set(&PwaExtKey::Metrics, &metrics);
442+
443+
env.events().publish(
444+
(symbol_short!("pwa_install"), user.clone()),
445+
outcome == InstallPromptOutcome::Accepted,
446+
);
447+
448+
Ok(())
449+
}
450+
451+
/// Return the install prompt history for `user`.
452+
pub fn get_install_history(env: &Env, user: &Address) -> Vec<InstallPromptRecord> {
453+
env.storage()
454+
.persistent()
455+
.get(&PwaExtKey::InstallHistory(user.clone()))
456+
.unwrap_or_else(|| Vec::new(env))
457+
}
458+
459+
/// Return global PWA metrics.
460+
pub fn get_metrics(env: &Env) -> PwaMetrics {
461+
env.storage()
462+
.persistent()
463+
.get(&PwaExtKey::Metrics)
464+
.unwrap_or(PwaMetrics {
465+
prompts_shown: 0,
466+
installs_accepted: 0,
467+
installs_dismissed: 0,
468+
notifications_sent: 0,
469+
notifications_delivered: 0,
470+
notifications_read: 0,
471+
active_push_subscribers: 0,
472+
})
473+
}
234474
}
235475

236476
#[contracttype]

0 commit comments

Comments
 (0)