Start wiring together request for a mailer to database

This commit is contained in:
Eli Ribble 2026-04-16 10:15:28 +00:00
parent 74e24b7de3
commit 2ea47f03f4
No known key found for this signature in database
14 changed files with 321 additions and 83 deletions

View file

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

View file

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

View file

@ -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;

View file

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

89
platform/compliance.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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"`
}

34
resource/compliance.go Normal file
View file

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

View file

@ -18,7 +18,7 @@ import (
"github.com/rs/zerolog/log"
)
func Compliance(r *router) *complianceR {
func PublicReportCompliance(r *router) *complianceR {
return &complianceR{
router: r,
}

View file

@ -44,6 +44,7 @@ body {
</template>
<template #right>
<ReviewSiteColumnAction
@doRequestComplianceMailer="doRequestComplianceMailer"
:selectedSite="selectedSite"
:submitting="submitting"
/>
@ -71,6 +72,7 @@ interface Props {}
const props = withDefaults(defineProps<Props>(), {});
const error = ref<string>("");
const mapFlyoverCamera = ref<Camera>(new Camera());
const storeSite = useStoreSite();
const selectedSiteID = ref<number>(0);
@ -94,6 +96,29 @@ const mapMarkers = computed<Marker[]>(() => {
};
return [markers];
});
async function doRequestComplianceMailer(id: number) {
submitting.value = true;
try {
const payload: any = {
site_id: id,
};
const response = await fetch("/api/compliance-request/mailer", {
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
if (!response.ok) {
throw new Error("failed to create compliance request");
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Unknown error";
console.error("Error submitting review:", err);
} finally {
submitting.value = false;
}
}
function siteDeselect(id: number): void {
if (selectedSiteID.value == id) {
selectedSiteID.value = 0;