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:
parent
3e1b56a266
commit
e2af49a323
27 changed files with 821 additions and 365 deletions
91
api/lead.go
91
api/lead.go
|
|
@ -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
41
api/publicreport.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
db/migrations/00106_publicreport_location_org_id.sql
Normal file
72
db/migrations/00106_publicreport_location_org_id.sql
Normal 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;
|
||||
4
db/migrations/00107_lead_type_publicreport.sql
Normal file
4
db/migrations/00107_lead_type_publicreport.sql
Normal 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';
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = ¢roid_y
|
||||
req.FocusPointLng = ¢roid_x
|
||||
req.SetFocusPoint(centroid_x, centroid_y)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
184
platform/lead.go
Normal 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
40
platform/publicreport.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
64
platform/site.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
69
stadia/geocode_raw.go
Normal 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
|
||||
}
|
||||
|
|
@ -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®ion=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
6
stadia/request.go
Normal 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
49
stadia/reverse_geocode.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue