Initial render of standing water reports from the public

This commit is contained in:
Eli Ribble 2026-03-09 22:59:21 +00:00
parent cd47aaba94
commit ce6c6c1cc1
No known key found for this signature in database
6 changed files with 246 additions and 176 deletions

View file

@ -9,6 +9,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/db/models"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform/publicreport"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/google/uuid"
//"github.com/rs/zerolog/log"
)
@ -19,22 +20,26 @@ type reporter struct {
Name string `json:"name"`
}
type communication struct {
Created time.Time `json:"created"`
ID string `json:"id"`
PublicReport publicreport.Nuisance `json:"public_report"`
Type string `json:"type"`
Created time.Time `json:"created"`
ID string `json:"id"`
PublicReport types.PublicReport `json:"public_report"`
Type string `json:"type"`
}
type contentListCommunication struct {
Communications []communication `json:"communications"`
}
func listCommunication(ctx context.Context, r *http.Request, org *models.Organization, user *models.User, query queryParams) (*contentListCommunication, *nhttp.ErrorWithStatus) {
reports, err := publicreport.NuisanceReportForOrganization(ctx, org.ID)
nreports, err := publicreport.NuisanceReportForOrganization(ctx, org.ID)
if err != nil {
return nil, nhttp.NewError("query report: %w", err)
return nil, nhttp.NewError("nuisance report query: %w", err)
}
comms := make([]communication, len(reports))
for i, report := range reports {
wreports, err := publicreport.WaterReportForOrganization(ctx, org.ID)
if err != nil {
return nil, nhttp.NewError("water report query: %w", err)
}
comms := make([]communication, len(nreports)+len(wreports))
for i, report := range nreports {
comms[i] = communication{
Created: report.Created,
ID: report.PublicID,
@ -42,6 +47,14 @@ func listCommunication(ctx context.Context, r *http.Request, org *models.Organiz
Type: "nuisance",
}
}
for i, report := range wreports {
comms[i+len(nreports)] = communication{
Created: report.Created,
ID: report.PublicID,
PublicReport: report,
Type: "water",
}
}
return &contentListCommunication{
Communications: comms,
}, nil

View file

@ -27,143 +27,152 @@
<script src="/static/js/location.js"></script>
<script src="/static/js/map-locator.js"></script>
<script src="/static/js/photo-upload.js"></script>
<script>
const MAPBOX_ACCESS_TOKEN = '{{.MapboxToken}}';
async function handleMapClick(mapLocator, lngLat) {
mapLocator.SetMarker(lngLat);
mapLocator.PanTo(lngLat, {duration: 2000});
const response = await geocodeReverse(MAPBOX_ACCESS_TOKEN, {
lat: lngLat.lat,
lng: lngLat.lng,
});
console.log("click reverse geocode", response);
if (response !== undefined && response.features.length > 0) {
const addressInput = document.querySelector("address-input");
addressInput.SetValue(response.features[0]);
setLocationInputs(response.features[0]);
}
}
async function handleMarkerDrag(lngLat) {
const response = await geocodeReverse(MAPBOX_ACCESS_TOKEN, {
lat: lngLat.lat,
lng: lngLat.lng,
})
console.log("marker drag reverse geocode", response);
if (response !== undefined && response.features.length > 0) {
const addressInput = document.querySelector("address-input");
addressInput.SetValue(response.features[0]);
}
}
function setLocationInputs(location) {
let country = document.getElementById('address-country');
let latitude = document.getElementById('latitude');
let longitude = document.getElementById('longitude');
let latlngAccuracyType = document.getElementById('latlng-accuracy-type');
let latlngAccuracyValue = document.getElementById('latlng-accuracy-value');
let number = document.getElementById('address-number');
let postalcode = document.getElementById('address-postalcode');
let place = document.getElementById('address-place');
let region = document.getElementById('address-region');
let street = document.getElementById('address-street');
// Extract context data from properties
const props = location.properties;
const context = props.context || {};
// Populate structured fields
country.value = context.iso_3166_a3;
latitude.value = location.geometry.coordinates[1];
longitude.value = location.geometry.coordinates[0];
latlngAccuracyType.value = props.precision;
latlngAccuracyValue.value = props.distance;
number.value = props.address_components.number;
postalcode.value = props.address_components.postal_code;
locality.value = context.whosonfirst.locality.name;
region.value = props.context.whosonfirst.region.abbreviation;
street.value = props.address_components.street;
}
function toggleCollapse(something) {
el = document.getElementById(something)
if (el.classList.contains('collapse')) {
el.classList.remove('collapse');
} else {
el.classList.add('collapse');
}
document.getElementById("toggle-additional").classList.add("visually-hidden");
}
document.addEventListener('DOMContentLoaded', function() {
// Elements
const addressInput = document.querySelector("address-input");
const latitudeInput = document.getElementById('latitude');
const longitudeInput = document.getElementById('longitude');
const latlngAccuracyType = document.getElementById('latlng-accuracy-type');
const latlngAccuracyValue = document.getElementById('latlng-accuracy-value');
const mapLocator = document.querySelector("map-locator");
mapLocator.addEventListener("load", (event) => {
getGeolocation({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}).then(position => {
console.log("Got location", position);
latitudeInput.value = position.coords.latitude;
longitudeInput.value = position.coords.longitude;
latlngAccuracyType.value = 'browser';
latlngAccuracyValue.value = position.coords.accuracy;
mapLocator.JumpTo({
center: {
lng: position.coords.longitude,
lat: position.coords.latitude,
},
zoom: 14,
<script>
async function handleMapClick(mapLocator, lngLat) {
mapLocator.SetMarker(lngLat);
mapLocator.PanTo(lngLat, { duration: 2000 });
const response = await geocodeReverse({
lat: lngLat.lat,
lng: lngLat.lng,
});
const coords = [
position.coords.longitude,
position.coords.latitude,
];
mapLocator.SetMarker(coords);
mapLocator.JumpTo({center: coords, zoom: 14});
handleMarkerDrag({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
}).catch(error => {
console.log("location error", error);
})
})
let mapZoom = document.getElementById('map-zoom');
mapLocator.addEventListener("zoomend", function(e) {
mapZoom.value = e.target.GetZoom();
});
mapLocator.addEventListener("click", (e) => {
// We get some events without the lngLat
if (e.detail !== undefined && e.detail.lngLat !== undefined) {
handleMapClick(mapLocator, e.detail.lngLat);
console.log("click reverse geocode", response);
if (response !== undefined && response.features.length > 0) {
const addressInput = document.querySelector("address-input");
addressInput.SetValue(response.features[0]);
setLocationInputs(response.features[0]);
}
}
});
mapLocator.addEventListener("markerdragend", (e) => {
const marker = event.detail.marker;
const lngLat = marker.getLngLat();
handleMarkerDrag(lngLat);
});
async function handleMarkerDrag(lngLat) {
const response = await geocodeReverse({
lat: lngLat.lat,
lng: lngLat.lng,
});
console.log("marker drag reverse geocode", response);
if (response !== undefined && response.features.length > 0) {
const addressInput = document.querySelector("address-input");
addressInput.SetValue(response.features[0]);
}
}
function setLocationInputs(location) {
let country = document.getElementById("address-country");
let latitude = document.getElementById("latitude");
let longitude = document.getElementById("longitude");
let latlngAccuracyType = document.getElementById("latlng-accuracy-type");
let latlngAccuracyValue = document.getElementById(
"latlng-accuracy-value",
);
let number = document.getElementById("address-number");
let postalcode = document.getElementById("address-postalcode");
let place = document.getElementById("address-place");
let region = document.getElementById("address-region");
let street = document.getElementById("address-street");
const addressDisplay = document.querySelector("address-display");
addressInput.addEventListener("address-selected", (event) => {
const l = event.detail.location;
console.log("Address selected", l);
// Center map on selected address
mapLocator.SetMarker(l.geometry.coordinates);
mapLocator.JumpTo({
center: l.geometry.coordinates,
zoom: 14,
// Extract context data from properties
const props = location.properties;
const context = props.context || {};
// Populate structured fields
country.value = context.iso_3166_a3;
latitude.value = location.geometry.coordinates[1];
longitude.value = location.geometry.coordinates[0];
latlngAccuracyType.value = props.precision;
latlngAccuracyValue.value = props.distance;
number.value = props.address_components.number;
postalcode.value = props.address_components.postal_code;
locality.value = context.whosonfirst.locality.name;
region.value = props.context.whosonfirst.region.abbreviation;
street.value = props.address_components.street;
}
function toggleCollapse(something) {
el = document.getElementById(something);
if (el.classList.contains("collapse")) {
el.classList.remove("collapse");
} else {
el.classList.add("collapse");
}
document
.getElementById("toggle-additional")
.classList.add("visually-hidden");
}
document.addEventListener("DOMContentLoaded", function () {
// Elements
const addressInput = document.querySelector("address-input");
const latitudeInput = document.getElementById("latitude");
const longitudeInput = document.getElementById("longitude");
const latlngAccuracyType = document.getElementById(
"latlng-accuracy-type",
);
const latlngAccuracyValue = document.getElementById(
"latlng-accuracy-value",
);
const mapLocator = document.querySelector("map-locator");
mapLocator.addEventListener("load", (event) => {
getGeolocation({
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
})
.then((position) => {
console.log("Got location", position);
latitudeInput.value = position.coords.latitude;
longitudeInput.value = position.coords.longitude;
latlngAccuracyType.value = "browser";
latlngAccuracyValue.value = position.coords.accuracy;
mapLocator.JumpTo({
center: {
lng: position.coords.longitude,
lat: position.coords.latitude,
},
zoom: 14,
});
const coords = [
position.coords.longitude,
position.coords.latitude,
];
mapLocator.SetMarker(coords);
mapLocator.JumpTo({ center: coords, zoom: 14 });
handleMarkerDrag({
lat: position.coords.latitude,
lng: position.coords.longitude,
});
})
.catch((error) => {
console.log("location error", error);
});
});
let mapZoom = document.getElementById("map-zoom");
mapLocator.addEventListener("zoomend", function (e) {
mapZoom.value = e.target.GetZoom();
});
mapLocator.addEventListener("click", (e) => {
// We get some events without the lngLat
if (e.detail !== undefined && e.detail.lngLat !== undefined) {
handleMapClick(mapLocator, e.detail.lngLat);
}
});
mapLocator.addEventListener("markerdragend", (e) => {
const marker = event.detail.marker;
const lngLat = marker.getLngLat();
handleMarkerDrag(lngLat);
});
const addressDisplay = document.querySelector("address-display");
addressInput.addEventListener("address-selected", (event) => {
const l = event.detail.location;
console.log("Address selected", l);
// Center map on selected address
mapLocator.SetMarker(l.geometry.coordinates);
mapLocator.JumpTo({
center: l.geometry.coordinates,
zoom: 14,
});
setLocationInputs(l);
});
});
setLocationInputs(l);
});
});
</script>
</script>
{{ end }}
{{ define "content" }}
{{ if (eq .District nil) }}
@ -283,7 +292,6 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="mb-3 position-relative">
<address-input
placeholder="Start typing an address (min 3 characters)"
api-key="{{ .MapboxToken }}"
>
</address-input>
</div>

View file

@ -28,7 +28,7 @@ LEFT JOIN publicreport.image_exif e ON i.id = e.image_id
WHERE i.id IN (1, 2, 3, 4)
GROUP BY i.id;
*/
// Get all the images that belong to the list of report IDs
// Get all the images that belong to the list of nuisance report IDs
func loadImagesForReportNuisance(ctx context.Context, org_id int32, report_ids []int32) (results map[int32][]types.Image, err error) {
rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select(
sm.Columns(
@ -39,7 +39,7 @@ func loadImagesForReportNuisance(ctx context.Context, org_id int32, report_ids [
"COALESCE(MAX(e.value) FILTER (WHERE e.name = 'Make'), '') AS exif_make",
"COALESCE(MAX(e.value) FILTER (WHERE e.name = 'Model'), '') AS exif_model",
"COALESCE(MAX(e.value) FILTER (WHERE e.name = 'DateTime'), '') AS exif_datetime",
"ni.nuisance_id AS nuisance_id",
"ni.nuisance_id AS report_id",
),
sm.From("publicreport.image").As("i"),
sm.LeftJoin("publicreport.image_exif").As("e").OnEQ(
@ -66,12 +66,60 @@ func loadImagesForReportNuisance(ctx context.Context, org_id int32, report_ids [
}
results = make(map[int32][]types.Image, len(report_ids))
for _, row := range rows {
r, ok := results[row.NuisanceID]
r, ok := results[row.ReportID]
if !ok {
r = make([]types.Image, 0)
}
r = append(r, row)
results[row.NuisanceID] = r
results[row.ReportID] = r
}
return results, nil
}
// Get all the images that belong to the list of water report IDs
func loadImagesForReportWater(ctx context.Context, org_id int32, report_ids []int32) (results map[int32][]types.Image, err error) {
rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select(
sm.Columns(
"i.storage_uuid AS uuid",
"COALESCE(ST_X(i.location), 0) AS \"location.longitude\"",
"COALESCE(ST_Y(i.location), 0) AS \"location.latitude\"",
"ST_Distance(i.location::geography, w.location::geography) AS \"distance_from_reporter_meters\"",
"COALESCE(MAX(e.value) FILTER (WHERE e.name = 'Make'), '') AS exif_make",
"COALESCE(MAX(e.value) FILTER (WHERE e.name = 'Model'), '') AS exif_model",
"COALESCE(MAX(e.value) FILTER (WHERE e.name = 'DateTime'), '') AS exif_datetime",
"wi.water_id AS report_id",
),
sm.From("publicreport.image").As("i"),
sm.LeftJoin("publicreport.image_exif").As("e").OnEQ(
psql.Quote("i", "id"),
psql.Quote("e", "image_id"),
),
sm.InnerJoin("publicreport.water_image").As("wi").OnEQ(
psql.Quote("wi", "image_id"),
psql.Quote("i", "id"),
),
sm.InnerJoin("publicreport.water").As("w").OnEQ(
psql.Quote("wi", "water_id"),
psql.Quote("w", "id"),
),
sm.Where(psql.Quote("wi", "water_id").EQ(psql.Any(report_ids))),
sm.GroupBy(
//psql.Quote("i", "id"),
//psql.Quote("ni", "nuisance_id"),
psql.Raw("i.id, wi.water_id, w.location"),
),
), scan.StructMapper[types.Image]())
if err != nil {
return nil, fmt.Errorf("get images: %w", err)
}
results = make(map[int32][]types.Image, len(report_ids))
for _, row := range rows {
r, ok := results[row.ReportID]
if !ok {
r = make([]types.Image, 0)
}
r = append(r, row)
results[row.ReportID] = r
}
return results, nil
}

View file

@ -7,6 +7,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/google/uuid"
//"github.com/rs/zerolog/log"
)
type Exif struct {
@ -35,13 +36,13 @@ func (e Exif) MarshalJSON() ([]byte, error) {
}
type Image struct {
DistanceToReporterMeters float64 `db:"distance_from_reporter_meters"`
DistanceToReporterMeters *float64 `db:"distance_from_reporter_meters"`
Exif Exif `db:"-" json:"exif"`
ExifMake string `db:"exif_make" json:"-"`
ExifModel string `db:"exif_model" json:"-"`
ExifDateTime string `db:"exif_datetime" json:"-"`
Location Location `db:"location"`
NuisanceID int32 `db:"nuisance_id"`
ReportID int32 `db:"report_id" json:"-"`
URLContent string `db:"-" json:"url_content"`
UUID uuid.UUID `db:"uuid"`
}
@ -55,7 +56,7 @@ func (i *Image) MarshalJSON() ([]byte, error) {
Model: i.ExifModel,
}
to_marshal["location"] = i.Location
to_marshal["nuisance_id"] = i.NuisanceID
//to_marshal["report_id"] = i.ReportID
to_marshal["url_content"] = config.MakeURLNidus("/api/image/%s/content", i.UUID.String())
to_marshal["uuid"] = i.UUID

View file

@ -202,20 +202,20 @@ func contentFromNuisance(ctx context.Context, report_id string) (result ContentS
return result, err
}
func contentFromWater(ctx context.Context, report_id string) (result ContentStatusByID, err error) {
pool, err := models.PublicreportWaters.Query(
water, err := models.PublicreportWaters.Query(
models.SelectWhere.PublicreportWaters.PublicID.EQ(report_id),
).One(ctx, db.PGInstance.BobDB)
if err != nil {
return result, fmt.Errorf("Failed to query pool %s: %w", report_id, err)
return result, fmt.Errorf("Failed to query water %s: %w", report_id, err)
}
images, err := sql.PublicreportImageWithJSONByWaterID(pool.ID).All(ctx, db.PGInstance.BobDB)
images, err := sql.PublicreportImageWithJSONByWaterID(water.ID).All(ctx, db.PGInstance.BobDB)
if err != nil {
return result, fmt.Errorf("Failed to get images %s: %w", report_id, err)
}
if !pool.OrganizationID.IsNull() {
org_id := pool.OrganizationID.MustGet()
if !water.OrganizationID.IsNull() {
org_id := water.OrganizationID.MustGet()
org, err := models.FindOrganization(ctx, db.PGInstance.BobDB, org_id)
if err != nil {
return result, fmt.Errorf("Failed to get district %d information: %w", org_id, err)
@ -224,44 +224,44 @@ func contentFromWater(ctx context.Context, report_id string) (result ContentStat
}
result.Report.ID = report_id
result.Report.Address = pool.AddressRaw
result.Report.Created = pool.Created
result.Report.Address = water.AddressRaw
result.Report.Created = water.Created
result.Report.ImageCount = len(images)
result.Report.Status = strings.Title(pool.Status.String())
result.Report.Status = strings.Title(water.Status.String())
result.Report.Type = "Mosquito Nuisance"
result.Report.Details = []DetailEntry{
DetailEntry{
Name: "Has a gate that affects access?",
Value: strconv.FormatBool(pool.AccessGate),
Value: strconv.FormatBool(water.AccessGate),
},
DetailEntry{
Name: "Has dog that affects access?",
Value: strconv.FormatBool(pool.AccessDog),
Value: strconv.FormatBool(water.AccessDog),
},
DetailEntry{
Name: "Has a fence that affects access?",
Value: strconv.FormatBool(pool.AccessFence),
Value: strconv.FormatBool(water.AccessFence),
},
DetailEntry{
Name: "Has a locked entrace that affects access?",
Value: strconv.FormatBool(pool.AccessLocked),
Value: strconv.FormatBool(water.AccessLocked),
},
DetailEntry{
Name: "Reporter observed larvae (wigglers)?",
Value: strconv.FormatBool(pool.HasLarvae),
Value: strconv.FormatBool(water.HasLarvae),
},
DetailEntry{
Name: "Reporter observed pupae (tumblers)?",
Value: strconv.FormatBool(pool.HasPupae),
Value: strconv.FormatBool(water.HasPupae),
},
DetailEntry{
Name: "Reporter observed adult mosquitoes?",
Value: strconv.FormatBool(pool.HasAdult),
Value: strconv.FormatBool(water.HasAdult),
},
}
result.Timeline = []TimelineEntry{
TimelineEntry{
At: pool.Created,
At: water.Created,
Detail: "Initial report was submitted",
Title: "Created",
},
@ -273,11 +273,11 @@ func contentFromWater(ctx context.Context, report_id string) (result ContentStat
sm.Columns(
psql.F("ST_AsGeoJSON", "location"),
),
sm.From("publicreport.pool"),
sm.From("publicreport.water"),
sm.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
), scan.SingleColumnMapper[string])
if err != nil {
return result, fmt.Errorf("Failed to query pool %s: %w", report_id, err)
return result, fmt.Errorf("Failed to query water %s: %w", report_id, err)
}
result.Report.Location = location
@ -299,7 +299,7 @@ func getStatusByID(w http.ResponseWriter, r *http.Request) {
switch location.TableName.MustGet() {
case "nuisance":
content, err = contentFromNuisance(ctx, report_id)
case "pool":
case "water":
content, err = contentFromWater(ctx, report_id)
}
if err != nil {

View file

@ -81,7 +81,7 @@ func postWater(w http.ResponseWriter, r *http.Request) {
latlng, err := parseLatLng(r)
if err != nil {
respondError(w, "Failed to parse lat lng for pool report", err, http.StatusInternalServerError)
respondError(w, "Failed to parse lat lng for water report", err, http.StatusInternalServerError)
return
}
@ -92,7 +92,7 @@ func postWater(w http.ResponseWriter, r *http.Request) {
}
public_id, err := report.GenerateReportID()
if err != nil {
respondError(w, "Failed to create pool report public ID", err, http.StatusInternalServerError)
respondError(w, "Failed to create water report public ID", err, http.StatusInternalServerError)
return
}
@ -156,7 +156,7 @@ func postWater(w http.ResponseWriter, r *http.Request) {
ReporterPhone: omit.From(""),
Status: omit.From(enums.PublicreportReportstatustypeReported),
}
pool, err := models.PublicreportWaters.Insert(&setter).One(ctx, tx)
water, err := models.PublicreportWaters.Insert(&setter).One(ctx, tx)
if err != nil {
respondError(w, "Failed to create database record", err, http.StatusInternalServerError)
return
@ -164,22 +164,22 @@ func postWater(w http.ResponseWriter, r *http.Request) {
if geospatial.Populated {
_, err = psql.Update(
um.Table("publicreport.pool"),
um.Table("publicreport.water"),
um.SetCol("h3cell").ToArg(geospatial.Cell),
um.SetCol("location").To(geospatial.GeometryQuery),
um.Where(psql.Quote("id").EQ(psql.Arg(pool.ID))),
um.Where(psql.Quote("id").EQ(psql.Arg(water.ID))),
).Exec(ctx, tx)
if err != nil {
respondError(w, "Failed to update publicreport.pool geospatial", err, http.StatusInternalServerError)
respondError(w, "Failed to update publicreport.water geospatial", err, http.StatusInternalServerError)
return
}
}
log.Info().Int32("id", pool.ID).Str("public_id", pool.PublicID).Msg("Created pool report")
log.Info().Int32("id", water.ID).Str("public_id", water.PublicID).Msg("Created water report")
setters := make([]*models.PublicreportWaterImageSetter, 0)
for _, image := range images {
setters = append(setters, &models.PublicreportWaterImageSetter{
ImageID: omit.From(int32(image.ID)),
WaterID: omit.From(int32(pool.ID)),
WaterID: omit.From(int32(water.ID)),
})
}
if len(setters) > 0 {