Make lead creation and invalidation for public reports work

The only thing wrong at this point that I can tell is that address
aren't being correctly populated when I reverse geocode.
This commit is contained in:
Eli Ribble 2026-03-14 01:14:30 +00:00
parent 3e1b56a266
commit e2af49a323
No known key found for this signature in database
27 changed files with 821 additions and 365 deletions

View file

@ -18,6 +18,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
"github.com/Gleipnir-Technology/nidus-sync/platform/geom"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
@ -214,7 +215,10 @@ func insertFlyover(ctx context.Context, txn bob.Tx, file *models.FileuploadFile,
if err != nil {
return nil, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", lng, lat)
}
geom_query := geom.PostgisPointQuery(lng, lat)
geom_query := geom.PostgisPointQuery(types.Location{
Latitude: lat,
Longitude: lng,
})
_, err = psql.Update(
um.TableAs("fileupload.pool", "pool"),
um.SetCol("h3cell").ToArg(cell),

View file

@ -139,11 +139,11 @@ func geocodePool(ctx context.Context, txn bob.Tx, client *stadia.StadiaMaps, job
PostalCode: pool.AddressPostalCode,
Street: pool.AddressStreet,
}
address, err := geocode.Geocode(ctx, job.org, a)
address, err := geocode.GeocodeStructured(ctx, job.org, a)
if err != nil {
addError(ctx, txn, job.csv, job.rownumber, 0, err.Error())
}
geom_query := geom.PostgisPointQuery(address.Longitude, address.Latitude)
geom_query := geom.PostgisPointQuery(address.Location)
_, err = psql.Update(
um.Table("fileupload.pool"),
um.SetCol("h3cell").ToArg(address.Cell),

View file

@ -20,10 +20,9 @@ import (
)
type GeocodeResult struct {
Address types.Address
Cell h3.Cell
Longitude float64
Latitude float64
Address types.Address
Cell h3.Cell
Location types.Location
}
var client *stadia.StadiaMaps
@ -105,7 +104,7 @@ func EnsureAddressWithGeocode(ctx context.Context, txn bob.Tx, org *models.Organ
return address, nil
}
// Geocode
geo, err := Geocode(ctx, org, a)
geo, err := GeocodeStructured(ctx, org, a)
if err != nil {
return nil, fmt.Errorf("geocode: %w", err)
}
@ -122,7 +121,7 @@ func EnsureAddressWithGeocode(ctx context.Context, txn bob.Tx, org *models.Organ
psql.Arg(geo.Cell),
psql.Raw("DEFAULT"),
psql.Arg(geo.Address.Locality),
psql.F("ST_Point", geo.Longitude, geo.Latitude, 4326),
psql.F("ST_Point", geo.Location.Longitude, geo.Location.Latitude, 4326),
psql.Arg(geo.Address.Number),
psql.Arg(geo.Address.PostalCode),
psql.Arg(geo.Address.Region),
@ -149,51 +148,66 @@ func EnsureAddressWithGeocode(ctx context.Context, txn bob.Tx, org *models.Organ
Number: geo.Address.Number,
}, nil
}
func Geocode(ctx context.Context, org *models.Organization, a types.Address) (GeocodeResult, error) {
func GeocodeRaw(ctx context.Context, org *models.Organization, address string) (*GeocodeResult, error) {
req := stadia.RequestGeocodeRaw{
Text: address,
}
maybeAddServiceArea(&req, org)
resp, err := client.GeocodeRaw(ctx, req)
if err != nil {
return nil, fmt.Errorf("client raw geocode failure on %s: %w", address, err)
}
return toGeocodeResult(*resp, address)
}
func GeocodeStructured(ctx context.Context, org *models.Organization, a types.Address) (*GeocodeResult, error) {
street := fmt.Sprintf("%s %s", a.Number, a.Street)
country_s := a.Country
/*
sublog := log.With().
Str("street", street).
Str("country", country).
Str("locality", a.Locality).
Str("postal", a.PostalCode).
Str("region", a.Region).
Logger()
*/
req := stadia.StructuredGeocodeRequest{
Address: &street,
Country: &country_s,
req := stadia.RequestGeocodeStructured{
Address: &street,
//Country: &a.Country,
Locality: &a.Locality,
PostalCode: &a.PostalCode,
Region: &a.Region,
}
maybeAddServiceArea(&req, org)
resp, err := client.StructuredGeocode(ctx, req)
resp, err := client.GeocodeStructured(ctx, req)
if err != nil {
return GeocodeResult{}, fmt.Errorf("client structured geocode failure on %s: %w", a.String(), err)
return nil, fmt.Errorf("client structured geocode failure on %s: %w", a.String(), err)
}
return toGeocodeResult(*resp, a.String())
}
func ReverseGeocode(ctx context.Context, location types.Location) (*GeocodeResult, error) {
req := stadia.RequestReverseGeocode{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
resp, err := client.ReverseGeocode(ctx, req)
if err != nil {
return nil, fmt.Errorf("client reverse geocode failure on %s: %w", location.String(), err)
}
return toGeocodeResult(*resp, location.String())
}
func toGeocodeResult(resp stadia.GeocodeResponse, address string) (*GeocodeResult, error) {
if len(resp.Features) < 1 {
return GeocodeResult{}, fmt.Errorf("%s matched no locations", a.String())
return nil, fmt.Errorf("%s matched no locations", address)
}
feature := resp.Features[0]
if len(resp.Features) > 1 {
if !allFeaturesIdenticalEnough(resp.Features) {
return GeocodeResult{}, fmt.Errorf("%s matched more than one location, and they differ a lot", a.String())
return nil, fmt.Errorf("%s matched more than one location, and they differ a lot", address)
}
}
if feature.Geometry.Type != "Point" {
return GeocodeResult{}, fmt.Errorf("wrong type %s from %s", feature.Geometry.Type, a.String())
return nil, fmt.Errorf("wrong type %s from %s", feature.Geometry.Type, address)
}
longitude := feature.Geometry.Coordinates[0]
latitude := feature.Geometry.Coordinates[1]
cell, err := h3utils.GetCell(longitude, latitude, 15)
if err != nil {
return GeocodeResult{}, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", longitude, latitude)
return nil, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", longitude, latitude)
}
country_s = strings.ToLower(feature.Properties.CountryA)
return GeocodeResult{
country_s := strings.ToLower(feature.Properties.CountryA)
return &GeocodeResult{
Address: types.Address{
Country: country_s,
Locality: feature.Properties.Locality,
@ -203,9 +217,11 @@ func Geocode(ctx context.Context, org *models.Organization, a types.Address) (Ge
Street: feature.Properties.Street,
Unit: "",
},
Cell: cell,
Longitude: feature.Geometry.Coordinates[0],
Latitude: feature.Geometry.Coordinates[1],
Cell: cell,
Location: types.Location{
Longitude: feature.Geometry.Coordinates[0],
Latitude: feature.Geometry.Coordinates[1],
},
}, nil
}
@ -239,7 +255,7 @@ func allFeaturesIdenticalEnough(features []stadia.GeocodeFeature) bool {
}
return true
}
func maybeAddServiceArea(req *stadia.StructuredGeocodeRequest, org *models.Organization) {
func maybeAddServiceArea(req stadia.RequestGeocode, org *models.Organization) {
if org.ServiceAreaXmax.IsNull() ||
org.ServiceAreaYmax.IsNull() ||
org.ServiceAreaXmin.IsNull() ||
@ -250,10 +266,7 @@ func maybeAddServiceArea(req *stadia.StructuredGeocodeRequest, org *models.Organ
ymax := org.ServiceAreaYmax.MustGet()
xmin := org.ServiceAreaXmin.MustGet()
ymin := org.ServiceAreaYmin.MustGet()
req.BoundaryRectMaxLon = &xmax
req.BoundaryRectMaxLat = &ymax
req.BoundaryRectMinLon = &xmin
req.BoundaryRectMinLat = &ymin
req.SetBoundaryRect(xmin, ymin, xmax, ymax)
if org.ServiceAreaCentroidX.IsNull() || org.ServiceAreaCentroidY.IsNull() {
return
@ -261,6 +274,5 @@ func maybeAddServiceArea(req *stadia.StructuredGeocodeRequest, org *models.Organ
centroid_x := org.ServiceAreaCentroidX.MustGet()
centroid_y := org.ServiceAreaCentroidY.MustGet()
req.FocusPointLat = &centroid_y
req.FocusPointLng = &centroid_x
req.SetFocusPoint(centroid_x, centroid_y)
}

View file

@ -2,8 +2,10 @@ package geom
import (
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
)
func PostgisPointQuery(longitude, latitude float64) string {
return fmt.Sprintf("ST_SetSRID(ST_MakePoint(%f, %f), 4326)", longitude, latitude)
func PostgisPointQuery(location types.Location) string {
return fmt.Sprintf("ST_SetSRID(ST_MakePoint(%f, %f), 4326)", location.Longitude, location.Latitude)
}

184
platform/lead.go Normal file
View file

@ -0,0 +1,184 @@
package platform
import (
"context"
"errors"
"fmt"
"time"
"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/bob/dialect/psql"
"github.com/Gleipnir-Technology/bob/dialect/psql/um"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
"github.com/Gleipnir-Technology/nidus-sync/platform/geom"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
)
// Create a lead from the given signal and site
func LeadCreate(ctx context.Context, user User, signal_id int32, site_id int32, pool_location *Location) (*int32, error) {
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
defer txn.Rollback(ctx)
if err != nil {
return nil, fmt.Errorf("start transaction: %w", err)
}
lead, err := models.Leads.Insert(&models.LeadSetter{
Created: omit.From(time.Now()),
Creator: omit.From(int32(user.ID)),
// ID
OrganizationID: omit.From(int32(user.Organization.ID())),
SiteID: omitnull.From(site_id),
Type: omit.From(enums.LeadtypeGreenPool),
}).One(ctx, txn)
if err != nil {
return nil, fmt.Errorf("failed to create lead: %w", err)
}
_, err = psql.Update(
um.Table("signal"),
um.SetCol("addressed").ToArg(time.Now()),
um.SetCol("addressor").ToArg(user.ID),
um.Where(psql.Quote("id").EQ(psql.Arg(signal_id))),
).Exec(ctx, txn)
if err != nil {
return nil, fmt.Errorf("failed to update signal %d: %w", signal_id, err)
}
if pool_location != nil {
log.Info().Float64("lat", pool_location.Latitude).Float64("lng", pool_location.Longitude).Msg("got pool location")
geom_query := geom.PostgisPointQuery(*pool_location)
_, err = psql.Update(
um.Table("pool"),
um.SetCol("geometry").To(geom_query),
um.From("signal_pool"),
um.Where(psql.Quote("signal_pool", "pool_id").EQ(psql.Quote("pool", "id"))),
um.Where(psql.Quote("signal_pool", "signal_id").EQ(psql.Arg(signal_id))),
).Exec(ctx, txn)
if err != nil {
return nil, fmt.Errorf("failed to update pool through signal %d: %w", signal_id, err)
}
}
txn.Commit(ctx)
return &lead.ID, nil
}
// Create a lead from the given signal and site
func LeadCreateFromPublicreport(ctx context.Context, user User, report_id string) (*int32, error) {
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
defer txn.Rollback(ctx)
if err != nil {
return nil, fmt.Errorf("start transaction: %w", err)
}
location, err := models.PublicreportReportLocations.Query(
models.SelectWhere.PublicreportReportLocations.PublicID.EQ(report_id),
models.SelectWhere.PublicreportReportLocations.OrganizationID.EQ(user.Organization.ID()),
).One(ctx, txn)
if err != nil {
return nil, fmt.Errorf("query report existence: %w", err)
}
// At this point we have a report. We need to decide where to put it based on either the address or
// the location.
var site_id int32
if location.AddressID.IsValue() {
site, err := siteFromAddress(ctx, txn, user, location.AddressID.MustGet())
if err != nil {
return nil, fmt.Errorf("site from address: %w", err)
}
site_id = site.ID
} else if location.LocationLatitude.IsValue() && location.LocationLongitude.IsValue() {
site, err := siteFromLocation(ctx, txn, user, Location{
Latitude: location.LocationLatitude.MustGet(),
Longitude: location.LocationLongitude.MustGet(),
})
if err != nil {
return nil, fmt.Errorf("site from address: %w", err)
}
site_id = site.ID
} else if location.AddressRaw.GetOr("") != "" {
// At this point we don't have an address, and we don't have GPS
// We'll try geocoding and creating an address from that.
site, err := siteFromAddressRaw(ctx, txn, user, location.AddressRaw.MustGet())
if err != nil {
return nil, fmt.Errorf("site from address: %w", err)
}
site_id = site.ID
} else {
// We have no structured address, no GPS, no unstructued address.
// There's really nothing we can make this lead from and have it be meaningful
return nil, errors.New("Refusing to create a lead with no location data.")
}
lead_type := enums.LeadtypeUnknown
tablename := location.TableName.MustGet()
switch tablename {
case "nuisance":
lead_type = enums.LeadtypePublicreportNuisance
case "water":
lead_type = enums.LeadtypePublicreportWater
}
lead, err := models.Leads.Insert(&models.LeadSetter{
Created: omit.From(time.Now()),
Creator: omit.From(int32(user.ID)),
// ID
OrganizationID: omit.From(int32(user.Organization.ID())),
SiteID: omitnull.From(site_id),
Type: omit.From(lead_type),
}).One(ctx, txn)
_, err = psql.Update(
um.Table("publicreport."+tablename),
um.SetCol("reviewed").ToArg(time.Now()),
um.SetCol("reviewer_id").ToArg(user.ID),
um.SetCol("status").ToArg(enums.PublicreportReportstatustypeReviewed),
um.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
).Exec(ctx, txn)
if err != nil {
return nil, fmt.Errorf("failed to update report %d: %w", report_id, err)
}
txn.Commit(ctx)
return &lead.ID, nil
}
func siteFromAddress(ctx context.Context, txn bob.Tx, user User, address_id int32) (*models.Site, error) {
site, err := models.Sites.Query(
models.SelectWhere.Sites.AddressID.EQ(address_id),
models.SelectWhere.Sites.OrganizationID.EQ(user.Organization.ID()),
).One(ctx, txn)
if err == nil {
return site, nil
}
if err.Error() != "sql: no rows in result set" {
return nil, fmt.Errorf("query site: %w", err)
}
return SiteCreate(ctx, txn, user, address_id)
}
func siteFromAddressRaw(ctx context.Context, txn bob.Tx, user User, address string) (*models.Site, error) {
// Geocode
geo, err := geocode.GeocodeRaw(ctx, user.Organization.model, address)
if err != nil {
return nil, fmt.Errorf("geocode: %w", err)
}
a, err := geocode.EnsureAddress(ctx, txn, geo.Address, geo.Location)
if err != nil {
return nil, fmt.Errorf("ensure address: %w", err)
}
return siteFromAddress(ctx, txn, user, a.ID)
}
func siteFromLocation(ctx context.Context, txn bob.Tx, user User, location Location) (*models.Site, error) {
// Reverse geocode at the location
resp, err := geocode.ReverseGeocode(ctx, location)
if err != nil {
return nil, fmt.Errorf("reverse geocode: %w", err)
}
// Ensure we have an address at that newly created location
a, err := geocode.EnsureAddress(ctx, txn, resp.Address, resp.Location)
if err != nil {
return nil, fmt.Errorf("ensure address: %w", err)
}
return siteFromAddress(ctx, txn, user, a.ID)
}

40
platform/publicreport.go Normal file
View file

@ -0,0 +1,40 @@
package platform
import (
"context"
"fmt"
"time"
//"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/bob/dialect/psql"
"github.com/Gleipnir-Technology/bob/dialect/psql/um"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/rs/zerolog/log"
)
func PublicreportInvalid(ctx context.Context, user User, report_id string) error {
location, err := models.PublicreportReportLocations.Query(
models.SelectWhere.PublicreportReportLocations.PublicID.EQ(report_id),
models.SelectWhere.PublicreportReportLocations.OrganizationID.EQ(user.Organization.ID()),
).One(ctx, db.PGInstance.BobDB)
if err != nil {
return fmt.Errorf("query report existence: %w", err)
}
tablename := location.TableName.MustGet()
_, err = psql.Update(
um.Table("publicreport."+tablename),
um.SetCol("reviewed").ToArg(time.Now()),
um.SetCol("reviewer_id").ToArg(user.ID),
um.SetCol("status").ToArg(enums.PublicreportReportstatustypeInvalidated),
um.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
).Exec(ctx, db.PGInstance.BobDB)
if err != nil {
return fmt.Errorf("update report %s.%s: %w", tablename, report_id, err)
}
log.Info().Str("report-id", report_id).Str("tablename", tablename).Msg("Marked as invalid")
return nil
}

View file

@ -79,6 +79,7 @@ func NuisanceReportForOrganization(ctx context.Context, org_id int32) ([]Nuisanc
),
sm.From("publicreport.nuisance"),
sm.Where(psql.Quote("publicreport", "nuisance", "organization_id").EQ(psql.Arg(org_id))),
sm.Where(psql.Quote("publicreport", "nuisance", "reviewed").IsNull()),
), scan.StructMapper[Nuisance]())
if err != nil {
return nil, fmt.Errorf("get reports: %w", err)

View file

@ -83,6 +83,7 @@ func WaterReportForOrganization(ctx context.Context, org_id int32) ([]Water, err
),
sm.From("publicreport.water"),
sm.Where(psql.Quote("publicreport", "water", "organization_id").EQ(psql.Arg(org_id))),
sm.Where(psql.Quote("publicreport", "water", "reviewed").IsNull()),
), scan.StructMapper[Water]())
if err != nil {
return nil, fmt.Errorf("get reports: %w", err)

64
platform/site.go Normal file
View file

@ -0,0 +1,64 @@
package platform
import (
"context"
"fmt"
"net/http"
"time"
"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/bob/dialect/psql"
"github.com/Gleipnir-Technology/bob/dialect/psql/sm"
"github.com/Gleipnir-Technology/bob/types/pgtypes"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/stephenafamo/scan"
)
func SiteFromSignal(ctx context.Context, user User, signal_id int32) (*int32, error) {
type _Row struct {
ID int32 `db:"site_id"`
}
site, err := bob.One(ctx, db.PGInstance.BobDB, psql.Select(
sm.Columns(
"pool.site_id AS site_id",
),
sm.From("signal_pool"),
sm.InnerJoin("pool").OnEQ(
psql.Quote("signal_pool", "pool_id"),
psql.Quote("pool", "id"),
),
sm.InnerJoin("site").On(
psql.Quote("pool", "site_id").EQ(psql.Quote("site", "id")),
),
sm.Where(psql.Quote("signal_pool", "signal_id").EQ(psql.Arg(signal_id))),
sm.Where(psql.Quote("site", "organization_id").EQ(psql.Arg(user.Organization.ID()))),
), scan.StructMapper[_Row]())
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, nhttp.NewErrorStatus(http.StatusBadRequest, "Can't make a lead from signal %d: %w", signal_id, err)
}
return nil, fmt.Errorf("failed getting site: %w", err)
}
return &site.ID, nil
}
func SiteCreate(ctx context.Context, txn bob.Tx, user User, address_id int32) (*models.Site, error) {
return models.Sites.Insert(&models.SiteSetter{
AddressID: omit.From(address_id),
Created: omit.From(time.Now()),
CreatorID: omit.From(int32(user.ID)),
FileID: omitnull.FromPtr[int32](nil),
//ID:
Notes: omit.From(""),
OrganizationID: omit.From(user.Organization.ID()),
OwnerName: omit.From(""),
OwnerPhoneE164: omitnull.FromPtr[string](nil),
ParcelID: omitnull.FromPtr[int32](nil),
ResidentOwned: omitnull.FromPtr[bool](nil),
Tags: omit.From(pgtypes.HStore{}),
Version: omit.From(int32(1)),
}).One(ctx, txn)
}

View file

@ -4,8 +4,11 @@ import (
"time"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
)
type Location = types.Location
type ClientSync struct {
Fieldseeker FieldseekerRecordsSync
Since time.Time

View file

@ -1,6 +1,14 @@
package types
import (
"fmt"
)
type Location struct {
Latitude float64 `db:"latitude" json:"latitude"`
Longitude float64 `db:"longitude" json:"longitude"`
}
func (l Location) String() string {
return fmt.Sprintf("%f %f", l.Longitude, l.Latitude)
}