Skip to content

Commit 8617293

Browse files
authored
Merge pull request #1 from elsiribot/2026-03-global-scan-list
feat: add paginated admin scan log with daily scan graph
2 parents 734a3ff + 4ed8d22 commit 8617293

File tree

9 files changed

+491
-6
lines changed

9 files changed

+491
-6
lines changed

src/db.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use crate::balance::{compute_balance_msats, BalanceConfig};
22
use crate::models::{
3-
AuthMethod, Claim, ClaimResult, Donation, Location, NfcCard, NfcScan, Photo, ScanWithLocation,
4-
ScanWithUser, Stats, User, UserRole, UserTransaction, WithdrawalStatus,
3+
AdminScan, AuthMethod, Claim, ClaimResult, DailyScanCount, Donation, Location, NfcCard,
4+
NfcScan, Photo, ScanWithLocation, ScanWithUser, Stats, User, UserRole, UserTransaction,
5+
WithdrawalStatus,
56
};
67
use anyhow::Result;
78
use chrono::Utc;
@@ -612,6 +613,79 @@ impl Database {
612613
.map_err(Into::into)
613614
}
614615

616+
/// Get paginated global scan list for admin page
617+
pub async fn get_admin_scans(&self, limit: i64, offset: i64) -> Result<Vec<AdminScan>> {
618+
sqlx::query_as::<_, AdminScan>(
619+
r#"
620+
SELECT
621+
s.id,
622+
s.location_id,
623+
s.user_id,
624+
s.scanned_at,
625+
s.claimed_at,
626+
c.msats_claimed,
627+
l.name AS location_name,
628+
creator.username AS creator_username,
629+
l.user_id AS creator_user_id,
630+
scanner.username AS scanner_username
631+
FROM scans s
632+
LEFT JOIN claims c ON s.claim_id = c.id
633+
JOIN locations l ON s.location_id = l.id
634+
LEFT JOIN users creator ON l.user_id = creator.id
635+
LEFT JOIN users scanner ON s.user_id = scanner.id
636+
ORDER BY s.scanned_at DESC
637+
LIMIT ? OFFSET ?
638+
"#,
639+
)
640+
.bind(limit)
641+
.bind(offset)
642+
.fetch_all(&self.pool)
643+
.await
644+
.map_err(Into::into)
645+
}
646+
647+
/// Get total scan count for pagination
648+
pub async fn count_scans(&self) -> Result<i64> {
649+
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scans")
650+
.fetch_one(&self.pool)
651+
.await?;
652+
Ok(row.0)
653+
}
654+
655+
/// Get daily scan counts for a date range (inclusive)
656+
pub async fn get_daily_scan_counts(
657+
&self,
658+
from_date: &str,
659+
to_date: &str,
660+
) -> Result<Vec<DailyScanCount>> {
661+
sqlx::query_as::<_, DailyScanCount>(
662+
r#"
663+
SELECT
664+
DATE(scanned_at) AS date,
665+
COUNT(*) AS count
666+
FROM scans
667+
WHERE DATE(scanned_at) >= ? AND DATE(scanned_at) <= ?
668+
GROUP BY DATE(scanned_at)
669+
ORDER BY date ASC
670+
"#,
671+
)
672+
.bind(from_date)
673+
.bind(to_date)
674+
.fetch_all(&self.pool)
675+
.await
676+
.map_err(Into::into)
677+
}
678+
679+
/// Get the date of the earliest scan (for "all time" graph range)
680+
pub async fn get_earliest_scan_date(&self) -> Result<Option<chrono::NaiveDate>> {
681+
let date: Option<chrono::NaiveDate> = sqlx::query_scalar(
682+
"SELECT DATE(MIN(scanned_at)) FROM scans WHERE scanned_at IS NOT NULL",
683+
)
684+
.fetch_one(&self.pool)
685+
.await?;
686+
Ok(date)
687+
}
688+
615689
/// Claim sats from a previous scan
616690
/// Returns ClaimResult indicating success or reason for failure
617691
pub async fn claim_from_scan(

src/handlers/pages.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,3 +883,82 @@ pub async fn admin_locations_page(
883883

884884
Ok(Html(page.into_string()))
885885
}
886+
887+
#[derive(Deserialize)]
888+
pub struct AdminScansQuery {
889+
pub page: Option<i64>,
890+
pub days: Option<i64>,
891+
}
892+
893+
/// Admin scans page - global scan list with daily scan graph
894+
pub async fn admin_scans_page(
895+
user: CookieUser,
896+
State(state): State<Arc<AppState>>,
897+
Query(query): Query<AdminScansQuery>,
898+
) -> Result<Html<String>, Response> {
899+
let username = user.ensure_registered_with_role(UserRole::Admin)?;
900+
901+
let days = query.days.unwrap_or(30).max(0); // 0 = all time
902+
let per_page: i64 = 50;
903+
904+
let total_scans = state.db.count_scans().await.map_err(|e| {
905+
tracing::error!("Failed to count scans: {}", e);
906+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
907+
})?;
908+
let total_pages = (total_scans + per_page - 1) / per_page;
909+
let page = query.page.unwrap_or(1).max(1).min(total_pages.max(1));
910+
let offset = (page - 1) * per_page;
911+
912+
let scans = state
913+
.db
914+
.get_admin_scans(per_page, offset)
915+
.await
916+
.map_err(|e| {
917+
tracing::error!("Failed to get admin scans: {}", e);
918+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
919+
})?;
920+
921+
let today = chrono::Utc::now().date_naive();
922+
let from_date = if days == 0 {
923+
// All time: find earliest scan date, fall back to today
924+
state
925+
.db
926+
.get_earliest_scan_date()
927+
.await
928+
.ok()
929+
.flatten()
930+
.unwrap_or(today)
931+
} else {
932+
today - chrono::Duration::days(days - 1)
933+
};
934+
let daily_counts = state
935+
.db
936+
.get_daily_scan_counts(&from_date.to_string(), &today.to_string())
937+
.await
938+
.map_err(|e| {
939+
tracing::error!("Failed to get daily scan counts: {}", e);
940+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
941+
})?;
942+
943+
// Fill in missing days with zero counts
944+
let counts_map: std::collections::HashMap<String, i64> = daily_counts
945+
.iter()
946+
.map(|d| (d.date.clone(), d.count))
947+
.collect();
948+
let mut filled_counts = Vec::new();
949+
let mut current = from_date;
950+
while current <= today {
951+
let date_str = current.to_string();
952+
let count = counts_map.get(&date_str).copied().unwrap_or(0);
953+
filled_counts.push(crate::models::DailyScanCount {
954+
date: date_str,
955+
count,
956+
});
957+
current += chrono::Duration::days(1);
958+
}
959+
960+
let content = templates::admin_scans(&scans, &filled_counts, page, total_pages, days);
961+
let page_html = templates::base_with_user("Scan Log", content, username, user.role(), true);
962+
963+
Ok(Html(page_html.into_string()))
964+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ async fn main() -> Result<()> {
128128
"/admin/locations",
129129
get(auth(handlers::admin_locations_page)),
130130
)
131+
.route("/admin/scans", get(auth(handlers::admin_scans_page)))
131132
// API routes
132133
.route("/api/locations", post(handlers::create_location))
133134
.route(

src/models.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,54 @@ impl ScanWithLocation {
461461
}
462462
}
463463

464+
/// A scan record with full context for the admin global scan list
465+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
466+
pub struct AdminScan {
467+
pub id: String,
468+
pub location_id: String,
469+
pub user_id: String,
470+
pub scanned_at: DateTime<Utc>,
471+
pub claimed_at: Option<DateTime<Utc>>,
472+
/// Amount claimed in msats (None if not claimed)
473+
pub msats_claimed: Option<i64>,
474+
/// Location name
475+
pub location_name: String,
476+
/// Location creator username
477+
pub creator_username: Option<String>,
478+
/// Location creator user ID (for fallback display)
479+
pub creator_user_id: String,
480+
/// Scanner username
481+
pub scanner_username: Option<String>,
482+
}
483+
484+
impl AdminScan {
485+
pub fn sats_claimed(&self) -> i64 {
486+
self.msats_claimed.unwrap_or(0) / 1000
487+
}
488+
489+
pub fn scanner_display_name(&self) -> String {
490+
self.scanner_username
491+
.clone()
492+
.unwrap_or_else(|| format!("anon_{}", &self.user_id[..8.min(self.user_id.len())]))
493+
}
494+
495+
pub fn creator_display_name(&self) -> String {
496+
self.creator_username.clone().unwrap_or_else(|| {
497+
format!(
498+
"anon_{}",
499+
&self.creator_user_id[..8.min(self.creator_user_id.len())]
500+
)
501+
})
502+
}
503+
}
504+
505+
/// Daily scan count for the admin scan graph
506+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
507+
pub struct DailyScanCount {
508+
pub date: String,
509+
pub count: i64,
510+
}
511+
464512
/// Result of attempting to claim sats from a scan
465513
#[derive(Debug)]
466514
pub enum ClaimResult {

src/templates/admin_scans.rs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use crate::models::{AdminScan, DailyScanCount};
2+
use maud::{html, Markup};
3+
4+
pub fn admin_scans(
5+
scans: &[AdminScan],
6+
daily_counts: &[DailyScanCount],
7+
current_page: i64,
8+
total_pages: i64,
9+
days: i64,
10+
) -> Markup {
11+
let max_count = daily_counts
12+
.iter()
13+
.map(|d| d.count)
14+
.max()
15+
.unwrap_or(1)
16+
.max(1);
17+
18+
html! {
19+
div class="mb-8" {
20+
div class="flex justify-between items-center mb-8" {
21+
h1 class="text-4xl font-black text-primary" style="letter-spacing: -0.02em;" {
22+
"SCAN LOG"
23+
}
24+
}
25+
26+
// Timeframe selector
27+
div class="flex flex-wrap gap-2 mb-6" {
28+
@for &d in &[30, 60, 90] {
29+
@if d == days {
30+
a href={"/admin/scans?days=" (d)} class="btn-brutal-fill" {
31+
(d) " DAYS"
32+
}
33+
} @else {
34+
a href={"/admin/scans?days=" (d)} class="btn-brutal" {
35+
(d) " DAYS"
36+
}
37+
}
38+
}
39+
@if days == 0 {
40+
a href="/admin/scans?days=0" class="btn-brutal-fill" { "ALL" }
41+
} @else {
42+
a href="/admin/scans?days=0" class="btn-brutal" { "ALL" }
43+
}
44+
}
45+
46+
// Daily scan graph
47+
div class="card-brutal mb-8" {
48+
h2 class="text-xl font-black text-primary mb-4" {
49+
"DAILY SCANS"
50+
}
51+
@let num_days = daily_counts.len();
52+
@let label_interval = if num_days <= 14 { 1 } else if num_days <= 31 { 7 } else if num_days <= 90 { 14 } else { 30 };
53+
div style="overflow-x: auto;" {
54+
// Bars
55+
div style="display: flex; align-items: flex-end; height: 180px; gap: 1px; min-width: 100%;" {
56+
@for day in daily_counts {
57+
@let bar_height = if max_count > 0 { (day.count as f64 / max_count as f64 * 100.0).max(1.0) } else { 1.0 };
58+
div
59+
style={"flex: 1; min-width: 2px; height: " (bar_height as i64) "%; background: var(--highlight);"}
60+
title={(day.date) ": " (day.count) " scans"} {}
61+
}
62+
}
63+
// Date labels
64+
div style="display: flex; gap: 1px; min-width: 100%;" {
65+
@for (i, day) in daily_counts.iter().enumerate() {
66+
div style="flex: 1; min-width: 2px; text-align: center; overflow: visible; position: relative;" {
67+
@if i % label_interval == 0 {
68+
span class="mono text-muted select-none" style="font-size: 0.55rem; position: absolute; left: 0; white-space: nowrap;" {
69+
(&day.date[5..])
70+
}
71+
}
72+
}
73+
}
74+
}
75+
// Spacer for labels
76+
div style="height: 14px;" {}
77+
}
78+
}
79+
80+
// Scan list table
81+
@if scans.is_empty() {
82+
div class="card-brutal-inset text-center" style="padding: 3rem;" {
83+
div class="text-6xl mb-6 text-muted" {
84+
i class="fa-solid fa-nfc-magnifying-glass" {}
85+
}
86+
h3 class="text-2xl font-black text-primary mb-3" { "NO SCANS" }
87+
p class="text-secondary mb-8 font-bold" {
88+
"NO SCANS RECORDED YET."
89+
}
90+
}
91+
} @else {
92+
div class="card-brutal overflow-x-auto" {
93+
table class="w-full text-sm" style="border-collapse: collapse;" {
94+
thead {
95+
tr style="border-bottom: 3px solid var(--accent-muted);" {
96+
th class="text-left py-3 px-3 font-black text-primary" { "TIMESTAMP" }
97+
th class="text-left py-3 px-3 font-black text-primary" { "LOCATION" }
98+
th class="text-left py-3 px-3 font-black text-primary" { "SCANNER" }
99+
th class="text-right py-3 px-3 font-black text-primary" { "SATS" }
100+
}
101+
}
102+
tbody {
103+
@for scan in scans {
104+
tr style="border-bottom: 1px solid var(--accent-muted);" {
105+
td class="py-2 px-3 mono text-secondary" style="white-space: nowrap;" {
106+
(scan.scanned_at.format("%Y-%m-%dT%H:%M:%S").to_string())
107+
}
108+
td class="py-2 px-3" {
109+
a href={"/locations/" (scan.location_id)} class="font-bold text-primary hover:text-highlight" {
110+
(scan.location_name)
111+
}
112+
span class="text-xs text-muted ml-1" {
113+
"by " (scan.creator_display_name())
114+
}
115+
}
116+
td class="py-2 px-3 font-bold mono" {
117+
(scan.scanner_display_name())
118+
}
119+
td class="py-2 px-3 text-right mono" {
120+
@if scan.claimed_at.is_some() {
121+
span class="text-highlight font-bold" {
122+
(scan.sats_claimed())
123+
}
124+
} @else {
125+
span class="text-muted" { "-" }
126+
}
127+
}
128+
}
129+
}
130+
}
131+
}
132+
}
133+
134+
// Pagination
135+
@if total_pages > 1 {
136+
div class="flex justify-center items-center gap-2 mt-6" {
137+
@if current_page > 1 {
138+
a href={"/admin/scans?page=" (current_page - 1) "&days=" (days)} class="btn-brutal" {
139+
i class="fa-solid fa-chevron-left mr-1" {}
140+
"PREV"
141+
}
142+
}
143+
span class="px-4 py-2 font-bold mono text-secondary" {
144+
(current_page) " / " (total_pages)
145+
}
146+
@if current_page < total_pages {
147+
a href={"/admin/scans?page=" (current_page + 1) "&days=" (days)} class="btn-brutal" {
148+
"NEXT"
149+
i class="fa-solid fa-chevron-right ml-1" {}
150+
}
151+
}
152+
}
153+
}
154+
}
155+
}
156+
}
157+
}

0 commit comments

Comments
 (0)