2026-01-13 20:26:15 +00:00
|
|
|
package sync
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
2026-01-14 20:15:48 +00:00
|
|
|
"html/template"
|
2026-01-13 20:26:15 +00:00
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-01-27 18:44:02 +00:00
|
|
|
"github.com/Gleipnir-Technology/bob/dialect/psql/sm"
|
2026-01-13 20:26:15 +00:00
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/auth"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/background"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/config"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/db"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/db/models"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/uber/h3-go/v4"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Authenticated pages
|
|
|
|
|
var (
|
|
|
|
|
cellT = buildTemplate("cell", "authenticated")
|
|
|
|
|
dashboardT = buildTemplate("dashboard", "authenticated")
|
|
|
|
|
districtT = buildTemplate("district", "base")
|
|
|
|
|
settingsT = buildTemplate("settings", "authenticated")
|
|
|
|
|
sourceT = buildTemplate("source", "authenticated")
|
2026-01-15 22:12:35 +00:00
|
|
|
trapT = buildTemplate("trap", "authenticated")
|
2026-01-13 20:26:15 +00:00
|
|
|
)
|
|
|
|
|
|
2026-01-14 21:39:58 +00:00
|
|
|
type Config struct {
|
|
|
|
|
URLTegola string
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 22:12:35 +00:00
|
|
|
type ContentSource struct {
|
|
|
|
|
Inspections []Inspection
|
|
|
|
|
MapData ComponentMap
|
|
|
|
|
Source *BreedingSourceDetail
|
|
|
|
|
Traps []TrapNearby
|
|
|
|
|
Treatments []Treatment
|
|
|
|
|
//TreatmentCadence TreatmentCadence
|
|
|
|
|
TreatmentModels []TreatmentModel
|
|
|
|
|
User User
|
|
|
|
|
}
|
|
|
|
|
type ContentTrap struct {
|
|
|
|
|
MapData ComponentMap
|
|
|
|
|
Trap Trap
|
|
|
|
|
User User
|
|
|
|
|
}
|
2026-01-15 21:00:42 +00:00
|
|
|
type ContextCell struct {
|
|
|
|
|
BreedingSources []BreedingSourceSummary
|
|
|
|
|
CellBoundary h3.CellBoundary
|
|
|
|
|
Inspections []Inspection
|
|
|
|
|
MapData ComponentMap
|
2026-01-15 22:12:35 +00:00
|
|
|
Traps []TrapSummary
|
2026-01-15 21:00:42 +00:00
|
|
|
Treatments []Treatment
|
|
|
|
|
User User
|
|
|
|
|
}
|
2026-01-14 20:15:48 +00:00
|
|
|
type ContextDashboard struct {
|
2026-01-14 21:39:58 +00:00
|
|
|
Config Config
|
2026-01-15 19:32:42 +00:00
|
|
|
CountTraps int
|
2026-01-14 20:15:48 +00:00
|
|
|
CountMosquitoSources int
|
|
|
|
|
CountServiceRequests int
|
|
|
|
|
Geo template.JS
|
|
|
|
|
IsSyncOngoing bool
|
|
|
|
|
LastSync *time.Time
|
|
|
|
|
MapData ComponentMap
|
|
|
|
|
RecentRequests []ServiceRequestSummary
|
|
|
|
|
User User
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 20:26:15 +00:00
|
|
|
type ContextDistrict struct {
|
|
|
|
|
MapboxToken string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getCellDetails(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
|
|
|
cell_str := chi.URLParam(r, "cell")
|
|
|
|
|
if cell_str == "" {
|
|
|
|
|
respondError(w, "There should always be a cell", nil, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c, err := HexToInt64(cell_str)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Cannot convert provided cell to uint64", err, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cell(r.Context(), w, user, c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getDistrict(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
context := ContextDistrict{
|
|
|
|
|
MapboxToken: config.MapboxToken,
|
|
|
|
|
}
|
|
|
|
|
htmlpage.RenderOrError(w, districtT, &context)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getRoot(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
user, err := auth.GetAuthenticatedUser(r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// No credentials or user not found: go to login
|
|
|
|
|
if errors.Is(err, &auth.NoCredentialsError{}) || errors.Is(err, &auth.NoUserError{}) {
|
|
|
|
|
http.Redirect(w, r, "/signin", http.StatusFound)
|
|
|
|
|
return
|
|
|
|
|
} else {
|
|
|
|
|
respondError(w, "Failed to get root", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if user == nil {
|
|
|
|
|
errorCode := r.URL.Query().Get("error")
|
|
|
|
|
signin(w, errorCode)
|
|
|
|
|
return
|
|
|
|
|
} else {
|
|
|
|
|
has, err := background.HasFieldseekerConnection(r.Context(), user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to check for ArcGIS connection", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if has {
|
|
|
|
|
dashboard(r.Context(), w, user)
|
|
|
|
|
return
|
|
|
|
|
} else {
|
2026-01-14 20:15:48 +00:00
|
|
|
oauthPrompt(w, r, user)
|
2026-01-13 20:26:15 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to render root", err, http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getSettings(w http.ResponseWriter, r *http.Request, u *models.User) {
|
|
|
|
|
settings(w, r, u)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getSource(w http.ResponseWriter, r *http.Request, u *models.User) {
|
|
|
|
|
globalid_s := chi.URLParam(r, "globalid")
|
|
|
|
|
if globalid_s == "" {
|
|
|
|
|
respondError(w, "No globalid provided", nil, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
globalid, err := uuid.Parse(globalid_s)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "globalid is not a UUID", nil, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
source(w, r, u, globalid)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 22:12:35 +00:00
|
|
|
func getTrap(w http.ResponseWriter, r *http.Request, u *models.User) {
|
|
|
|
|
globalid_s := chi.URLParam(r, "globalid")
|
|
|
|
|
if globalid_s == "" {
|
|
|
|
|
respondError(w, "No globalid provided", nil, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
globalid, err := uuid.Parse(globalid_s)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "globalid is not a UUID", nil, http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
trap(w, r, u, globalid)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 20:26:15 +00:00
|
|
|
func cell(ctx context.Context, w http.ResponseWriter, user *models.User, c int64) {
|
|
|
|
|
org, err := user.Organization().One(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get org", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
userContent, err := contentForUser(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get user", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
center, err := h3.Cell(c).LatLng()
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get center", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
boundary, err := h3.Cell(c).Boundary()
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get boundary", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
inspections, err := inspectionsByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get inspections by cell", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
geojson, err := h3utils.H3ToGeoJSON([]h3.Cell{h3.Cell(c)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get boundaries", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resolution := h3.Cell(c).Resolution()
|
|
|
|
|
sources, err := breedingSourcesByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get sources", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-15 21:00:42 +00:00
|
|
|
traps, err := trapsByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get traps", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 20:26:15 +00:00
|
|
|
treatments, err := treatmentsByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get treatments", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-15 21:00:42 +00:00
|
|
|
data := ContextCell{
|
2026-01-13 20:26:15 +00:00
|
|
|
BreedingSources: sources,
|
|
|
|
|
CellBoundary: boundary,
|
|
|
|
|
Inspections: inspections,
|
|
|
|
|
MapData: ComponentMap{
|
|
|
|
|
Center: h3.LatLng{
|
|
|
|
|
Lat: center.Lat,
|
|
|
|
|
Lng: center.Lng,
|
|
|
|
|
},
|
|
|
|
|
GeoJSON: geojson,
|
|
|
|
|
MapboxToken: config.MapboxToken,
|
|
|
|
|
Zoom: resolution + 5,
|
|
|
|
|
},
|
2026-01-15 21:00:42 +00:00
|
|
|
Traps: traps,
|
2026-01-13 20:26:15 +00:00
|
|
|
Treatments: treatments,
|
|
|
|
|
User: userContent,
|
|
|
|
|
}
|
|
|
|
|
htmlpage.RenderOrError(w, cellT, &data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dashboard(ctx context.Context, w http.ResponseWriter, user *models.User) {
|
|
|
|
|
org, err := user.Organization().One(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get org", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var lastSync *time.Time
|
|
|
|
|
sync, err := org.FieldseekerSyncs(sm.OrderBy("created").Desc()).One(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err.Error() != "sql: no rows in result set" {
|
|
|
|
|
respondError(w, "Failed to get syncs", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
lastSync = &sync.Created
|
|
|
|
|
}
|
|
|
|
|
is_syncing := background.IsSyncOngoing(org.ID)
|
2026-01-15 19:32:42 +00:00
|
|
|
trapCount, err := org.Traplocations().Count(ctx, db.PGInstance.BobDB)
|
2026-01-13 20:26:15 +00:00
|
|
|
if err != nil {
|
2026-01-15 19:32:42 +00:00
|
|
|
respondError(w, "Failed to get trap count", err, http.StatusInternalServerError)
|
2026-01-13 20:26:15 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
sourceCount, err := org.Pointlocations().Count(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get source count", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
serviceCount, err := org.Servicerequests().Count(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get service count", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
recentRequests, err := org.Servicerequests(sm.OrderBy("creationdate").Desc(), sm.Limit(10)).All(ctx, db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get recent service", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requests := make([]ServiceRequestSummary, 0)
|
|
|
|
|
for _, r := range recentRequests {
|
|
|
|
|
requests = append(requests, ServiceRequestSummary{
|
|
|
|
|
Date: r.Creationdate.MustGet(),
|
|
|
|
|
Location: r.Reqaddr1.MustGet(),
|
|
|
|
|
Status: "Completed",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
userContent, err := contentForUser(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get user context", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-14 20:15:48 +00:00
|
|
|
data := ContextDashboard{
|
2026-01-14 21:39:58 +00:00
|
|
|
Config: Config{
|
2026-01-22 03:27:32 +00:00
|
|
|
URLTegola: config.MakeURLTegola("/"),
|
2026-01-14 21:39:58 +00:00
|
|
|
},
|
2026-01-15 19:32:42 +00:00
|
|
|
CountTraps: int(trapCount),
|
2026-01-13 20:26:15 +00:00
|
|
|
CountMosquitoSources: int(sourceCount),
|
|
|
|
|
CountServiceRequests: int(serviceCount),
|
|
|
|
|
IsSyncOngoing: is_syncing,
|
|
|
|
|
LastSync: lastSync,
|
|
|
|
|
MapData: ComponentMap{
|
|
|
|
|
MapboxToken: config.MapboxToken,
|
|
|
|
|
},
|
|
|
|
|
RecentRequests: requests,
|
|
|
|
|
User: userContent,
|
|
|
|
|
}
|
|
|
|
|
htmlpage.RenderOrError(w, dashboardT, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func settings(w http.ResponseWriter, r *http.Request, user *models.User) {
|
|
|
|
|
userContent, err := contentForUser(r.Context(), user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get user content", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
data := ContentAuthenticatedPlaceholder{
|
|
|
|
|
User: userContent,
|
|
|
|
|
}
|
|
|
|
|
htmlpage.RenderOrError(w, settingsT, data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func source(w http.ResponseWriter, r *http.Request, user *models.User, id uuid.UUID) {
|
|
|
|
|
org, err := user.Organization().One(r.Context(), db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get org", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
userContent, err := contentForUser(r.Context(), user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get user content", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s, err := sourceByGlobalId(r.Context(), org, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get source", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
inspections, err := inspectionsBySource(r.Context(), org, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get inspections", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
traps, err := trapsBySource(r.Context(), org, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get traps", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
treatments, err := treatmentsBySource(r.Context(), org, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get treatments", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
treatment_models := modelTreatment(treatments)
|
|
|
|
|
latlng, err := s.H3Cell.LatLng()
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get latlng", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
data := ContentSource{
|
|
|
|
|
Inspections: inspections,
|
|
|
|
|
MapData: ComponentMap{
|
|
|
|
|
Center: latlng,
|
|
|
|
|
//GeoJSON:
|
|
|
|
|
MapboxToken: config.MapboxToken,
|
|
|
|
|
Markers: []MapMarker{
|
|
|
|
|
MapMarker{
|
|
|
|
|
LatLng: latlng,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Zoom: 13,
|
|
|
|
|
},
|
|
|
|
|
Source: s,
|
|
|
|
|
Traps: traps,
|
|
|
|
|
Treatments: treatments,
|
|
|
|
|
TreatmentModels: treatment_models,
|
|
|
|
|
User: userContent,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
htmlpage.RenderOrError(w, sourceT, data)
|
|
|
|
|
}
|
2026-01-15 22:12:35 +00:00
|
|
|
|
|
|
|
|
func trap(w http.ResponseWriter, r *http.Request, user *models.User, id uuid.UUID) {
|
|
|
|
|
org, err := user.Organization().One(r.Context(), db.PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get org", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
userContent, err := contentForUser(r.Context(), user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get user content", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
t, err := trapByGlobalId(r.Context(), org, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get trap", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
latlng, err := t.H3Cell.LatLng()
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get latlng", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
data := ContentTrap{
|
|
|
|
|
MapData: ComponentMap{
|
|
|
|
|
Center: latlng,
|
|
|
|
|
//GeoJSON:
|
|
|
|
|
MapboxToken: config.MapboxToken,
|
|
|
|
|
Markers: []MapMarker{
|
|
|
|
|
MapMarker{
|
|
|
|
|
LatLng: latlng,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Zoom: 13,
|
|
|
|
|
},
|
|
|
|
|
Trap: t,
|
|
|
|
|
User: userContent,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
htmlpage.RenderOrError(w, trapT, data)
|
|
|
|
|
}
|