scan-alert-hardening: different-finder re-alert within 250m window + fingerprint 24h block (12/12 scenarios pass)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, `<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">Please enter a valid phone number.</p>`)
|
||||
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, `<p class="mt-4 rounded border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800">Thanks! The owner has been notified with your number.</p>`)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user