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

@ -3,26 +3,13 @@ package api
import (
"context"
"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/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"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/geom"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
"github.com/stephenafamo/scan"
)
type createLead struct {
PoolLocations map[int]Location `json:"pool_locations"`
PoolLocations map[int]platform.Location `json:"pool_locations"`
SignalIDs []int `json:"signal_ids"`
}
type createdLead struct {
@ -48,75 +35,21 @@ func postLeads(ctx context.Context, r *http.Request, user platform.User, req cre
return nil, nhttp.NewErrorStatus(http.StatusBadRequest, "can't make a lead with multiple signals yet")
}
signal_id := req.SignalIDs[0]
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
defer txn.Rollback(ctx)
if err != nil {
return nil, nhttp.NewError("start transaction: %w", err)
}
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, nhttp.NewError("failed getting site: %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, nhttp.NewError("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, nhttp.NewError("failed to update signal %d: %w", signal_id, err)
}
pool_location, ok := req.PoolLocations[signal_id]
var pool_location *platform.Location
l, ok := req.PoolLocations[signal_id]
if ok {
log.Info().Float64("lat", pool_location.Latitude).Float64("lng", pool_location.Longitude).Msg("got pool location")
geom_query := geom.PostgisPointQuery(pool_location.Longitude, pool_location.Latitude)
_, 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, nhttp.NewError("failed to update pool through signal %d: %w", signal_id, err)
pool_location = &l
}
site_id, err := platform.SiteFromSignal(ctx, user, int32(signal_id))
if err != nil || site_id == nil {
return nil, nhttp.NewError("site from signal: %w", err)
}
lead_id, err := platform.LeadCreate(ctx, user, int32(signal_id), *site_id, pool_location)
if err != nil || lead_id == nil {
return nil, nhttp.NewError("lead create: %w", err)
}
txn.Commit(ctx)
return &createdLead{
ID: lead.ID,
ID: *lead_id,
}, nil
}

41
api/publicreport.go Normal file
View file

@ -0,0 +1,41 @@
package api
import (
"context"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/config"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
)
type formPublicreportLead struct {
ReportID string `json:"reportID"`
}
func postPublicreportLead(ctx context.Context, r *http.Request, user platform.User, req formPublicreportLead) (*createdLead, *nhttp.ErrorWithStatus) {
lead_id, err := platform.LeadCreateFromPublicreport(ctx, user, req.ReportID)
if err != nil {
return nil, nhttp.NewError("create lead: %w", err)
}
return &createdLead{
ID: *lead_id,
}, nil
}
type formPublicreportInvalid struct {
ReportID string `json:"reportID"`
}
type createdReport struct {
URI string `json:"uri"`
}
func postPublicreportInvalid(ctx context.Context, r *http.Request, user platform.User, req formPublicreportLead) (*createdReport, *nhttp.ErrorWithStatus) {
err := platform.PublicreportInvalid(ctx, user, req.ReportID)
if err != nil {
return nil, nhttp.NewError("create lead: %w", err)
}
return &createdReport{
URI: config.MakeURLNidus("/publicreport/%s", req.ReportID),
}, nil
}

View file

