2025-11-03 12:38:47 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2025-11-10 22:16:23 +00:00
|
|
|
"bytes"
|
2025-11-07 10:45:59 +00:00
|
|
|
"context"
|
2025-11-11 17:47:27 +00:00
|
|
|
"embed"
|
2025-11-03 12:38:47 +00:00
|
|
|
"errors"
|
2025-11-05 17:15:33 +00:00
|
|
|
"fmt"
|
2025-11-03 12:38:47 +00:00
|
|
|
"html/template"
|
|
|
|
|
"io"
|
2025-11-05 17:49:19 +00:00
|
|
|
"log/slog"
|
2025-11-10 22:16:23 +00:00
|
|
|
"net/http"
|
2025-11-03 12:38:47 +00:00
|
|
|
"os"
|
2025-11-13 15:05:05 +00:00
|
|
|
"strconv"
|
2025-11-05 17:49:19 +00:00
|
|
|
"strings"
|
2025-11-07 10:45:59 +00:00
|
|
|
"time"
|
2025-11-05 17:15:33 +00:00
|
|
|
|
|
|
|
|
"github.com/Gleipnir-Technology/nidus-sync/models"
|
2025-11-07 10:45:59 +00:00
|
|
|
"github.com/aarondl/opt/null"
|
2025-11-10 15:27:22 +00:00
|
|
|
//"github.com/riverqueue/river/rivershared/util/slogutil"
|
2025-11-19 15:59:51 +00:00
|
|
|
"github.com/stephenafamo/bob/dialect/psql"
|
2025-11-07 10:45:59 +00:00
|
|
|
"github.com/stephenafamo/bob/dialect/psql/sm"
|
2025-11-19 15:21:06 +00:00
|
|
|
"github.com/uber/h3-go/v4"
|
2025-11-03 12:38:47 +00:00
|
|
|
)
|
|
|
|
|
|
2025-11-11 17:47:27 +00:00
|
|
|
//go:embed templates/*
|
|
|
|
|
var embeddedFiles embed.FS
|
|
|
|
|
|
2025-11-13 15:50:10 +00:00
|
|
|
// Authenticated pages
|
|
|
|
|
var (
|
2025-11-19 15:21:06 +00:00
|
|
|
cell = newBuiltTemplate("cell", "authenticated")
|
2025-11-13 15:50:10 +00:00
|
|
|
dashboard = newBuiltTemplate("dashboard", "authenticated")
|
|
|
|
|
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
|
|
|
|
|
settings = newBuiltTemplate("settings", "authenticated")
|
2025-11-20 14:56:34 +00:00
|
|
|
source = newBuiltTemplate("source", "authenticated")
|
2025-11-13 15:50:10 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Unauthenticated pages
|
2025-11-03 12:38:47 +00:00
|
|
|
var (
|
2025-11-10 22:42:19 +00:00
|
|
|
phoneCall = newBuiltTemplate("phone-call", "base")
|
2025-11-10 15:27:22 +00:00
|
|
|
report = newBuiltTemplate("report", "base")
|
|
|
|
|
reportConfirmation = newBuiltTemplate("report-confirmation", "base")
|
|
|
|
|
reportContribute = newBuiltTemplate("report-contribute", "base")
|
|
|
|
|
reportDetail = newBuiltTemplate("report-detail", "base")
|
|
|
|
|
reportEvidence = newBuiltTemplate("report-evidence", "base")
|
|
|
|
|
reportSchedule = newBuiltTemplate("report-schedule", "base")
|
|
|
|
|
reportUpdate = newBuiltTemplate("report-update", "base")
|
|
|
|
|
serviceRequest = newBuiltTemplate("service-request", "base")
|
|
|
|
|
serviceRequestDetail = newBuiltTemplate("service-request-detail", "base")
|
|
|
|
|
serviceRequestLocation = newBuiltTemplate("service-request-location", "base")
|
|
|
|
|
serviceRequestMosquito = newBuiltTemplate("service-request-mosquito", "base")
|
|
|
|
|
serviceRequestPool = newBuiltTemplate("service-request-pool", "base")
|
|
|
|
|
serviceRequestQuick = newBuiltTemplate("service-request-quick", "base")
|
|
|
|
|
serviceRequestQuickConfirmation = newBuiltTemplate("service-request-quick-confirmation", "base")
|
|
|
|
|
serviceRequestUpdates = newBuiltTemplate("service-request-updates", "base")
|
|
|
|
|
signin = newBuiltTemplate("signin", "base")
|
|
|
|
|
signup = newBuiltTemplate("signup", "base")
|
2025-11-03 12:38:47 +00:00
|
|
|
)
|
2025-11-19 15:21:06 +00:00
|
|
|
var components = [...]string{"header", "map"}
|
2025-11-03 12:38:47 +00:00
|
|
|
|
2025-11-20 14:56:34 +00:00
|
|
|
type BreedingSourceSummary struct {
|
2025-11-19 16:32:56 +00:00
|
|
|
ID string
|
2025-11-19 15:59:51 +00:00
|
|
|
Type string
|
2025-11-19 16:32:56 +00:00
|
|
|
LastInspected *time.Time
|
|
|
|
|
LastTreated *time.Time
|
2025-11-19 15:59:51 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-03 12:38:47 +00:00
|
|
|
type BuiltTemplate struct {
|
|
|
|
|
files []string
|
2025-11-10 22:16:23 +00:00
|
|
|
name string
|
2025-11-03 12:38:47 +00:00
|
|
|
template *template.Template
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-20 14:56:34 +00:00
|
|
|
type MapMarker struct {
|
|
|
|
|
LatLng LatLng
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
type ComponentMap struct {
|
|
|
|
|
Center LatLng
|
|
|
|
|
GeoJSON interface{}
|
|
|
|
|
MapboxToken string
|
2025-11-20 14:56:34 +00:00
|
|
|
Markers []MapMarker
|
2025-11-19 15:21:06 +00:00
|
|
|
Zoom int
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
2025-11-13 15:50:10 +00:00
|
|
|
type ContentAuthenticatedPlaceholder struct {
|
|
|
|
|
User User
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
type ContentCell struct {
|
2025-11-20 14:56:34 +00:00
|
|
|
BreedingSources []BreedingSourceSummary
|
2025-11-19 15:59:51 +00:00
|
|
|
CellBoundary h3.CellBoundary
|
2025-11-19 22:30:01 +00:00
|
|
|
Inspections []Inspection
|
2025-11-19 15:59:51 +00:00
|
|
|
MapData ComponentMap
|
2025-11-19 16:32:56 +00:00
|
|
|
Treatments []Treatment
|
2025-11-19 15:59:51 +00:00
|
|
|
User User
|
2025-11-19 15:21:06 +00:00
|
|
|
}
|
2025-11-10 22:42:19 +00:00
|
|
|
type ContentPhoneCall struct {
|
|
|
|
|
DistrictName string
|
|
|
|
|
}
|
2025-11-05 21:37:11 +00:00
|
|
|
type ContentReportDetail struct {
|
2025-11-06 22:31:51 +00:00
|
|
|
NextURL string
|
2025-11-05 22:11:51 +00:00
|
|
|
UpdateURL string
|
2025-11-05 21:37:11 +00:00
|
|
|
}
|
2025-11-05 21:21:58 +00:00
|
|
|
type ContentReportDiagnostic struct {
|
|
|
|
|
URL string
|
|
|
|
|
}
|
2025-11-03 12:38:47 +00:00
|
|
|
type ContentDashboard struct {
|
2025-11-07 10:45:59 +00:00
|
|
|
CountInspections int
|
|
|
|
|
CountMosquitoSources int
|
|
|
|
|
CountServiceRequests int
|
2025-11-13 20:01:15 +00:00
|
|
|
Geo template.JS
|
2025-11-11 20:09:47 +00:00
|
|
|
LastSync *time.Time
|
2025-11-19 15:21:06 +00:00
|
|
|
MapData ComponentMap
|
2025-11-07 10:45:59 +00:00
|
|
|
Org string
|
2025-11-07 11:03:06 +00:00
|
|
|
RecentRequests []ServiceRequestSummary
|
2025-11-07 10:45:59 +00:00
|
|
|
User User
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
2025-11-05 21:05:10 +00:00
|
|
|
type ContentPlaceholder struct {
|
|
|
|
|
}
|
2025-11-05 17:15:33 +00:00
|
|
|
type ContentSignin struct {
|
|
|
|
|
InvalidCredentials bool
|
2025-11-04 00:02:51 +00:00
|
|
|
}
|
2025-11-06 22:31:51 +00:00
|
|
|
type ContentSignup struct{}
|
2025-11-20 14:56:34 +00:00
|
|
|
type ContentSource struct {
|
|
|
|
|
MapData ComponentMap
|
|
|
|
|
Source *BreedingSourceDetail
|
|
|
|
|
User User
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
type LatLng struct {
|
|
|
|
|
Lat float64
|
|
|
|
|
Lng float64
|
|
|
|
|
}
|
2025-11-19 22:30:01 +00:00
|
|
|
type Inspection struct {
|
|
|
|
|
Action string
|
|
|
|
|
Date time.Time
|
|
|
|
|
Notes string
|
|
|
|
|
Location string
|
|
|
|
|
LocationID string
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
type Link struct {
|
|
|
|
|
Href string
|
|
|
|
|
Title string
|
|
|
|
|
}
|
2025-11-07 11:03:06 +00:00
|
|
|
type ServiceRequestSummary struct {
|
|
|
|
|
Date time.Time
|
|
|
|
|
Location string
|
|
|
|
|
Status string
|
|
|
|
|
}
|
2025-11-19 16:32:56 +00:00
|
|
|
type Treatment struct {
|
|
|
|
|
Date time.Time
|
|
|
|
|
LocationID string
|
|
|
|
|
Notes string
|
|
|
|
|
Product string
|
|
|
|
|
}
|
2025-11-05 17:49:19 +00:00
|
|
|
type User struct {
|
2025-11-10 22:48:57 +00:00
|
|
|
DisplayName string
|
|
|
|
|
Initials string
|
|
|
|
|
Notifications []Notification
|
|
|
|
|
Username string
|
2025-11-05 17:49:19 +00:00
|
|
|
}
|
2025-11-03 12:38:47 +00:00
|
|
|
|
|
|
|
|
func (bt *BuiltTemplate) ExecuteTemplate(w io.Writer, data any) error {
|
|
|
|
|
name := bt.files[0] + ".html"
|
|
|
|
|
if bt.template == nil {
|
2025-11-05 17:15:33 +00:00
|
|
|
templ, err := parseFromDisk(bt.files)
|
|
|
|
|
if err != nil {
|
2025-11-13 20:53:20 +00:00
|
|
|
return fmt.Errorf("Failed to parse template file: %w", err)
|
2025-11-05 17:15:33 +00:00
|
|
|
}
|
2025-11-03 12:38:47 +00:00
|
|
|
if templ == nil {
|
2025-11-05 17:15:33 +00:00
|
|
|
w.Write([]byte("Failed to read from disk: "))
|
2025-11-03 12:38:47 +00:00
|
|
|
return errors.New("Template parsing failed")
|
|
|
|
|
}
|
|
|
|
|
return templ.ExecuteTemplate(w, name, data)
|
|
|
|
|
} else {
|
|
|
|
|
return bt.template.ExecuteTemplate(w, name, data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-13 15:05:05 +00:00
|
|
|
func bigNumber(n int) string {
|
|
|
|
|
// Convert the number to a string
|
|
|
|
|
numStr := strconv.FormatInt(int64(n), 10)
|
|
|
|
|
|
|
|
|
|
// Add commas every three digits from the right
|
|
|
|
|
var result strings.Builder
|
|
|
|
|
for i, char := range numStr {
|
|
|
|
|
if i > 0 && (len(numStr)-i)%3 == 0 {
|
|
|
|
|
result.WriteByte(',')
|
|
|
|
|
}
|
|
|
|
|
result.WriteRune(char)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.String()
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-13 15:50:10 +00:00
|
|
|
func contentForUser(ctx context.Context, user *models.User) (User, error) {
|
|
|
|
|
notifications, err := notificationsForUser(ctx, user)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return User{}, err
|
|
|
|
|
}
|
|
|
|
|
return User{
|
|
|
|
|
DisplayName: user.DisplayName,
|
|
|
|
|
Initials: extractInitials(user.DisplayName),
|
|
|
|
|
Notifications: notifications,
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
}, nil
|
|
|
|
|
|
|
|
|
|
}
|
2025-11-05 17:49:19 +00:00
|
|
|
func extractInitials(name string) string {
|
|
|
|
|
parts := strings.Fields(name)
|
2025-11-06 22:31:51 +00:00
|
|
|
var initials strings.Builder
|
|
|
|
|
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
if len(part) > 0 {
|
|
|
|
|
initials.WriteString(strings.ToUpper(string(part[0])))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return initials.String()
|
2025-11-05 17:49:19 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-19 15:21:06 +00:00
|
|
|
func htmlCell(ctx context.Context, w http.ResponseWriter, user *models.User, c int64) {
|
2025-11-19 15:59:51 +00:00
|
|
|
org, err := user.Organization().One(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get org", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
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()
|
2025-11-13 20:01:15 +00:00
|
|
|
if err != nil {
|
2025-11-19 15:21:06 +00:00
|
|
|
respondError(w, "Failed to get boundary", err, http.StatusInternalServerError)
|
2025-11-13 20:01:15 +00:00
|
|
|
return
|
|
|
|
|
}
|
2025-11-19 22:30:01 +00:00
|
|
|
inspections, err := inspectionsByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get inspections", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
geojson, err := h3ToGeoJSON([]h3.Cell{h3.Cell(c)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get boundaries", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resolution := h3.Cell(c).Resolution()
|
2025-11-19 15:59:51 +00:00
|
|
|
sources, err := breedingSourcesByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
2025-11-19 16:32:56 +00:00
|
|
|
respondError(w, "Failed to get sources", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
treatments, err := treatmentsByCell(ctx, org, h3.Cell(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
respondError(w, "Failed to get treatments", err, http.StatusInternalServerError)
|
2025-11-19 15:59:51 +00:00
|
|
|
return
|
|
|
|
|
}
|
2025-11-19 15:21:06 +00:00
|
|
|
data := ContentCell{
|
2025-11-19 15:59:51 +00:00
|
|
|
BreedingSources: sources,
|
|
|
|
|
CellBoundary: boundary,
|
2025-11-19 22:30:01 +00:00
|
|
|
Inspections: inspections,
|
2025-11-19 15:21:06 +00:00
|
|
|
MapData: ComponentMap{
|
|
|
|
|
Center: LatLng{
|
|
|
|
|
Lat: center.Lat,
|
|
|
|
|
Lng: center.Lng,
|
|
|
|
|
},
|
|
|
|
|
GeoJSON: geojson,
|
|
|
|
|
MapboxToken: MapboxToken,
|
|
|
|
|
Zoom: resolution + 5,
|
|
|
|
|
},
|
2025-11-19 16:32:56 +00:00
|
|
|
Treatments: treatments,
|
|
|
|
|
User: userContent,
|
2025-11-19 15:21:06 +00:00
|
|
|
}
|
|
|
|
|
renderOrError(w, cell, &data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func htmlDashboard(ctx context.Context, w http.ResponseWriter, user *models.User) {
|
2025-11-07 10:45:59 +00:00
|
|
|
org, err := user.Organization().One(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
2025-11-10 22:16:23 +00:00
|
|
|
respondError(w, "Failed to get org", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
2025-11-07 10:45:59 +00:00
|
|
|
}
|
2025-11-11 20:09:47 +00:00
|
|
|
var lastSync *time.Time
|
2025-11-13 14:57:14 +00:00
|
|
|
sync, err := org.FieldseekerSyncs(sm.OrderBy("created").Desc()).One(ctx, PGInstance.BobDB)
|
2025-11-07 10:45:59 +00:00
|
|
|
if err != nil {
|
2025-11-11 20:09:11 +00:00
|
|
|
if err.Error() != "sql: no rows in result set" {
|
|
|
|
|
respondError(w, "Failed to get syncs", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-11-07 10:45:59 +00:00
|
|
|
} else {
|
2025-11-11 20:09:47 +00:00
|
|
|
lastSync = &sync.Created
|
2025-11-07 10:45:59 +00:00
|
|
|
}
|
|
|
|
|
inspectionCount, err := org.FSMosquitoinspections().Count(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
2025-11-10 22:16:23 +00:00
|
|
|
respondError(w, "Failed to get inspection count", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
2025-11-07 10:45:59 +00:00
|
|
|
}
|
|
|
|
|
sourceCount, err := org.FSPointlocations().Count(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
2025-11-10 22:16:23 +00:00
|
|
|
respondError(w, "Failed to get source count", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
2025-11-07 10:45:59 +00:00
|
|
|
}
|
|
|
|
|
serviceCount, err := org.FSServicerequests().Count(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
2025-11-10 22:16:23 +00:00
|
|
|
respondError(w, "Failed to get service count", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
2025-11-07 10:45:59 +00:00
|
|
|
}
|
2025-11-07 11:03:06 +00:00
|
|
|
recentRequests, err := org.FSServicerequests(sm.OrderBy("creationdate").Desc(), sm.Limit(10)).All(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
2025-11-10 22:16:23 +00:00
|
|
|
respondError(w, "Failed to get recent service", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
2025-11-07 11:03:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requests := make([]ServiceRequestSummary, 0)
|
|
|
|
|
for _, r := range recentRequests {
|
|
|
|
|
requests = append(requests, ServiceRequestSummary{
|
|
|
|
|
Date: time.UnixMilli(r.Creationdate.MustGet()),
|
|
|
|
|
Location: r.Reqaddr1.MustGet(),
|
|
|
|
|
Status: "Completed",
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-11-13 15:50:10 +00:00
|
|
|
userContent, err := contentForUser(ctx, user)
|
2025-11-03 12:38:47 +00:00
|
|
|
data := ContentDashboard{
|
2025-11-07 10:45:59 +00:00
|
|
|
CountInspections: int(inspectionCount),
|
|
|
|
|
CountMosquitoSources: int(sourceCount),
|
|
|
|
|
CountServiceRequests: int(serviceCount),
|
2025-11-08 00:04:44 +00:00
|
|
|
LastSync: lastSync,
|
2025-11-19 15:21:06 +00:00
|
|
|
MapData: ComponentMap{
|
|
|
|
|
MapboxToken: MapboxToken,
|
|
|
|
|
},
|
|
|
|
|
Org: org.Name.MustGet(),
|
|
|
|
|
RecentRequests: requests,
|
|
|
|
|
User: userContent,
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
2025-11-10 22:16:23 +00:00
|
|
|
renderOrError(w, dashboard, data)
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlOauthPrompt(w http.ResponseWriter, user *models.User) {
|
2025-11-06 22:58:18 +00:00
|
|
|
data := ContentDashboard{
|
|
|
|
|
User: User{
|
|
|
|
|
DisplayName: user.DisplayName,
|
|
|
|
|
Initials: extractInitials(user.DisplayName),
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
},
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, oauthPrompt, data)
|
2025-11-06 22:58:18 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:42:19 +00:00
|
|
|
func htmlPhoneCall(w http.ResponseWriter) {
|
|
|
|
|
data := ContentPhoneCall{
|
|
|
|
|
DistrictName: "[District Name]",
|
|
|
|
|
}
|
|
|
|
|
renderOrError(w, phoneCall, data)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReport(w http.ResponseWriter) {
|
2025-11-05 21:21:58 +00:00
|
|
|
url := BaseURL + "/report/t78fd3"
|
|
|
|
|
data := ContentReportDiagnostic{
|
|
|
|
|
URL: url,
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, report, data)
|
2025-11-05 21:05:10 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReportConfirmation(w http.ResponseWriter, code string) {
|
2025-11-05 22:03:33 +00:00
|
|
|
url := BaseURL + "/report/" + code + "/history"
|
|
|
|
|
data := ContentReportDiagnostic{
|
|
|
|
|
URL: url,
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, reportConfirmation, data)
|
2025-11-05 22:03:33 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReportContribute(w http.ResponseWriter, code string) {
|
2025-11-05 21:51:23 +00:00
|
|
|
nextURL := BaseURL + "/report/" + code + "/schedule"
|
|
|
|
|
data := ContentReportDetail{
|
|
|
|
|
NextURL: nextURL,
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, reportContribute, data)
|
2025-11-05 21:51:23 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReportDetail(w http.ResponseWriter, code string) {
|
2025-11-05 21:37:11 +00:00
|
|
|
nextURL := BaseURL + "/report/" + code + "/evidence"
|
|
|
|
|
data := ContentReportDetail{
|
2025-11-06 22:31:51 +00:00
|
|
|
NextURL: nextURL,
|
2025-11-05 22:11:51 +00:00
|
|
|
UpdateURL: BaseURL + "/report/" + code + "/update",
|
2025-11-05 21:37:11 +00:00
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, reportDetail, data)
|
2025-11-05 21:37:11 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReportEvidence(w http.ResponseWriter, code string) {
|
2025-11-05 21:51:23 +00:00
|
|
|
nextURL := BaseURL + "/report/" + code + "/contribute"
|
|
|
|
|
data := ContentReportDetail{
|
|
|
|
|
NextURL: nextURL,
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, reportEvidence, data)
|
2025-11-05 21:51:23 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReportSchedule(w http.ResponseWriter, code string) {
|
2025-11-05 21:57:59 +00:00
|
|
|
nextURL := BaseURL + "/report/" + code + "/confirm"
|
|
|
|
|
data := ContentReportDetail{
|
|
|
|
|
NextURL: nextURL,
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, reportSchedule, data)
|
2025-11-05 21:57:59 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlReportUpdate(w http.ResponseWriter, code string) {
|
2025-11-05 23:41:21 +00:00
|
|
|
nextURL := BaseURL + "/report/" + code + "/evidence"
|
|
|
|
|
data := ContentReportDetail{
|
|
|
|
|
NextURL: nextURL,
|
|
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, reportUpdate, data)
|
2025-11-05 23:41:21 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequest(w http.ResponseWriter) {
|
2025-11-08 00:04:44 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequest, data)
|
2025-11-08 00:04:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestDetail(w http.ResponseWriter, code string) {
|
2025-11-08 00:04:44 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestDetail, data)
|
2025-11-08 00:04:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestLocation(w http.ResponseWriter) {
|
2025-11-08 00:04:44 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestLocation, data)
|
2025-11-08 00:04:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestMosquito(w http.ResponseWriter) {
|
2025-11-08 00:04:44 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestMosquito, data)
|
2025-11-08 00:04:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestPool(w http.ResponseWriter) {
|
2025-11-08 00:04:44 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestPool, data)
|
2025-11-08 00:04:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestQuick(w http.ResponseWriter) {
|
2025-11-10 15:27:22 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestQuick, data)
|
2025-11-10 15:27:22 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestQuickConfirmation(w http.ResponseWriter) {
|
2025-11-10 15:27:22 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestQuickConfirmation, data)
|
2025-11-10 15:27:22 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlServiceRequestUpdates(w http.ResponseWriter) {
|
2025-11-08 00:04:44 +00:00
|
|
|
data := ContentPlaceholder{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, serviceRequestUpdates, data)
|
2025-11-08 00:04:44 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-13 15:50:10 +00:00
|
|
|
func htmlSettings(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,
|
|
|
|
|
}
|
|
|
|
|
renderOrError(w, settings, data)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlSignin(w http.ResponseWriter, errorCode string) {
|
2025-11-05 17:15:33 +00:00
|
|
|
data := ContentSignin{
|
|
|
|
|
InvalidCredentials: errorCode == "invalid-credentials",
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, signin, data)
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-10 22:23:46 +00:00
|
|
|
func htmlSignup(w http.ResponseWriter, path string) {
|
2025-11-06 22:31:51 +00:00
|
|
|
data := ContentSignup{}
|
2025-11-10 22:23:46 +00:00
|
|
|
renderOrError(w, signup, data)
|
2025-11-04 00:02:51 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-20 14:56:34 +00:00
|
|
|
func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id string) {
|
|
|
|
|
org, err := user.Organization().One(r.Context(), 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
|
|
|
|
|
}
|
|
|
|
|
data := ContentSource{
|
|
|
|
|
MapData: ComponentMap{
|
|
|
|
|
Center: LatLng{
|
|
|
|
|
Lat: s.GeometryY,
|
|
|
|
|
Lng: s.GeometryX,
|
|
|
|
|
},
|
|
|
|
|
//GeoJSON:
|
|
|
|
|
MapboxToken: MapboxToken,
|
|
|
|
|
Markers: []MapMarker{
|
|
|
|
|
MapMarker{
|
|
|
|
|
LatLng: LatLng{
|
|
|
|
|
Lat: s.GeometryY,
|
|
|
|
|
Lng: s.GeometryX,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Zoom: 13,
|
|
|
|
|
},
|
|
|
|
|
Source: s,
|
|
|
|
|
User: userContent,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderOrError(w, source, data)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-19 15:59:51 +00:00
|
|
|
func gisStatement(cb h3.CellBoundary) string {
|
|
|
|
|
var content strings.Builder
|
|
|
|
|
for i, p := range cb {
|
|
|
|
|
if i != 0 {
|
|
|
|
|
content.WriteString(", ")
|
|
|
|
|
}
|
|
|
|
|
content.WriteString(fmt.Sprintf("%f %f", p.Lng, p.Lat))
|
|
|
|
|
}
|
|
|
|
|
// Repeat the first coordinate to close the polygon
|
|
|
|
|
content.WriteString(fmt.Sprintf(", %f %f", cb[0].Lng, cb[0].Lat))
|
|
|
|
|
return fmt.Sprintf("ST_GeomFromText('POLYGON((%s))', 3857)", content.String())
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-19 15:21:06 +00:00
|
|
|
func latLngDisplay(ll h3.LatLng) string {
|
|
|
|
|
latDir := "N"
|
|
|
|
|
latVal := ll.Lat
|
|
|
|
|
if ll.Lat < 0 {
|
|
|
|
|
latDir = "S"
|
|
|
|
|
latVal = -ll.Lat
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lngDir := "E"
|
|
|
|
|
lngVal := ll.Lng
|
|
|
|
|
if ll.Lng < 0 {
|
|
|
|
|
lngDir = "W"
|
|
|
|
|
lngVal = -ll.Lng
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Sprintf("%.4f° %s, %.4f° %s", latVal, latDir, lngVal, lngDir)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 12:38:47 +00:00
|
|
|
func makeFuncMap() template.FuncMap {
|
2025-11-07 10:45:59 +00:00
|
|
|
funcMap := template.FuncMap{
|
2025-11-19 15:21:06 +00:00
|
|
|
"bigNumber": bigNumber,
|
2025-11-19 15:59:51 +00:00
|
|
|
"GISStatement": gisStatement,
|
2025-11-19 15:21:06 +00:00
|
|
|
"latLngDisplay": latLngDisplay,
|
|
|
|
|
"timeElapsed": timeElapsed,
|
|
|
|
|
"timeSince": timeSince,
|
2025-11-19 16:32:56 +00:00
|
|
|
"uuidShort": uuidShort,
|
2025-11-07 10:45:59 +00:00
|
|
|
}
|
2025-11-03 12:38:47 +00:00
|
|
|
return funcMap
|
|
|
|
|
}
|
2025-11-10 22:16:23 +00:00
|
|
|
func newBuiltTemplate(name string, files ...string) BuiltTemplate {
|
2025-11-03 12:38:47 +00:00
|
|
|
files_on_disk := true
|
2025-11-10 22:16:23 +00:00
|
|
|
all_files := append([]string{name}, files...)
|
|
|
|
|
for _, f := range all_files {
|
2025-11-03 12:38:47 +00:00
|
|
|
full_path := "templates/" + f + ".html"
|
|
|
|
|
_, err := os.Stat(full_path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
files_on_disk = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if files_on_disk {
|
|
|
|
|
return BuiltTemplate{
|
2025-11-10 22:16:23 +00:00
|
|
|
files: all_files,
|
|
|
|
|
name: name,
|
2025-11-03 12:38:47 +00:00
|
|
|
template: nil,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return BuiltTemplate{
|
2025-11-10 22:16:23 +00:00
|
|
|
files: all_files,
|
|
|
|
|
name: name,
|
|
|
|
|
template: parseEmbedded(all_files),
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseEmbedded(files []string) *template.Template {
|
2025-11-11 17:47:27 +00:00
|
|
|
funcMap := makeFuncMap()
|
|
|
|
|
// Remap the file names to embedded paths
|
|
|
|
|
paths := make([]string, 0)
|
|
|
|
|
for _, f := range files {
|
|
|
|
|
paths = append(paths, "templates/"+f+".html")
|
|
|
|
|
}
|
|
|
|
|
for _, f := range components {
|
|
|
|
|
paths = append(paths, "templates/components/"+f+".html")
|
|
|
|
|
}
|
|
|
|
|
name := files[0]
|
|
|
|
|
return template.Must(
|
|
|
|
|
template.New(name).Funcs(funcMap).ParseFS(embeddedFiles, paths...))
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-05 17:15:33 +00:00
|
|
|
func parseFromDisk(files []string) (*template.Template, error) {
|
2025-11-03 12:38:47 +00:00
|
|
|
funcMap := makeFuncMap()
|
|
|
|
|
paths := make([]string, 0)
|
|
|
|
|
for _, f := range files {
|
|
|
|
|
paths = append(paths, "templates/"+f+".html")
|
|
|
|
|
}
|
|
|
|
|
name := files[0] + ".html"
|
2025-11-05 17:49:19 +00:00
|
|
|
for _, f := range components {
|
|
|
|
|
paths = append(paths, "templates/components/"+f+".html")
|
|
|
|
|
}
|
2025-11-10 15:27:22 +00:00
|
|
|
//slog.Info("Rendering templates from disk", slog.Any("paths", slogutil.SliceString(paths)))
|
2025-11-03 12:38:47 +00:00
|
|
|
templ, err := template.New(name).Funcs(funcMap).ParseFiles(paths...)
|
|
|
|
|
if err != nil {
|
2025-11-13 20:53:20 +00:00
|
|
|
return nil, fmt.Errorf("Failed to parse %s: %w", paths, err)
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
2025-11-05 17:15:33 +00:00
|
|
|
return templ, nil
|
2025-11-03 12:38:47 +00:00
|
|
|
}
|
2025-11-13 15:50:10 +00:00
|
|
|
|
2025-11-07 10:45:59 +00:00
|
|
|
func timeElapsed(seconds null.Val[float32]) string {
|
|
|
|
|
if !seconds.IsValue() {
|
|
|
|
|
return "none"
|
|
|
|
|
}
|
|
|
|
|
s := int(seconds.MustGet())
|
|
|
|
|
hours := s / 3600
|
|
|
|
|
remainder := s - (hours * 3600)
|
|
|
|
|
minutes := remainder / 60
|
|
|
|
|
remainder = remainder - (minutes * 60)
|
|
|
|
|
if hours > 0 {
|
|
|
|
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainder)
|
|
|
|
|
} else if minutes > 0 {
|
|
|
|
|
return fmt.Sprintf("%02d:%02d", minutes, remainder)
|
|
|
|
|
} else {
|
|
|
|
|
return fmt.Sprintf("%d seconds", remainder)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-11 20:09:47 +00:00
|
|
|
func timeSince(t *time.Time) string {
|
|
|
|
|
if t == nil {
|
|
|
|
|
return "never"
|
|
|
|
|
}
|
2025-11-07 10:45:59 +00:00
|
|
|
now := time.Now()
|
2025-11-11 20:09:47 +00:00
|
|
|
diff := now.Sub(*t)
|
2025-11-07 10:45:59 +00:00
|
|
|
|
|
|
|
|
hours := diff.Hours()
|
|
|
|
|
if hours < 1 {
|
|
|
|
|
minutes := diff.Minutes()
|
|
|
|
|
return fmt.Sprintf("%d minutes ago", int(minutes))
|
|
|
|
|
} else if hours < 24 {
|
|
|
|
|
return fmt.Sprintf("%d hours ago", int(hours))
|
|
|
|
|
} else {
|
|
|
|
|
days := hours / 24
|
|
|
|
|
return fmt.Sprintf("%d days ago", int(days))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-10 22:16:23 +00:00
|
|
|
|
|
|
|
|
func renderOrError(w http.ResponseWriter, template BuiltTemplate, context interface{}) {
|
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
|
err := template.ExecuteTemplate(buf, context)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("Failed to render template", slog.String("err", err.Error()), slog.String("template", template.name))
|
|
|
|
|
respondError(w, "Failed to render template", err, http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
buf.WriteTo(w)
|
|
|
|
|
}
|
2025-11-19 15:59:51 +00:00
|
|
|
|
2025-11-19 16:32:56 +00:00
|
|
|
func treatmentsByCell(ctx context.Context, org *models.Organization, c h3.Cell) ([]Treatment, error) {
|
|
|
|
|
var results []Treatment
|
|
|
|
|
boundary, err := c.Boundary()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return results, fmt.Errorf("Failed to get cell boundary: %w", err)
|
|
|
|
|
}
|
|
|
|
|
geom_query := gisStatement(boundary)
|
|
|
|
|
rows, err := org.FSTreatments(
|
|
|
|
|
sm.Where(
|
|
|
|
|
psql.F("ST_Within", "geom", geom_query),
|
|
|
|
|
),
|
2025-11-19 22:30:01 +00:00
|
|
|
sm.OrderBy("pointlocid"),
|
|
|
|
|
sm.OrderBy("enddatetime"),
|
2025-11-19 16:32:56 +00:00
|
|
|
).All(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return results, fmt.Errorf("Failed to query rows: %w", err)
|
|
|
|
|
}
|
|
|
|
|
for _, r := range rows {
|
|
|
|
|
results = append(results, Treatment{
|
|
|
|
|
Date: *fsTimestampToTime(r.Enddatetime),
|
|
|
|
|
LocationID: r.Pointlocid.GetOr("none"),
|
|
|
|
|
Notes: r.Comments.GetOr("none"),
|
|
|
|
|
Product: r.Product.GetOr("none"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return results, nil
|
|
|
|
|
}
|
2025-11-19 22:30:01 +00:00
|
|
|
func inspectionsByCell(ctx context.Context, org *models.Organization, c h3.Cell) ([]Inspection, error) {
|
|
|
|
|
var results []Inspection
|
|
|
|
|
|
|
|
|
|
boundary, err := c.Boundary()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return results, fmt.Errorf("Failed to get cell boundary: %w", err)
|
|
|
|
|
}
|
|
|
|
|
geom_query := gisStatement(boundary)
|
|
|
|
|
rows, err := org.FSMosquitoinspections(
|
|
|
|
|
sm.Where(
|
|
|
|
|
psql.F("ST_Within", "geom", geom_query),
|
|
|
|
|
),
|
2025-11-19 22:36:19 +00:00
|
|
|
sm.OrderBy("pointlocid"),
|
|
|
|
|
sm.OrderBy("enddatetime"),
|
2025-11-19 22:30:01 +00:00
|
|
|
).All(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return results, fmt.Errorf("Failed to query rows: %w", err)
|
|
|
|
|
}
|
|
|
|
|
for _, r := range rows {
|
|
|
|
|
results = append(results, Inspection{
|
|
|
|
|
Action: r.Actiontaken.GetOr("none"),
|
|
|
|
|
Date: *fsTimestampToTime(r.Enddatetime),
|
|
|
|
|
Notes: r.Comments.GetOr("none"),
|
|
|
|
|
Location: r.Locationname.GetOr("none"),
|
|
|
|
|
LocationID: r.Pointlocid.GetOr(""),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return results, nil
|
|
|
|
|
}
|
2025-11-20 14:56:34 +00:00
|
|
|
func breedingSourcesByCell(ctx context.Context, org *models.Organization, c h3.Cell) ([]BreedingSourceSummary, error) {
|
|
|
|
|
var results []BreedingSourceSummary
|
2025-11-19 15:59:51 +00:00
|
|
|
|
|
|
|
|
boundary, err := c.Boundary()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return results, fmt.Errorf("Failed to get cell boundary: %w", err)
|
|
|
|
|
}
|
|
|
|
|
geom_query := gisStatement(boundary)
|
|
|
|
|
rows, err := org.FSPointlocations(
|
|
|
|
|
sm.Where(
|
|
|
|
|
psql.F("ST_Within", "geom", geom_query),
|
|
|
|
|
),
|
2025-11-19 22:36:19 +00:00
|
|
|
sm.OrderBy("lasttreatdate"),
|
2025-11-19 15:59:51 +00:00
|
|
|
).All(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return results, fmt.Errorf("Failed to query rows: %w", err)
|
|
|
|
|
}
|
|
|
|
|
for _, r := range rows {
|
2025-11-20 14:56:34 +00:00
|
|
|
results = append(results, BreedingSourceSummary{
|
2025-11-19 16:32:56 +00:00
|
|
|
ID: r.Globalid,
|
|
|
|
|
LastInspected: fsTimestampToTime(r.Lastinspectdate),
|
|
|
|
|
LastTreated: fsTimestampToTime(r.Lasttreatdate),
|
2025-11-19 15:59:51 +00:00
|
|
|
Type: r.Habitat.GetOr("none"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return results, nil
|
|
|
|
|
}
|
2025-11-19 16:32:56 +00:00
|
|
|
|
|
|
|
|
func uuidShort(uuid string) string {
|
|
|
|
|
if len(uuid) < 7 {
|
|
|
|
|
return uuid // Return as is if too short
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return uuid[:3] + "..." + uuid[len(uuid)-4:]
|
|
|
|
|
}
|
2025-11-20 14:56:34 +00:00
|
|
|
|
|
|
|
|
func sourceByGlobalId(ctx context.Context, org *models.Organization, id string) (*BreedingSourceDetail, error) {
|
|
|
|
|
row, err := org.FSPointlocations(
|
|
|
|
|
sm.Where(models.FSPointlocations.Columns.Globalid.EQ(psql.Arg(id))),
|
|
|
|
|
).One(ctx, PGInstance.BobDB)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("Failed to get point location: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return ConvertToDisplayModel(row), nil
|
|
|
|
|
}
|