feat: beacon safety system - add severity/incident_status/radius to DB, model, and API

This commit is contained in:
Patrick Britton 2026-02-09 10:21:07 -06:00
parent fa0cca9b34
commit 442b4bef32
4 changed files with 139 additions and 5 deletions

View file

@ -233,7 +233,45 @@ func (h *PostHandler) GetNearbyBeacons(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"beacons": beacons}) // Transform to beacon-specific JSON with correct field names for Flutter client
results := make([]gin.H, 0, len(beacons))
for _, b := range beacons {
item := gin.H{
"id": b.ID,
"body": b.Body,
"author_id": b.AuthorID,
"beacon_type": b.BeaconType,
"confidence_score": b.Confidence,
"is_active_beacon": b.IsActiveBeacon,
"created_at": b.CreatedAt,
"image_url": b.ImageURL,
"tags": b.Tags,
"beacon_lat": b.Lat,
"beacon_long": b.Long,
"severity": b.Severity,
"incident_status": b.IncidentStatus,
"radius": b.Radius,
"vouch_count": b.LikeCount, // mapped from vouch subquery
"report_count": b.CommentCount, // mapped from report subquery
"verification_count": b.LikeCount, // vouches = verification
"status_color": beaconStatusColor(b.Confidence),
"author_handle": "Anonymous",
"author_display_name": "Anonymous",
}
results = append(results, item)
}
c.JSON(http.StatusOK, gin.H{"beacons": results})
}
// beaconStatusColor returns green/yellow/red based on confidence score.
func beaconStatusColor(confidence float64) string {
if confidence > 0.7 {
return "green"
} else if confidence >= 0.3 {
return "yellow"
}
return "red"
} }
func (h *PostHandler) CreatePost(c *gin.Context) { func (h *PostHandler) CreatePost(c *gin.Context) {
@ -251,6 +289,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
ChainParentID *string `json:"chain_parent_id"` ChainParentID *string `json:"chain_parent_id"`
IsBeacon bool `json:"is_beacon"` IsBeacon bool `json:"is_beacon"`
BeaconType *string `json:"beacon_type"` BeaconType *string `json:"beacon_type"`
Severity *string `json:"severity"`
BeaconLat *float64 `json:"beacon_lat"` BeaconLat *float64 `json:"beacon_lat"`
BeaconLong *float64 `json:"beacon_long"` BeaconLong *float64 `json:"beacon_long"`
TTLHours *int `json:"ttl_hours"` TTLHours *int `json:"ttl_hours"`
@ -321,6 +360,11 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
Msg("CreatePost without chain parent") Msg("CreatePost without chain parent")
} }
severity := "medium"
if req.Severity != nil && *req.Severity != "" {
severity = *req.Severity
}
post := &models.Post{ post := &models.Post{
AuthorID: userID, AuthorID: userID,
Body: req.Body, Body: req.Body,
@ -335,6 +379,9 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
Tags: tags, Tags: tags,
IsBeacon: req.IsBeacon, IsBeacon: req.IsBeacon,
BeaconType: req.BeaconType, BeaconType: req.BeaconType,
Severity: severity,
IncidentStatus: "active",
Radius: 500,
Confidence: 0.5, // Initial confidence Confidence: 0.5, // Initial confidence
IsActiveBeacon: req.IsBeacon, IsActiveBeacon: req.IsBeacon,
AllowChain: allowChain, AllowChain: allowChain,

View file

@ -28,6 +28,9 @@ type Post struct {
Long *float64 `json:"long,omitempty"` Long *float64 `json:"long,omitempty"`
Confidence float64 `json:"confidence_score" db:"confidence_score"` Confidence float64 `json:"confidence_score" db:"confidence_score"`
IsActiveBeacon bool `json:"is_active_beacon" db:"is_active_beacon"` IsActiveBeacon bool `json:"is_active_beacon" db:"is_active_beacon"`
Severity string `json:"severity" db:"severity"`
IncidentStatus string `json:"incident_status" db:"incident_status"`
Radius int `json:"radius" db:"radius"`
AllowChain bool `json:"allow_chain" db:"allow_chain"` AllowChain bool `json:"allow_chain" db:"allow_chain"`
ChainParentID *uuid.UUID `json:"chain_parent_id" db:"chain_parent_id"` ChainParentID *uuid.UUID `json:"chain_parent_id" db:"chain_parent_id"`
Visibility string `json:"visibility" db:"visibility"` Visibility string `json:"visibility" db:"visibility"`

View file

@ -42,7 +42,8 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
image_url, video_url, thumbnail_url, duration_ms, body_format, background_id, tags, image_url, video_url, thumbnail_url, duration_ms, body_format, background_id, tags,
is_beacon, beacon_type, location, confidence_score, is_beacon, beacon_type, location, confidence_score,
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at, is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
is_nsfw, nsfw_reason is_nsfw, nsfw_reason,
severity, incident_status, radius
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $14, $15,
@ -50,7 +51,8 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
THEN ST_SetSRID(ST_MakePoint(($17::double precision), ($16::double precision)), 4326)::geography THEN ST_SetSRID(ST_MakePoint(($17::double precision), ($16::double precision)), 4326)::geography
ELSE NULL END, ELSE NULL END,
$18, $19, $20, $21, $22, $23, $18, $19, $20, $21, $22, $23,
$24, $25 $24, $25,
$26, $27, $28
) RETURNING id, created_at ) RETURNING id, created_at
` `
@ -66,6 +68,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence, post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt, post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
post.IsNSFW, post.NSFWReason, post.IsNSFW, post.NSFWReason,
post.Severity, post.IncidentStatus, post.Radius,
).Scan(&post.ID, &post.CreatedAt) ).Scan(&post.ID, &post.CreatedAt)
if err != nil { if err != nil {
@ -521,11 +524,17 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
SELECT SELECT
p.id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at, p.id, p.category_id, p.body, COALESCE(p.image_url, ''), p.tags, p.created_at,
p.beacon_type, p.confidence_score, p.is_active_beacon, p.beacon_type, p.confidence_score, p.is_active_beacon,
ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as long,
COALESCE(p.severity, 'medium') as severity,
COALESCE(p.incident_status, 'active') as incident_status,
COALESCE(p.radius, 500) as radius,
COALESCE((SELECT COUNT(*) FROM beacon_votes bv WHERE bv.beacon_id = p.id AND bv.vote_type = 'vouch'), 0) as vouch_count,
COALESCE((SELECT COUNT(*) FROM beacon_votes bv WHERE bv.beacon_id = p.id AND bv.vote_type = 'report'), 0) as report_count
FROM public.posts p FROM public.posts p
WHERE p.is_beacon = true WHERE p.is_beacon = true
AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3) AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
AND p.status = 'active' AND p.status = 'active'
AND COALESCE(p.incident_status, 'active') = 'active'
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
` `
rows, err := r.pool.Query(ctx, query, lat, long, radius) rows, err := r.pool.Query(ctx, query, lat, long, radius)
@ -537,9 +546,12 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
var beacons []models.Post var beacons []models.Post
for rows.Next() { for rows.Next() {
var p models.Post var p models.Post
var vouchCount, reportCount int
err := rows.Scan( err := rows.Scan(
&p.ID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt, &p.ID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
&p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long, &p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
&p.Severity, &p.IncidentStatus, &p.Radius,
&vouchCount, &reportCount,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -553,6 +565,8 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
DisplayName: "Anonymous", DisplayName: "Anonymous",
AvatarURL: "", AvatarURL: "",
} }
p.LikeCount = vouchCount // repurpose like_count as vouch_count for beacon API
p.CommentCount = reportCount // repurpose comment_count as report_count for beacon API
beacons = append(beacons, p) beacons = append(beacons, p)
} }
return beacons, nil return beacons, nil

View file

@ -1,13 +1,56 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Beacon severity levels controls pin color and alert priority
enum BeaconSeverity {
low('low', 'Info', Colors.green, Icons.info_outline),
medium('medium', 'Caution', Colors.amber, Icons.warning_amber),
high('high', 'Danger', Colors.deepOrange, Icons.error_outline),
critical('critical', 'Critical', Colors.red, Icons.dangerous);
final String value;
final String label;
final Color color;
final IconData icon;
const BeaconSeverity(this.value, this.label, this.color, this.icon);
static BeaconSeverity fromString(String value) {
return BeaconSeverity.values.firstWhere(
(s) => s.value == value,
orElse: () => BeaconSeverity.medium,
);
}
}
/// Beacon incident lifecycle status
enum BeaconIncidentStatus {
active('active', 'Active'),
resolved('resolved', 'Resolved'),
falseAlarm('false_alarm', 'False Alarm');
final String value;
final String label;
const BeaconIncidentStatus(this.value, this.label);
static BeaconIncidentStatus fromString(String value) {
return BeaconIncidentStatus.values.firstWhere(
(s) => s.value == value,
orElse: () => BeaconIncidentStatus.active,
);
}
}
/// Beacon type enum for different alert categories /// Beacon type enum for different alert categories
/// Uses neutral naming for App Store compliance /// Uses neutral naming for App Store compliance
enum BeaconType { enum BeaconType {
suspiciousActivity('suspicious', 'Suspicious Activity', 'Report unusual behavior or people', Icons.visibility, Colors.orange),
police('police', 'Police Presence', 'General presence (Speed traps, patrol)', Icons.local_police, Colors.blue), police('police', 'Police Presence', 'General presence (Speed traps, patrol)', Icons.local_police, Colors.blue),
checkpoint('checkpoint', 'Checkpoint / Stop', 'Report stationary stops, roadblocks, or inspection points.', Icons.stop_circle, Colors.indigo), checkpoint('checkpoint', 'Checkpoint / Stop', 'Report stationary stops, roadblocks, or inspection points.', Icons.stop_circle, Colors.indigo),
taskForce('taskForce', 'Task Force / Operation', 'Report heavy coordinated activity, raids, or multiple units.', Icons.warning, Colors.deepOrange), taskForce('taskForce', 'Task Force / Operation', 'Report heavy coordinated activity, raids, or multiple units.', Icons.warning, Colors.deepOrange),
hazard('hazard', 'Road Hazard', 'Physical danger (Debris, Ice, Floods)', Icons.report_problem, Colors.amber), hazard('hazard', 'Road Hazard', 'Physical danger (Debris, Ice, Floods)', Icons.report_problem, Colors.amber),
safety('safety', 'Safety Alert', 'Events (Fire, Crime, Fights)', Icons.shield, Colors.red), fire('fire', 'Fire', 'Report fires or smoke', Icons.local_fire_department, Colors.red),
safety('safety', 'Safety Alert', 'Events (Crime, Fights, Gunshots)', Icons.shield, Colors.red),
community('community', 'Community Event', 'Helpful (Food drives, Lost pets)', Icons.volunteer_activism, Colors.teal); community('community', 'Community Event', 'Helpful (Food drives, Lost pets)', Icons.volunteer_activism, Colors.teal);
final String value; final String value;
@ -88,6 +131,12 @@ class Beacon {
final int? reportCount; final int? reportCount;
final String? userVote; // 'vouch', 'report', or null final String? userVote; // 'vouch', 'report', or null
// Safety system fields
final BeaconSeverity severity;
final BeaconIncidentStatus incidentStatus;
final int radius; // area of effect in meters
final int verificationCount; // "I see this too" vouches
Beacon({ Beacon({
required this.id, required this.id,
required this.body, required this.body,
@ -107,6 +156,10 @@ class Beacon {
this.vouchCount, this.vouchCount,
this.reportCount, this.reportCount,
this.userVote, this.userVote,
this.severity = BeaconSeverity.medium,
this.incidentStatus = BeaconIncidentStatus.active,
this.radius = 500,
this.verificationCount = 0,
}); });
/// Parse double from various types /// Parse double from various types
@ -148,6 +201,10 @@ class Beacon {
vouchCount: _parseInt(json['vouch_count']), vouchCount: _parseInt(json['vouch_count']),
reportCount: _parseInt(json['report_count']), reportCount: _parseInt(json['report_count']),
userVote: json['user_vote'] as String?, userVote: json['user_vote'] as String?,
severity: BeaconSeverity.fromString(json['severity'] as String? ?? 'medium'),
incidentStatus: BeaconIncidentStatus.fromString(json['incident_status'] as String? ?? 'active'),
radius: _parseInt(json['radius'] ?? 500),
verificationCount: _parseInt(json['verification_count'] ?? 0),
); );
} }
@ -189,6 +246,12 @@ class Beacon {
} }
} }
/// Whether this beacon was reported in the last 10 minutes (for pulse animation)
bool get isRecent => DateTime.now().difference(createdAt).inMinutes < 10;
/// Color for the map pin based on severity
Color get pinColor => severity.color;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
@ -209,6 +272,10 @@ class Beacon {
'vouch_count': vouchCount, 'vouch_count': vouchCount,
'report_count': reportCount, 'report_count': reportCount,
'user_vote': userVote, 'user_vote': userVote,
'severity': severity.value,
'incident_status': incidentStatus.value,
'radius': radius,
'verification_count': verificationCount,
}; };
} }
} }
@ -220,6 +287,7 @@ class CreateBeaconRequest {
final String title; final String title;
final String description; final String description;
final BeaconType type; final BeaconType type;
final BeaconSeverity severity;
final String? imageUrl; final String? imageUrl;
CreateBeaconRequest({ CreateBeaconRequest({
@ -228,6 +296,7 @@ class CreateBeaconRequest {
required this.title, required this.title,
required this.description, required this.description,
required this.type, required this.type,
this.severity = BeaconSeverity.medium,
this.imageUrl, this.imageUrl,
}); });
@ -238,6 +307,7 @@ class CreateBeaconRequest {
'title': title, 'title': title,
'description': description, 'description': description,
'type': type.value, 'type': type.value,
'severity': severity.value,
if (imageUrl != null) 'image_url': imageUrl, if (imageUrl != null) 'image_url': imageUrl,
}; };
} }