@ -22,7 +22,7 @@ type reviewTaskPool struct {
Created time.Time `json:"created"`
Creator platform.User `json:"creator"`
ID int32 `json:"id"`
Location Location `json:"location"`
Location types.Location `json:"location"`
Reviewed *time.Time `json:"addressed"`
Reviewer *platform.User `json:"addressor"`
}
@ -122,7 +122,7 @@ func listReviewTaskPool(ctx context.Context, r *http.Request, user platform.User
Created: row.Created,
Creator: *users_by_id[row.CreatorID],
ID: row.ID,
Location: Location{
Location: types.Location{
Latitude: row.Latitude,
Longitude: row.Longitude,
},

View file

@ -21,6 +21,8 @@ func AddRoutes(r chi.Router) {
r.Method("GET", "/leads", authenticatedHandlerJSON(listLead))
r.Method("POST", "/leads", authenticatedHandlerJSONPost(postLeads))
r.Method("GET", "/mosquito-source", auth.NewEnsureAuth(apiMosquitoSource))
r.Method("POST", "/publicreport/invalid", authenticatedHandlerJSONPost(postPublicreportInvalid))
r.Method("POST", "/publicreport/lead", authenticatedHandlerJSONPost(postPublicreportLead))
r.Method("POST", "/review/pool", authenticatedHandlerJSONPost(postReviewPool))
r.Method("GET", "/review-task/pool", authenticatedHandlerJSON(listReviewTaskPool))
r.Method("GET", "/service-request", auth.NewEnsureAuth(apiServiceRequest))

View file

@ -23,7 +23,7 @@ type signal struct {
Created time.Time `json:"created"`
Creator platform.User `json:"creator"`
ID int32 `json:"id"`
Location Location `json:"location"`
Location types.Location `json:"location"`
Species string `json:"species"`
Title string `json:"title"`
Type string `json:"type"`
@ -42,7 +42,7 @@ func listSignal(ctx context.Context, r *http.Request, user platform.User, query
ID int32 `db:"id"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
Location Location `db:"location"`
Location types.Location `db:"location"`
Species *string `db:"species"`
Title string `db:"title"`
Type string `db:"type"`
@ -118,7 +118,7 @@ func listSignal(ctx context.Context, r *http.Request, user platform.User, query
Created: row.Created,
Creator: *users_by_id[row.Creator],
ID: row.ID,
Location: Location{
Location: types.Location{
Latitude: row.Latitude,
Longitude: row.Longitude,
},

View file

@ -34,11 +34,6 @@ func NewBounds() Bounds {
}
}
type Location struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
type NoteImagePayload struct {
UUID string `json:"uuid"`
Cell H3Cell `json:"cell"`

View file

@ -31,6 +31,15 @@ var PublicreportReportLocations = Table[
Generated: false,
AutoIncr: false,
},
AddressID: column{
Name: "address_id",
DBType: "integer",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
AddressRaw: column{
Name: "address_raw",
DBType: "text",
@ -58,6 +67,33 @@ var PublicreportReportLocations = Table[
Generated: false,
AutoIncr: false,
},
LocationLatitude: column{
Name: "location_latitude",
DBType: "double precision",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
LocationLongitude: column{
Name: "location_longitude",
DBType: "double precision",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
OrganizationID: column{
Name: "organization_id",
DBType: "integer",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
PublicID: column{
Name: "public_id",
DBType: "text",
@ -84,16 +120,20 @@ var PublicreportReportLocations = Table[
type publicreportReportLocationColumns struct {
ID column
TableName column
AddressID column
AddressRaw column
Created column
Location column
LocationLatitude column
LocationLongitude column
OrganizationID column
PublicID column
Status column
}
func (c publicreportReportLocationColumns) AsSlice() []column {
return []column{
c.ID, c.TableName, c.AddressRaw, c.Created, c.Location, c.PublicID, c.Status,
c.ID, c.TableName, c.AddressID, c.AddressRaw, c.Created, c.Location, c.LocationLatitude, c.LocationLongitude, c.OrganizationID, c.PublicID, c.Status,
}
}

View file

@ -1294,12 +1294,18 @@ func (e *Imagedatatype) Scan(value any) error {
// Enum values for Leadtype
const (
LeadtypeUnknown Leadtype = "unknown"
LeadtypeGreenPool Leadtype = "green-pool"
LeadtypePublicreportNuisance Leadtype = "publicreport-nuisance"
LeadtypePublicreportWater Leadtype = "publicreport-water"
)
func AllLeadtype() []Leadtype {
return []Leadtype{
LeadtypeUnknown,
LeadtypeGreenPool,
LeadtypePublicreportNuisance,
LeadtypePublicreportWater,
}
}
@ -1311,7 +1317,10 @@ func (e Leadtype) String() string {
func (e Leadtype) Valid() bool {
switch e {
case LeadtypeGreenPool:
case LeadtypeUnknown,
LeadtypeGreenPool,
LeadtypePublicreportNuisance,
LeadtypePublicreportWater:
return true
default:
return false

View file

@ -0,0 +1,72 @@
-- +goose Up
DROP VIEW publicreport.report_location;
CREATE VIEW publicreport.report_location AS
SELECT
ROW_NUMBER() OVER (ORDER BY table_name, public_id) AS id,
table_name,
address_id,
address_raw,
created,
location,
location_latitude,
location_longitude,
organization_id,
public_id,
status
FROM (
SELECT
'nuisance' AS table_name,
address_id,
address_raw,
created,
location,
ST_X(location) AS location_longitude,
ST_Y(location) AS location_latitude,
organization_id,
public_id,
status
FROM publicreport.nuisance
UNION
SELECT
'water' AS table_name,
address_id,
address_raw,
created,
location,
ST_X(location) AS location_longitude,
ST_Y(location) AS location_latitude,
organization_id,
public_id,
status
FROM publicreport.water
) AS combined_data;
-- +goose Down
DROP VIEW publicreport.report_location;
CREATE VIEW publicreport.report_location AS
SELECT
ROW_NUMBER() OVER (ORDER BY table_name, public_id) AS id,
table_name,
address_raw,
created,
location,
public_id,
status
FROM (
SELECT
'nuisance' AS table_name,
address_raw,
created,
location,
public_id,
status
FROM publicreport.nuisance
UNION
SELECT
'water' AS table_name,
address_raw,
created,
location,
public_id,
status
FROM publicreport.water
) AS combined_data;

View file

@ -0,0 +1,4 @@
-- +goose Up
ALTER TYPE LeadType ADD VALUE 'unknown' BEFORE 'green-pool';
ALTER TYPE LeadType ADD VALUE 'publicreport-nuisance' AFTER 'green-pool';
ALTER TYPE LeadType ADD VALUE 'publicreport-water' AFTER 'publicreport-nuisance';

View file

@ -18,9 +18,13 @@ import (
type PublicreportReportLocation struct {
ID null.Val[int64] `db:"id" `
TableName null.Val[string] `db:"table_name" `
AddressID null.Val[int32] `db:"address_id" `
AddressRaw null.Val[string] `db:"address_raw" `
Created null.Val[time.Time] `db:"created" `
Location null.Val[string] `db:"location" `
LocationLatitude null.Val[float64] `db:"location_latitude" `
LocationLongitude null.Val[float64] `db:"location_longitude" `
OrganizationID null.Val[int32] `db:"organization_id" `
PublicID null.Val[string] `db:"public_id" `
Status null.Val[enums.PublicreportReportstatustype] `db:"status" `
}
@ -38,14 +42,18 @@ type PublicreportReportLocationsQuery = *psql.ViewQuery[*PublicreportReportLocat
func buildPublicreportReportLocationColumns(alias string) publicreportReportLocationColumns {
return publicreportReportLocationColumns{
ColumnsExpr: expr.NewColumnsExpr(
"id", "table_name", "address_raw", "created", "location", "public_id", "status",
"id", "table_name", "address_id", "address_raw", "created", "location", "location_latitude", "location_longitude", "organization_id", "public_id", "status",
).WithParent("publicreport.report_location"),
tableAlias: alias,
ID: psql.Quote(alias, "id"),
TableName: psql.Quote(alias, "table_name"),
AddressID: psql.Quote(alias, "address_id"),
AddressRaw: psql.Quote(alias, "address_raw"),
Created: psql.Quote(alias, "created"),
Location: psql.Quote(alias, "location"),
LocationLatitude: psql.Quote(alias, "location_latitude"),
LocationLongitude: psql.Quote(alias, "location_longitude"),
OrganizationID: psql.Quote(alias, "organization_id"),
PublicID: psql.Quote(alias, "public_id"),
Status: psql.Quote(alias, "status"),
}
@ -56,9 +64,13 @@ type publicreportReportLocationColumns struct {
tableAlias string
ID psql.Expression
TableName psql.Expression
AddressID psql.Expression
AddressRaw psql.Expression
Created psql.Expression
Location psql.Expression
LocationLatitude psql.Expression
LocationLongitude psql.Expression
OrganizationID psql.Expression
PublicID psql.Expression
Status psql.Expression
}
@ -98,9 +110,13 @@ func (o PublicreportReportLocationSlice) AfterQueryHook(ctx context.Context, exe
type publicreportReportLocationWhere[Q psql.Filterable] struct {
ID psql.WhereNullMod[Q, int64]
TableName psql.WhereNullMod[Q, string]
AddressID psql.WhereNullMod[Q, int32]
AddressRaw psql.WhereNullMod[Q, string]
Created psql.WhereNullMod[Q, time.Time]
Location psql.WhereNullMod[Q, string]
LocationLatitude psql.WhereNullMod[Q, float64]
LocationLongitude psql.WhereNullMod[Q, float64]
OrganizationID psql.WhereNullMod[Q, int32]
PublicID psql.WhereNullMod[Q, string]
Status psql.WhereNullMod[Q, enums.PublicreportReportstatustype]
}
@ -113,9 +129,13 @@ func buildPublicreportReportLocationWhere[Q psql.Filterable](cols publicreportRe
return publicreportReportLocationWhere[Q]{
ID: psql.WhereNull[Q, int64](cols.ID),
TableName: psql.WhereNull[Q, string](cols.TableName),
AddressID: psql.WhereNull[Q, int32](cols.AddressID),
AddressRaw: psql.WhereNull[Q, string](cols.AddressRaw),
Created: psql.WhereNull[Q, time.Time](cols.Created),
Location: psql.WhereNull[Q, string](cols.Location),
LocationLatitude: psql.WhereNull[Q, float64](cols.LocationLatitude),
LocationLongitude: psql.WhereNull[Q, float64](cols.LocationLongitude),
OrganizationID: psql.WhereNull[Q, int32](cols.OrganizationID),
PublicID: psql.WhereNull[Q, string](cols.PublicID),
Status: psql.WhereNull[Q, enums.PublicreportReportstatustype](cols.Status),
}

View file

@ -45,10 +45,7 @@
typeFilter: "all",
messageText: "",
showPhotoModal: false,
showInvalidModal: false,
currentPhotoIndex: 0,
invalidReason: "",
invalidNotes: "",
showToast: false,
toastTitle: "",
toastMessage: "",
@ -82,7 +79,7 @@
});
},
async loadCommunications() {
async fetchCommunications() {
try {
// Build query parameters from filters
const params = new URLSearchParams();
@ -108,7 +105,7 @@
this.loading = true;
this.error = null;
try {
await Promise.all([this.loadCommunications()]);
await Promise.all([this.fetchCommunications()]);
} catch (err) {
this.error = err.message;
console.error("Error loading data:", err);
@ -143,54 +140,66 @@
}
},
createLead() {
// TODO: Implement API call to create lead
console.log(
"Creating lead for report:",
this.selectedCommunication.id,
);
// Add to activity log
if (!this.selectedCommunication.history) {
this.selectedCommunication.history = [];
}
this.selectedCommunication.history.push({
action: "Lead created",
timestamp: new Date(),
async createLead() {
try {
const payload = {
reportID: this.selectedCommunication.id,
};
const response = await fetch(`api/publicreport/lead`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
this.showNotification(
"Lead Created",
`Lead successfully created for report #${this.selectedCommunication.id}`,
);
if (!response.ok) {
throw new Error("Failed to submit lead");
}
// Remove from list after creating lead
// this.communications = this.communications.filter(r => r.id !== this.selectedCommunication.id);
// this.selectedCommunication = null;
this.removeCurrentFromList();
this.fetchCommunications();
} catch (err) {
this.error = err.message;
console.error("Error creating lead:", err);
}
},
markInvalid() {
// TODO: Implement API call to mark as invalid
async markInvalid() {
console.log(
"Marking report as invalid:",
this.selectedCommunication.id,
this.invalidReason,
this.invalidNotes,
);
const payload = {
reportID: this.selectedCommunication.id,
};
const response = await fetch(`api/publicreport/invalid`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
this.showNotification(
"Report Marked Invalid",
`Report #${this.selectedCommunication.id} has been marked as ${this.invalidReason}`,
`Report #${this.selectedCommunication.id} has been marked as invalid`,
);
// Remove from list
this.communications = this.communications.filter(
(r) => r.id !== this.selectedCommunication.id,
this.removeCurrentFromList();
this.fetchCommunications();
},
removeCurrentFromList() {
const index = this.communications.findIndex(
(c) => c.id === this.selectedCommunication.id,
);
if (index > -1) {
this.communications = this.communications.splice(index, 1);
}
if (this.communications.length > 0) {
const nextIndex = Math.min(index, this.communications.length - 1);
this.selectedCommunication = this.communications[nextIndex];
} else {
this.selectedCommunication = null;
this.showInvalidModal = false;
this.invalidReason = "";
this.invalidNotes = "";
}
},
sendMessage() {
@ -744,10 +753,7 @@
<!-- Mark Invalid -->
<div class="d-grid mb-3">
<button
class="btn btn-outline-danger"
@click="showInvalidModal = true"
>
<button class="btn btn-outline-danger" @click="markInvalid()">
<i class="bi bi-x-circle me-2"></i>Mark Invalid
</button>
<small class="text-muted mt-1">
@ -921,122 +927,6 @@
@click="showPhotoModal = false"
></div>
<!-- Invalid Report Modal -->
<div
class="modal fade"
:class="{ 'show d-block': showInvalidModal }"
tabindex="-1"
x-show="showInvalidModal"
@click.self="showInvalidModal = false"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-x-circle text-danger"></i> Mark as Invalid
</h5>
<button
type="button"
class="btn-close"
@click="showInvalidModal = false"
></button>
</div>
<div class="modal-body">
<p>Please select a reason for marking this report as invalid:</p>
<div class="form-check mb-2">
<input
class="form-check-input"
type="radio"
name="invalidReason"
id="reason1"
value="spam"
x-model="invalidReason"
/>
<label class="form-check-label" for="reason1">Spam or junk</label>
</div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="radio"
name="invalidReason"
id="reason2"
value="duplicate"
x-model="invalidReason"
/>
<label class="form-check-label" for="reason2"
>Duplicate report</label
>
</div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="radio"
name="invalidReason"
id="reason3"
value="out_of_district"
x-model="invalidReason"
/>
<label class="form-check-label" for="reason3"
>Outside service district</label
>
</div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="radio"
name="invalidReason"
id="reason4"
value="insufficient"
x-model="invalidReason"
/>
<label class="form-check-label" for="reason4"
>Insufficient information</label
>
</div>
<div class="form-check mb-3">
<input
class="form-check-input"
type="radio"
name="invalidReason"
id="reason5"
value="other"
x-model="invalidReason"
/>
<label class="form-check-label" for="reason5">Other</label>
</div>
<textarea
class="form-control"
rows="2"
placeholder="Additional notes (optional)"
x-model="invalidNotes"
></textarea>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
@click="showInvalidModal = false"
>
Cancel
</button>
<button
type="button"
class="btn btn-danger"
@click="markInvalid()"
:disabled="!invalidReason"
>
Confirm Invalid
</button>
</div>
</div>
</div>
</div>
<div
class="modal-backdrop fade show"
x-show="showInvalidModal"
@click="showInvalidModal = false"
></div>
<!-- Toast Notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast" :class="{ 'show': showToast }" role="alert">

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

@ -22,8 +22,7 @@ import (
type GeocodeResult struct {
Address types.Address
Cell h3.Cell
Longitude float64
Latitude float64
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{
req := stadia.RequestGeocodeStructured{
Address: &street,
Country: &country_s,
//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,
@ -204,8 +218,10 @@ func Geocode(ctx context.Context, org *models.Organization, a types.Address) (Ge
Unit: "",
},
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)
}

69
stadia/geocode_raw.go Normal file
View file

@ -0,0 +1,69 @@
package stadia
import (
"context"
"fmt"
"github.com/google/go-querystring/query"
)
type RequestGeocodeRaw struct {
Text string `url:"text" json:"text"`
// Boundary circle parameters
BoundaryCircleLat *float64 `url:"boundary.circle.lat,omitempty"`
BoundaryCircleLon *float64 `url:"boundary.circle.lon,omitempty"`
BoundaryCircleRadius *float64 `url:"boundary.circle.radius,omitempty"`
// Boundary parameters
BoundaryRectMaxLat *float64 `url:"boundary.rect.max_lat,omitempty"`
BoundaryRectMinLat *float64 `url:"boundary.rect.min_lat,omitempty"`
BoundaryRectMaxLon *float64 `url:"boundary.rect.max_lon,omitempty"`
BoundaryRectMinLon *float64 `url:"boundary.rect.min_lon,omitempty"`
// Focus point
FocusPointLat *float64 `url:"focus.point.lat,omitempty" json:",omitempty"`
FocusPointLng *float64 `url:"focus.point.lon,omitempty" json:",omitempty"`
// Other parameters
Lang *string `url:"lang,omitempty" json:"lang,omitempty"`
Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty"`
Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty"`
Size *int `url:"size,omitempty" json:"size,omitempty"`
}
func (r *RequestGeocodeRaw) SetBoundaryRect(xmin, ymin, xmax, ymax float64) {
r.BoundaryRectMaxLat = &ymax
r.BoundaryRectMinLat = &ymin
r.BoundaryRectMaxLon = &xmax
r.BoundaryRectMinLon = &xmin
}
func (r *RequestGeocodeRaw) SetFocusPoint(x, y float64) {
r.FocusPointLat = &y
r.FocusPointLng = &x
}
func (s *StadiaMaps) GeocodeRaw(ctx context.Context, req RequestGeocodeRaw) (*GeocodeResponse, error) {
// https://docs.stadiamaps.com/geocoding-search-autocomplete/search/
var result GeocodeResponse
query, err := query.Values(req)
if err != nil {
return nil, fmt.Errorf("structured geocode query: %w", err)
}
//var api_error Error
resp, err := s.client.R().
SetQueryParamsFromValues(query).
SetContext(ctx).
SetResult(&result).
SetPathParam("urlBase", s.urlBase).
SetQueryParam("api_key", s.APIKey).
Get("https://{urlBase}/geocoding/v1/search")
if err != nil {
return nil, fmt.Errorf("geocoding get: %w", err)
}
if !resp.IsSuccess() {
return nil, parseError(resp)
}
return &result, nil
}

View file

@ -7,8 +7,8 @@ import (
"github.com/google/go-querystring/query"
)
// StructuredGeocodeRequest represents the query parameters for structured geocoding
type StructuredGeocodeRequest struct {
// RequestGeocodeStructured represents the query parameters for structured geocoding
type RequestGeocodeStructured struct {
// Address components
Address *string `url:"address,omitempty" json:"address,omitempty"`
Neighbourhood *string `url:"neighbourhood,omitempty" json:"neighbourhood,omitempty"`
@ -24,7 +24,7 @@ type StructuredGeocodeRequest struct {
BoundaryCircleLon *float64 `url:"boundary.circle.lon,omitempty"`
BoundaryCircleRadius *float64 `url:"boundary.circle.radius,omitempty"`
BoundaryCountry []string `url:"boundary.country,omitempty,comma" json:"boundary.country,omitempty,comma"`
BoundaryCountry []string `url:"boundary.country,omitempty,comma" json:"boundary.country,omitempty"`
BoundaryGid *string `url:"boundary.gid,omitempty" json:"boundary.gid,omitempty"`
// Boundary parameters
@ -38,13 +38,24 @@ type StructuredGeocodeRequest struct {
FocusPointLng *float64 `url:"focus.point.lon,omitempty" json:",omitempty"`
// Other parameters
Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty,comma"`
Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty,comma"`
Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty"`
Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty"`
Size *int `url:"size,omitempty" json:"size,omitempty"`
Lang *string `url:"lang,omitempty" json:"lang,omitempty"`
}
func (s *StadiaMaps) StructuredGeocode(ctx context.Context, req StructuredGeocodeRequest) (*GeocodeResponse, error) {
func (r *RequestGeocodeStructured) SetBoundaryRect(xmin, ymin, xmax, ymax float64) {
r.BoundaryRectMaxLat = &ymax
r.BoundaryRectMinLat = &ymin
r.BoundaryRectMaxLon = &xmax
r.BoundaryRectMinLon = &xmin
}
func (r *RequestGeocodeStructured) SetFocusPoint(x, y float64) {
r.FocusPointLat = &y
r.FocusPointLng = &x
}
func (s *StadiaMaps) GeocodeStructured(ctx context.Context, req RequestGeocodeStructured) (*GeocodeResponse, error) {
// https://docs.stadiamaps.com/geocoding-search-autocomplete/structured-search/
// curl "https://api.stadiamaps.com/geocoding/v1/search/structured?address=P%C3%B5hja%20pst%2027a&region=Harju&country=EE&api_key=YOUR-API-KEY"
var result GeocodeResponse
@ -70,7 +81,3 @@ func (s *StadiaMaps) StructuredGeocode(ctx context.Context, req StructuredGeocod
}
return &result, nil
}
func (sgr StructuredGeocodeRequest) endpoint() string {
return "/v1/search/structured"
}

6
stadia/request.go Normal file
View file

@ -0,0 +1,6 @@
package stadia
type RequestGeocode interface {
SetBoundaryRect(xmin, ymin, xmax, ymax float64)
SetFocusPoint(x, y float64)
}

49
stadia/reverse_geocode.go Normal file
View file

@ -0,0 +1,49 @@
package stadia
import (
"context"
"fmt"
"github.com/google/go-querystring/query"
)
type RequestReverseGeocode struct {
Latitude float64 `url:"point.lat" json:"point.lat"`
Longitude float64 `url:"point.lon" json:"point.lon"`
// Boundary circle parameters
BoundaryCircleRadius *float64 `url:"boundary.circle.radius,omitempty"`
BoundaryCountry []string `url:"boundary.country,omitempty"`
BoundaryGID string `url:"boundary.gid,omitempty"`
// Other parameters
Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty"`
Size *int `url:"size,omitempty" json:"size,omitempty"`
Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty"`
}
func (s *StadiaMaps) ReverseGeocode(ctx context.Context, req RequestReverseGeocode) (*GeocodeResponse, error) {
// https://docs.stadiamaps.com/geocoding-search-autocomplete/reverse-search/
var result GeocodeResponse
query, err := query.Values(req)
if err != nil {
return nil, fmt.Errorf("reverse geocode query: %w", err)
}
//var api_error Error
resp, err := s.client.R().
SetQueryParamsFromValues(query).
SetContext(ctx).
SetResult(&result).
SetPathParam("urlBase", s.urlBase).
SetQueryParam("api_key", s.APIKey).
Get("https://{urlBase}/geocoding/v2/reverse")
if err != nil {
return nil, fmt.Errorf("reverse geocoding get: %w", err)
}
if !resp.IsSuccess() {
return nil, parseError(resp)
}
return &result, nil
}