feat: beacon safety system - add severity/incident_status/radius to DB, model, and API
This commit is contained in:
parent
fa0cca9b34
commit
442b4bef32
|
|
@ -233,7 +233,45 @@ func (h *PostHandler) GetNearbyBeacons(c *gin.Context) {
|
|||
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) {
|
||||
|
|
@ -251,6 +289,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
ChainParentID *string `json:"chain_parent_id"`
|
||||
IsBeacon bool `json:"is_beacon"`
|
||||
BeaconType *string `json:"beacon_type"`
|
||||
Severity *string `json:"severity"`
|
||||
BeaconLat *float64 `json:"beacon_lat"`
|
||||
BeaconLong *float64 `json:"beacon_long"`
|
||||
TTLHours *int `json:"ttl_hours"`
|
||||
|
|
@ -321,6 +360,11 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
Msg("CreatePost without chain parent")
|
||||
}
|
||||
|
||||
severity := "medium"
|
||||
if req.Severity != nil && *req.Severity != "" {
|
||||
severity = *req.Severity
|
||||
}
|
||||
|
||||
post := &models.Post{
|
||||
AuthorID: userID,
|
||||
Body: req.Body,
|
||||
|
|
@ -335,6 +379,9 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
Tags: tags,
|
||||
IsBeacon: req.IsBeacon,
|
||||
BeaconType: req.BeaconType,
|
||||
Severity: severity,
|
||||
IncidentStatus: "active",
|
||||
Radius: 500,
|
||||
Confidence: 0.5, // Initial confidence
|
||||
IsActiveBeacon: req.IsBeacon,
|
||||
AllowChain: allowChain,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ type Post struct {
|
|||
Long *float64 `json:"long,omitempty"`
|
||||
Confidence float64 `json:"confidence_score" db:"confidence_score"`
|
||||
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"`
|
||||
ChainParentID *uuid.UUID `json:"chain_parent_id" db:"chain_parent_id"`
|
||||
Visibility string `json:"visibility" db:"visibility"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
is_beacon, beacon_type, location, confidence_score,
|
||||
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
|
||||
is_nsfw, nsfw_reason
|
||||
is_nsfw, nsfw_reason,
|
||||
severity, incident_status, radius
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$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
|
||||
ELSE NULL END,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25
|
||||
$24, $25,
|
||||
$26, $27, $28
|
||||
) 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.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
||||
post.IsNSFW, post.NSFWReason,
|
||||
post.Severity, post.IncidentStatus, post.Radius,
|
||||
).Scan(&post.ID, &post.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -521,11 +524,17 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
|
|||
SELECT
|
||||
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,
|
||||
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
|
||||
WHERE p.is_beacon = true
|
||||
AND ST_DWithin(p.location, ST_SetSRID(ST_Point($2, $1), 4326)::geography, $3)
|
||||
AND p.status = 'active'
|
||||
AND COALESCE(p.incident_status, 'active') = 'active'
|
||||
ORDER BY p.created_at DESC
|
||||
`
|
||||
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
|
||||
for rows.Next() {
|
||||
var p models.Post
|
||||
var vouchCount, reportCount int
|
||||
err := rows.Scan(
|
||||
&p.ID, &p.CategoryID, &p.Body, &p.ImageURL, &p.Tags, &p.CreatedAt,
|
||||
&p.BeaconType, &p.Confidence, &p.IsActiveBeacon, &p.Lat, &p.Long,
|
||||
&p.Severity, &p.IncidentStatus, &p.Radius,
|
||||
&vouchCount, &reportCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -553,6 +565,8 @@ func (r *PostRepository) GetNearbyBeacons(ctx context.Context, lat float64, long
|
|||
DisplayName: "Anonymous",
|
||||
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)
|
||||
}
|
||||
return beacons, nil
|
||||
|
|
|
|||
|
|
@ -1,13 +1,56 @@
|
|||
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
|
||||
/// Uses neutral naming for App Store compliance
|
||||
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),
|
||||
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),
|
||||
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);
|
||||
|
||||
final String value;
|
||||
|
|
@ -88,6 +131,12 @@ class Beacon {
|
|||
final int? reportCount;
|
||||
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({
|
||||
required this.id,
|
||||
required this.body,
|
||||
|
|
@ -107,6 +156,10 @@ class Beacon {
|
|||
this.vouchCount,
|
||||
this.reportCount,
|
||||
this.userVote,
|
||||
this.severity = BeaconSeverity.medium,
|
||||
this.incidentStatus = BeaconIncidentStatus.active,
|
||||
this.radius = 500,
|
||||
this.verificationCount = 0,
|
||||
});
|
||||
|
||||
/// Parse double from various types
|
||||
|
|
@ -148,6 +201,10 @@ class Beacon {
|
|||
vouchCount: _parseInt(json['vouch_count']),
|
||||
reportCount: _parseInt(json['report_count']),
|
||||
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() {
|
||||
return {
|
||||
'id': id,
|
||||
|
|
@ -209,6 +272,10 @@ class Beacon {
|
|||
'vouch_count': vouchCount,
|
||||
'report_count': reportCount,
|
||||
'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 description;
|
||||
final BeaconType type;
|
||||
final BeaconSeverity severity;
|
||||
final String? imageUrl;
|
||||
|
||||
CreateBeaconRequest({
|
||||
|
|
@ -228,6 +296,7 @@ class CreateBeaconRequest {
|
|||
required this.title,
|
||||
required this.description,
|
||||
required this.type,
|
||||
this.severity = BeaconSeverity.medium,
|
||||
this.imageUrl,
|
||||
});
|
||||
|
||||
|
|
@ -238,6 +307,7 @@ class CreateBeaconRequest {
|
|||
'title': title,
|
||||
'description': description,
|
||||
'type': type.value,
|
||||
'severity': severity.value,
|
||||
if (imageUrl != null) 'image_url': imageUrl,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue