Break apart sync into parts more like public-reports
I like this layout makes it easier to track what functions do what and keeps templates near their render functions.
This commit is contained in:
parent
96c144ca74
commit
cf06bb9f49
9 changed files with 919 additions and 713 deletions
314
sync/dash.go
Normal file
314
sync/dash.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/stephenafamo/bob/dialect/psql/sm"
|
||||
"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")
|
||||
)
|
||||
|
||||
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 getFavicon(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-type", "image/x-icon")
|
||||
|
||||
http.ServeFile(w, r, "static/favicon.ico")
|
||||
}
|
||||
|
||||
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 {
|
||||
oauthPrompt(w, user)
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
treatments, err := treatmentsByCell(ctx, org, h3.Cell(c))
|
||||
if err != nil {
|
||||
respondError(w, "Failed to get treatments", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := ContentCell{
|
||||
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,
|
||||
},
|
||||
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)
|
||||
inspectionCount, err := org.Mosquitoinspections().Count(ctx, db.PGInstance.BobDB)
|
||||
if err != nil {
|
||||
respondError(w, "Failed to get inspection count", err, http.StatusInternalServerError)
|
||||
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
|
||||
}
|
||||
data := ContentDashboard{
|
||||
CountInspections: int(inspectionCount),
|
||||
CountMosquitoSources: int(sourceCount),
|
||||
CountServiceRequests: int(serviceCount),
|
||||
IsSyncOngoing: is_syncing,
|
||||
LastSync: lastSync,
|
||||
MapData: ComponentMap{
|
||||
MapboxToken: config.MapboxToken,
|
||||
},
|
||||
Org: org.Name.MustGet(),
|
||||
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)
|
||||
}
|
||||
392
sync/endpoint.go
392
sync/endpoint.go
|
|
@ -1,392 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/api"
|
||||
"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/models"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
func Router() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
// Root is a special endpoint that is neither authenticated nor unauthenticated
|
||||
r.Get("/", getRoot)
|
||||
|
||||
// Unauthenticated endpoints
|
||||
r.Get("/arcgis/oauth/begin", getArcgisOauthBegin)
|
||||
r.Get("/arcgis/oauth/callback", getArcgisOauthCallback)
|
||||
r.Get("/favicon.ico", getFavicon)
|
||||
|
||||
r.Get("/mock", renderMock("mock-root"))
|
||||
r.Get("/mock/admin", renderMock("admin"))
|
||||
r.Get("/mock/admin/service-request", renderMock("admin-service-request"))
|
||||
r.Get("/mock/data-entry", renderMock("data-entry"))
|
||||
r.Get("/mock/data-entry/bad", renderMock("data-entry-bad"))
|
||||
r.Get("/mock/data-entry/good", renderMock("data-entry-good"))
|
||||
r.Get("/mock/dispatch", renderMock("dispatch"))
|
||||
r.Get("/mock/dispatch-results", renderMock("dispatch-results"))
|
||||
r.Get("/mock/report", renderMock("report"))
|
||||
r.Get("/mock/report/{code}", renderMock("report-detail"))
|
||||
r.Get("/mock/report/{code}/confirm", renderMock("report-confirmation"))
|
||||
r.Get("/mock/report/{code}/contribute", renderMock("report-contribute"))
|
||||
r.Get("/mock/report/{code}/evidence", renderMock("report-evidence"))
|
||||
r.Get("/mock/report/{code}/schedule", renderMock("report-schedule"))
|
||||
r.Get("/mock/report/{code}/update", renderMock("report-update"))
|
||||
r.Get("/mock/service-request", renderMock("service-request"))
|
||||
r.Get("/mock/service-request/{code}", renderMock("service-request-detail"))
|
||||
r.Get("/mock/service-request-location", renderMock("service-request-location"))
|
||||
r.Get("/mock/service-request-mosquito", renderMock("service-request-mosquito"))
|
||||
r.Get("/mock/service-request-pool", renderMock("service-request-pool"))
|
||||
r.Get("/mock/service-request-quick", renderMock("service-request-quick"))
|
||||
r.Get("/mock/service-request-quick-confirmation", renderMock("service-request-quick-confirmation"))
|
||||
r.Get("/mock/service-request-updates", renderMock("service-request-updates"))
|
||||
r.Get("/mock/setting", renderMock("setting-mock"))
|
||||
r.Get("/mock/setting/integration", renderMock("setting-integration"))
|
||||
r.Get("/mock/setting/pesticide", renderMock("setting-pesticide"))
|
||||
r.Get("/mock/setting/pesticide/add", renderMock("setting-pesticide-add"))
|
||||
r.Get("/mock/setting/user", renderMock("setting-user"))
|
||||
r.Get("/mock/setting/user/add", renderMock("setting-user-add"))
|
||||
|
||||
r.Get("/oauth/refresh", getOAuthRefresh)
|
||||
|
||||
r.Get("/qr-code/report/{code}", getQRCodeReport)
|
||||
r.Get("/signin", getSignin)
|
||||
r.Post("/signin", postSignin)
|
||||
r.Get("/signup", getSignup)
|
||||
r.Post("/signup", postSignup)
|
||||
r.Get("/sms", getSMS)
|
||||
r.Post("/sms", postSMS)
|
||||
r.Get("/sms.php", getSMS)
|
||||
r.Get("/sms/{org}", getSMS)
|
||||
r.Post("/sms/{org}", postSMS)
|
||||
|
||||
// Authenticated endpoints
|
||||
r.Route("/api", api.AddRoutes)
|
||||
r.Method("GET", "/cell/{cell}", auth.NewEnsureAuth(getCellDetails))
|
||||
r.Method("GET", "/settings", auth.NewEnsureAuth(getSettings))
|
||||
r.Method("GET", "/source/{globalid}", auth.NewEnsureAuth(getSource))
|
||||
//r.Method("GET", "/vector-tiles/{org_id}/{tileset_id}/{zoom}/{x}/{y}.{format}", auth.NewEnsureAuth(getVectorTiles))
|
||||
|
||||
localFS := http.Dir("./sync/static")
|
||||
htmlpage.FileServer(r, "/static", localFS, EmbeddedStaticFS, "static")
|
||||
return r
|
||||
}
|
||||
|
||||
func getArcgisOauthBegin(w http.ResponseWriter, r *http.Request) {
|
||||
authURL := config.BuildArcGISAuthURL(config.ClientID)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func getArcgisOauthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
log.Info().Str("code", code).Msg("Handling oauth callback")
|
||||
if code == "" {
|
||||
respondError(w, "Access code is empty", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := auth.GetAuthenticatedUser(r)
|
||||
if err != nil {
|
||||
respondError(w, "You're not currently authenticated, which really shouldn't happen.", err, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = background.HandleOauthAccessCode(r.Context(), user, code)
|
||||
if err != nil {
|
||||
respondError(w, "Failed to handle access code", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, config.MakeURLSync("/"), http.StatusFound)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
cell, err := HexToInt64(cell_str)
|
||||
if err != nil {
|
||||
respondError(w, "Cannot convert provided cell to uint64", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
Cell(r.Context(), w, user, cell)
|
||||
}
|
||||
|
||||
func getFavicon(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-type", "image/x-icon")
|
||||
|
||||
http.ServeFile(w, r, "static/favicon.ico")
|
||||
}
|
||||
|
||||
func getOAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := auth.GetAuthenticatedUser(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?next=/oauth/refresh", http.StatusFound)
|
||||
return
|
||||
}
|
||||
OauthPrompt(w, user)
|
||||
}
|
||||
|
||||
func getQRCodeReport(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
if code == "" {
|
||||
respondError(w, "There should always be a code", nil, http.StatusBadRequest)
|
||||
}
|
||||
content := config.MakeURLSync("/report/" + code)
|
||||
// Get optional size parameter (default to 256)
|
||||
size := 256
|
||||
if sizeStr := r.URL.Query().Get("size"); sizeStr != "" {
|
||||
var err error
|
||||
size, err = strconv.Atoi(sizeStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid 'size' parameter, must be an integer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get optional error correction level (default to Medium)
|
||||
level := qrcode.Medium
|
||||
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
|
||||
switch levelStr {
|
||||
case "L", "l":
|
||||
level = qrcode.Low
|
||||
case "M", "m":
|
||||
level = qrcode.Medium
|
||||
case "Q", "q":
|
||||
level = qrcode.High
|
||||
case "H", "h":
|
||||
level = qrcode.Highest
|
||||
default:
|
||||
respondError(w, "Invalid 'level' parameter, must be L, M, Q, or H", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the QR code
|
||||
var qr *qrcode.QRCode
|
||||
var err error
|
||||
qr, err = qrcode.New(content, level)
|
||||
if err != nil {
|
||||
respondError(w, "Error generating QR code", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the appropriate content type
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
|
||||
// Generate PNG and write directly to the response writer
|
||||
png, err := qr.PNG(size)
|
||||
if err != nil {
|
||||
respondError(w, "Error encoding QR code to PNG", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write(png)
|
||||
if err != nil {
|
||||
respondError(w, "Error writing response", err, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
OauthPrompt(w, user)
|
||||
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 getSignin(w http.ResponseWriter, r *http.Request) {
|
||||
errorCode := r.URL.Query().Get("error")
|
||||
Signin(w, errorCode)
|
||||
}
|
||||
|
||||
func getSignup(w http.ResponseWriter, r *http.Request) {
|
||||
Signup(w, r.URL.Path)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func postSMS(w http.ResponseWriter, r *http.Request) {
|
||||
// Log all request headers
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
log.Info().Str("name", name).Str("value", value).Msg("header")
|
||||
}
|
||||
}
|
||||
|
||||
// Read the request body
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
//return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
respondError(w, "Failed to read request body", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Info().Str("body", string(bodyBytes)).Msg("body")
|
||||
// Close the original body
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse JSON into webhook struct
|
||||
var body SMSWebhookBody
|
||||
if err := json.Unmarshal(bodyBytes, &body); err != nil {
|
||||
respondError(w, "Failed to parse JSON", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handleSMSMessage(&body.Data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to handle SMS Message")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
func getSMS(w http.ResponseWriter, r *http.Request) {
|
||||
org := chi.URLParam(r, "org")
|
||||
|
||||
to := r.URL.Query().Get("error")
|
||||
from := r.URL.Query().Get("error")
|
||||
message := r.URL.Query().Get("error")
|
||||
files := r.URL.Query().Get("error")
|
||||
id := r.URL.Query().Get("error")
|
||||
date := r.URL.Query().Get("error")
|
||||
|
||||
log.Info().Str("org", org).Str("to", to).Str("from", from).Str("message", message).Str("files", files).Str("id", id).Str("date", date).Msg("Got SMS Message")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-type", "text/plain")
|
||||
// Signifies to Voip.ms that the callback worked.
|
||||
fmt.Fprintf(w, "ok")
|
||||
}
|
||||
func getVectorTiles(w http.ResponseWriter, r *http.Request, u *models.User) {
|
||||
org_id := chi.URLParam(r, "org_id")
|
||||
tileset_id := chi.URLParam(r, "tileset_id")
|
||||
zoom := chi.URLParam(r, "zoom")
|
||||
x := chi.URLParam(r, "x")
|
||||
y := chi.URLParam(r, "y")
|
||||
format := chi.URLParam(r, "format")
|
||||
|
||||
log.Info().Str("org_id", org_id).Str("tileset_id", tileset_id).Str("zoom", zoom).Str("x", x).Str("y", y).Str("format", format).Msg("Get vector tiles")
|
||||
|
||||
}
|
||||
|
||||
func postSignin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondError(w, "Could not parse form", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
log.Info().Str("username", username).Msg("Signin")
|
||||
|
||||
_, err := auth.SigninUser(r, username, password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.InvalidCredentials{}) {
|
||||
http.Redirect(w, r, "/signin?error=invalid-credentials", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.InvalidUsername{}) {
|
||||
http.Redirect(w, r, "/signin?error=invalid-credentials", http.StatusFound)
|
||||
return
|
||||
}
|
||||
respondError(w, "Failed to signin user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func postSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondError(w, "Could not parse form", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
name := r.FormValue("name")
|
||||
password := r.FormValue("password")
|
||||
terms := r.FormValue("terms")
|
||||
|
||||
log.Info().Str("username", username).Str("name", name).Str("password", strings.Repeat("*", len(password))).Msg("Signup")
|
||||
|
||||
if terms != "on" {
|
||||
log.Warn().Msg("Terms not agreed")
|
||||
http.Error(w, "You must agree to the terms to register", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := auth.SignupUser(r.Context(), username, name, password)
|
||||
if err != nil {
|
||||
respondError(w, "Failed to signup user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auth.AddUserSession(r, user)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func renderMock(templateName string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
if code == "" {
|
||||
code = "abc-123"
|
||||
}
|
||||
Mock(templateName, w, code)
|
||||
}
|
||||
}
|
||||
145
sync/mock.go
Normal file
145
sync/mock.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/config"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// Unauthenticated pages
|
||||
var (
|
||||
admin = buildTemplate("admin", "base")
|
||||
dataEntry = buildTemplate("data-entry", "base")
|
||||
dataEntryGood = buildTemplate("data-entry-good", "base")
|
||||
dataEntryBad = buildTemplate("data-entry-bad", "base")
|
||||
dispatch = buildTemplate("dispatch", "base")
|
||||
dispatchResults = buildTemplate("dispatch-results", "base")
|
||||
mockRoot = buildTemplate("mock-root", "base")
|
||||
reportPage = buildTemplate("report", "base")
|
||||
reportConfirmation = buildTemplate("report-confirmation", "base")
|
||||
reportContribute = buildTemplate("report-contribute", "base")
|
||||
reportDetail = buildTemplate("report-detail", "base")
|
||||
reportEvidence = buildTemplate("report-evidence", "base")
|
||||
reportSchedule = buildTemplate("report-schedule", "base")
|
||||
reportUpdate = buildTemplate("report-update", "base")
|
||||
serviceRequest = buildTemplate("service-request", "base")
|
||||
serviceRequestDetail = buildTemplate("service-request-detail", "base")
|
||||
serviceRequestLocation = buildTemplate("service-request-location", "base")
|
||||
serviceRequestMosquito = buildTemplate("service-request-mosquito", "base")
|
||||
serviceRequestPool = buildTemplate("service-request-pool", "base")
|
||||
serviceRequestQuick = buildTemplate("service-request-quick", "base")
|
||||
serviceRequestQuickConfirmation = buildTemplate("service-request-quick-confirmation", "base")
|
||||
serviceRequestUpdates = buildTemplate("service-request-updates", "base")
|
||||
settingRoot = buildTemplate("setting-mock", "base")
|
||||
settingIntegration = buildTemplate("setting-integration", "base")
|
||||
settingPesticide = buildTemplate("setting-pesticide", "base")
|
||||
settingPesticideAdd = buildTemplate("setting-pesticide-add", "base")
|
||||
settingUsers = buildTemplate("setting-user", "base")
|
||||
settingUsersAdd = buildTemplate("setting-user-add", "base")
|
||||
)
|
||||
|
||||
func getQRCodeReport(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
if code == "" {
|
||||
respondError(w, "There should always be a code", nil, http.StatusBadRequest)
|
||||
}
|
||||
content := config.MakeURLSync("/report/" + code)
|
||||
// Get optional size parameter (default to 256)
|
||||
size := 256
|
||||
if sizeStr := r.URL.Query().Get("size"); sizeStr != "" {
|
||||
var err error
|
||||
size, err = strconv.Atoi(sizeStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid 'size' parameter, must be an integer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get optional error correction level (default to Medium)
|
||||
level := qrcode.Medium
|
||||
if levelStr := r.URL.Query().Get("level"); levelStr != "" {
|
||||
switch levelStr {
|
||||
case "L", "l":
|
||||
level = qrcode.Low
|
||||
case "M", "m":
|
||||
level = qrcode.Medium
|
||||
case "Q", "q":
|
||||
level = qrcode.High
|
||||
case "H", "h":
|
||||
level = qrcode.Highest
|
||||
default:
|
||||
respondError(w, "Invalid 'level' parameter, must be L, M, Q, or H", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the QR code
|
||||
var qr *qrcode.QRCode
|
||||
var err error
|
||||
qr, err = qrcode.New(content, level)
|
||||
if err != nil {
|
||||
respondError(w, "Error generating QR code", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the appropriate content type
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
|
||||
// Generate PNG and write directly to the response writer
|
||||
png, err := qr.PNG(size)
|
||||
if err != nil {
|
||||
respondError(w, "Error encoding QR code to PNG", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write(png)
|
||||
if err != nil {
|
||||
respondError(w, "Error writing response", err, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func mock(t string, w http.ResponseWriter, code string) {
|
||||
data := ContentMock{
|
||||
DistrictName: "Delta MVCD",
|
||||
URLs: ContentMockURLs{
|
||||
Dispatch: "/mock/dispatch",
|
||||
DispatchResults: "/mock/dispatch-results",
|
||||
ReportConfirmation: fmt.Sprintf("/mock/report/%s/confirm", code),
|
||||
ReportDetail: fmt.Sprintf("/mock/report/%s", code),
|
||||
ReportContribute: fmt.Sprintf("/mock/report/%s/contribute", code),
|
||||
ReportEvidence: fmt.Sprintf("/mock/report/%s/evidence", code),
|
||||
ReportSchedule: fmt.Sprintf("/mock/report/%s/schedule", code),
|
||||
ReportUpdate: fmt.Sprintf("/mock/report/%s/update", code),
|
||||
Root: "/mock",
|
||||
Setting: "/mock/setting",
|
||||
SettingIntegration: "/mock/setting/integration",
|
||||
SettingPesticide: "/mock/setting/pesticide",
|
||||
SettingPesticideAdd: "/mock/setting/pesticide/add",
|
||||
SettingUser: "/mock/setting/user",
|
||||
SettingUserAdd: "/mock/setting/user/add",
|
||||
},
|
||||
}
|
||||
template, ok := htmlpage.TemplatesByFilename[t+".html"]
|
||||
if !ok {
|
||||
log.Error().Str("template", t).Msg("Failed to find template")
|
||||
respondError(w, "Failed to render template", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
htmlpage.RenderOrError(w, &template, data)
|
||||
}
|
||||
|
||||
func renderMock(templateName string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
if code == "" {
|
||||
code = "abc-123"
|
||||
}
|
||||
mock(templateName, w, code)
|
||||
}
|
||||
}
|
||||
62
sync/oauth.go
Normal file
62
sync/oauth.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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/models"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
oauthPromptT = buildTemplate("oauth-prompt", "authenticated")
|
||||
)
|
||||
|
||||
func getArcgisOauthBegin(w http.ResponseWriter, r *http.Request) {
|
||||
authURL := config.BuildArcGISAuthURL(config.ClientID)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func getArcgisOauthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
log.Info().Str("code", code).Msg("Handling oauth callback")
|
||||
if code == "" {
|
||||
respondError(w, "Access code is empty", nil, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := auth.GetAuthenticatedUser(r)
|
||||
if err != nil {
|
||||
respondError(w, "You're not currently authenticated, which really shouldn't happen.", err, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err = background.HandleOauthAccessCode(r.Context(), user, code)
|
||||
if err != nil {
|
||||
respondError(w, "Failed to handle access code", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, config.MakeURLSync("/"), http.StatusFound)
|
||||
}
|
||||
|
||||
func getOAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := auth.GetAuthenticatedUser(r)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/?next=/oauth/refresh", http.StatusFound)
|
||||
return
|
||||
}
|
||||
oauthPrompt(w, user)
|
||||
}
|
||||
|
||||
func oauthPrompt(w http.ResponseWriter, user *models.User) {
|
||||
dp := user.DisplayName
|
||||
data := ContentDashboard{
|
||||
User: User{
|
||||
DisplayName: dp,
|
||||
Initials: extractInitials(dp),
|
||||
Username: user.Username,
|
||||
},
|
||||
}
|
||||
htmlpage.RenderOrError(w, oauthPromptT, data)
|
||||
}
|
||||
323
sync/page.go
323
sync/page.go
|
|
@ -2,32 +2,11 @@ package sync
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||
|
||||
//"bytes"
|
||||
"context"
|
||||
//"errors"
|
||||
"fmt"
|
||||
//"html/template"
|
||||
//"io"
|
||||
//"math"
|
||||
"net/http"
|
||||
//"os"
|
||||
//"strconv"
|
||||
//"strings"
|
||||
"time"
|
||||
|
||||
"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/aarondl/opt/null"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stephenafamo/bob/dialect/psql/sm"
|
||||
"github.com/uber/h3-go/v4"
|
||||
)
|
||||
|
||||
//go:embed template/*
|
||||
|
|
@ -36,49 +15,6 @@ var embeddedFiles embed.FS
|
|||
//go:embed static/*
|
||||
var EmbeddedStaticFS embed.FS
|
||||
|
||||
// Authenticated pages
|
||||
var (
|
||||
cell = buildTemplate("cell", "authenticated")
|
||||
dashboard = buildTemplate("dashboard", "authenticated")
|
||||
oauthPrompt = buildTemplate("oauth-prompt", "authenticated")
|
||||
settings = buildTemplate("settings", "authenticated")
|
||||
source = buildTemplate("source", "authenticated")
|
||||
)
|
||||
|
||||
// Unauthenticated pages
|
||||
var (
|
||||
admin = buildTemplate("admin", "base")
|
||||
dataEntry = buildTemplate("data-entry", "base")
|
||||
dataEntryGood = buildTemplate("data-entry-good", "base")
|
||||
dataEntryBad = buildTemplate("data-entry-bad", "base")
|
||||
dispatch = buildTemplate("dispatch", "base")
|
||||
dispatchResults = buildTemplate("dispatch-results", "base")
|
||||
mockRoot = buildTemplate("mock-root", "base")
|
||||
reportPage = buildTemplate("report", "base")
|
||||
reportConfirmation = buildTemplate("report-confirmation", "base")
|
||||
reportContribute = buildTemplate("report-contribute", "base")
|
||||
reportDetail = buildTemplate("report-detail", "base")
|
||||
reportEvidence = buildTemplate("report-evidence", "base")
|
||||
reportSchedule = buildTemplate("report-schedule", "base")
|
||||
reportUpdate = buildTemplate("report-update", "base")
|
||||
serviceRequest = buildTemplate("service-request", "base")
|
||||
serviceRequestDetail = buildTemplate("service-request-detail", "base")
|
||||
serviceRequestLocation = buildTemplate("service-request-location", "base")
|
||||
serviceRequestMosquito = buildTemplate("service-request-mosquito", "base")
|
||||
serviceRequestPool = buildTemplate("service-request-pool", "base")
|
||||
serviceRequestQuick = buildTemplate("service-request-quick", "base")
|
||||
serviceRequestQuickConfirmation = buildTemplate("service-request-quick-confirmation", "base")
|
||||
serviceRequestUpdates = buildTemplate("service-request-updates", "base")
|
||||
settingRoot = buildTemplate("setting-mock", "base")
|
||||
settingIntegration = buildTemplate("setting-integration", "base")
|
||||
settingPesticide = buildTemplate("setting-pesticide", "base")
|
||||
settingPesticideAdd = buildTemplate("setting-pesticide-add", "base")
|
||||
settingUsers = buildTemplate("setting-user", "base")
|
||||
settingUsersAdd = buildTemplate("setting-user-add", "base")
|
||||
signin = buildTemplate("signin", "base")
|
||||
signup = buildTemplate("signup", "base")
|
||||
)
|
||||
|
||||
var components = [...]string{"header", "map"}
|
||||
|
||||
func buildTemplate(files ...string) *htmlpage.BuiltTemplate {
|
||||
|
|
@ -93,261 +29,6 @@ func buildTemplate(files ...string) *htmlpage.BuiltTemplate {
|
|||
return htmlpage.NewBuiltTemplate(embeddedFiles, "sync/", full_files...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
treatments, err := treatmentsByCell(ctx, org, h3.Cell(c))
|
||||
if err != nil {
|
||||
respondError(w, "Failed to get treatments", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := ContentCell{
|
||||
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,
|
||||
},
|
||||
Treatments: treatments,
|
||||
User: userContent,
|
||||
}
|
||||
htmlpage.RenderOrError(w, cell, &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)
|
||||
inspectionCount, err := org.Mosquitoinspections().Count(ctx, db.PGInstance.BobDB)
|
||||
if err != nil {
|
||||
respondError(w, "Failed to get inspection count", err, http.StatusInternalServerError)
|
||||
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
|
||||
}
|
||||
data := ContentDashboard{
|
||||
CountInspections: int(inspectionCount),
|
||||
CountMosquitoSources: int(sourceCount),
|
||||
CountServiceRequests: int(serviceCount),
|
||||
IsSyncOngoing: is_syncing,
|
||||
LastSync: lastSync,
|
||||
MapData: ComponentMap{
|
||||
MapboxToken: config.MapboxToken,
|
||||
},
|
||||
Org: org.Name.MustGet(),
|
||||
RecentRequests: requests,
|
||||
User: userContent,
|
||||
}
|
||||
htmlpage.RenderOrError(w, dashboard, data)
|
||||
}
|
||||
|
||||
func Mock(t string, w http.ResponseWriter, code string) {
|
||||
data := ContentMock{
|
||||
DistrictName: "Delta MVCD",
|
||||
URLs: ContentMockURLs{
|
||||
Dispatch: "/mock/dispatch",
|
||||
DispatchResults: "/mock/dispatch-results",
|
||||
ReportConfirmation: fmt.Sprintf("/mock/report/%s/confirm", code),
|
||||
ReportDetail: fmt.Sprintf("/mock/report/%s", code),
|
||||
ReportContribute: fmt.Sprintf("/mock/report/%s/contribute", code),
|
||||
ReportEvidence: fmt.Sprintf("/mock/report/%s/evidence", code),
|
||||
ReportSchedule: fmt.Sprintf("/mock/report/%s/schedule", code),
|
||||
ReportUpdate: fmt.Sprintf("/mock/report/%s/update", code),
|
||||
Root: "/mock",
|
||||
Setting: "/mock/setting",
|
||||
SettingIntegration: "/mock/setting/integration",
|
||||
SettingPesticide: "/mock/setting/pesticide",
|
||||
SettingPesticideAdd: "/mock/setting/pesticide/add",
|
||||
SettingUser: "/mock/setting/user",
|
||||
SettingUserAdd: "/mock/setting/user/add",
|
||||
},
|
||||
}
|
||||
template, ok := htmlpage.TemplatesByFilename[t+".html"]
|
||||
if !ok {
|
||||
log.Error().Str("template", t).Msg("Failed to find template")
|
||||
respondError(w, "Failed to render template", nil, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
htmlpage.RenderOrError(w, &template, data)
|
||||
}
|
||||
|
||||
func OauthPrompt(w http.ResponseWriter, user *models.User) {
|
||||
dp := user.DisplayName
|
||||
data := ContentDashboard{
|
||||
User: User{
|
||||
DisplayName: dp,
|
||||
Initials: extractInitials(dp),
|
||||
Username: user.Username,
|
||||
},
|
||||
}
|
||||
htmlpage.RenderOrError(w, oauthPrompt, 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, settings, data)
|
||||
}
|
||||
|
||||
func Signin(w http.ResponseWriter, errorCode string) {
|
||||
data := ContentSignin{
|
||||
InvalidCredentials: errorCode == "invalid-credentials",
|
||||
}
|
||||
htmlpage.RenderOrError(w, signin, data)
|
||||
}
|
||||
|
||||
func Signup(w http.ResponseWriter, path string) {
|
||||
data := ContentSignup{}
|
||||
htmlpage.RenderOrError(w, signup, 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, source, data)
|
||||
}
|
||||
|
||||
// Respond with an error that is visible to the user
|
||||
func respondError(w http.ResponseWriter, m string, e error, s int) {
|
||||
log.Warn().Int("status", s).Err(e).Str("user message", m).Msg("Responding with an error from sync pages")
|
||||
|
|
|
|||
76
sync/routes.go
Normal file
76
sync/routes.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/api"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/auth"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func Router() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
// Root is a special endpoint that is neither authenticated nor unauthenticated
|
||||
r.Get("/", getRoot)
|
||||
|
||||
// Unauthenticated endpoints
|
||||
r.Get("/arcgis/oauth/begin", getArcgisOauthBegin)
|
||||
r.Get("/arcgis/oauth/callback", getArcgisOauthCallback)
|
||||
r.Get("/district", getDistrict)
|
||||
r.Get("/favicon.ico", getFavicon)
|
||||
|
||||
r.Get("/mock", renderMock("mock-root"))
|
||||
r.Get("/mock/admin", renderMock("admin"))
|
||||
r.Get("/mock/admin/service-request", renderMock("admin-service-request"))
|
||||
r.Get("/mock/data-entry", renderMock("data-entry"))
|
||||
r.Get("/mock/data-entry/bad", renderMock("data-entry-bad"))
|
||||
r.Get("/mock/data-entry/good", renderMock("data-entry-good"))
|
||||
r.Get("/mock/dispatch", renderMock("dispatch"))
|
||||
r.Get("/mock/dispatch-results", renderMock("dispatch-results"))
|
||||
r.Get("/mock/report", renderMock("report"))
|
||||
r.Get("/mock/report/{code}", renderMock("report-detail"))
|
||||
r.Get("/mock/report/{code}/confirm", renderMock("report-confirmation"))
|
||||
r.Get("/mock/report/{code}/contribute", renderMock("report-contribute"))
|
||||
r.Get("/mock/report/{code}/evidence", renderMock("report-evidence"))
|
||||
r.Get("/mock/report/{code}/schedule", renderMock("report-schedule"))
|
||||
r.Get("/mock/report/{code}/update", renderMock("report-update"))
|
||||
r.Get("/mock/service-request", renderMock("service-request"))
|
||||
r.Get("/mock/service-request/{code}", renderMock("service-request-detail"))
|
||||
r.Get("/mock/service-request-location", renderMock("service-request-location"))
|
||||
r.Get("/mock/service-request-mosquito", renderMock("service-request-mosquito"))
|
||||
r.Get("/mock/service-request-pool", renderMock("service-request-pool"))
|
||||
r.Get("/mock/service-request-quick", renderMock("service-request-quick"))
|
||||
r.Get("/mock/service-request-quick-confirmation", renderMock("service-request-quick-confirmation"))
|
||||
r.Get("/mock/service-request-updates", renderMock("service-request-updates"))
|
||||
r.Get("/mock/setting", renderMock("setting-mock"))
|
||||
r.Get("/mock/setting/integration", renderMock("setting-integration"))
|
||||
r.Get("/mock/setting/pesticide", renderMock("setting-pesticide"))
|
||||
r.Get("/mock/setting/pesticide/add", renderMock("setting-pesticide-add"))
|
||||
r.Get("/mock/setting/user", renderMock("setting-user"))
|
||||
r.Get("/mock/setting/user/add", renderMock("setting-user-add"))
|
||||
|
||||
r.Get("/oauth/refresh", getOAuthRefresh)
|
||||
|
||||
r.Get("/qr-code/report/{code}", getQRCodeReport)
|
||||
r.Get("/signin", getSignin)
|
||||
r.Post("/signin", postSignin)
|
||||
r.Get("/signup", getSignup)
|
||||
r.Post("/signup", postSignup)
|
||||
r.Get("/sms", getSMS)
|
||||
r.Post("/sms", postSMS)
|
||||
r.Get("/sms.php", getSMS)
|
||||
r.Get("/sms/{org}", getSMS)
|
||||
r.Post("/sms/{org}", postSMS)
|
||||
|
||||
// Authenticated endpoints
|
||||
r.Route("/api", api.AddRoutes)
|
||||
r.Method("GET", "/cell/{cell}", auth.NewEnsureAuth(getCellDetails))
|
||||
r.Method("GET", "/settings", auth.NewEnsureAuth(getSettings))
|
||||
r.Method("GET", "/source/{globalid}", auth.NewEnsureAuth(getSource))
|
||||
//r.Method("GET", "/vector-tiles/{org_id}/{tileset_id}/{zoom}/{x}/{y}.{format}", auth.NewEnsureAuth(getVectorTiles))
|
||||
|
||||
localFS := http.Dir("./sync/static")
|
||||
htmlpage.FileServer(r, "/static", localFS, EmbeddedStaticFS, "static")
|
||||
return r
|
||||
}
|
||||
95
sync/signin.go
Normal file
95
sync/signin.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/auth"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
signinT = buildTemplate("signin", "base")
|
||||
signupT = buildTemplate("signup", "base")
|
||||
)
|
||||
|
||||
func getSignin(w http.ResponseWriter, r *http.Request) {
|
||||
errorCode := r.URL.Query().Get("error")
|
||||
signin(w, errorCode)
|
||||
}
|
||||
|
||||
func getSignup(w http.ResponseWriter, r *http.Request) {
|
||||
signup(w, r.URL.Path)
|
||||
}
|
||||
|
||||
func postSignin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondError(w, "Could not parse form", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
log.Info().Str("username", username).Msg("Signin")
|
||||
|
||||
_, err := auth.SigninUser(r, username, password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.InvalidCredentials{}) {
|
||||
http.Redirect(w, r, "/signin?error=invalid-credentials", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.InvalidUsername{}) {
|
||||
http.Redirect(w, r, "/signin?error=invalid-credentials", http.StatusFound)
|
||||
return
|
||||
}
|
||||
respondError(w, "Failed to signin user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func postSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondError(w, "Could not parse form", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
name := r.FormValue("name")
|
||||
password := r.FormValue("password")
|
||||
terms := r.FormValue("terms")
|
||||
|
||||
log.Info().Str("username", username).Str("name", name).Str("password", strings.Repeat("*", len(password))).Msg("Signup")
|
||||
|
||||
if terms != "on" {
|
||||
log.Warn().Msg("Terms not agreed")
|
||||
http.Error(w, "You must agree to the terms to register", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := auth.SignupUser(r.Context(), username, name, password)
|
||||
if err != nil {
|
||||
respondError(w, "Failed to signup user", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auth.AddUserSession(r, user)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func signin(w http.ResponseWriter, errorCode string) {
|
||||
data := ContentSignin{
|
||||
InvalidCredentials: errorCode == "invalid-credentials",
|
||||
}
|
||||
htmlpage.RenderOrError(w, signinT, data)
|
||||
}
|
||||
|
||||
func signup(w http.ResponseWriter, path string) {
|
||||
data := ContentSignup{}
|
||||
htmlpage.RenderOrError(w, signupT, data)
|
||||
}
|
||||
51
sync/sms.go
51
sync/sms.go
|
|
@ -1,6 +1,7 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
|
@ -148,3 +150,52 @@ func sanitizeFilename(name string) string {
|
|||
}
|
||||
return result
|
||||
}
|
||||
func postSMS(w http.ResponseWriter, r *http.Request) {
|
||||
// Log all request headers
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
log.Info().Str("name", name).Str("value", value).Msg("header")
|
||||
}
|
||||
}
|
||||
|
||||
// Read the request body
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
//return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
respondError(w, "Failed to read request body", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Info().Str("body", string(bodyBytes)).Msg("body")
|
||||
// Close the original body
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse JSON into webhook struct
|
||||
var body SMSWebhookBody
|
||||
if err := json.Unmarshal(bodyBytes, &body); err != nil {
|
||||
respondError(w, "Failed to parse JSON", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handleSMSMessage(&body.Data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to handle SMS Message")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
func getSMS(w http.ResponseWriter, r *http.Request) {
|
||||
org := chi.URLParam(r, "org")
|
||||
|
||||
to := r.URL.Query().Get("error")
|
||||
from := r.URL.Query().Get("error")
|
||||
message := r.URL.Query().Get("error")
|
||||
files := r.URL.Query().Get("error")
|
||||
id := r.URL.Query().Get("error")
|
||||
date := r.URL.Query().Get("error")
|
||||
|
||||
log.Info().Str("org", org).Str("to", to).Str("from", from).Str("message", message).Str("files", files).Str("id", id).Str("date", date).Msg("Got SMS Message")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-type", "text/plain")
|
||||
// Signifies to Voip.ms that the callback worked.
|
||||
fmt.Fprintf(w, "ok")
|
||||
}
|
||||
|
|
|
|||
174
sync/template/district.html
Normal file
174
sync/template/district.html
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
{{template "authenticated.html" .}}
|
||||
|
||||
{{define "title"}}Dash{{end}}
|
||||
{{define "extraheader"}}
|
||||
<script src='https://api.mapbox.com/mapbox-gl-js/v3.17.0-beta.1/mapbox-gl.js'></script>
|
||||
<link href='https://api.mapbox.com/mapbox-gl-js/v3.17.0-beta.1/mapbox-gl.css' rel='stylesheet' />
|
||||
<script>
|
||||
function onLoad() {
|
||||
console.log("Setting up the map...");
|
||||
mapboxgl.accessToken = {{ .MapData.MapboxToken }};
|
||||
const map = new mapboxgl.Map({
|
||||
container: 'map', // container ID
|
||||
style: 'mapbox://styles/mapbox/standard', // style URL
|
||||
center: [-119.3, 36.327], // starting position [lng, lat]
|
||||
//center: [7.01, 50.74],
|
||||
zoom: 9 // starting zoom
|
||||
});
|
||||
map.on("load", function() {
|
||||
console.log("Map post-load...");
|
||||
map.addSource('tegola-bonn', {
|
||||
'type': 'vector',
|
||||
'tiles': [
|
||||
//'https://tiles.mapillary.com/maps/vtp/mly1_public/2/{z}/{x}/{y}?access_token=MLY|4142433049200173|72206abe5035850d6743b23a49c41333'
|
||||
'https://tegola.nidus.cloud/maps/bonn/{z}/{x}/{y}'
|
||||
]
|
||||
//'minzoom': 6,
|
||||
//'maxzoom': 14
|
||||
});
|
||||
map.addSource('tegola-nidus', {
|
||||
'type': 'vector',
|
||||
'tiles': [
|
||||
//'https://tiles.mapillary.com/maps/vtp/mly1_public/2/{z}/{x}/{y}?access_token=MLY|4142433049200173|72206abe5035850d6743b23a49c41333'
|
||||
'https://tegola.nidus.cloud/maps/nidus/{z}/{x}/{y}?organization_id=1'
|
||||
]
|
||||
//'minzoom': 6,
|
||||
//'maxzoom': 14
|
||||
});
|
||||
map.addLayer({
|
||||
'id': 'bonn', // Layer ID
|
||||
'type': 'fill',
|
||||
'source': 'tegola-bonn', // ID of the tile source created above
|
||||
'source-layer': 'lakes',
|
||||
'paint': {
|
||||
'fill-opacity': 0.1,
|
||||
'fill-color': 'rgb(100, 50, 20)'
|
||||
}
|
||||
//slot: 'middle' // middle slot in Mapbox Standard style
|
||||
});
|
||||
map.addLayer({
|
||||
'id': 'nidus', // Layer ID
|
||||
'type': 'fill',
|
||||
'filter': ['==', ['zoom'], ['+', 2, ['to-number', ['get', 'resolution']]]],
|
||||
'source': 'tegola-nidus', // ID of the tile source created above
|
||||
'source-layer': 'h3_aggregation',
|
||||
'paint': {
|
||||
'fill-opacity': 0.3,
|
||||
'fill-color': 'rgb(250, 100, 100)'
|
||||
}
|
||||
//slot: 'middle' // middle slot in Mapbox Standard style
|
||||
});
|
||||
map.addInteraction("nidus-click-interaction", {
|
||||
type: 'click',
|
||||
target: { layerId: 'nidus' },
|
||||
handler: (e) => {
|
||||
const coordinates = e.feature.geometry.coordinates.slice();
|
||||
const properties = e.feature.properties;
|
||||
//console.log("Coordinates", coordinates[0]);
|
||||
//console.log("Properties", properties.cell, properties.count_);
|
||||
/*new mapboxgl.Popup()
|
||||
.setLngLat(coordinates[0][0])
|
||||
.setHTML("Cell: " + properties.cell)
|
||||
.addTo(map);*/
|
||||
window.location.href = '/cell/' + properties.cell;
|
||||
}
|
||||
});
|
||||
map.addInteraction('nidus-mouseenter-interaction', {
|
||||
type: 'mouseenter',
|
||||
target: { layerId: 'nidus' },
|
||||
handler: () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
map.addInteraction('nidus-mouseleave-interaction', {
|
||||
type: 'mouseleave',
|
||||
target: { layerId: 'nidus' },
|
||||
handler: () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Map post-load done.");
|
||||
});
|
||||
|
||||
map.addControl(new mapboxgl.NavigationControl());
|
||||
console.log("Map init done.");
|
||||
}
|
||||
window.addEventListener("load", onLoad);
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.dashboard-container {
|
||||
padding: 20px 0;
|
||||
}
|
||||
.stats-card {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
}
|
||||
.stats-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
.map-container {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
||||
height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#map {
|
||||
height: 500px;
|
||||
width:100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#map img {
|
||||
max-width: none;
|
||||
min-width: 0px;
|
||||
height: auto;
|
||||
}
|
||||
.section-title {
|
||||
margin: 30px 0 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.last-refreshed {
|
||||
color: #6c757d;
|
||||
}
|
||||
.logo-placeholder {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
background-color: #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.metric-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.syncing {
|
||||
color: #28a745;
|
||||
animation: fa-spin 2s linear infinite;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<p>District page placeholder</p>
|
||||
{{end}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue