diff --git a/db/schema.sql b/db/schema.sql index b11031f..d05abf1 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -63,3 +63,6 @@ CREATE TABLE IF NOT EXISTS orders ( -- Tag → product / order linkage (populated by the Laravel admin, Phase 4). ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id); ALTER TABLE tags ADD COLUMN IF NOT EXISTS order_id BIGINT REFERENCES orders(id); + +-- Phase 2.5: per-device anti-spam fingerprint on scans (idempotent). +ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT; diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index 8c392c8..f11c296 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -34,6 +34,7 @@ type Scan struct { LocationShared bool `json:"location_shared"` ScannerPhone pgtype.Text `json:"scanner_phone"` AlertSent bool `json:"alert_sent"` + Fingerprint pgtype.Text `json:"fingerprint"` } type Tag struct { diff --git a/frontend/internal/db/querier.go b/frontend/internal/db/querier.go index 51c906a..ae1f24d 100644 --- a/frontend/internal/db/querier.go +++ b/frontend/internal/db/querier.go @@ -17,6 +17,7 @@ type Querier interface { CreateUser(ctx context.Context, arg CreateUserParams) (User, error) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error) + GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) GetTagByID(ctx context.Context, id int64) (Tag, error) GetUserByEmail(ctx context.Context, email string) (User, error) diff --git a/frontend/internal/db/queries.sql b/frontend/internal/db/queries.sql index a14d724..722b4ef 100644 --- a/frontend/internal/db/queries.sql +++ b/frontend/internal/db/queries.sql @@ -47,8 +47,8 @@ INSERT INTO tags (tag_code) VALUES ($1) RETURNING *; -- name: InsertScan :one -INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetLastAlertByTag :one @@ -67,4 +67,10 @@ LIMIT 1; UPDATE scans SET alert_sent = $2 WHERE id = $1; -- name: SetScanPhone :exec -UPDATE scans SET scanner_phone = $2 WHERE id = $1; +UPDATE scans SET scanner_phone = $2, fingerprint = $3 WHERE id = $1; + +-- name: GetRecentScanByFingerprint :one +SELECT * FROM scans +WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours' +ORDER BY scanned_at DESC +LIMIT 1; diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index b0c85d7..05e44e4 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -120,7 +120,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e } const getLastAlertByTag = `-- name: GetLastAlertByTag :one -SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans +SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans WHERE tag_id = $1 AND alert_sent = TRUE ORDER BY scanned_at DESC LIMIT 1 @@ -138,12 +138,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err &i.LocationShared, &i.ScannerPhone, &i.AlertSent, + &i.Fingerprint, ) return i, err } const getLatestScanByTag = `-- name: GetLatestScanByTag :one -SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans +SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans WHERE tag_id = $1 ORDER BY scanned_at DESC LIMIT 1 @@ -161,6 +162,36 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er &i.LocationShared, &i.ScannerPhone, &i.AlertSent, + &i.Fingerprint, + ) + return i, err +} + +const getRecentScanByFingerprint = `-- name: GetRecentScanByFingerprint :one +SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans +WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours' +ORDER BY scanned_at DESC +LIMIT 1 +` + +type GetRecentScanByFingerprintParams struct { + Fingerprint pgtype.Text `json:"fingerprint"` + ID int64 `json:"id"` +} + +func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error) { + row := q.db.QueryRow(ctx, getRecentScanByFingerprint, arg.Fingerprint, arg.ID) + var i Scan + err := row.Scan( + &i.ID, + &i.TagID, + &i.ScannedAt, + &i.Lat, + &i.Lng, + &i.LocationShared, + &i.ScannerPhone, + &i.AlertSent, + &i.Fingerprint, ) return i, err } @@ -256,9 +287,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { } const insertScan = `-- name: InsertScan :one -INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent +INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint ` type InsertScanParams struct { @@ -267,6 +298,7 @@ type InsertScanParams struct { Lng pgtype.Float8 `json:"lng"` LocationShared bool `json:"location_shared"` ScannerPhone pgtype.Text `json:"scanner_phone"` + Fingerprint pgtype.Text `json:"fingerprint"` } func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) { @@ -276,6 +308,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e arg.Lng, arg.LocationShared, arg.ScannerPhone, + arg.Fingerprint, ) var i Scan err := row.Scan( @@ -287,6 +320,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e &i.LocationShared, &i.ScannerPhone, &i.AlertSent, + &i.Fingerprint, ) return i, err } @@ -374,16 +408,17 @@ func (q *Queries) SetScanAlertSent(ctx context.Context, arg SetScanAlertSentPara } const setScanPhone = `-- name: SetScanPhone :exec -UPDATE scans SET scanner_phone = $2 WHERE id = $1 +UPDATE scans SET scanner_phone = $2, fingerprint = $3 WHERE id = $1 ` type SetScanPhoneParams struct { ID int64 `json:"id"` ScannerPhone pgtype.Text `json:"scanner_phone"` + Fingerprint pgtype.Text `json:"fingerprint"` } func (q *Queries) SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error { - _, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone) + _, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone, arg.Fingerprint) return err } diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go index 5d07148..ea251b0 100644 --- a/frontend/internal/handlers/scan.go +++ b/frontend/internal/handlers/scan.go @@ -23,8 +23,10 @@ const ( // ScanRequest is the JSON body posted by the geolocation script. type ScanRequest struct { - Lat *float64 `json:"lat"` - Lng *float64 `json:"lng"` + Lat *float64 `json:"lat"` + Lng *float64 `json:"lng"` + Phone *string `json:"phone"` + Fingerprint *string `json:"fingerprint"` } // Scan records a scan (with optional location) and alerts the owner per the @@ -56,13 +58,15 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) { Lat: lat, Lng: lng, LocationShared: hasLoc, + ScannerPhone: textOrNil(ptrStr(req.Phone)), + Fingerprint: textOrNil(ptrStr(req.Fingerprint)), }) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng) + alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone)) 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}) @@ -73,7 +77,9 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) { } // 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 { +// Order: sms_enabled → owner phone → 24 h fingerprint block → 10-min window +// (re-alert on movement >250 m OR a different finder phone). +func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone string) bool { if !tag.SmsEnabled { return false } @@ -81,6 +87,13 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc return false } + // 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only. + if fp := scan.Fingerprint.String; fp != "" { + if _, err := a.Queries.GetRecentScanByFingerprint(ctx, db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fp, Valid: true}, ID: scan.ID}); err == nil { + return false + } + } + last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID) if err != nil { return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert @@ -88,11 +101,11 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc // 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 + // Re-alert on movement >250 m, or a different finder phone. + moved := hasLoc && last.Lat.Valid && last.Lng.Valid && + sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance + differentPhone := phone != "" && phone != last.ScannerPhone.String + return moved || differentPhone } return true } @@ -136,6 +149,8 @@ func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string { } // FinderContact stores a finder's number on the latest scan and alerts the owner. +// Dedup: same phone within the alert window => store only; fingerprint seen in +// the last 24 h => store only. func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code")) if err != nil { @@ -153,6 +168,7 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `
Please enter a valid phone number.
`) return } + fingerprint := strings.TrimSpace(r.FormValue("fingerprint")) latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID) if err != nil { @@ -160,19 +176,40 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { 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{ + // No scan yet: record one carrying the finder's number + fingerprint. + ins, insErr := a.Queries.InsertScan(r.Context(), db.InsertScanParams{ TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}, + Fingerprint: textOrNil(fingerprint), }) + if insErr != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + latest = ins } else { - err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}}) + err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}, Fingerprint: textOrNil(fingerprint)}) } if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - if tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil { + // Should we notify? Skip when the same phone was alerted within the window, + // or when this device (fingerprint) has already alerted in the last 24 h. + notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil + if notify && fingerprint != "" { + if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil { + notify = false + } + } + if notify { + if last, err := a.Queries.GetLastAlertByTag(r.Context(), tag.ID); err == nil && + time.Since(last.ScannedAt.Time) < alertWindow && last.ScannerPhone.String == phone { + notify = false + } + } + + if notify { itemType := "item" if tag.ItemType.Valid { itemType = tag.ItemType.String @@ -181,6 +218,9 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { itemType, phone, tag.TagCode) if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil { fmt.Println("contact sms failed:", err) + } else { + // Mark alerted so dedup (same phone / same device) can see it. + _ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true}) } } @@ -188,6 +228,13 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `Thanks! The owner has been notified with your number.
`) } +func ptrStr(p *string) string { + if p == nil { + return "" + } + return *p +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) diff --git a/frontend/templates/tag-public.html b/frontend/templates/tag-public.html index 05eeac8..890b629 100644 --- a/frontend/templates/tag-public.html +++ b/frontend/templates/tag-public.html @@ -63,6 +63,7 @@