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,27 +3,14 @@ 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"`
SignalIDs []int `json:"signal_ids"`
PoolLocations map[int]platform.Location `json:"pool_locations"`
SignalIDs []int `json:"signal_ids"`
}
type createdLead struct {
ID int32 `json:"id"`
@ -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"`
@ -34,18 +34,18 @@ type contentListSignal struct {
func listSignal(ctx context.Context, r *http.Request, user platform.User, query queryParams) (*contentListSignal, *nhttp.ErrorWithStatus) {
type _Row struct {
Address types.Address `db:"address"`
Addressed *time.Time `db:"addressed"`
Addressor *int32 `db:"addressor"`
Created time.Time `db:"created"`
Creator int32 `db:"creator_id"`
ID int32 `db:"id"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
Location Location `db:"location"`
Species *string `db:"species"`
Title string `db:"title"`
Type string `db:"type"`
Address types.Address `db:"address"`
Addressed *time.Time `db:"addressed"`
Addressor *int32 `db:"addressor"`
Created time.Time `db:"created"`
Creator int32 `db:"creator_id"`
ID int32 `db:"id"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
Location types.Location `db:"location"`
Species *string `db:"species"`
Title string `db:"title"`
Type string `db:"type"`
}
limit := 20
if query.Limit != nil {
@ -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",
@ -82,18 +118,22 @@ var PublicreportReportLocations = Table[
}
type publicreportReportLocationColumns struct {
ID column
TableName column
AddressRaw column
Created column
Location column
PublicID column
Status column
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 (
LeadtypeGreenPool Leadtype = "green-pool"
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

@ -16,13 +16,17 @@ import (
// PublicreportReportLocation is an object representing the database table.
type PublicreportReportLocation struct {
ID null.Val[int64] `db:"id" `
TableName null.Val[string] `db:"table_name" `
AddressRaw null.Val[string] `db:"address_raw" `
Created null.Val[time.Time] `db:"created" `
Location null.Val[string] `db:"location" `
PublicID null.Val[string] `db:"public_id" `
Status null.Val[enums.PublicreportReportstatustype] `db:"status" `
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" `
}
// PublicreportReportLocationSlice is an alias for a slice of pointers to PublicreportReportLocation.
@ -38,29 +42,37 @@ 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"),
AddressRaw: psql.Quote(alias, "address_raw"),
Created: psql.Quote(alias, "created"),
Location: psql.Quote(alias, "location"),
PublicID: psql.Quote(alias, "public_id"),
Status: psql.Quote(alias, "status"),
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"),
}
}
type publicreportReportLocationColumns struct {
expr.ColumnsExpr
tableAlias string
ID psql.Expression
TableName psql.Expression
AddressRaw psql.Expression
Created psql.Expression
Location psql.Expression
PublicID psql.Expression
Status psql.Expression
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
}
func (c publicreportReportLocationColumns) Alias() string {
@ -96,13 +108,17 @@ func (o PublicreportReportLocationSlice) AfterQueryHook(ctx context.Context, exe
}
type publicreportReportLocationWhere[Q psql.Filterable] struct {
ID psql.WhereNullMod[Q, int64]
TableName psql.WhereNullMod[Q, string]
AddressRaw psql.WhereNullMod[Q, string]
Created psql.WhereNullMod[Q, time.Time]
Location psql.WhereNullMod[Q, string]
PublicID psql.WhereNullMod[Q, string]
Status psql.WhereNullMod[Q, enums.PublicreportReportstatustype]
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]
}
func (publicreportReportLocationWhere[Q]) AliasedAs(alias string) publicreportReportLocationWhere[Q] {
@ -111,12 +127,16 @@ func (publicreportReportLocationWhere[Q]) AliasedAs(alias string) publicreportRe
func buildPublicreportReportLocationWhere[Q psql.Filterable](cols publicreportReportLocationColumns) publicreportReportLocationWhere[Q] {
return publicreportReportLocationWhere[Q]{
ID: psql.WhereNull[Q, int64](cols.ID),
TableName: psql.WhereNull[Q, string](cols.TableName),
AddressRaw: psql.WhereNull[Q, string](cols.AddressRaw),
Created: psql.WhereNull[Q, time.Time](cols.Created),
Location: psql.WhereNull[Q, string](cols.Location),
PublicID: psql.WhereNull[Q, string](cols.PublicID),
Status: psql.WhereNull[Q, enums.PublicreportReportstatustype](cols.Status),
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 = [];
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),
});
if (!response.ok) {
throw new Error("Failed to submit lead");
}
// Remove from list after creating lead
this.removeCurrentFromList();
this.fetchCommunications();
} catch (err) {
this.error = err.message;
console.error("Error creating lead:", err);
}
this.selectedCommunication.history.push({
action: "Lead created",
timestamp: new Date(),
});
this.showNotification(
"Lead Created",
`Lead successfully created for report #${this.selectedCommunication.id}`,
);
// Remove from list after creating lead
// this.communications = this.communications.filter(r => r.id !== this.selectedCommunication.id);
// this.selectedCommunication = null;
},
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,
);
this.selectedCommunication = null;
this.showInvalidModal = false;
this.invalidReason = "";
this.invalidNotes = "";
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;
}
},
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

@ -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)
}

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
}