Initial creation of endpoint to send messages to public reporters

This commit is contained in:
Eli Ribble 2026-03-15 22:38:36 +00:00
parent 9707e8793b
commit cc95c38ab5
No known key found for this signature in database
12 changed files with 240 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

10
platform/email/report.go Normal file
View file

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

View file

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

View file

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

10
platform/text/report.go Normal file
View file

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

View file

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