diff --git a/endpoint.go b/endpoint.go index 7ba8c8f8..7020ad18 100644 --- a/endpoint.go +++ b/endpoint.go @@ -13,7 +13,7 @@ import ( "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/Gleipnir-Technology/nidus-sync/htmlpage/sync" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/rs/zerolog/log" @@ -56,7 +56,7 @@ func getCellDetails(w http.ResponseWriter, r *http.Request, user *models.User) { respondError(w, "Cannot convert provided cell to uint64", err, http.StatusBadRequest) return } - htmlpage.Cell(r.Context(), w, user, cell) + sync.Cell(r.Context(), w, user, cell) } func getFavicon(w http.ResponseWriter, r *http.Request) { @@ -71,7 +71,7 @@ func getOAuthRefresh(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/?next=/oauth/refresh", http.StatusFound) return } - htmlpage.OauthPrompt(w, user) + sync.OauthPrompt(w, user) } func getQRCodeReport(w http.ResponseWriter, r *http.Request) { @@ -148,7 +148,7 @@ func getRoot(w http.ResponseWriter, r *http.Request) { } if user == nil { errorCode := r.URL.Query().Get("error") - htmlpage.Signin(w, errorCode) + sync.Signin(w, errorCode) return } else { has, err := background.HasFieldseekerConnection(r.Context(), user) @@ -157,10 +157,10 @@ func getRoot(w http.ResponseWriter, r *http.Request) { return } if has { - htmlpage.Dashboard(r.Context(), w, user) + sync.Dashboard(r.Context(), w, user) return } else { - htmlpage.OauthPrompt(w, user) + sync.OauthPrompt(w, user) return } } @@ -170,16 +170,16 @@ func getRoot(w http.ResponseWriter, r *http.Request) { } func getSettings(w http.ResponseWriter, r *http.Request, u *models.User) { - htmlpage.Settings(w, r, u) + sync.Settings(w, r, u) } func getSignin(w http.ResponseWriter, r *http.Request) { errorCode := r.URL.Query().Get("error") - htmlpage.Signin(w, errorCode) + sync.Signin(w, errorCode) } func getSignup(w http.ResponseWriter, r *http.Request) { - htmlpage.Signup(w, r.URL.Path) + sync.Signup(w, r.URL.Path) } func getSource(w http.ResponseWriter, r *http.Request, u *models.User) { @@ -193,7 +193,7 @@ func getSource(w http.ResponseWriter, r *http.Request, u *models.User) { respondError(w, "globalid is not a UUID", nil, http.StatusBadRequest) return } - htmlpage.Source(w, r, u, globalid) + sync.Source(w, r, u, globalid) } func postSMS(w http.ResponseWriter, r *http.Request) { @@ -327,6 +327,6 @@ func renderMock(templateName string) http.HandlerFunc { if code == "" { code = "abc-123" } - htmlpage.Mock(templateName, w, code) + sync.Mock(templateName, w, code) } } diff --git a/htmlpage/h3.go b/htmlpage/h3.go new file mode 100644 index 00000000..6de2b817 --- /dev/null +++ b/htmlpage/h3.go @@ -0,0 +1,39 @@ +package htmlpage + +import ( + "fmt" + "strings" + + "github.com/uber/h3-go/v4" +) + +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()) +} + +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) +} diff --git a/htmlpage/html.go b/htmlpage/html.go index 39f66c19..29831177 100644 --- a/htmlpage/html.go +++ b/htmlpage/html.go @@ -2,7 +2,6 @@ package htmlpage import ( "bytes" - "context" "embed" "errors" "fmt" @@ -11,194 +10,30 @@ import ( "math" "net/http" "os" + "path" "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/db/sql" - "github.com/Gleipnir-Technology/nidus-sync/h3utils" - "github.com/Gleipnir-Technology/nidus-sync/notification" "github.com/aarondl/opt/null" "github.com/google/uuid" "github.com/rs/zerolog/log" - "github.com/stephenafamo/bob" - "github.com/stephenafamo/bob/dialect/psql" - "github.com/stephenafamo/bob/dialect/psql/sm" - "github.com/uber/h3-go/v4" ) -//go:embed templates/* -var embeddedFiles embed.FS - -// Authenticated pages -var ( - cell = newBuiltTemplate("cell", "authenticated") - dashboard = newBuiltTemplate("dashboard", "authenticated") - oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated") - settings = newBuiltTemplate("settings", "authenticated") - source = newBuiltTemplate("source", "authenticated") -) - -// Unauthenticated pages -var ( - admin = newBuiltTemplate("admin", "base") - dataEntry = newBuiltTemplate("data-entry", "base") - dataEntryGood = newBuiltTemplate("data-entry-good", "base") - dataEntryBad = newBuiltTemplate("data-entry-bad", "base") - dispatch = newBuiltTemplate("dispatch", "base") - dispatchResults = newBuiltTemplate("dispatch-results", "base") - mockRoot = newBuiltTemplate("mock-root", "base") - reportPage = 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") - settingRoot = newBuiltTemplate("setting-mock", "base") - settingIntegration = newBuiltTemplate("setting-integration", "base") - settingPesticide = newBuiltTemplate("setting-pesticide", "base") - settingPesticideAdd = newBuiltTemplate("setting-pesticide-add", "base") - settingUsers = newBuiltTemplate("setting-user", "base") - settingUsersAdd = newBuiltTemplate("setting-user-add", "base") - signin = newBuiltTemplate("signin", "base") - signup = newBuiltTemplate("signup", "base") -) -var components = [...]string{"header", "map"} -var templatesByFilename = make(map[string]BuiltTemplate, 0) - -type BreedingSourceSummary struct { - ID uuid.UUID - Type string - LastInspected *time.Time - LastTreated *time.Time -} +var TemplatesByFilename = make(map[string]BuiltTemplate, 0) type BuiltTemplate struct { - files []string - name string + files []string + subdir string + // Nil if we are going to read templates off disk every time we render + // because we are in development mode. template *template.Template } -type MapMarker struct { - LatLng h3.LatLng -} -type ComponentMap struct { - Center h3.LatLng - GeoJSON interface{} - MapboxToken string - Markers []MapMarker - Zoom int -} -type ContentAuthenticatedPlaceholder struct { - User User -} -type ContentCell struct { - BreedingSources []BreedingSourceSummary - CellBoundary h3.CellBoundary - Inspections []Inspection - MapData ComponentMap - Treatments []Treatment - User User -} -type ContentMockURLs struct { - Dispatch string - DispatchResults string - ReportConfirmation string - ReportDetail string - ReportContribute string - ReportEvidence string - ReportSchedule string - ReportUpdate string - Root string - Setting string - SettingIntegration string - SettingPesticide string - SettingPesticideAdd string - SettingUser string - SettingUserAdd string -} -type ContentMock struct { - DistrictName string - URLs ContentMockURLs -} -type ContentReportDetail struct { - NextURL string - UpdateURL string -} -type ContentReportDiagnostic struct { -} -type ContentDashboard struct { - CountInspections int - CountMosquitoSources int - CountServiceRequests int - Geo template.JS - IsSyncOngoing bool - LastSync *time.Time - MapData ComponentMap - Org string - RecentRequests []ServiceRequestSummary - User User -} - -type ContentDashboardLoading struct { - User User -} - -type ContentPlaceholder struct { -} -type ContentSignin struct { - InvalidCredentials bool -} -type ContentSignup struct{} -type ContentSource struct { - Inspections []Inspection - MapData ComponentMap - Source *BreedingSourceDetail - Traps []TrapNearby - Treatments []Treatment - //TreatmentCadence TreatmentCadence - TreatmentModels []TreatmentModel - User User -} -type Inspection struct { - Action string - Date *time.Time - Notes string - Location string - LocationID uuid.UUID -} -type Link struct { - Href string - Title string -} -type ServiceRequestSummary struct { - Date time.Time - Location string - Status string -} -type User struct { - DisplayName string - Initials string - Notifications []notification.Notification - Username string -} - -func (bt *BuiltTemplate) ExecuteTemplate(w io.Writer, data any) error { - name := bt.files[0] + ".html" +func (bt *BuiltTemplate) executeTemplate(w io.Writer, data any) error { if bt.template == nil { + name := path.Base(bt.files[0]) templ, err := parseFromDisk(bt.files) if err != nil { return fmt.Errorf("Failed to parse template file: %w", err) @@ -207,12 +42,55 @@ func (bt *BuiltTemplate) ExecuteTemplate(w io.Writer, data any) error { w.Write([]byte("Failed to read from disk: ")) return errors.New("Template parsing failed") } + log.Debug().Str("name", templ.Name()).Msg("Parsed template") return templ.ExecuteTemplate(w, name, data) } else { + name := path.Base(bt.files[0]) return bt.template.ExecuteTemplate(w, name, data) } } +func NewBuiltTemplate(embeddedFiles embed.FS, subdir string, files ...string) *BuiltTemplate { + files_on_disk := true + for _, f := range files { + _, err := os.Stat(f) + if err != nil { + files_on_disk = false + if !config.IsProductionEnvironment() { + log.Warn().Str("file", f).Msg("template file is not on disk") + } + break + } + } + var result BuiltTemplate + if files_on_disk { + result = BuiltTemplate{ + files: files, + subdir: subdir, + template: nil, + } + } else { + result = BuiltTemplate{ + files: files, + subdir: subdir, + template: parseEmbedded(embeddedFiles, subdir, files), + } + } + TemplatesByFilename[files[0]] = result + return &result +} + +func RenderOrError(w http.ResponseWriter, template *BuiltTemplate, context interface{}) { + buf := &bytes.Buffer{} + err := template.executeTemplate(buf, context) + if err != nil { + log.Error().Err(err).Strs("files", template.files).Msg("Failed to render template") + respondError(w, "Failed to render template", err, http.StatusInternalServerError) + return + } + buf.WriteTo(w) +} + func bigNumber(n int) string { // Convert the number to a string numStr := strconv.FormatInt(int64(n), 10) @@ -229,318 +107,6 @@ func bigNumber(n int) string { return result.String() } -func contentForUser(ctx context.Context, user *models.User) (User, error) { - notifications, err := notification.ForUser(ctx, user) - if err != nil { - return User{}, err - } - return User{ - DisplayName: user.DisplayName, - Initials: extractInitials(user.DisplayName), - Notifications: notifications, - Username: user.Username, - }, nil - -} -func extractInitials(name string) string { - parts := strings.Fields(name) - var initials strings.Builder - - for _, part := range parts { - if len(part) > 0 { - initials.WriteString(strings.ToUpper(string(part[0]))) - } - } - - return initials.String() -} - -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, - } - 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, - } - 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 := templatesByFilename[t] - if !ok { - log.Error().Str("template", t).Msg("Failed to find template") - respondError(w, "Failed to render template", nil, http.StatusInternalServerError) - return - } - 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, - }, - } - 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, - } - renderOrError(w, settings, data) -} - -func Signin(w http.ResponseWriter, errorCode string) { - data := ContentSignin{ - InvalidCredentials: errorCode == "invalid-credentials", - } - renderOrError(w, signin, data) -} - -func Signup(w http.ResponseWriter, path string) { - data := ContentSignup{} - 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, - } - - renderOrError(w, source, data) -} - -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()) -} - -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) -} - func makeFuncMap() template.FuncMap { funcMap := template.FuncMap{ "bigNumber": bigNumber, @@ -555,63 +121,26 @@ func makeFuncMap() template.FuncMap { } return funcMap } -func newBuiltTemplate(name string, files ...string) *BuiltTemplate { - files_on_disk := true - all_files := append([]string{name}, files...) - for _, f := range all_files { - full_path := "templates/" + f + ".html" - _, err := os.Stat(full_path) - if err != nil { - files_on_disk = false - break - } - } - var result BuiltTemplate - if files_on_disk { - result = BuiltTemplate{ - files: all_files, - name: name, - template: nil, - } - } else { - result = BuiltTemplate{ - files: all_files, - name: name, - template: parseEmbedded(all_files), - } - } - templatesByFilename[name] = result - return &result -} - -func parseEmbedded(files []string) *template.Template { +func parseEmbedded(embeddedFiles embed.FS, subdir string, files []string) *template.Template { funcMap := makeFuncMap() // Remap the file names to embedded paths - paths := make([]string, 0) + embeddedFilePaths := make([]string, 0) for _, f := range files { - paths = append(paths, "templates/"+f+".html") + embeddedFilePaths = append(embeddedFilePaths, strings.TrimPrefix(f, subdir)) } - for _, f := range components { - paths = append(paths, "templates/components/"+f+".html") - } - name := files[0] + name := path.Base(embeddedFilePaths[0]) + log.Debug().Str("name", name).Strs("paths", embeddedFilePaths).Msg("Parsing embedded template") return template.Must( - template.New(name).Funcs(funcMap).ParseFS(embeddedFiles, paths...)) + template.New(name).Funcs(funcMap).ParseFS(embeddedFiles, embeddedFilePaths...)) } func parseFromDisk(files []string) (*template.Template, error) { funcMap := makeFuncMap() - paths := make([]string, 0) - for _, f := range files { - paths = append(paths, "templates/"+f+".html") - } - name := files[0] + ".html" - for _, f := range components { - paths = append(paths, "templates/components/"+f+".html") - } - templ, err := template.New(name).Funcs(funcMap).ParseFiles(paths...) + name := path.Base(files[0]) + log.Debug().Str("name", name).Strs("files", files).Msg("parsing from disk") + templ, err := template.New(name).Funcs(funcMap).ParseFiles(files...) if err != nil { - return nil, fmt.Errorf("Failed to parse %s: %w", paths, err) + return nil, fmt.Errorf("Failed to parse %s: %w", files, err) } return templ, nil } @@ -750,203 +279,6 @@ func timeSince(t *time.Time) string { return fmt.Sprintf("%d days ago", int(days)) } } - -func trapsBySource(ctx context.Context, org *models.Organization, sourceID uuid.UUID) ([]TrapNearby, error) { - locations, err := sql.TrapLocationBySourceID(org.ID, sourceID).All(ctx, db.PGInstance.BobDB) - if err != nil { - return nil, fmt.Errorf("Failed to query rows: %w", err) - } - - location_ids := make([]uuid.UUID, 0) - var args []bob.Expression - for _, location := range locations { - location_ids = append(location_ids, location.TrapLocationGlobalid) - args = append(args, psql.Arg(location.TrapLocationGlobalid)) - } - /* - trap_data, err := org.FSTrapdata( - sm.Where( - models.FSTrapdata.Columns.LocID.In(args...), - ), - sm.OrderBy("enddatetime"), - ).All(ctx, db.PGInstance.BobDB) - */ - - /* - query := org.FSTrapdata( - sm.From( - psql.Select( - sm.From(psql.F("ROW_NUMBER")( - fm.Over( - wm.PartitionBy(models.FSTrapdata.Columns.LocID), - wm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), - ), - )).As("row_num"), - sm.Where(models.FSTrapdata.Columns.LocID.In(args...))), - ), - sm.Where(psql.Quote("row_num").LTE(psql.Arg(10))), - sm.OrderBy(models.FSTrapdata.Columns.LocID), - sm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), - ) - */ - /* - query := psql.Select( - sm.From( - psql.Select( - sm.Columns( - models.FSTrapdata.Columns.Globalid, - psql.F("ROW_NUMBER")( - fm.Over( - wm.PartitionBy(models.FSTrapdata.Columns.LocID), - wm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), - ), - ).As("row_num"), - sm.From(models.FSTrapdata.Name()), - ), - sm.Where(models.FSTrapdata.Columns.LocID.In(args...))), - ), - sm.Where(psql.Quote("row_num").LTE(psql.Arg(10))), - sm.OrderBy(models.FSTrapdata.Columns.LocID), - sm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), - ) - log.Info().Str("trapdata", queryToString(query)).Msg("Getting trap data") - trap_data, err := query.Exec(ctx, db.PGInstance.BobDB) - */ - - trap_data, err := sql.TrapDataByLocationIDRecent(org.ID, location_ids).All(ctx, db.PGInstance.BobDB) - if err != nil { - return nil, fmt.Errorf("Failed to query trap data: %w", err) - } - - counts, err := sql.TrapCountByLocationID(org.ID, location_ids).All(ctx, db.PGInstance.BobDB) - if err != nil { - return nil, fmt.Errorf("Failed to query trap counts: %w", err) - } - - traps, err := toTemplateTraps(locations, trap_data, counts) - if err != nil { - return nil, fmt.Errorf("Failed to convert trap data: %w", err) - } - return traps, nil -} - -func renderOrError(w http.ResponseWriter, template *BuiltTemplate, context interface{}) { - buf := &bytes.Buffer{} - err := template.ExecuteTemplate(buf, context) - if err != nil { - log.Error().Err(err).Str("template", template.name).Msg("Failed to render template") - respondError(w, "Failed to render template", err, http.StatusInternalServerError) - return - } - buf.WriteTo(w) -} - -func treatmentsBySource(ctx context.Context, org *models.Organization, sourceID uuid.UUID) ([]Treatment, error) { - var results []Treatment - rows, err := org.Treatments( - sm.Where( - models.FieldseekerTreatments.Columns.Pointlocid.EQ(psql.Arg(sourceID)), - ), - sm.OrderBy("enddatetime").Desc(), - ).All(ctx, db.PGInstance.BobDB) - if err != nil { - return results, fmt.Errorf("Failed to query rows: %w", err) - } - //log.Info().Int("row count", len(rows)).Msg("Getting treatments") - return toTemplateTreatment(rows) -} - -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.Treatments( - sm.Where( - psql.F("ST_Within", "geospatial", geom_query), - ), - sm.OrderBy("pointlocid"), - sm.OrderBy("enddatetime"), - ).All(ctx, db.PGInstance.BobDB) - if err != nil { - return results, fmt.Errorf("Failed to query rows: %w", err) - } - return toTemplateTreatment(rows) -} -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.Mosquitoinspections( - sm.Where( - psql.F("ST_Within", "geospatial", geom_query), - ), - sm.OrderBy("pointlocid"), - sm.OrderBy("enddatetime"), - ).All(ctx, db.PGInstance.BobDB) - if err != nil { - return results, fmt.Errorf("Failed to query rows: %w", err) - } - return toTemplateInspection(rows) -} -func inspectionsBySource(ctx context.Context, org *models.Organization, sourceID uuid.UUID) ([]Inspection, error) { - var results []Inspection - - rows, err := org.Mosquitoinspections( - sm.Where( - models.FieldseekerMosquitoinspections.Columns.Pointlocid.EQ(psql.Arg(sourceID)), - ), - sm.OrderBy("enddatetime").Desc(), - ).All(ctx, db.PGInstance.BobDB) - if err != nil { - return results, fmt.Errorf("Failed to query rows: %w", err) - } - return toTemplateInspection(rows) -} -func breedingSourcesByCell(ctx context.Context, org *models.Organization, c h3.Cell) ([]BreedingSourceSummary, error) { - var results []BreedingSourceSummary - - 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.Pointlocations( - sm.Where( - psql.F("ST_Within", "geospatial", geom_query), - ), - sm.OrderBy("lasttreatdate"), - ).All(ctx, db.PGInstance.BobDB) - if err != nil { - return results, fmt.Errorf("Failed to query rows: %w", err) - } - for _, r := range rows { - var last_inspected *time.Time - if !r.Lastinspectdate.IsNull() { - l := r.Lastinspectdate.MustGet() - last_inspected = &l - } - var last_treat_date *time.Time - if !r.Lasttreatdate.IsNull() { - l := r.Lasttreatdate.MustGet() - last_treat_date = &l - } - results = append(results, BreedingSourceSummary{ - ID: r.Globalid, - LastInspected: last_inspected, - LastTreated: last_treat_date, - Type: r.Habitat.GetOr("none"), - }) - } - return results, nil -} - func uuidShort(uuid uuid.UUID) string { s := uuid.String() if len(s) < 7 { @@ -955,13 +287,3 @@ func uuidShort(uuid uuid.UUID) string { return s[:3] + "..." + s[len(s)-4:] } - -func sourceByGlobalId(ctx context.Context, org *models.Organization, id uuid.UUID) (*BreedingSourceDetail, error) { - row, err := org.Pointlocations( - sm.Where(models.FieldseekerPointlocations.Columns.Globalid.EQ(psql.Arg(id))), - ).One(ctx, db.PGInstance.BobDB) - if err != nil { - return nil, fmt.Errorf("Failed to get point location: %w", err) - } - return toTemplateBreedingSource(row), nil -} diff --git a/htmlpage/public-reports/page.go b/htmlpage/public-reports/page.go new file mode 100644 index 00000000..c81b06b1 --- /dev/null +++ b/htmlpage/public-reports/page.go @@ -0,0 +1,31 @@ +package publicreports + +import ( + "embed" + "fmt" + + "github.com/Gleipnir-Technology/nidus-sync/htmlpage" +) + +//go:embed template/* +var embeddedFiles embed.FS + +type RootContext struct{} + +var ( + Root = buildTemplate("root", "base") +) + +var components = [...]string{} + +func buildTemplate(files ...string) *htmlpage.BuiltTemplate { + subdir := "htmlpage/public-reports" + full_files := make([]string, 0) + for _, f := range files { + full_files = append(full_files, fmt.Sprintf("%s/template/%s.html", subdir, f)) + } + for _, c := range components { + full_files = append(full_files, fmt.Sprintf("%s/template/components/%s.html", subdir, c)) + } + return htmlpage.NewBuiltTemplate(embeddedFiles, "htmlpage/public-reports/", full_files...) +} diff --git a/htmlpage/templates/base.html b/htmlpage/public-reports/template/base.html similarity index 100% rename from htmlpage/templates/base.html rename to htmlpage/public-reports/template/base.html diff --git a/htmlpage/public-reports/template/root.html b/htmlpage/public-reports/template/root.html new file mode 100644 index 00000000..dfe9c3ef --- /dev/null +++ b/htmlpage/public-reports/template/root.html @@ -0,0 +1,224 @@ +{{template "base.html" .}} + +{{define "title"}}Login{{end}} +{{define "extraheader"}} + +{{end}} +{{define "content"}} +
+
+
+
+

Green Pool Reporting

+

Entry Points Diagnostic Page

+
+ +
+ + This page demonstrates the various ways customers can access the Green Pool Reporting system. +
+ + +
+
+ +

Text Message Entry Point

+
+ +

Customers will receive the following text message with a link to begin the reporting process:

+ +
+ Vector Control: We noticed a potential green pool at your property. Please tap the link to report status or schedule inspection: /report-detail +
+ +
+

SMS Details:

+
    +
  • Sent via automated system after aerial detection
  • +
  • Contains unique tracking link for each property
  • +
  • Customers tap link to open mobile browser
  • +
+
+
+ + +
+
+ +

Door Hanger QR Code Entry Point

+
+ +

Inspectors will leave door hangers with a QR code for properties where no one is home:

+ +
+
IMPORTANT NOTICE
+

We visited regarding a potential mosquito breeding site.

+ + + +

Scan this code with your phone camera to report your pool status or schedule an inspection.

+

Or visit: /report-detail

+
+ +
+

Door Hanger Details:

+
    +
  • Physical notices left on the door handle
  • +
  • QR code contains property-specific link
  • +
  • Fallback URL provided for manual entry
  • +
+
+
+ + +
+
+ +

Email Notification Entry Point

+
+ +

Property owners will receive this email as a follow-up to other communication attempts:

+ + + +
+

Email Details:

+
    +
  • Sent as follow-up or for property owners with registered email addresses
  • +
  • Contains clear call-to-action button and alternative text link
  • +
  • Explains reason for contact and next steps
  • +
+
+
+ + +
+
+
+{{end}} diff --git a/htmlpage/model_conversion.go b/htmlpage/sync/model_conversion.go similarity index 99% rename from htmlpage/model_conversion.go rename to htmlpage/sync/model_conversion.go index e93f2480..6f07ec8a 100644 --- a/htmlpage/model_conversion.go +++ b/htmlpage/sync/model_conversion.go @@ -1,4 +1,4 @@ -package htmlpage +package sync import ( "errors" diff --git a/htmlpage/sync/page.go b/htmlpage/sync/page.go new file mode 100644 index 00000000..5daefbe7 --- /dev/null +++ b/htmlpage/sync/page.go @@ -0,0 +1,352 @@ +package sync + +import ( + "embed" + + "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/* +var embeddedFiles 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 { + subdir := "htmlpage/sync" + full_files := make([]string, 0) + for _, f := range files { + full_files = append(full_files, fmt.Sprintf("%s/template/%s.html", subdir, f)) + } + for _, c := range components { + full_files = append(full_files, fmt.Sprintf("%s/template/components/%s.html", subdir, c)) + } + return htmlpage.NewBuiltTemplate(embeddedFiles, "htmlpage/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] + 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") + http.Error(w, m, s) +} diff --git a/htmlpage/templates/admin.html b/htmlpage/sync/template/admin.html similarity index 100% rename from htmlpage/templates/admin.html rename to htmlpage/sync/template/admin.html diff --git a/htmlpage/templates/authenticated.html b/htmlpage/sync/template/authenticated.html similarity index 100% rename from htmlpage/templates/authenticated.html rename to htmlpage/sync/template/authenticated.html diff --git a/htmlpage/sync/template/base.html b/htmlpage/sync/template/base.html new file mode 100644 index 00000000..f1304fbb --- /dev/null +++ b/htmlpage/sync/template/base.html @@ -0,0 +1,19 @@ + + + + + + {{template "title" .}} - Nidus Sync + + + + + + + {{block "extraheader" .}} {{end}} + + +{{template "content" .}} + + + diff --git a/htmlpage/templates/cell.html b/htmlpage/sync/template/cell.html similarity index 100% rename from htmlpage/templates/cell.html rename to htmlpage/sync/template/cell.html diff --git a/htmlpage/templates/components/header.html b/htmlpage/sync/template/components/header.html similarity index 100% rename from htmlpage/templates/components/header.html rename to htmlpage/sync/template/components/header.html diff --git a/htmlpage/templates/components/map.html b/htmlpage/sync/template/components/map.html similarity index 100% rename from htmlpage/templates/components/map.html rename to htmlpage/sync/template/components/map.html diff --git a/htmlpage/templates/dashboard.html b/htmlpage/sync/template/dashboard.html similarity index 100% rename from htmlpage/templates/dashboard.html rename to htmlpage/sync/template/dashboard.html diff --git a/htmlpage/templates/data-entry-bad.html b/htmlpage/sync/template/data-entry-bad.html similarity index 100% rename from htmlpage/templates/data-entry-bad.html rename to htmlpage/sync/template/data-entry-bad.html diff --git a/htmlpage/templates/data-entry-good.html b/htmlpage/sync/template/data-entry-good.html similarity index 100% rename from htmlpage/templates/data-entry-good.html rename to htmlpage/sync/template/data-entry-good.html diff --git a/htmlpage/templates/data-entry.html b/htmlpage/sync/template/data-entry.html similarity index 100% rename from htmlpage/templates/data-entry.html rename to htmlpage/sync/template/data-entry.html diff --git a/htmlpage/templates/dispatch-results.html b/htmlpage/sync/template/dispatch-results.html similarity index 100% rename from htmlpage/templates/dispatch-results.html rename to htmlpage/sync/template/dispatch-results.html diff --git a/htmlpage/templates/dispatch.html b/htmlpage/sync/template/dispatch.html similarity index 100% rename from htmlpage/templates/dispatch.html rename to htmlpage/sync/template/dispatch.html diff --git a/htmlpage/templates/empty-auth.html b/htmlpage/sync/template/empty-auth.html similarity index 100% rename from htmlpage/templates/empty-auth.html rename to htmlpage/sync/template/empty-auth.html diff --git a/htmlpage/templates/empty.html b/htmlpage/sync/template/empty.html similarity index 100% rename from htmlpage/templates/empty.html rename to htmlpage/sync/template/empty.html diff --git a/htmlpage/templates/mock-root.html b/htmlpage/sync/template/mock-root.html similarity index 100% rename from htmlpage/templates/mock-root.html rename to htmlpage/sync/template/mock-root.html diff --git a/htmlpage/templates/oauth-prompt.html b/htmlpage/sync/template/oauth-prompt.html similarity index 100% rename from htmlpage/templates/oauth-prompt.html rename to htmlpage/sync/template/oauth-prompt.html diff --git a/htmlpage/templates/report-confirmation.html b/htmlpage/sync/template/report-confirmation.html similarity index 100% rename from htmlpage/templates/report-confirmation.html rename to htmlpage/sync/template/report-confirmation.html diff --git a/htmlpage/templates/report-contribute.html b/htmlpage/sync/template/report-contribute.html similarity index 100% rename from htmlpage/templates/report-contribute.html rename to htmlpage/sync/template/report-contribute.html diff --git a/htmlpage/templates/report-detail.html b/htmlpage/sync/template/report-detail.html similarity index 100% rename from htmlpage/templates/report-detail.html rename to htmlpage/sync/template/report-detail.html diff --git a/htmlpage/templates/report-evidence.html b/htmlpage/sync/template/report-evidence.html similarity index 100% rename from htmlpage/templates/report-evidence.html rename to htmlpage/sync/template/report-evidence.html diff --git a/htmlpage/templates/report-schedule.html b/htmlpage/sync/template/report-schedule.html similarity index 100% rename from htmlpage/templates/report-schedule.html rename to htmlpage/sync/template/report-schedule.html diff --git a/htmlpage/templates/report-update.html b/htmlpage/sync/template/report-update.html similarity index 100% rename from htmlpage/templates/report-update.html rename to htmlpage/sync/template/report-update.html diff --git a/htmlpage/templates/report.html b/htmlpage/sync/template/report.html similarity index 100% rename from htmlpage/templates/report.html rename to htmlpage/sync/template/report.html diff --git a/htmlpage/templates/service-request-detail.html b/htmlpage/sync/template/service-request-detail.html similarity index 100% rename from htmlpage/templates/service-request-detail.html rename to htmlpage/sync/template/service-request-detail.html diff --git a/htmlpage/templates/service-request-location.html b/htmlpage/sync/template/service-request-location.html similarity index 100% rename from htmlpage/templates/service-request-location.html rename to htmlpage/sync/template/service-request-location.html diff --git a/htmlpage/templates/service-request-mosquito.html b/htmlpage/sync/template/service-request-mosquito.html similarity index 100% rename from htmlpage/templates/service-request-mosquito.html rename to htmlpage/sync/template/service-request-mosquito.html diff --git a/htmlpage/templates/service-request-pool.html b/htmlpage/sync/template/service-request-pool.html similarity index 100% rename from htmlpage/templates/service-request-pool.html rename to htmlpage/sync/template/service-request-pool.html diff --git a/htmlpage/templates/service-request-quick-confirmation.html b/htmlpage/sync/template/service-request-quick-confirmation.html similarity index 100% rename from htmlpage/templates/service-request-quick-confirmation.html rename to htmlpage/sync/template/service-request-quick-confirmation.html diff --git a/htmlpage/templates/service-request-quick.html b/htmlpage/sync/template/service-request-quick.html similarity index 100% rename from htmlpage/templates/service-request-quick.html rename to htmlpage/sync/template/service-request-quick.html diff --git a/htmlpage/templates/service-request-updates.html b/htmlpage/sync/template/service-request-updates.html similarity index 100% rename from htmlpage/templates/service-request-updates.html rename to htmlpage/sync/template/service-request-updates.html diff --git a/htmlpage/templates/service-request.html b/htmlpage/sync/template/service-request.html similarity index 100% rename from htmlpage/templates/service-request.html rename to htmlpage/sync/template/service-request.html diff --git a/htmlpage/templates/setting-integration.html b/htmlpage/sync/template/setting-integration.html similarity index 100% rename from htmlpage/templates/setting-integration.html rename to htmlpage/sync/template/setting-integration.html diff --git a/htmlpage/templates/setting-mock.html b/htmlpage/sync/template/setting-mock.html similarity index 100% rename from htmlpage/templates/setting-mock.html rename to htmlpage/sync/template/setting-mock.html diff --git a/htmlpage/templates/setting-pesticide-add.html b/htmlpage/sync/template/setting-pesticide-add.html similarity index 100% rename from htmlpage/templates/setting-pesticide-add.html rename to htmlpage/sync/template/setting-pesticide-add.html diff --git a/htmlpage/templates/setting-pesticide.html b/htmlpage/sync/template/setting-pesticide.html similarity index 100% rename from htmlpage/templates/setting-pesticide.html rename to htmlpage/sync/template/setting-pesticide.html diff --git a/htmlpage/templates/setting-user-add.html b/htmlpage/sync/template/setting-user-add.html similarity index 100% rename from htmlpage/templates/setting-user-add.html rename to htmlpage/sync/template/setting-user-add.html diff --git a/htmlpage/templates/setting-user.html b/htmlpage/sync/template/setting-user.html similarity index 100% rename from htmlpage/templates/setting-user.html rename to htmlpage/sync/template/setting-user.html diff --git a/htmlpage/templates/settings.html b/htmlpage/sync/template/settings.html similarity index 100% rename from htmlpage/templates/settings.html rename to htmlpage/sync/template/settings.html diff --git a/htmlpage/templates/signin.html b/htmlpage/sync/template/signin.html similarity index 100% rename from htmlpage/templates/signin.html rename to htmlpage/sync/template/signin.html diff --git a/htmlpage/templates/signup.html b/htmlpage/sync/template/signup.html similarity index 100% rename from htmlpage/templates/signup.html rename to htmlpage/sync/template/signup.html diff --git a/htmlpage/templates/source.html b/htmlpage/sync/template/source.html similarity index 100% rename from htmlpage/templates/source.html rename to htmlpage/sync/template/source.html diff --git a/htmlpage/time.go b/htmlpage/sync/time.go similarity index 99% rename from htmlpage/time.go rename to htmlpage/sync/time.go index 24427292..848670ca 100644 --- a/htmlpage/time.go +++ b/htmlpage/sync/time.go @@ -1,4 +1,4 @@ -package htmlpage +package sync import ( "sort" diff --git a/htmlpage/sync/types.go b/htmlpage/sync/types.go new file mode 100644 index 00000000..de475d2c --- /dev/null +++ b/htmlpage/sync/types.go @@ -0,0 +1,121 @@ +package sync + +import ( + "html/template" + "time" + + "github.com/Gleipnir-Technology/nidus-sync/notification" + "github.com/google/uuid" + "github.com/uber/h3-go/v4" +) + +type BreedingSourceSummary struct { + ID uuid.UUID + Type string + LastInspected *time.Time + LastTreated *time.Time +} + +type MapMarker struct { + LatLng h3.LatLng +} +type ComponentMap struct { + Center h3.LatLng + GeoJSON interface{} + MapboxToken string + Markers []MapMarker + Zoom int +} +type ContentAuthenticatedPlaceholder struct { + User User +} +type ContentCell struct { + BreedingSources []BreedingSourceSummary + CellBoundary h3.CellBoundary + Inspections []Inspection + MapData ComponentMap + Treatments []Treatment + User User +} +type ContentMockURLs struct { + Dispatch string + DispatchResults string + ReportConfirmation string + ReportDetail string + ReportContribute string + ReportEvidence string + ReportSchedule string + ReportUpdate string + Root string + Setting string + SettingIntegration string + SettingPesticide string + SettingPesticideAdd string + SettingUser string + SettingUserAdd string +} +type ContentMock struct { + DistrictName string + URLs ContentMockURLs +} +type ContentReportDetail struct { + NextURL string + UpdateURL string +} +type ContentReportDiagnostic struct { +} +type ContentDashboard struct { + CountInspections int + CountMosquitoSources int + CountServiceRequests int + Geo template.JS + IsSyncOngoing bool + LastSync *time.Time + MapData ComponentMap + Org string + RecentRequests []ServiceRequestSummary + User User +} + +type ContentDashboardLoading struct { + User User +} + +type ContentPlaceholder struct { +} +type ContentSignin struct { + InvalidCredentials bool +} +type ContentSignup struct{} +type ContentSource struct { + Inspections []Inspection + MapData ComponentMap + Source *BreedingSourceDetail + Traps []TrapNearby + Treatments []Treatment + //TreatmentCadence TreatmentCadence + TreatmentModels []TreatmentModel + User User +} +type Inspection struct { + Action string + Date *time.Time + Notes string + Location string + LocationID uuid.UUID +} +type Link struct { + Href string + Title string +} +type ServiceRequestSummary struct { + Date time.Time + Location string + Status string +} +type User struct { + DisplayName string + Initials string + Notifications []notification.Notification + Username string +} diff --git a/htmlpage/sync/utils.go b/htmlpage/sync/utils.go new file mode 100644 index 00000000..827bfe44 --- /dev/null +++ b/htmlpage/sync/utils.go @@ -0,0 +1,253 @@ +package sync + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/Gleipnir-Technology/nidus-sync/db" + "github.com/Gleipnir-Technology/nidus-sync/db/models" + "github.com/Gleipnir-Technology/nidus-sync/db/sql" + "github.com/Gleipnir-Technology/nidus-sync/notification" + "github.com/google/uuid" + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/psql" + "github.com/stephenafamo/bob/dialect/psql/sm" + "github.com/uber/h3-go/v4" +) + +func breedingSourcesByCell(ctx context.Context, org *models.Organization, c h3.Cell) ([]BreedingSourceSummary, error) { + var results []BreedingSourceSummary + + 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.Pointlocations( + sm.Where( + psql.F("ST_Within", "geospatial", geom_query), + ), + sm.OrderBy("lasttreatdate"), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return results, fmt.Errorf("Failed to query rows: %w", err) + } + for _, r := range rows { + var last_inspected *time.Time + if !r.Lastinspectdate.IsNull() { + l := r.Lastinspectdate.MustGet() + last_inspected = &l + } + var last_treat_date *time.Time + if !r.Lasttreatdate.IsNull() { + l := r.Lasttreatdate.MustGet() + last_treat_date = &l + } + results = append(results, BreedingSourceSummary{ + ID: r.Globalid, + LastInspected: last_inspected, + LastTreated: last_treat_date, + Type: r.Habitat.GetOr("none"), + }) + } + return results, nil +} +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()) +} + +func sourceByGlobalId(ctx context.Context, org *models.Organization, id uuid.UUID) (*BreedingSourceDetail, error) { + row, err := org.Pointlocations( + sm.Where(models.FieldseekerPointlocations.Columns.Globalid.EQ(psql.Arg(id))), + ).One(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("Failed to get point location: %w", err) + } + return toTemplateBreedingSource(row), nil +} + +func extractInitials(name string) string { + parts := strings.Fields(name) + var initials strings.Builder + + for _, part := range parts { + if len(part) > 0 { + initials.WriteString(strings.ToUpper(string(part[0]))) + } + } + + return initials.String() +} + +func contentForUser(ctx context.Context, user *models.User) (User, error) { + notifications, err := notification.ForUser(ctx, user) + if err != nil { + return User{}, err + } + return User{ + DisplayName: user.DisplayName, + Initials: extractInitials(user.DisplayName), + Notifications: notifications, + Username: user.Username, + }, nil + +} + +func trapsBySource(ctx context.Context, org *models.Organization, sourceID uuid.UUID) ([]TrapNearby, error) { + locations, err := sql.TrapLocationBySourceID(org.ID, sourceID).All(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("Failed to query rows: %w", err) + } + + location_ids := make([]uuid.UUID, 0) + var args []bob.Expression + for _, location := range locations { + location_ids = append(location_ids, location.TrapLocationGlobalid) + args = append(args, psql.Arg(location.TrapLocationGlobalid)) + } + /* + trap_data, err := org.FSTrapdata( + sm.Where( + models.FSTrapdata.Columns.LocID.In(args...), + ), + sm.OrderBy("enddatetime"), + ).All(ctx, db.PGInstance.BobDB) + */ + + /* + query := org.FSTrapdata( + sm.From( + psql.Select( + sm.From(psql.F("ROW_NUMBER")( + fm.Over( + wm.PartitionBy(models.FSTrapdata.Columns.LocID), + wm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), + ), + )).As("row_num"), + sm.Where(models.FSTrapdata.Columns.LocID.In(args...))), + ), + sm.Where(psql.Quote("row_num").LTE(psql.Arg(10))), + sm.OrderBy(models.FSTrapdata.Columns.LocID), + sm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), + ) + */ + /* + query := psql.Select( + sm.From( + psql.Select( + sm.Columns( + models.FSTrapdata.Columns.Globalid, + psql.F("ROW_NUMBER")( + fm.Over( + wm.PartitionBy(models.FSTrapdata.Columns.LocID), + wm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), + ), + ).As("row_num"), + sm.From(models.FSTrapdata.Name()), + ), + sm.Where(models.FSTrapdata.Columns.LocID.In(args...))), + ), + sm.Where(psql.Quote("row_num").LTE(psql.Arg(10))), + sm.OrderBy(models.FSTrapdata.Columns.LocID), + sm.OrderBy(models.FSTrapdata.Columns.Enddatetime).Desc(), + ) + log.Info().Str("trapdata", queryToString(query)).Msg("Getting trap data") + trap_data, err := query.Exec(ctx, db.PGInstance.BobDB) + */ + + trap_data, err := sql.TrapDataByLocationIDRecent(org.ID, location_ids).All(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("Failed to query trap data: %w", err) + } + + counts, err := sql.TrapCountByLocationID(org.ID, location_ids).All(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("Failed to query trap counts: %w", err) + } + + traps, err := toTemplateTraps(locations, trap_data, counts) + if err != nil { + return nil, fmt.Errorf("Failed to convert trap data: %w", err) + } + return traps, nil +} + +func treatmentsBySource(ctx context.Context, org *models.Organization, sourceID uuid.UUID) ([]Treatment, error) { + var results []Treatment + rows, err := org.Treatments( + sm.Where( + models.FieldseekerTreatments.Columns.Pointlocid.EQ(psql.Arg(sourceID)), + ), + sm.OrderBy("enddatetime").Desc(), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return results, fmt.Errorf("Failed to query rows: %w", err) + } + //log.Info().Int("row count", len(rows)).Msg("Getting treatments") + return toTemplateTreatment(rows) +} + +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.Treatments( + sm.Where( + psql.F("ST_Within", "geospatial", geom_query), + ), + sm.OrderBy("pointlocid"), + sm.OrderBy("enddatetime"), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return results, fmt.Errorf("Failed to query rows: %w", err) + } + return toTemplateTreatment(rows) +} +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.Mosquitoinspections( + sm.Where( + psql.F("ST_Within", "geospatial", geom_query), + ), + sm.OrderBy("pointlocid"), + sm.OrderBy("enddatetime"), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return results, fmt.Errorf("Failed to query rows: %w", err) + } + return toTemplateInspection(rows) +} +func inspectionsBySource(ctx context.Context, org *models.Organization, sourceID uuid.UUID) ([]Inspection, error) { + var results []Inspection + + rows, err := org.Mosquitoinspections( + sm.Where( + models.FieldseekerMosquitoinspections.Columns.Pointlocid.EQ(psql.Arg(sourceID)), + ), + sm.OrderBy("enddatetime").Desc(), + ).All(ctx, db.PGInstance.BobDB) + if err != nil { + return results, fmt.Errorf("Failed to query rows: %w", err) + } + return toTemplateInspection(rows) +} diff --git a/report/endpoint.go b/report/endpoint.go index 8f97bca2..74f2a86a 100644 --- a/report/endpoint.go +++ b/report/endpoint.go @@ -1,9 +1,10 @@ package report import ( - "fmt" "net/http" + "github.com/Gleipnir-Technology/nidus-sync/htmlpage" + "github.com/Gleipnir-Technology/nidus-sync/htmlpage/public-reports" "github.com/go-chi/chi/v5" ) @@ -14,5 +15,9 @@ func Router() chi.Router { } func getRoot(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Herro.") + htmlpage.RenderOrError( + w, + publicreports.Root, + publicreports.RootContext{}, + ) }