package handlers import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "wherewoof/frontend/internal/db" "wherewoof/frontend/internal/sms" ) const ( alertWindow = 10 * time.Minute alertMinDistance = 250.0 // metres ) // ScanRequest is the JSON body posted by the geolocation script. type ScanRequest struct { Lat *float64 `json:"lat"` Lng *float64 `json:"lng"` } // Scan records a scan (with optional location) and alerts the owner per the // location-aware throttle rules: within the 10-minute window an alert is only // re-sent when the new location is more than 250 m from the last alerted spot. func (a *App) Scan(w http.ResponseWriter, r *http.Request) { tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code")) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "tag not found", http.StatusNotFound) return } http.Error(w, "internal error", http.StatusInternalServerError) return } var req ScanRequest _ = json.NewDecoder(r.Body).Decode(&req) hasLoc := req.Lat != nil && req.Lng != nil var lat, lng pgtype.Float8 if hasLoc { lat = pgtype.Float8{Float64: *req.Lat, Valid: true} lng = pgtype.Float8{Float64: *req.Lng, Valid: true} } scan, err := a.Queries.InsertScan(r.Context(), db.InsertScanParams{ TagID: tag.ID, Lat: lat, Lng: lng, LocationShared: hasLoc, }) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng) if alertSent { if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) { _ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true}) } } writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent}) } // shouldAlert applies the throttle rules and sms_enabled flag. func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64) bool { if !tag.SmsEnabled { return false } if !tag.Phone.Valid { return false } last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID) if err != nil { return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert } // Within the window? if time.Since(last.ScannedAt.Time) < alertWindow { // Re-alert only if we have a location and it moved >250 m. if !hasLoc || !last.Lat.Valid || !last.Lng.Valid { return false } return sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance } return true } // alertOwner sends the SMS to the owner and reports success. func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool { if a.Sender == nil { return false } msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng) if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil { fmt.Println("alert sms failed:", err) return false } return true } // alertMessage builds the owner alert: item type + name, time, maps link, tag link. func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string { itemType := "Item" if tag.ItemType.Valid { itemType = titleCase(tag.ItemType.String) } name := strings.TrimSpace(tag.Description.String) if len(name) > 30 { name = name[:30] + "…" } label := itemType if name != "" { label = itemType + ", " + name } msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm")) if lat != nil && lng != nil { msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng) } else { msg += " The finder didn't share a location." } msg += fmt.Sprintf(" See: https://where-woof.com/t/%s", tag.TagCode) return msg } // FinderContact stores a finder's number on the latest scan and alerts the owner. func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code")) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "tag not found", http.StatusNotFound) return } http.Error(w, "internal error", http.StatusInternalServerError) return } phone := sms.NormalizeAU(strings.TrimSpace(r.FormValue("phone"))) if len(phone) < 8 || len(phone) > 15 { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `
Please enter a valid phone number.
`) return } latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID) if err != nil { if !errors.Is(err, pgx.ErrNoRows) { http.Error(w, "internal error", http.StatusInternalServerError) return } // No scan yet: record one carrying the finder's number. _, err = a.Queries.InsertScan(r.Context(), db.InsertScanParams{ TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}, }) } else { err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}}) } if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil { itemType := "item" if tag.ItemType.Valid { itemType = tag.ItemType.String } msg := fmt.Sprintf("Where Woof: a finder of your %s left their number: %s. See: https://where-woof.com/t/%s", itemType, phone, tag.TagCode) if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil { fmt.Println("contact sms failed:", err) } } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `Thanks! The owner has been notified with your number.
`) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) }