- Rename module path from github.com/patbritton/sojorn-backend to gitlab.com/patrickbritton3/sojorn/go-backend - Updated 78 references across 41 files - Matches new GitLab repository structure
166 lines
5.9 KiB
Go
166 lines
5.9 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
|
|
)
|
|
|
|
type GroupService struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewGroupService(pool *pgxpool.Pool) *GroupService {
|
|
return &GroupService{pool: pool}
|
|
}
|
|
|
|
// CreateGeoGroup creates a new geo-cluster (neighborhood) group at the given coordinates.
|
|
func (s *GroupService) CreateGeoGroup(ctx context.Context, name string, lat, long float64, radiusMeters int, createdBy uuid.UUID) (*models.Group, error) {
|
|
var group models.Group
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO groups (name, type, privacy, location_center, radius_meters, created_by, member_count)
|
|
VALUES ($1, 'geo', 'public', ST_SetSRID(ST_MakePoint($2, $3), 4326)::geography, $4, $5, 0)
|
|
RETURNING id, name, description, type, privacy, radius_meters, member_count, is_active, category, created_at, updated_at
|
|
`, name, long, lat, radiusMeters, createdBy).Scan(
|
|
&group.ID, &group.Name, &group.Description, &group.Type, &group.Privacy,
|
|
&group.RadiusMeters, &group.MemberCount, &group.IsActive, &group.Category, &group.CreatedAt, &group.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create geo group: %w", err)
|
|
}
|
|
group.Lat = &lat
|
|
group.Long = &long
|
|
group.CreatedBy = &createdBy
|
|
return &group, nil
|
|
}
|
|
|
|
// FindNearestGeoGroup finds the closest geo group to the given coordinates within maxDistance meters.
|
|
func (s *GroupService) FindNearestGeoGroup(ctx context.Context, lat, long float64, maxDistanceMeters int) (*models.GroupWithDistance, error) {
|
|
var gwd models.GroupWithDistance
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT g.id, g.name, g.description, g.type, g.privacy, g.radius_meters,
|
|
g.member_count, g.is_active, g.category, g.created_at, g.updated_at,
|
|
ST_Distance(g.location_center, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters
|
|
FROM groups g
|
|
WHERE g.type = 'geo' AND g.is_active = TRUE
|
|
AND ST_DWithin(g.location_center, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
|
ORDER BY distance_meters ASC
|
|
LIMIT 1
|
|
`, long, lat, maxDistanceMeters).Scan(
|
|
&gwd.ID, &gwd.Name, &gwd.Description, &gwd.Type, &gwd.Privacy,
|
|
&gwd.RadiusMeters, &gwd.MemberCount, &gwd.IsActive, &gwd.Category, &gwd.CreatedAt, &gwd.UpdatedAt,
|
|
&gwd.DistanceMeters,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find nearest geo group: %w", err)
|
|
}
|
|
return &gwd, nil
|
|
}
|
|
|
|
// AutoJoinNearestGeoGroup finds the nearest geo group and adds the user as a member.
|
|
// Returns the group (or nil if none found). Idempotent — won't duplicate memberships.
|
|
func (s *GroupService) AutoJoinNearestGeoGroup(ctx context.Context, userID uuid.UUID, lat, long float64) (*models.Group, error) {
|
|
gwd, err := s.FindNearestGeoGroup(ctx, lat, long, 50000) // 50km max
|
|
if err != nil {
|
|
return nil, nil // no group found — that's OK
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx, `
|
|
INSERT INTO group_members (group_id, user_id, role)
|
|
VALUES ($1, $2, 'member')
|
|
ON CONFLICT (group_id, user_id) DO NOTHING
|
|
`, gwd.ID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auto-join geo group: %w", err)
|
|
}
|
|
|
|
// Bump member count (best-effort)
|
|
s.pool.Exec(ctx, `
|
|
UPDATE groups SET member_count = (
|
|
SELECT COUNT(*) FROM group_members WHERE group_id = $1
|
|
) WHERE id = $1
|
|
`, gwd.ID)
|
|
|
|
return &gwd.Group, nil
|
|
}
|
|
|
|
// GetUserGeoGroup returns the user's current geo group (if any).
|
|
func (s *GroupService) GetUserGeoGroup(ctx context.Context, userID uuid.UUID) (*models.Group, error) {
|
|
var group models.Group
|
|
var createdAt, updatedAt time.Time
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT g.id, g.name, g.description, g.type, g.privacy, g.radius_meters,
|
|
g.member_count, g.is_active, g.category, g.created_at, g.updated_at
|
|
FROM groups g
|
|
JOIN group_members gm ON gm.group_id = g.id
|
|
WHERE gm.user_id = $1 AND g.type = 'geo' AND g.is_active = TRUE
|
|
ORDER BY gm.joined_at DESC
|
|
LIMIT 1
|
|
`, userID).Scan(
|
|
&group.ID, &group.Name, &group.Description, &group.Type, &group.Privacy,
|
|
&group.RadiusMeters, &group.MemberCount, &group.IsActive, &group.Category, &createdAt, &updatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user geo group: %w", err)
|
|
}
|
|
group.CreatedAt = createdAt
|
|
group.UpdatedAt = updatedAt
|
|
return &group, nil
|
|
}
|
|
|
|
// GetGroupPosts returns posts belonging to a group, optionally filtered by beacon-only.
|
|
func (s *GroupService) GetGroupPosts(ctx context.Context, groupID uuid.UUID, beaconsOnly bool, limit, offset int) ([]models.Post, error) {
|
|
var filter string
|
|
if beaconsOnly {
|
|
filter = " AND p.is_beacon = TRUE"
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
|
|
SELECT p.id, p.author_id, p.body, p.status, p.is_beacon, p.beacon_type,
|
|
p.severity, p.incident_status, p.radius, p.created_at, p.group_id
|
|
FROM posts p
|
|
WHERE p.group_id = $1 AND p.status = 'active' AND p.deleted_at IS NULL%s
|
|
ORDER BY p.created_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, filter), groupID, limit, offset)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get group posts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var posts []models.Post
|
|
for rows.Next() {
|
|
var p models.Post
|
|
if err := rows.Scan(
|
|
&p.ID, &p.AuthorID, &p.Body, &p.Status, &p.IsBeacon, &p.BeaconType,
|
|
&p.Severity, &p.IncidentStatus, &p.Radius, &p.CreatedAt, &p.GroupID,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
posts = append(posts, p)
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
// GetGroupByID returns a single group by its ID.
|
|
func (s *GroupService) GetGroupByID(ctx context.Context, groupID uuid.UUID) (*models.Group, error) {
|
|
var group models.Group
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, name, description, type, privacy, radius_meters,
|
|
member_count, is_active, category, created_at, updated_at
|
|
FROM groups WHERE id = $1
|
|
`, groupID).Scan(
|
|
&group.ID, &group.Name, &group.Description, &group.Type, &group.Privacy,
|
|
&group.RadiusMeters, &group.MemberCount, &group.IsActive, &group.Category, &group.CreatedAt, &group.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get group: %w", err)
|
|
}
|
|
return &group, nil
|
|
}
|