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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue