diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index ac1ecce..574015d 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -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, diff --git a/go-backend/internal/models/post.go b/go-backend/internal/models/post.go index 7184c5d..654f443 100644 --- a/go-backend/internal/models/post.go +++ b/go-backend/internal/models/post.go @@ -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"` diff --git a/go-backend/internal/repository/post_repository.go b/go-backend/internal/repository/post_repository.go index 87bacc5..c567103 100644 --- a/go-backend/internal/repository/post_repository.go +++ b/go-backend/internal/repository/post_repository.go @@ -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 diff --git a/sojorn_app/lib/models/beacon.dart b/sojorn_app/lib/models/beacon.dart index 68f021e..b6eac63 100644 --- a/sojorn_app/lib/models/beacon.dart +++ b/sojorn_app/lib/models/beacon.dart @@ -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 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, }; }