diff --git a/api/routes.go b/api/routes.go index 90f77f87..fb63b050 100644 --- a/api/routes.go +++ b/api/routes.go @@ -22,6 +22,8 @@ func AddRoutes(r *mux.Router) { r.Handle("/client/ios", auth.NewEnsureAuth(handleClientIos)).Methods("GET") communication := resource.Communication(r) r.Handle("/communication", authenticatedHandlerJSON(communication.List)).Methods("GET") + compliance_request := resource.ComplianceRequest(router) + r.Handle("/compliance-request/mailer", authenticatedHandlerJSONPost(compliance_request.CreateMailer)).Methods("POST") r.Handle("/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis)).Methods("POST") r.Handle("/events", auth.NewEnsureAuth(streamEvents)).Methods("GET") r.Handle("/image/{uuid}", auth.NewEnsureAuth(apiImagePost)).Methods("POST") @@ -41,8 +43,8 @@ func AddRoutes(r *mux.Router) { r.Handle("/review/pool", authenticatedHandlerJSONPost(postReviewPool)).Methods("POST") review_task := resource.ReviewTask(r) r.Handle("/review-task", authenticatedHandlerJSON(review_task.List)).Methods("GET") - compliance := resource.Compliance(router) - r.HandleFunc("/rmo/compliance", handlerJSONPost(compliance.Create)).Methods("POST") + pr_compliance := resource.PublicReportCompliance(router) + r.HandleFunc("/rmo/compliance", handlerJSONPost(pr_compliance.Create)).Methods("POST") nuisance := resource.Nuisance(router) r.HandleFunc("/rmo/nuisance", handlerFormPost(nuisance.Create)).Methods("POST") water := resource.Water(router) @@ -88,8 +90,8 @@ func AddRoutes(r *mux.Router) { publicreport := resource.Publicreport(router) r.Handle("/publicreport/{id}", handlerBasic(publicreport.ByID)).Methods("GET").Name("publicreport.ByIDGet") r.Handle("/publicreport/compliance/{id}/image", handlerFormPost(publicreport.ImageCreate)).Methods("POST") - r.Handle("/publicreport/compliance/{id}", handlerJSON(compliance.ByID)).Methods("GET").Name("publicreport.compliance.ByIDGet") - r.Handle("/publicreport/compliance/{id}", handlerJSONPut(compliance.Update)).Methods("PUT") + r.Handle("/publicreport/compliance/{id}", handlerJSON(pr_compliance.ByID)).Methods("GET").Name("publicreport.compliance.ByIDGet") + r.Handle("/publicreport/compliance/{id}", handlerJSONPut(pr_compliance.Update)).Methods("PUT") r.Handle("/publicreport/nuisance/{id}", handlerJSON(nuisance.ByID)).Methods("GET").Name("publicreport.nuisance.ByIDGet") r.Handle("/publicreport/water/{id}", handlerJSON(water.ByID)).Methods("GET").Name("publicreport.water.ByIDGet") diff --git a/db/dbinfo/feature.bob.go b/db/dbinfo/feature.bob.go index fb0eb954..9c61bad8 100644 --- a/db/dbinfo/feature.bob.go +++ b/db/dbinfo/feature.bob.go @@ -69,6 +69,24 @@ var Features = Table[ Generated: false, AutoIncr: false, }, + LocationLatitude: column{ + Name: "location_latitude", + DBType: "double precision", + Default: "GENERATED", + Comment: "", + Nullable: true, + Generated: true, + AutoIncr: false, + }, + LocationLongitude: column{ + Name: "location_longitude", + DBType: "double precision", + Default: "GENERATED", + Comment: "", + Nullable: true, + Generated: true, + AutoIncr: false, + }, }, Indexes: featureIndexes{ FeaturePkey: index{ @@ -128,17 +146,19 @@ var Features = Table[ } type featureColumns struct { - Created column - CreatorID column - ID column - OrganizationID column - SiteID column - Location column + Created column + CreatorID column + ID column + OrganizationID column + SiteID column + Location column + LocationLatitude column + LocationLongitude column } func (c featureColumns) AsSlice() []column { return []column{ - c.Created, c.CreatorID, c.ID, c.OrganizationID, c.SiteID, c.Location, + c.Created, c.CreatorID, c.ID, c.OrganizationID, c.SiteID, c.Location, c.LocationLatitude, c.LocationLongitude, } } diff --git a/db/migrations/00137_feature_location.sql b/db/migrations/00137_feature_location.sql new file mode 100644 index 00000000..16ae6bfb --- /dev/null +++ b/db/migrations/00137_feature_location.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE feature ADD COLUMN location_latitude DOUBLE PRECISION GENERATED ALWAYS AS (ST_Y(location)) STORED; +ALTER TABLE feature ADD COLUMN location_longitude DOUBLE PRECISION GENERATED ALWAYS AS (ST_X(location)) STORED; +-- +goose Down +ALTER TABLE feature DROP COLUMN location_longitude; +ALTER TABLE feature DROP COLUMN location_latitude; diff --git a/db/models/feature.bob.go b/db/models/feature.bob.go index 2b60494a..095d6f7d 100644 --- a/db/models/feature.bob.go +++ b/db/models/feature.bob.go @@ -25,12 +25,14 @@ import ( // Feature is an object representing the database table. type Feature struct { - Created time.Time `db:"created" ` - CreatorID int32 `db:"creator_id" ` - ID int32 `db:"id,pk" ` - OrganizationID int32 `db:"organization_id" ` - SiteID int32 `db:"site_id" ` - Location null.Val[string] `db:"location" ` + Created time.Time `db:"created" ` + CreatorID int32 `db:"creator_id" ` + ID int32 `db:"id,pk" ` + OrganizationID int32 `db:"organization_id" ` + SiteID int32 `db:"site_id" ` + Location null.Val[string] `db:"location" ` + LocationLatitude null.Val[float64] `db:"location_latitude,generated" ` + LocationLongitude null.Val[float64] `db:"location_longitude,generated" ` R featureR `db:"-" ` } @@ -56,27 +58,31 @@ type featureR struct { func buildFeatureColumns(alias string) featureColumns { return featureColumns{ ColumnsExpr: expr.NewColumnsExpr( - "created", "creator_id", "id", "organization_id", "site_id", "location", + "created", "creator_id", "id", "organization_id", "site_id", "location", "location_latitude", "location_longitude", ).WithParent("feature"), - tableAlias: alias, - Created: psql.Quote(alias, "created"), - CreatorID: psql.Quote(alias, "creator_id"), - ID: psql.Quote(alias, "id"), - OrganizationID: psql.Quote(alias, "organization_id"), - SiteID: psql.Quote(alias, "site_id"), - Location: psql.Quote(alias, "location"), + tableAlias: alias, + Created: psql.Quote(alias, "created"), + CreatorID: psql.Quote(alias, "creator_id"), + ID: psql.Quote(alias, "id"), + OrganizationID: psql.Quote(alias, "organization_id"), + SiteID: psql.Quote(alias, "site_id"), + Location: psql.Quote(alias, "location"), + LocationLatitude: psql.Quote(alias, "location_latitude"), + LocationLongitude: psql.Quote(alias, "location_longitude"), } } type featureColumns struct { expr.ColumnsExpr - tableAlias string - Created psql.Expression - CreatorID psql.Expression - ID psql.Expression - OrganizationID psql.Expression - SiteID psql.Expression - Location psql.Expression + tableAlias string + Created psql.Expression + CreatorID psql.Expression + ID psql.Expression + OrganizationID psql.Expression + SiteID psql.Expression + Location psql.Expression + LocationLatitude psql.Expression + LocationLongitude psql.Expression } func (c featureColumns) Alias() string { @@ -760,12 +766,14 @@ func (feature0 *Feature) AttachFeaturePool(ctx context.Context, exec bob.Executo } type featureWhere[Q psql.Filterable] struct { - Created psql.WhereMod[Q, time.Time] - CreatorID psql.WhereMod[Q, int32] - ID psql.WhereMod[Q, int32] - OrganizationID psql.WhereMod[Q, int32] - SiteID psql.WhereMod[Q, int32] - Location psql.WhereNullMod[Q, string] + Created psql.WhereMod[Q, time.Time] + CreatorID psql.WhereMod[Q, int32] + ID psql.WhereMod[Q, int32] + OrganizationID psql.WhereMod[Q, int32] + SiteID psql.WhereMod[Q, int32] + Location psql.WhereNullMod[Q, string] + LocationLatitude psql.WhereNullMod[Q, float64] + LocationLongitude psql.WhereNullMod[Q, float64] } func (featureWhere[Q]) AliasedAs(alias string) featureWhere[Q] { @@ -774,12 +782,14 @@ func (featureWhere[Q]) AliasedAs(alias string) featureWhere[Q] { func buildFeatureWhere[Q psql.Filterable](cols featureColumns) featureWhere[Q] { return featureWhere[Q]{ - Created: psql.Where[Q, time.Time](cols.Created), - CreatorID: psql.Where[Q, int32](cols.CreatorID), - ID: psql.Where[Q, int32](cols.ID), - OrganizationID: psql.Where[Q, int32](cols.OrganizationID), - SiteID: psql.Where[Q, int32](cols.SiteID), - Location: psql.WhereNull[Q, string](cols.Location), + Created: psql.Where[Q, time.Time](cols.Created), + CreatorID: psql.Where[Q, int32](cols.CreatorID), + ID: psql.Where[Q, int32](cols.ID), + OrganizationID: psql.Where[Q, int32](cols.OrganizationID), + SiteID: psql.Where[Q, int32](cols.SiteID), + Location: psql.WhereNull[Q, string](cols.Location), + LocationLatitude: psql.WhereNull[Q, float64](cols.LocationLatitude), + LocationLongitude: psql.WhereNull[Q, float64](cols.LocationLongitude), } } diff --git a/platform/compliance.go b/platform/compliance.go new file mode 100644 index 00000000..197bc507 --- /dev/null +++ b/platform/compliance.go @@ -0,0 +1,89 @@ +package platform + +import ( + "context" + "fmt" + "time" + + "github.com/Gleipnir-Technology/bob/dialect/psql" + "github.com/Gleipnir-Technology/bob/dialect/psql/sm" + "github.com/Gleipnir-Technology/nidus-sync/db" + "github.com/Gleipnir-Technology/nidus-sync/db/models" + "github.com/Gleipnir-Technology/nidus-sync/platform/types" + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" +) + +func ComplianceRequestMailerCreate(ctx context.Context, user User, site_id int32) (int32, error) { + txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("start txn: %w", err) + } + defer txn.Rollback(ctx) + site, err := models.FindSite(ctx, txn, site_id) + if err != nil { + return 0, fmt.Errorf("find site: %w", err) + } + if site.OrganizationID != user.Organization.ID { + return 0, fmt.Errorf("permission denied") + } + features, err := models.Features.Query( + models.SelectWhere.Features.SiteID.EQ(site.ID), + ).All(ctx, txn) + if err != nil { + return 0, fmt.Errorf("find features: %w", err) + } + feature_ids := make([]int32, len(features)) + for i, f := range features { + feature_ids[i] = f.ID + } + feature_pools, err := models.FeaturePools.Query( + sm.Where( + models.FeaturePools.Columns.FeatureID.EQ(psql.Any(feature_ids)), + ), + ).All(ctx, txn) + if err != nil { + return 0, fmt.Errorf("find feature pools: %w", err) + } + if len(feature_pools) != 1 { + return 0, fmt.Errorf("wrong number of pools: %d", len(feature_pools)) + } + feature_pool := feature_pools[0] + var feature *models.Feature + for _, f := range features { + if f.ID == feature_pool.FeatureID { + feature = f + } + } + if feature == nil { + return 0, fmt.Errorf("match feature %d", feature_pool.FeatureID) + } + location := types.Location{ + Latitude: feature.LocationLatitude.GetOr(0), + Longitude: feature.LocationLongitude.GetOr(0), + } + signal, err := SignalCreateFromPool(ctx, txn, user, site.ID, feature_pool.FeatureID, location) + if err != nil { + return 0, fmt.Errorf("create signal from ppol: %w", err) + } + lead_id, err := leadCreate(ctx, txn, user, *signal, site.ID, &location) + if err != nil { + return 0, fmt.Errorf("create lead from ppol: %w", err) + } + public_id, err := GenerateReportID() + if err != nil { + return 0, fmt.Errorf("create public id: %w", err) + } + setter := models.ComplianceReportRequestSetter{ + Created: omit.From(time.Now()), + Creator: omit.From(int32(user.ID)), + // ID + PublicID: omit.From(public_id), + LeadID: omitnull.From(*lead_id), + } + req, err := models.ComplianceReportRequests.Insert(&setter).One(ctx, txn) + if err != nil { + return 0, fmt.Errorf("create compliance report request: %w", err) + } + return req.ID, nil +} diff --git a/platform/feature.go b/platform/feature.go index 2a36372e..7b05a919 100644 --- a/platform/feature.go +++ b/platform/feature.go @@ -6,20 +6,33 @@ import ( //"github.com/aarondl/opt/omit" //"github.com/aarondl/opt/omitnull" - //"github.com/Gleipnir-Technology/bob" + "github.com/Gleipnir-Technology/bob" "github.com/Gleipnir-Technology/bob/dialect/psql" "github.com/Gleipnir-Technology/bob/dialect/psql/sm" "github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db/models" - //"github.com/Gleipnir-Technology/nidus-sync/platform/geocode" "github.com/Gleipnir-Technology/nidus-sync/platform/types" - //"github.com/stephenafamo/scan" + "github.com/stephenafamo/scan" ) func featuresBySiteID(ctx context.Context, site_ids []int32) (map[int32][]types.Feature, error) { - rows, err := models.Features.Query( - sm.Where(models.Features.Columns.SiteID.EQ(psql.Any(site_ids))), - ).All(ctx, db.PGInstance.BobDB) + rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select( + sm.Columns( + "feature.id AS id", + "feature.site_id AS site_id", + "COALESCE(ST_X(feature.location), 0) AS \"location.longitude\"", + "COALESCE(ST_Y(feature.location), 0) AS \"location.latitude\"", + "'pool' AS type", + ), + sm.From("feature"), + sm.InnerJoin("feature_pool").OnEQ( + psql.Quote("feature", "id"), + psql.Quote("feature_pool", "feature_id"), + ), + sm.Where( + models.Features.Columns.ID.EQ(psql.Any(site_ids)), + ), + ), scan.StructMapper[types.Feature]()) if err != nil { return nil, fmt.Errorf("query features: %w", err) } diff --git a/platform/lead.go b/platform/lead.go index 12f8755b..3d85d903 100644 --- a/platform/lead.go +++ b/platform/lead.go @@ -5,6 +5,7 @@ import ( "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" @@ -20,11 +21,20 @@ import ( // Create a lead from the given signal and site func LeadCreate(ctx context.Context, user User, signal_id int32, site_id int32, pool_location *types.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) } + defer txn.Rollback(ctx) + lead_id, err := leadCreate(ctx, txn, user, signal_id, site_id, pool_location) + if err != nil { + return nil, fmt.Errorf("inner leadcreate: %w", err) + } + txn.Commit(ctx) + return lead_id, nil +} + +func leadCreate(ctx context.Context, txn bob.Executor, user User, signal_id int32, site_id int32, pool_location *types.Location) (*int32, error) { lead, err := models.Leads.Insert(&models.LeadSetter{ Created: omit.From(time.Now()), Creator: omit.From(int32(user.ID)), @@ -59,6 +69,5 @@ func LeadCreate(ctx context.Context, user User, signal_id int32, site_id int32, return nil, fmt.Errorf("failed to update pool through signal %d: %w", signal_id, err) } } - txn.Commit(ctx) return &lead.ID, nil } diff --git a/platform/publicreport.go b/platform/publicreport.go index 717b73f6..24f04b22 100644 --- a/platform/publicreport.go +++ b/platform/publicreport.go @@ -2,8 +2,11 @@ package platform import ( "context" + "crypto/rand" "errors" "fmt" + "math/big" + "strings" "time" "github.com/Gleipnir-Technology/bob" @@ -20,12 +23,36 @@ import ( "github.com/Gleipnir-Technology/nidus-sync/platform/event" "github.com/Gleipnir-Technology/nidus-sync/platform/geocode" "github.com/Gleipnir-Technology/nidus-sync/platform/publicreport" - "github.com/Gleipnir-Technology/nidus-sync/platform/report" "github.com/Gleipnir-Technology/nidus-sync/platform/text" "github.com/Gleipnir-Technology/nidus-sync/platform/types" "github.com/rs/zerolog/log" ) +// GenerateReportID creates a 12-character random string using only unambiguous +// capital letters and numbers +func GenerateReportID() (string, error) { + // Define character set (no O/0, I/l/1, 2/Z to avoid confusion) + const charset = "ABCDEFGHJKLMNPQRSTUVWXY3456789" + const length = 12 + + var builder strings.Builder + builder.Grow(length) + + // Use crypto/rand for secure randomness + for i := 0; i < length; i++ { + // Generate a random index within our charset + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", fmt.Errorf("failed to generate random number: %w", err) + } + + // Add the randomly selected character to our ID + builder.WriteByte(charset[n.Int64()]) + } + + return builder.String(), nil +} + func PublicreportByID(ctx context.Context, report_id string) (*types.PublicReport, error) { return publicreport.ByID(ctx, report_id) } @@ -238,7 +265,7 @@ func publicReportCreate(ctx context.Context, setter_report models.PublicreportRe } defer txn.Rollback(ctx) - public_id, err := report.GenerateReportID() + public_id, err := GenerateReportID() if err != nil { return nil, fmt.Errorf("create public ID: %w", err) } diff --git a/platform/report/notification.go b/platform/report/notification.go index cb1a17d8..c8a06539 100644 --- a/platform/report/notification.go +++ b/platform/report/notification.go @@ -2,10 +2,7 @@ package report import ( "context" - "crypto/rand" "fmt" - "math/big" - "strings" "time" "github.com/Gleipnir-Technology/bob" @@ -31,31 +28,6 @@ func DistrictForReport(ctx context.Context, report_id string) (*models.Organizat return result, nil } -// GenerateReportID creates a 12-character random string using only unambiguous -// capital letters and numbers -func GenerateReportID() (string, error) { - // Define character set (no O/0, I/l/1, 2/Z to avoid confusion) - const charset = "ABCDEFGHJKLMNPQRSTUVWXY3456789" - const length = 12 - - var builder strings.Builder - builder.Grow(length) - - // Use crypto/rand for secure randomness - for i := 0; i < length; i++ { - // Generate a random index within our charset - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) - if err != nil { - return "", fmt.Errorf("failed to generate random number: %w", err) - } - - // Add the randomly selected character to our ID - builder.WriteByte(charset[n.Int64()]) - } - - return builder.String(), nil -} - func RegisterNotificationEmail(ctx context.Context, txn bob.Executor, report_id string, destination string) error { report, e := reportByPublicID(ctx, db.PGInstance.BobDB, report_id) if e != nil { diff --git a/platform/signal.go b/platform/signal.go index 8cda221d..dfa14b76 100644 --- a/platform/signal.go +++ b/platform/signal.go @@ -39,6 +39,36 @@ type Signal struct { Type string `db:"type" json:"type"` } +func SignalCreateFromPool(ctx context.Context, txn bob.Executor, user User, site_id int32, feature_id int32, location types.Location) (*int32, error) { + setter := models.SignalSetter{ + Addressed: omitnull.FromPtr[time.Time](nil), + Addressor: omitnull.FromPtr[int32](nil), + Created: omit.From(time.Now()), + Creator: omit.From(int32(user.ID)), + //ID + OrganizationID: omit.From(user.Organization.ID), + Species: omitnull.FromPtr[enums.Mosquitospecies](nil), + Type: omit.From(enums.SignaltypeFlyoverPool), + SiteID: omitnull.From(site_id), + //Location: + //LocationType null.Val[string] `db:"location_type,generated" ` + FeaturePoolFeatureID: omitnull.From(feature_id), + ReportID: omitnull.FromPtr[int32](nil), + } + signal, err := models.Signals.Insert(&setter).One(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("insert signal: %w", err) + } + geom_query, _ := location.GeometryQuery() + _, err = psql.Update( + um.Table(models.Signals.Name()), + um.SetCol(models.Signals.Columns.Location.String()).To(geom_query), + um.Where(models.Signals.Columns.ID.EQ(psql.Arg(signal.ID))), + ).Exec(ctx, txn) + + return &signal.ID, nil +} + // Create a lead from the given signal and site func SignalCreateFromPublicreport(ctx context.Context, user User, report_id string) (*int32, error) { txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) diff --git a/platform/types/feature.go b/platform/types/feature.go index 91e483f4..438b97ad 100644 --- a/platform/types/feature.go +++ b/platform/types/feature.go @@ -3,5 +3,6 @@ package types type Feature struct { ID int32 `db:"id" json:"id"` Location Location `db:"location" json:"location"` + SiteID int32 `db:"site_id" json:"-"` Type string `db:"-" json:"type"` } diff --git a/resource/compliance.go b/resource/compliance.go new file mode 100644 index 00000000..24794c33 --- /dev/null +++ b/resource/compliance.go @@ -0,0 +1,34 @@ +package resource + +import ( + "context" + nhttp "github.com/Gleipnir-Technology/nidus-sync/http" + "github.com/Gleipnir-Technology/nidus-sync/platform" + "net/http" +) + +func ComplianceRequest(r *router) *complianceRequestR { + return &complianceRequestR{ + router: r, + } +} + +type complianceRequestR struct { + router *router +} +type complianceRequestMailer struct { + ID int32 `json:"id"` +} +type complianceRequestMailerForm struct { + SiteID int32 `json:"site_id"` +} + +func (res *complianceRequestR) CreateMailer(ctx context.Context, r *http.Request, user platform.User, n complianceRequestMailerForm) (*complianceRequestMailer, *nhttp.ErrorWithStatus) { + id, err := platform.ComplianceRequestMailerCreate(ctx, user, n.SiteID) + if err != nil { + return nil, nhttp.NewError("create mailer: %w", err) + } + return &complianceRequestMailer{ + ID: id, + }, nil +} diff --git a/resource/publicreport_compliance.go b/resource/publicreport_compliance.go index af6e6204..91f35dba 100644 --- a/resource/publicreport_compliance.go +++ b/resource/publicreport_compliance.go @@ -18,7 +18,7 @@ import ( "github.com/rs/zerolog/log" ) -func Compliance(r *router) *complianceR { +func PublicReportCompliance(r *router) *complianceR { return &complianceR{ router: r, } diff --git a/ts/view/review/Site.vue b/ts/view/review/Site.vue index 34361ddb..b2ee9165 100644 --- a/ts/view/review/Site.vue +++ b/ts/view/review/Site.vue @@ -44,6 +44,7 @@ body {