From cc95c38ab5e68a4d8a8402eb5fb66e4279a9e456 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Sun, 15 Mar 2026 22:38:36 +0000 Subject: [PATCH] Initial creation of endpoint to send messages to public reporters --- api/publicreport.go | 19 ++++ api/routes.go | 1 + db/dbinfo/publicreport.report_location.bob.go | 22 ++++- .../00109_publicreport_reporter_contact.sql | 90 +++++++++++++++++++ db/models/publicreport.report_location.bob.go | 12 ++- html/template/sync/communication-root.html | 25 ++++-- platform/background/text.go | 5 +- platform/email/report.go | 10 +++ platform/publicreport.go | 42 +++++++-- platform/text/report-subscription.go | 4 +- platform/text/report.go | 10 +++ platform/text/text.go | 20 ++++- 12 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 db/migrations/00109_publicreport_reporter_contact.sql create mode 100644 platform/email/report.go create mode 100644 platform/text/report.go diff --git a/api/publicreport.go b/api/publicreport.go index 9df70d0a..9601627b 100644 --- a/api/publicreport.go +++ b/api/publicreport.go @@ -3,6 +3,7 @@ package api import ( "context" "net/http" + "strconv" "github.com/Gleipnir-Technology/nidus-sync/config" nhttp "github.com/Gleipnir-Technology/nidus-sync/http" @@ -39,3 +40,21 @@ func postPublicreportInvalid(ctx context.Context, r *http.Request, user platform URI: config.MakeURLNidus("/publicreport/%s", req.ReportID), }, nil } + +type formPublicreportMessage struct { + Message string `json:"message"` + ReportID string `json:"reportID"` +} +type createdMessage struct { + URI string `json:"uri"` +} + +func postPublicreportMessage(ctx context.Context, r *http.Request, user platform.User, req formPublicreportMessage) (*createdMessage, *nhttp.ErrorWithStatus) { + msg_id, err := platform.PublicReportMessageCreate(ctx, user, req.ReportID, req.Message) + if err != nil { + return nil, nhttp.NewError("failed to create message: %s", err) + } + return &createdMessage{ + URI: config.MakeURLNidus("/message/%s", strconv.Itoa(int(*msg_id))), + }, nil +} diff --git a/api/routes.go b/api/routes.go index 01dcbb01..a458b6b9 100644 --- a/api/routes.go +++ b/api/routes.go @@ -23,6 +23,7 @@ func AddRoutes(r chi.Router) { 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", "/publicreport/message", authenticatedHandlerJSONPost(postPublicreportMessage)) r.Method("POST", "/review/pool", authenticatedHandlerJSONPost(postReviewPool)) r.Method("GET", "/review-task/pool", authenticatedHandlerJSON(listReviewTaskPool)) r.Method("GET", "/service-request", auth.NewEnsureAuth(apiServiceRequest)) diff --git a/db/dbinfo/publicreport.report_location.bob.go b/db/dbinfo/publicreport.report_location.bob.go index 398c63b0..b34724d5 100644 --- a/db/dbinfo/publicreport.report_location.bob.go +++ b/db/dbinfo/publicreport.report_location.bob.go @@ -103,6 +103,24 @@ var PublicreportReportLocations = Table[ Generated: false, AutoIncr: false, }, + ReporterEmail: column{ + Name: "reporter_email", + DBType: "text", + Default: "NULL", + Comment: "", + Nullable: true, + Generated: false, + AutoIncr: false, + }, + ReporterPhone: column{ + Name: "reporter_phone", + DBType: "text", + Default: "NULL", + Comment: "", + Nullable: true, + Generated: false, + AutoIncr: false, + }, Status: column{ Name: "status", DBType: "publicreport.reportstatustype", @@ -128,12 +146,14 @@ type publicreportReportLocationColumns struct { LocationLongitude column OrganizationID column PublicID column + ReporterEmail column + ReporterPhone column Status column } func (c publicreportReportLocationColumns) AsSlice() []column { return []column{ - c.ID, c.TableName, c.AddressID, c.AddressRaw, c.Created, c.Location, c.LocationLatitude, c.LocationLongitude, c.OrganizationID, 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.ReporterEmail, c.ReporterPhone, c.Status, } } diff --git a/db/migrations/00109_publicreport_reporter_contact.sql b/db/migrations/00109_publicreport_reporter_contact.sql new file mode 100644 index 00000000..b72af336 --- /dev/null +++ b/db/migrations/00109_publicreport_reporter_contact.sql @@ -0,0 +1,90 @@ +-- +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, + reporter_email, + reporter_phone, + 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, + reporter_email, + reporter_phone, + 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, + reporter_email, + reporter_phone, + 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_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; diff --git a/db/models/publicreport.report_location.bob.go b/db/models/publicreport.report_location.bob.go index d6f268fe..e01b9b4c 100644 --- a/db/models/publicreport.report_location.bob.go +++ b/db/models/publicreport.report_location.bob.go @@ -26,6 +26,8 @@ type PublicreportReportLocation struct { LocationLongitude null.Val[float64] `db:"location_longitude" ` OrganizationID null.Val[int32] `db:"organization_id" ` PublicID null.Val[string] `db:"public_id" ` + ReporterEmail null.Val[string] `db:"reporter_email" ` + ReporterPhone null.Val[string] `db:"reporter_phone" ` Status null.Val[enums.PublicreportReportstatustype] `db:"status" ` } @@ -42,7 +44,7 @@ type PublicreportReportLocationsQuery = *psql.ViewQuery[*PublicreportReportLocat func buildPublicreportReportLocationColumns(alias string) publicreportReportLocationColumns { return publicreportReportLocationColumns{ ColumnsExpr: expr.NewColumnsExpr( - "id", "table_name", "address_id", "address_raw", "created", "location", "location_latitude", "location_longitude", "organization_id", "public_id", "status", + "id", "table_name", "address_id", "address_raw", "created", "location", "location_latitude", "location_longitude", "organization_id", "public_id", "reporter_email", "reporter_phone", "status", ).WithParent("publicreport.report_location"), tableAlias: alias, ID: psql.Quote(alias, "id"), @@ -55,6 +57,8 @@ func buildPublicreportReportLocationColumns(alias string) publicreportReportLoca LocationLongitude: psql.Quote(alias, "location_longitude"), OrganizationID: psql.Quote(alias, "organization_id"), PublicID: psql.Quote(alias, "public_id"), + ReporterEmail: psql.Quote(alias, "reporter_email"), + ReporterPhone: psql.Quote(alias, "reporter_phone"), Status: psql.Quote(alias, "status"), } } @@ -72,6 +76,8 @@ type publicreportReportLocationColumns struct { LocationLongitude psql.Expression OrganizationID psql.Expression PublicID psql.Expression + ReporterEmail psql.Expression + ReporterPhone psql.Expression Status psql.Expression } @@ -118,6 +124,8 @@ type publicreportReportLocationWhere[Q psql.Filterable] struct { LocationLongitude psql.WhereNullMod[Q, float64] OrganizationID psql.WhereNullMod[Q, int32] PublicID psql.WhereNullMod[Q, string] + ReporterEmail psql.WhereNullMod[Q, string] + ReporterPhone psql.WhereNullMod[Q, string] Status psql.WhereNullMod[Q, enums.PublicreportReportstatustype] } @@ -137,6 +145,8 @@ func buildPublicreportReportLocationWhere[Q psql.Filterable](cols publicreportRe LocationLongitude: psql.WhereNull[Q, float64](cols.LocationLongitude), OrganizationID: psql.WhereNull[Q, int32](cols.OrganizationID), PublicID: psql.WhereNull[Q, string](cols.PublicID), + ReporterEmail: psql.WhereNull[Q, string](cols.ReporterEmail), + ReporterPhone: psql.WhereNull[Q, string](cols.ReporterPhone), Status: psql.WhereNull[Q, enums.PublicreportReportstatustype](cols.Status), } } diff --git a/html/template/sync/communication-root.html b/html/template/sync/communication-root.html index e332c8a8..28b731bb 100644 --- a/html/template/sync/communication-root.html +++ b/html/template/sync/communication-root.html @@ -216,15 +216,26 @@ } }, - sendMessage() { + async sendMessage() { if (!this.messageText.trim()) return; - // TODO: Implement API call to send message - console.log( - "Sending message to:", - this.selectedCommunication.public_report.reporter.has_email, - ); - console.log("Message:", this.messageText); + console.log("Sending message reporter:", this.messageText); + + const payload = { + message: this.messageText, + reportID: this.selectedCommunication.id, + }; + const response = await fetch(`${this.apiBase}/publicreport/message`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } // Add to activity log if (!this.selectedCommunication.history) { diff --git a/platform/background/text.go b/platform/background/text.go index 38c7937d..14c72a66 100644 --- a/platform/background/text.go +++ b/platform/background/text.go @@ -10,11 +10,14 @@ import ( var channelJobText chan text.Job +func ReportUserText(destination text.E164, report_id string, message string) { + //enqueueJobText(text.N +} func ReportSubscriptionConfirmationText(destination text.E164, report_id string) { enqueueJobText(text.NewJobReportSubscriptionConfirmation( destination, report_id, - config.PhoneNumberReport, + *text.NewE164(&config.PhoneNumberReport), )) } diff --git a/platform/email/report.go b/platform/email/report.go new file mode 100644 index 00000000..ab06769b --- /dev/null +++ b/platform/email/report.go @@ -0,0 +1,10 @@ +package email + +import ( + "context" + //"github.com/Gleipnir-Technology/nidus-sync/platform/types" +) + +func ReportMessage(ctx context.Context, user_id int32, report_id, destination, message string) (*int32, error) { + return nil, nil +} diff --git a/platform/publicreport.go b/platform/publicreport.go index 74350dde..f0a89490 100644 --- a/platform/publicreport.go +++ b/platform/publicreport.go @@ -2,6 +2,7 @@ package platform import ( "context" + "errors" "fmt" "time" @@ -11,20 +12,19 @@ import ( "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/background" + "github.com/Gleipnir-Technology/nidus-sync/platform/email" "github.com/Gleipnir-Technology/nidus-sync/platform/event" + "github.com/Gleipnir-Technology/nidus-sync/platform/text" "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) + tablename, _, err := reportFromID(ctx, user, report_id) 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()), @@ -42,6 +42,27 @@ func PublicreportInvalid(ctx context.Context, user User, report_id string) error return nil } +func PublicReportMessageCreate(ctx context.Context, user User, report_id, message string) (message_id *int32, err error) { + _, report, err := reportFromID(ctx, user, report_id) + if err != nil { + return nil, fmt.Errorf("query report existence: %w", err) + } + if report.ReporterPhone.GetOr("") != "" { + msg_id, err := text.ReportMessage(ctx, int32(user.ID), report_id, report.ReporterPhone.MustGet(), message) + if err != nil { + return nil, fmt.Errorf("send text: %w", err) + } + return msg_id, nil + } else if report.ReporterEmail.GetOr("") != "" { + msg_id, err := email.ReportMessage(ctx, int32(user.ID), report_id, report.ReporterEmail.MustGet(), message) + if err != nil { + return nil, fmt.Errorf("send email: %w", err) + } + return msg_id, nil + } else { + return nil, errors.New("no contact methods available") + } +} func PublicReportReporterUpdated(ctx context.Context, org_id int32, report_id string, tablename string) { resource := resourceTypeFromTablename(tablename) event.Updated(resource, org_id, report_id) @@ -56,3 +77,14 @@ func resourceTypeFromTablename(tablename string) event.ResourceType { return event.TypeUnknown } } +func reportFromID(ctx context.Context, user User, report_id string) (string, *models.PublicreportReportLocation, error) { + report, 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 "", nil, err + } + tablename := report.TableName.MustGet() + return tablename, report, nil +} diff --git a/platform/text/report-subscription.go b/platform/text/report-subscription.go index 19710bf6..e55ebfce 100644 --- a/platform/text/report-subscription.go +++ b/platform/text/report-subscription.go @@ -31,7 +31,7 @@ func (j jobReportSubscription) content() string { return fmt.Sprintf("Thanks for submitting mosquito report %s. Text for any questions. We'll send you updates as we get them.", j.reportID) } func (j jobReportSubscription) destination() string { - return phonenumbers.Format(&j.dst, phonenumbers.E164) + return phonenumbers.Format(j.dst.number, phonenumbers.E164) } func (j jobReportSubscription) messageType() MessageType { return ReportSubscription @@ -40,7 +40,7 @@ func (j jobReportSubscription) messageTypeName() string { return "report-subscription" } func (j jobReportSubscription) source() string { - return phonenumbers.Format(&j.src, phonenumbers.E164) + return phonenumbers.Format(j.src.number, phonenumbers.E164) } func sendReportSubscription(ctx context.Context, job Job) error { diff --git a/platform/text/report.go b/platform/text/report.go new file mode 100644 index 00000000..9aa046f5 --- /dev/null +++ b/platform/text/report.go @@ -0,0 +1,10 @@ +package text + +import ( + "context" + //"github.com/Gleipnir-Technology/nidus-sync/platform/types" +) + +func ReportMessage(ctx context.Context, user_id int32, report_id, destination, message string) (*int32, error) { + return nil, nil +} diff --git a/platform/text/text.go b/platform/text/text.go index b3f06d7e..339cbe7a 100644 --- a/platform/text/text.go +++ b/platform/text/text.go @@ -23,7 +23,15 @@ import ( "github.com/rs/zerolog/log" ) -type E164 = phonenumbers.PhoneNumber +type E164 struct { + number *phonenumbers.PhoneNumber +} + +func NewE164(n *phonenumbers.PhoneNumber) *E164 { + return &E164{ + number: n, + } +} func EnsureInDB(ctx context.Context, ex bob.Executor, destination E164) (err error) { return ensureInDB(ctx, ex, PhoneString(destination)) @@ -96,11 +104,17 @@ func HandleTextMessage(src string, dst string, body string) { } func ParsePhoneNumber(input string) (*E164, error) { - return phonenumbers.Parse(input, "US") + n, err := phonenumbers.Parse(input, "US") + if err != nil { + return nil, err + } + return &E164{ + number: n, + }, nil } func PhoneString(p E164) string { - return phonenumbers.Format(&p, phonenumbers.E164) + return phonenumbers.Format(p.number, phonenumbers.E164) } func StoreSources() error {