diff --git a/api/communication.go b/api/communication.go index 41784688..29c77dc9 100644 --- a/api/communication.go +++ b/api/communication.go @@ -15,13 +15,8 @@ import ( //"github.com/rs/zerolog/log" ) -type historyEntry struct { - Action string `json:"action"` - Timestamp time.Time `json:"timestamp"` -} type communication struct { Created time.Time `json:"created"` - History []historyEntry `json:"history"` ID string `json:"id"` PublicReport types.PublicReport `json:"public_report"` Type string `json:"type"` @@ -38,13 +33,7 @@ func listCommunication(ctx context.Context, r *http.Request, user platform.User, comms := make([]communication, len(reports)) for i, report := range reports { comms[i] = communication{ - Created: report.Created, - History: []historyEntry{ - historyEntry{ - Action: "created", - Timestamp: report.Created, - }, - }, + Created: report.Created, ID: report.PublicID, PublicReport: report, Type: "nuisance", diff --git a/html/template/sync/communication-root.html b/html/template/sync/communication-root.html index 28b731bb..1ef324e0 100644 --- a/html/template/sync/communication-root.html +++ b/html/template/sync/communication-root.html @@ -237,15 +237,6 @@ throw new Error(`HTTP error! status: ${response.status}`); } - // Add to activity log - if (!this.selectedCommunication.history) { - this.selectedCommunication.history = []; - } - this.selectedCommunication.history.push({ - action: "Message sent to reporter", - timestamp: new Date(), - }); - this.showNotification( "Message Sent", `Message successfully sent to ${this.selectedCommunication.public_report.reporter.name}`, @@ -844,19 +835,38 @@
Activity Log
diff --git a/llm/openai.go b/llm/openai.go index 1712100d..1b643aac 100644 --- a/llm/openai.go +++ b/llm/openai.go @@ -2,6 +2,7 @@ package llm import ( "context" + "errors" "fmt" "strings" @@ -50,6 +51,9 @@ type QueryReportStatusInput struct { var client *openAIClient func (c *openAIClient) continueConversation(ctx context.Context, tools genai.OptionsTools, msg genai.Message) (Message, error) { + if c.client == nil { + return Message{}, errors.New("Client not initialized") + } res, _, err := adapters.GenSyncWithToolCallLoop(ctx, c.client, genai.Messages{msg}, &tools) if err != nil { return Message{}, fmt.Errorf("Failed to continue conversation: %v", err) diff --git a/platform/publicreport.go b/platform/publicreport.go index d16055f1..5ef67781 100644 --- a/platform/publicreport.go +++ b/platform/publicreport.go @@ -6,16 +6,21 @@ import ( "fmt" "time" - "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/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/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" //"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/geocode" + "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" ) @@ -58,7 +63,7 @@ func PublicReportMessageCreate(ctx context.Context, user User, report_id, messag return nil, fmt.Errorf("send text: %w", err) } txn.Commit(ctx) - log.Debug().Int32("msg_id", *msg_id).Msg("Created text.ReportMessage") + //log.Debug().Int32("msg_id", *msg_id).Msg("Created text.ReportMessage") return msg_id, nil } else if report.ReporterEmail != "" { msg_id, err := email.ReportMessage(ctx, int32(user.ID), report_id, report.ReporterEmail, message) @@ -72,9 +77,134 @@ func PublicReportMessageCreate(ctx context.Context, user User, report_id, messag return nil, errors.New("no contact methods available") } } -func PublicReportReporterUpdated(ctx context.Context, org_id int32, report_id string, tablename string) { +func PublicReportReporterUpdated(ctx context.Context, org_id int32, report_id string) { event.Updated(event.TypeRMOReport, org_id, report_id) } +func ReportNuisanceCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_nuisance models.PublicreportNuisanceSetter, latlng LatLng, address Address, images []ImageUpload) (*models.PublicreportReport, error) { + return reportCreate(ctx, setter_report, latlng, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) error { + setter_nuisance.ReportID = omit.From(report_id) + _, err := models.PublicreportNuisances.Insert(&setter_nuisance).One(ctx, txn) + if err != nil { + return fmt.Errorf("Failed to create nuisance database record: %w", err) + } + return nil + }) +} + +func ReportWaterCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_water models.PublicreportWaterSetter, latlng LatLng, address Address, images []ImageUpload) (*models.PublicreportReport, error) { + return reportCreate(ctx, setter_report, latlng, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) error { + setter_water.ReportID = omit.From(report_id) + _, err := models.PublicreportWaters.Insert(&setter_water).One(ctx, txn) + if err != nil { + return fmt.Errorf("Failed to create water database record: %w", err) + } + return nil + }) +} + +type funcSetReportDetail = func(context.Context, bob.Executor, int32) error + +func reportCreate(ctx context.Context, setter_report models.PublicreportReportSetter, latlng LatLng, address Address, images []ImageUpload, detail_setter funcSetReportDetail) (result *models.PublicreportReport, err error) { + txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("create txn: %w", err) + } + defer txn.Rollback(ctx) + + public_id, err := report.GenerateReportID() + if err != nil { + return nil, fmt.Errorf("create public ID: %w", err) + } + setter_report.PublicID = omit.From(public_id) + + // If we've got an locality value it was set by geocoding so we should save it + var a *models.Address + if address.Locality != "" && latlng.Latitude != nil && latlng.Longitude != nil { + a, err = geocode.EnsureAddress(ctx, txn, address, types.Location{ + Latitude: *latlng.Latitude, + Longitude: *latlng.Longitude, + }) + if err != nil { + return nil, fmt.Errorf("Failed to ensure address: %w", err) + } + } + + saved_images, err := saveImageUploads(ctx, txn, images) + if err != nil { + return nil, fmt.Errorf("Failed to save image uploads: %w", err) + } + var organization_id *int32 + organization_id, err = MatchDistrict(ctx, latlng.Longitude, latlng.Latitude, images) + if err != nil { + log.Warn().Err(err).Msg("Failed to match district") + } + + if a != nil { + setter_report.AddressID = omitnull.From(a.ID) + } + if organization_id != nil { + setter_report.OrganizationID = omit.FromPtr(organization_id) + } + result, err = models.PublicreportReports.Insert(&setter_report).One(ctx, txn) + if err != nil { + return nil, fmt.Errorf("Failed to create report database record: %w", err) + } + if latlng.Latitude != nil && latlng.Longitude != nil { + h3cell, _ := latlng.H3Cell() + geom_query, _ := latlng.GeometryQuery() + _, err = psql.Update( + um.Table("publicreport.report"), + um.SetCol("h3cell").ToArg(h3cell), + um.SetCol("location").To(geom_query), + um.Where(psql.Quote("id").EQ(psql.Arg(result.ID))), + ).Exec(ctx, txn) + if err != nil { + return nil, fmt.Errorf("Failed to insert publicreport.report geospatial", err) + } + } + log.Info().Str("public_id", public_id).Int32("id", result.ID).Msg("Created base report") + + if len(saved_images) > 0 { + setters := make([]*models.PublicreportReportImageSetter, 0) + for _, image := range saved_images { + setters = append(setters, &models.PublicreportReportImageSetter{ + ImageID: omit.From(int32(image.ID)), + ReportID: omit.From(int32(result.ID)), + }) + } + _, err = models.PublicreportReportImages.Insert(bob.ToMods(setters...)).Exec(ctx, txn) + if err != nil { + return nil, fmt.Errorf("Failed to save reference to images: %w", err) + } + log.Info().Int("len", len(images)).Msg("saved uploaded images") + } + + err = detail_setter(ctx, txn, result.ID) + if err != nil { + return nil, fmt.Errorf("detail setter: %w", err) + } + + models.PublicreportReportLogs.Insert(&models.PublicreportReportLogSetter{ + Created: omit.From(time.Now()), + EmailLogID: omitnull.FromPtr[int32](nil), + // ID + ReportID: omit.From(result.ID), + TextLogID: omitnull.FromPtr[int32](nil), + Type: omit.From(enums.PublicreportReportlogtypeCreated), + UserID: omitnull.FromPtr[int32](nil), + }).One(ctx, txn) + + txn.Commit(ctx) + + if organization_id != nil { + event.Created( + event.TypeRMONuisance, + *organization_id, + result.PublicID, + ) + } + return result, nil +} func reportFromID(ctx context.Context, user User, report_id string) (*models.PublicreportReport, error) { report, err := models.PublicreportReports.Query( models.SelectWhere.PublicreportReports.PublicID.EQ(report_id), diff --git a/platform/publicreport/log.go b/platform/publicreport/log.go index de2608f6..b32457d0 100644 --- a/platform/publicreport/log.go +++ b/platform/publicreport/log.go @@ -32,6 +32,7 @@ func logEntriesByReportID(ctx context.Context, report_ids []int32) (map[int32][] sm.Columns( "l.created", "l.id", + "COALESCE(t.content, '') AS message", "l.report_id", "l.type_", "l.user_id", diff --git a/platform/publicreport/report.go b/platform/publicreport/report.go index 93e6913b..c584e24c 100644 --- a/platform/publicreport/report.go +++ b/platform/publicreport/report.go @@ -18,6 +18,7 @@ import ( ) type Report struct { + Log []LogEntry `db:"-" json:"log"` Address types.Address `db:"address" json:"address"` AddressRaw string `db:"address_raw" json:"address_raw"` Created time.Time `db:"created" json:"created"` @@ -31,7 +32,7 @@ type Report struct { Water *Water `db:"water" json:"water"` } -func ReportsForOrganization(ctx context.Context, org_id int32) ([]Report, error) { +func ReportsForOrganization(ctx context.Context, org_id int32) ([]*Report, error) { rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select( sm.Columns( "address_country AS \"address.country\"", @@ -67,6 +68,10 @@ func ReportsForOrganization(ctx context.Context, org_id int32) ([]Report, error) if err != nil { return nil, fmt.Errorf("images for report: %w", err) } + logs_by_report_id, err := logEntriesByReportID(ctx, report_ids) + if err != nil { + return nil, fmt.Errorf("log entries for reports: %w", err) + } nuisances_by_report_id, err := nuisancesByReportID(ctx, report_ids) if err != nil { return nil, fmt.Errorf("nuisances: %w", err) @@ -76,17 +81,20 @@ func ReportsForOrganization(ctx context.Context, org_id int32) ([]Report, error) return nil, fmt.Errorf("waters: %w", err) } - for _, row := range rows { + results := make([]*Report, len(rows)) + for i, row := range rows { images, ok := images_by_id[row.ID] if ok { row.Images = images } else { row.Images = []types.Image{} } + row.Log = logs_by_report_id[row.ID] row.Nuisance = nuisances_by_report_id[row.ID] row.Water = waters_by_report_id[row.ID] + results[i] = &row } - return rows, nil + return results, nil } func ReportsForOrganizationCount(ctx context.Context, org_id int32) (uint, error) { type _Row struct { diff --git a/platform/start.go b/platform/start.go index 41e35b85..25946aa2 100644 --- a/platform/start.go +++ b/platform/start.go @@ -169,7 +169,7 @@ func listenAndDoOneJob(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to parse int from payload '%s': %w", notification.Payload, err) } - log.Debug().Int("job_id", job_id).Msg("got notification for job") + //log.Debug().Int("job_id", job_id).Msg("got notification for job") c := bobpgx.NewConn(conn.Conn()) job, err := models.FindJob(ctx, c, int32(job_id)) @@ -199,6 +199,6 @@ func listenAndDoOneJob(ctx context.Context) error { return fmt.Errorf("delete job: %w", err) } txn.Commit(ctx) - sublog.Debug().Msg("job complete") + //sublog.Debug().Msg("job complete") } } diff --git a/platform/text/job.go b/platform/text/job.go index 494641e6..347028aa 100644 --- a/platform/text/job.go +++ b/platform/text/job.go @@ -7,7 +7,7 @@ import ( "github.com/Gleipnir-Technology/bob" "github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/platform/types" - "github.com/rs/zerolog/log" + //"github.com/rs/zerolog/log" ) func JobRespond(ctx context.Context, txn bob.Executor, log_id int32) error { @@ -18,7 +18,7 @@ func JobSend(ctx context.Context, txn bob.Executor, job_id int32) error { if err != nil { return fmt.Errorf("find text: %w", err) } - log.Debug().Int32("job.id", job.ID).Msg("completing text job") + //log.Debug().Int32("job.id", job.ID).Msg("completing text job") return sendTextComplete(ctx, txn, job) } func handleWaitingTextJobs(ctx context.Context, txn bob.Executor, dst types.E164) error { diff --git a/platform/text/report.go b/platform/text/report.go index 5bc9dd95..2a836d6d 100644 --- a/platform/text/report.go +++ b/platform/text/report.go @@ -5,10 +5,14 @@ import ( "fmt" "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/enums" - "github.com/Gleipnir-Technology/nidus-sync/db/models" + //"github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/platform/types" //"github.com/rs/zerolog/log" + "github.com/stephenafamo/scan" ) // Send a message from a district to a public reporter within the context of the public report @@ -29,12 +33,35 @@ func ReportSubscriptionConfirmationText(ctx context.Context, txn bob.Executor, d } return err } -func reportForTextRecipient(ctx context.Context, txn bob.Executor, destination types.E164) (*models.PublicreportReport, error) { - /*return models.ReportText - psql.Query( - return Addresses.Query( - sm.Where(Addresses.Columns.ID.EQ(psql.Arg(IDPK))), - ).Exists(ctx, exec) - */ - return nil, nil + +type reportIDs struct { + ID int32 `db:"id"` + PublicID string `db:"public_id"` + OrganizationID int32 `db:"organization_id"` +} + +// Get the list of reports that are still open for a particular text message recipient +// 'still open' is not well-defined throughout the system, but for now we'll go with +// 'not reviewed in any way'. +func reportsForTextRecipient(ctx context.Context, txn bob.Executor, destination types.E164) ([]reportIDs, error) { + rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select( + sm.Columns( + "r.id", + "r.public_id", + "r.organization_id", + ), + sm.From("comms.text_job").As("t"), + sm.InnerJoin("publicreport.report").As("r").OnEQ( + psql.Quote("t", "report_id"), + psql.Quote("r", "id"), + ), + sm.Where(psql.Quote("t", "report_id").IsNotNull()), + sm.Where(psql.Quote("t", "destination").EQ(psql.Arg(destination.PhoneString()))), + sm.Where(psql.Quote("r", "status").EQ(psql.Arg(enums.PublicreportReportstatustypeReported))), + ), scan.StructMapper[reportIDs]()) + if err != nil { + return []reportIDs{}, fmt.Errorf("query reports: %w", err) + } + + return rows, nil } diff --git a/platform/text/send.go b/platform/text/send.go index 03ade096..569033fc 100644 --- a/platform/text/send.go +++ b/platform/text/send.go @@ -14,6 +14,7 @@ import ( "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/event" "github.com/Gleipnir-Technology/nidus-sync/platform/types" "github.com/rs/zerolog/log" ) @@ -125,14 +126,11 @@ func sendTextComplete(ctx context.Context, txn bob.Executor, job *models.CommsTe return fmt.Errorf("Failed to ensure initial text has been sent: %w", err) } return nil - case enums.CommsPhonestatustypeOkToSend: - _, err = sendTextDirect(ctx, txn, origin, dst.PhoneString(), job.Content, true, false) - if err != nil { - return fmt.Errorf("Failed to send report subscription confirmation: %w", err) - } - return nil + //case enums.CommsPhonestatustypeOkToSend: + // allow to drop through case enums.CommsPhonestatustypeStopped: resendInitialText(ctx, txn, *dst) + return nil } text_log, err := sendTextDirect(ctx, txn, origin, job.Destination, job.Content, true, false) if err != nil { @@ -145,14 +143,33 @@ func sendTextComplete(ctx context.Context, txn bob.Executor, job *models.CommsTe return fmt.Errorf("update job: %w", err) } if job.ReportID.IsValue() { + creator_id := job.CreatorID.MustGet() + report_id := job.ReportID.MustGet() + log.Debug().Int32("creator", creator_id).Int32("report_id", report_id).Msg("Creating report entries for text message") _, err := models.ReportTexts.Insert(&models.ReportTextSetter{ - CreatorID: omit.From(job.CreatorID.MustGet()), - ReportID: omit.From(job.ReportID.MustGet()), + CreatorID: omit.From(creator_id), + ReportID: omit.From(report_id), TextLogID: omit.From(text_log.ID), }).One(ctx, txn) if err != nil { return fmt.Errorf("insert report_text: %w", err) } + models.PublicreportReportLogs.Insert(&models.PublicreportReportLogSetter{ + Created: omit.From(time.Now()), + EmailLogID: omitnull.FromPtr[int32](nil), + // ID + ReportID: omit.From(report_id), + TextLogID: omitnull.From(text_log.ID), + Type: omit.From(enums.PublicreportReportlogtypeMessageText), + UserID: omitnull.From(creator_id), + }).One(ctx, txn) + report, err := models.FindPublicreportReport(ctx, txn, report_id) + if err != nil { + return fmt.Errorf("find public report: %w", err) + } + event.Updated(event.TypeRMOReport, report.OrganizationID, report.PublicID) + } else { + log.Debug().Msg("no report info on text") } return nil } diff --git a/platform/text/text.go b/platform/text/text.go index 931925b7..cbc424dd 100644 --- a/platform/text/text.go +++ b/platform/text/text.go @@ -129,13 +129,26 @@ func respondText(ctx context.Context, txn bob.Executor, log_id int32) error { return nil } // If we've got an open public report from this phone number then we'll let the district respond - report, err := reportForTextRecipient(ctx, txn, *src) + reports, err := reportsForTextRecipient(ctx, txn, *src) if err != nil { return fmt.Errorf("has open report: %w", err) } - if report != nil { + for _, report := range reports { + models.PublicreportReportLogs.Insert(&models.PublicreportReportLogSetter{ + Created: omit.From(time.Now()), + EmailLogID: omitnull.FromPtr[int32](nil), + // ID + ReportID: omit.From(report.ID), + TextLogID: omitnull.From(log_id), + Type: omit.From(enums.PublicreportReportlogtypeMessageText), + UserID: omitnull.FromPtr[int32](nil), + }).One(ctx, txn) event.Updated(event.TypeRMOReport, report.OrganizationID, report.PublicID) } + // If humans are involved, wait for them. + if len(reports) > 0 { + return nil + } // Otherwise let the LLM handle the response return respondTextLLM(ctx, txn, *src) }