Return communication database rows from communication API

This is a pretty big refactor of how communication works to start moving
us in the direction we want to go long-term. This adds the new
communication row and migrates existing reports to add rows for
communication.

There's also a bunch of automatic fixes from the new linter. I should
have added them separately, but whatever.
This commit is contained in:
Eli Ribble 2026-05-01 20:49:37 +00:00
parent a6ce0b7e67
commit a82732a49c
No known key found for this signature in database
41 changed files with 365 additions and 220 deletions

View file

@ -67,7 +67,7 @@ func (c *ConnectionSSE) SendHeartbeat(w http.ResponseWriter, t time.Time) error
func SetEventChannel(chan_envelopes <-chan platform.Envelope) { func SetEventChannel(chan_envelopes <-chan platform.Envelope) {
go func() { go func() {
for envelope := range chan_envelopes { for envelope := range chan_envelopes {
for conn, _ := range connectionsSSE { for conn := range connectionsSSE {
if conn.organizationID == envelope.OrganizationID || envelope.OrganizationID == 0 { if conn.organizationID == envelope.OrganizationID || envelope.OrganizationID == 0 {
log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Msg("pushed event to client") log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Msg("pushed event to client")
conn.chanEvent <- envelope.Event conn.chanEvent <- envelope.Event

View file

@ -345,19 +345,20 @@ func parseRequest[RequestType any](r *http.Request) (*RequestType, *nhttp.ErrorW
var err error var err error
var req RequestType var req RequestType
content_type := r.Header.Get("Content-Type") content_type := r.Header.Get("Content-Type")
if content_type == "application/json" { switch content_type {
case "application/json":
body, e := io.ReadAll(r.Body) body, e := io.ReadAll(r.Body)
if e != nil { if e != nil {
return nil, nhttp.NewError("Failed to read body: %w", err) return nil, nhttp.NewError("Failed to read body: %w", err)
} }
err = json.Unmarshal(body, &req) err = json.Unmarshal(body, &req)
} else if content_type == "application/x-www-form-urlencoded" { case "application/x-www-form-urlencoded":
e := r.ParseForm() e := r.ParseForm()
if err != nil { if err != nil {
return nil, nhttp.NewBadRequest("parsing form: %w", e) return nil, nhttp.NewBadRequest("parsing form: %w", e)
} }
err = decoder.Decode(&req, r.PostForm) err = decoder.Decode(&req, r.PostForm)
} else { default:
return nil, nhttp.NewBadRequest("unrecognized content type '%s'", content_type) return nil, nhttp.NewBadRequest("unrecognized content type '%s'", content_type)
} }
if err != nil { if err != nil {

View file

@ -98,7 +98,7 @@ func AddRoutesSync(r *mux.Router) {
r.Handle("/avatar", authenticatedHandlerPostMultipart(avatar.Create, file.CollectionAvatar)).Methods("POST") r.Handle("/avatar", authenticatedHandlerPostMultipart(avatar.Create, file.CollectionAvatar)).Methods("POST")
r.Handle("/client/ios", auth.NewEnsureAuth(handleClientIos)).Methods("GET") r.Handle("/client/ios", auth.NewEnsureAuth(handleClientIos)).Methods("GET")
communication := resource.Communication(router) communication := resource.Communication(router)
r.Handle("/communication", authenticatedHandlerJSON(communication.List)).Methods("GET") r.Handle("/communication", authenticatedHandlerJSONSlice(communication.List)).Methods("GET")
r.Handle("/compliance-request/mailer", authenticatedHandlerJSONPost(compliance_request.CreateMailer)).Methods("POST") r.Handle("/compliance-request/mailer", authenticatedHandlerJSONPost(compliance_request.CreateMailer)).Methods("POST")
//r.HandleFunc("/compliance-request/image/pool/{public_id}", getComplianceRequestImagePool).Methods("GET") //r.HandleFunc("/compliance-request/image/pool/{public_id}", getComplianceRequestImagePool).Methods("GET")
r.Handle("/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis)).Methods("POST") r.Handle("/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis)).Methods("POST")
@ -121,7 +121,7 @@ func AddRoutesSync(r *mux.Router) {
r.Handle("/publicreport/invalid", authenticatedHandlerJSONPost(postPublicreportInvalid)).Methods("POST") r.Handle("/publicreport/invalid", authenticatedHandlerJSONPost(postPublicreportInvalid)).Methods("POST")
r.Handle("/publicreport/signal", authenticatedHandlerJSONPost(postPublicreportSignal)).Methods("POST") r.Handle("/publicreport/signal", authenticatedHandlerJSONPost(postPublicreportSignal)).Methods("POST")
r.Handle("/publicreport/message", authenticatedHandlerJSONPost(postPublicreportMessage)).Methods("POST") r.Handle("/publicreport/message", authenticatedHandlerJSONPost(postPublicreportMessage)).Methods("POST")
r.Handle("/publicreport/{id}", authenticatedHandlerBasic(publicreport.ByID)).Methods("GET").Name("publicreport.ByIDGetPublic") r.Handle("/publicreport/{id}", authenticatedHandlerBasic(publicreport.ByID)).Methods("GET").Name("publicreport.ByIDGet")
r.Handle("/publicreport/compliance/{id}", authenticatedHandlerJSON(pr_compliance.ByID)).Methods("GET").Name("publicreport.compliance.ByIDGet") r.Handle("/publicreport/compliance/{id}", authenticatedHandlerJSON(pr_compliance.ByID)).Methods("GET").Name("publicreport.compliance.ByIDGet")
r.Handle("/publicreport/nuisance/{id}", authenticatedHandlerJSON(nuisance.ByID)).Methods("GET").Name("publicreport.nuisance.ByIDGet") r.Handle("/publicreport/nuisance/{id}", authenticatedHandlerJSON(nuisance.ByID)).Methods("GET").Name("publicreport.nuisance.ByIDGet")
r.Handle("/publicreport/water/{id}", authenticatedHandlerJSON(water.ByID)).Methods("GET").Name("publicreport.water.ByIDGet") r.Handle("/publicreport/water/{id}", authenticatedHandlerJSON(water.ByID)).Methods("GET").Name("publicreport.water.ByIDGet")

View file

@ -126,7 +126,7 @@ func (ea *EnsureAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Don't send authentication headers for browsers because it forces the authentication popup // Don't send authentication headers for browsers because it forces the authentication popup
requested_with := r.Header.Get("X-Requested-With") requested_with := r.Header.Get("X-Requested-With")
//log.Debug().Str("x-requested-with", requested_with).Send() //log.Debug().Str("x-requested-with", requested_with).Send()
if !(strings.HasPrefix(requested_with, "nidus-web") || accept == "text/event-stream") { if !strings.HasPrefix(requested_with, "nidus-web") && accept != "text/event-stream" {
w.Header().Set("WWW-Authenticate", `Basic realm="Nidus Sync"`) w.Header().Set("WWW-Authenticate", `Basic realm="Nidus Sync"`)
// Separate return codes for different authentication failures // Separate return codes for different authentication failures
if _, ok := err.(*NoCredentialsError); ok { if _, ok := err.(*NoCredentialsError); ok {

View file

@ -26,7 +26,7 @@ func main() {
} }
func scanValue(message string, result *string) { func scanValue(message string, result *string) {
fmt.Printf(message) fmt.Print("%s", message)
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
if ok := scanner.Scan(); !ok { if ok := scanner.Scan(); !ok {
log.Fatal(errors.New("Failed to scan input")) log.Fatal(errors.New("Failed to scan input"))

View file

@ -98,7 +98,7 @@ func Parse() (err error) {
if Environment == "" { if Environment == "" {
return fmt.Errorf("You must specify a non-empty ENVIRONMENT") return fmt.Errorf("You must specify a non-empty ENVIRONMENT")
} }
if !(Environment == "PRODUCTION" || Environment == "DEVELOPMENT") { if Environment != "PRODUCTION" && Environment != "DEVELOPMENT" {
return fmt.Errorf("ENVIRONMENT should be either DEVELOPMENT or PRODUCTION") return fmt.Errorf("ENVIRONMENT should be either DEVELOPMENT or PRODUCTION")
} }
FieldseekerSchemaDirectory = os.Getenv("FIELDSEEKER_SCHEMA_DIRECTORY") FieldseekerSchemaDirectory = os.Getenv("FIELDSEEKER_SCHEMA_DIRECTORY")

View file

@ -149,10 +149,6 @@ func InitializeDatabase(ctx context.Context, uri string) error {
if err != nil { if err != nil {
return fmt.Errorf("Failed to get database current: %w", err) return fmt.Errorf("Failed to get database current: %w", err)
} }
err = prepareStatements(ctx)
if err != nil {
return fmt.Errorf("Failed to initialize prepared statements: %w", err)
}
return nil return nil
} }

View file

@ -15,6 +15,8 @@ import (
var schemas []string = []string{ var schemas []string = []string{
"arcgis", "arcgis",
"public",
"publicreport",
"stadia", "stadia",
} }

View file

@ -8,6 +8,7 @@ CREATE TABLE communication (
invalidated_by INTEGER REFERENCES user_(id), invalidated_by INTEGER REFERENCES user_(id),
opened TIMESTAMP WITHOUT TIME ZONE, opened TIMESTAMP WITHOUT TIME ZONE,
opened_by INTEGER REFERENCES user_(id), opened_by INTEGER REFERENCES user_(id),
organization_id INTEGER NOT NULL REFERENCES organization(id),
response_email_log_id INTEGER REFERENCES comms.email_log(id), response_email_log_id INTEGER REFERENCES comms.email_log(id),
response_text_log_id INTEGER REFERENCES comms.text_log(id), response_text_log_id INTEGER REFERENCES comms.text_log(id),
set_pending TIMESTAMP WITHOUT TIME ZONE, set_pending TIMESTAMP WITHOUT TIME ZONE,

View file

@ -0,0 +1,37 @@
-- +goose Up
INSERT INTO communication (
closed,
closed_by,
created,
--id,
invalidated,
invalidated_by,
opened,
opened_by,
organization_id,
response_email_log_id,
response_text_log_id,
set_pending,
set_pending_by,
source_email_log_id,
source_report_id,
source_text_log_id
) SELECT
NULL,
NULL,
created,
NULL,
NULL,
NULL,
NULL,
organization_id,
NULL,
NULL,
NULL,
NULL,
NULL,
id,
NULL
FROM publicreport.report;
-- +goose Down
DELETE FROM communication;

View file

@ -0,0 +1,22 @@
-- +goose Up
CREATE TYPE publicreport.PermissionAccessType AS ENUM (
'denied',
'granted',
'unselected',
'with-owner'
);
ALTER TABLE publicreport.compliance
ALTER COLUMN permission_type
TYPE publicreport.PermissionAccessType USING permission_type::text::publicreport.PermissionAccessType;
DROP TYPE PermissionAccessType;
-- +goose Down
CREATE TYPE PermissionAccessType AS ENUM (
'denied',
'granted',
'unselected',
'with-owner'
);
ALTER TABLE publicreport.compliance
ALTER COLUMN permission_type
TYPE PermissionAccessType;
DROP TYPE publicreport.PermissionAccessType;

View file

@ -5,7 +5,6 @@ import (
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"strings" "strings"
"time" "time"
@ -21,52 +20,6 @@ import (
//go:embed prepared_functions/*.sql //go:embed prepared_functions/*.sql
var sqlFiles embed.FS var sqlFiles embed.FS
// PrepareStatements reads all embedded SQL files and executes them
// against the provided database connection. This is intended for
// preparing statements that will be used later.
func prepareStatements(ctx context.Context) error {
return nil
// Get a list of all embedded SQL files
entries, err := sqlFiles.ReadDir("prepared_functions")
if err != nil {
return fmt.Errorf("failed to read SQL directory: %w", err)
}
log.Info().Int("len", len(entries)).Msg("Reading prepared functions")
// Process each SQL file
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
log.Info().Str("name", entry.Name()).Msg("Skipping")
continue
}
// Read the SQL file content
content, err := sqlFiles.ReadFile(filepath.Join("prepared_functions", entry.Name()))
if err != nil {
return fmt.Errorf("failed to read SQL file %s: %w", entry.Name(), err)
}
// Get the statement name from the filename (without extension)
statementName := strings.TrimSuffix(filepath.Base(entry.Name()), ".sql")
// Execute the SQL to prepare the statement
_, err = PGInstance.BobDB.Exec(string(content))
if err != nil {
return fmt.Errorf("failed to prepare statement %s: %w", statementName, err)
}
/*
query := psql.RawQuery(string(content))
stmt, err := bob.Prepare(ctx, PGInstance.BobDB, query)
if err != nil {
return fmt.Errorf("failed to prepare statement %s: %w", statementName, err)
}
*/
log.Info().Str("statement", statementName).Msg("Prepared statement")
}
return nil
}
func TestPreparedQueryOld(ctx context.Context) error { func TestPreparedQueryOld(ctx context.Context) error {
type Skn struct { type Skn struct {
Result int Result int

View file

@ -0,0 +1,26 @@
package public
import (
"context"
"time"
"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/public/model"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/public/table"
"github.com/go-jet/jet/v2/postgres"
)
func CommunicationInsert(ctx context.Context, txn bob.Tx, m *model.Communication) (*model.Communication, error) {
m.Created = time.Now()
statement := table.Communication.INSERT(table.Communication.MutableColumns).
MODEL(m)
return db.ExecuteOne[model.Communication](ctx, statement)
}
func CommunicationsFromOrganization(ctx context.Context, org_id int64) ([]*model.Communication, error) {
statement := table.Communication.SELECT(
table.Communication.AllColumns,
).FROM(table.Communication).
WHERE(table.Communication.OrganizationID.EQ(postgres.Int(org_id)))
return db.ExecuteMany[model.Communication](ctx, statement)
}

View file

@ -0,0 +1,32 @@
package public
import (
"context"
//"time"
//"github.com/Gleipnir-Technology/bob"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/publicreport/model"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/publicreport/table"
"github.com/go-jet/jet/v2/postgres"
)
/*
func CommunicationInsert(ctx context.Context, txn bob.Tx, m *model.Communication) (*model.Communication, error) {
m.Created = time.Now()
statement := table.Communication.INSERT(table.Communication.MutableColumns).
MODEL(m)
return db.ExecuteOne[model.Communication](ctx, statement)
}
*/
func PublicReportsFromIDs(ctx context.Context, report_ids []int64) ([]*model.Report, error) {
sql_ids := make([]postgres.Expression, len(report_ids))
for i, report_id := range report_ids {
sql_ids[i] = postgres.Int(report_id)
}
statement := table.Report.SELECT(
table.Report.AllColumns,
).FROM(table.Report).
WHERE(table.Report.ID.IN(sql_ids...))
return db.ExecuteMany[model.Report](ctx, statement)
}

View file

@ -76,9 +76,7 @@ func (ts templateSystemEmbed) loadTemplateSubdir(subdir string) error {
} }
func (ts templateSystemEmbed) addSubdirTemplates(t *template.Template, subdir string) error { func (ts templateSystemEmbed) addSubdirTemplates(t *template.Template, subdir string) error {
var err error var err error = fs.WalkDir(ts.sourceFS, subdir, func(path string, d fs.DirEntry, err error) error {
//log.Debug().Msg("Adding subdir templates")
err = fs.WalkDir(ts.sourceFS, subdir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
return err return err
} }

View file

@ -42,7 +42,7 @@ type Draft struct {
func NewDraft(projectID int) *DraftRequest { func NewDraft(projectID int) *DraftRequest {
return &DraftRequest{ return &DraftRequest{
DraftID: 0, DraftID: 0,
Project: string(projectID), Project: fmt.Sprint(rune(projectID)),
StartedAt: time.Now().UTC().Format(time.RFC3339Nano), StartedAt: time.Now().UTC().Format(time.RFC3339Nano),
} }
} }

View file

@ -69,7 +69,7 @@ func (c *openAIClient) continueConversation(ctx context.Context, tools genai.Opt
if m.String() == "" { if m.String() == "" {
//log.Debug().Msg("Tool called") //log.Debug().Msg("Tool called")
} else { } else {
var toSay string = m.String() var toSay = m.String()
toSay = strings.Replace(toSay, "report-mosquitoes-online: ", "", 1) toSay = strings.Replace(toSay, "report-mosquitoes-online: ", "", 1)
return Message{ return Message{
Content: toSay, Content: toSay,

View file

@ -164,9 +164,7 @@ func (l *defaultLogEntry) Panic(v interface{}, stack []byte) {
} }
func init() { func init() {
color := true color := !(runtime.GOOS == "windows")
if runtime.GOOS == "windows" {
color = false
}
DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: !color}) DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: !color})
} }

View file

@ -146,7 +146,7 @@ type flushWriter struct {
func (f *flushWriter) Flush() { func (f *flushWriter) Flush() {
f.wroteHeader = true f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher) fl := f.ResponseWriter.(http.Flusher)
fl.Flush() fl.Flush()
} }
@ -158,7 +158,7 @@ type hijackWriter struct {
} }
func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker) hj := f.ResponseWriter.(http.Hijacker)
return hj.Hijack() return hj.Hijack()
} }
@ -171,12 +171,12 @@ type flushHijackWriter struct {
func (f *flushHijackWriter) Flush() { func (f *flushHijackWriter) Flush() {
f.wroteHeader = true f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher) fl := f.ResponseWriter.(http.Flusher)
fl.Flush() fl.Flush()
} }
func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker) hj := f.ResponseWriter.(http.Hijacker)
return hj.Hijack() return hj.Hijack()
} }
@ -193,12 +193,12 @@ type httpFancyWriter struct {
func (f *httpFancyWriter) Flush() { func (f *httpFancyWriter) Flush() {
f.wroteHeader = true f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher) fl := f.ResponseWriter.(http.Flusher)
fl.Flush() fl.Flush()
} }
func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker) hj := f.ResponseWriter.(http.Hijacker)
return hj.Hijack() return hj.Hijack()
} }
@ -207,15 +207,15 @@ func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error {
} }
func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) {
if f.basicWriter.tee != nil { if f.tee != nil {
n, err := io.Copy(&f.basicWriter, r) n, err := io.Copy(&f.basicWriter, r)
f.basicWriter.bytes += int(n) f.bytes += int(n)
return n, err return n, err
} }
rf := f.basicWriter.ResponseWriter.(io.ReaderFrom) rf := f.ResponseWriter.(io.ReaderFrom)
f.basicWriter.maybeWriteHeader() f.maybeWriteHeader()
n, err := rf.ReadFrom(r) n, err := rf.ReadFrom(r)
f.basicWriter.bytes += int(n) f.bytes += int(n)
return n, err return n, err
} }
@ -234,7 +234,7 @@ type http2FancyWriter struct {
func (f *http2FancyWriter) Flush() { func (f *http2FancyWriter) Flush() {
f.wroteHeader = true f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher) fl := f.ResponseWriter.(http.Flusher)
fl.Flush() fl.Flush()
} }

View file

@ -792,9 +792,7 @@ func rowmapViaQuery(ctx context.Context, table string, sorted_columns []string,
// +2 for geometry x and geometry x // +2 for geometry x and geometry x
columnNames := make([]string, len(sorted_columns)+2) columnNames := make([]string, len(sorted_columns)+2)
for i, c := range sorted_columns { copy(columnNames, sorted_columns)
columnNames[i] = c
}
columnNames[len(sorted_columns)] = "geometry_x" columnNames[len(sorted_columns)] = "geometry_x"
columnNames[len(sorted_columns)+1] = "geometry_y" columnNames[len(sorted_columns)+1] = "geometry_y"
@ -1031,7 +1029,7 @@ func selectAllFromQueryResult(table string, sorted_columns []string) string {
return sb.String() return sb.String()
} }
func toHistoryTable(table string) string { func toHistoryTable(table string) string {
return "History_" + table[3:len(table)] return "History_" + table[3:]
} }
func updateRowFromFeatureFS(ctx context.Context, transaction bob.Tx, table string, sorted_columns []string, feature *response.Feature) error { func updateRowFromFeatureFS(ctx context.Context, transaction bob.Tx, table string, sorted_columns []string, feature *response.Feature) error {
@ -1618,7 +1616,7 @@ func aggregateAtResolution(ctx context.Context, resolution int, org_id int32, ty
if err != nil { if err != nil {
return fmt.Errorf("Failed to clear previous aggregation: %w", err) return fmt.Errorf("Failed to clear previous aggregation: %w", err)
} }
var to_insert []bob.Mod[*dialect.InsertQuery] = make([]bob.Mod[*dialect.InsertQuery], 0) var to_insert = make([]bob.Mod[*dialect.InsertQuery], 0)
to_insert = append(to_insert, im.Into("h3_aggregation", "cell", "resolution", "count_", "type_", "organization_id", "geometry")) to_insert = append(to_insert, im.Into("h3_aggregation", "cell", "resolution", "count_", "type_", "organization_id", "geometry"))
for cell, count := range cellToCount { for cell, count := range cellToCount {
polygon, err := h3utils.CellToPostgisGeometry(cell) polygon, err := h3utils.CellToPostgisGeometry(cell)

12
platform/communication.go Normal file
View file

@ -0,0 +1,12 @@
package platform
import (
"context"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/public/model"
querypublic "github.com/Gleipnir-Technology/nidus-sync/db/query/public"
)
func CommunicationsForOrganization(ctx context.Context, org_id int64) ([]*model.Communication, error) {
return querypublic.CommunicationsFromOrganization(ctx, org_id)
}

View file

@ -156,7 +156,7 @@ func geocodePool(ctx context.Context, txn bob.Tx, client *stadia.StadiaMaps, job
return nil return nil
} }
if geo.Address.Location == nil { if geo.Address.Location == nil {
addError(ctx, txn, job.csv, pool.LineNumber, 0, fmt.Sprintf("nil location from geocoding")) addError(ctx, txn, job.csv, pool.LineNumber, 0, "nil location from geocoding")
return nil return nil
} }
geom_query := geom.PostgisPointQuery(*geo.Address.Location) geom_query := geom.PostgisPointQuery(*geo.Address.Location)
@ -329,7 +329,7 @@ func parseHeaders(row []string) ([]headerPoolEnum, []string) {
ht := strings.TrimSpace(h) ht := strings.TrimSpace(h)
hl := strings.ToLower(ht) hl := strings.ToLower(ht)
log.Debug().Str("header", hl).Msg("Saw CSV header") log.Debug().Str("header", hl).Msg("Saw CSV header")
var type_ headerPoolEnum = headerPoolTag var type_ = headerPoolTag
switch hl { switch hl {
case "city": case "city":
type_ = headerPoolAddressLocality type_ = headerPoolAddressLocality

View file

@ -64,7 +64,7 @@ func createLabelStudioClient() (*labelstudio.Client, error) {
// Get and store the access token // Get and store the access token
err := labelStudioClient.GetAccessToken() err := labelStudioClient.GetAccessToken()
if err != nil { if err != nil {
return nil, errors.New(fmt.Sprintf("Failed to get access token: %v", err)) return nil, fmt.Errorf("Failed to get access token: %v", err)
} }
log.Println("Got label studio client access token") log.Println("Got label studio client access token")
@ -116,7 +116,7 @@ func createTask(client *labelstudio.Client, project *labelstudio.Project, minioC
return fmt.Errorf("Failed to upload audio: %v", err) return fmt.Errorf("Failed to upload audio: %v", err)
} }
} }
var transcription string = "" var transcription = ""
//if note.Transcription.IsValue() { //if note.Transcription.IsValue() {
//transcription = note.Transcription.MustGet() //transcription = note.Transcription.MustGet()
//} //}

View file

@ -15,7 +15,11 @@ import (
"github.com/Gleipnir-Technology/bob/dialect/psql/um" "github.com/Gleipnir-Technology/bob/dialect/psql/um"
"github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums" "github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/public/model"
modelpublicreport "github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/publicreport/model"
"github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/db/models"
querypublic "github.com/Gleipnir-Technology/nidus-sync/db/query/public"
querypublicreport "github.com/Gleipnir-Technology/nidus-sync/db/query/publicreport"
"github.com/aarondl/opt/omit" "github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull" "github.com/aarondl/opt/omitnull"
//"github.com/Gleipnir-Technology/nidus-sync/platform/background" //"github.com/Gleipnir-Technology/nidus-sync/platform/background"
@ -214,6 +218,9 @@ func PublicReportReporterUpdated(ctx context.Context, org_id int32, report_id st
func PublicReportsForOrganization(ctx context.Context, org_id int32, is_public bool) ([]*types.PublicReport, error) { func PublicReportsForOrganization(ctx context.Context, org_id int32, is_public bool) ([]*types.PublicReport, error) {
return publicreport.ReportsForOrganization(ctx, org_id, is_public) return publicreport.ReportsForOrganization(ctx, org_id, is_public)
} }
func PublicReportsFromIDs(ctx context.Context, report_ids []int64) ([]*modelpublicreport.Report, error) {
return querypublicreport.PublicReportsFromIDs(ctx, report_ids)
}
func PublicReportComplianceCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_compliance models.PublicreportComplianceSetter, org_id int32) (*models.PublicreportReport, error) { func PublicReportComplianceCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_compliance models.PublicreportComplianceSetter, org_id int32) (*models.PublicreportReport, error) {
return publicReportCreate(ctx, setter_report, nil, nil, nil, org_id, func(ctx context.Context, txn bob.Executor, report_id int32) error { return publicReportCreate(ctx, setter_report, nil, nil, nil, org_id, func(ctx context.Context, txn bob.Executor, report_id int32) error {
setter_compliance.ReportID = omit.From(report_id) setter_compliance.ReportID = omit.From(report_id)
@ -382,6 +389,15 @@ func publicReportCreate(ctx context.Context, setter_report models.PublicreportRe
UserID: omitnull.FromPtr[int32](nil), UserID: omitnull.FromPtr[int32](nil),
}).One(ctx, txn) }).One(ctx, txn)
comm := &model.Communication{
SourceReportID: &result.ID,
}
comm, err = querypublic.CommunicationInsert(ctx, txn, comm)
if err != nil {
return nil, fmt.Errorf("insert communication: %w", err)
}
log.Debug().Int32("id", comm.ID).Msg("inserted new communication")
txn.Commit(ctx) txn.Commit(ctx)
event.Created( event.Created(

View file

@ -59,9 +59,7 @@ func logEntriesByReportID(ctx context.Context, report_ids []int32, is_public boo
if !ok { if !ok {
return results, fmt.Errorf("no text logs for %d", report_id) return results, fmt.Errorf("no text logs for %d", report_id)
} }
for _, l := range logs { cur_logs = append(cur_logs, logs...)
cur_logs = append(cur_logs, l)
}
results[report_id] = cur_logs results[report_id] = cur_logs
} }
} }

View file

@ -38,10 +38,8 @@ func HandleTextMessage(ctx context.Context, source string, destination string, c
if err != nil { if err != nil {
return fmt.Errorf("Failed to get phone status") return fmt.Errorf("Failed to get phone status")
} }
is_visible_to_llm := true is_visible_to_llm := !(status == enums.CommsPhonestatustypeUnconfirmed)
if status == enums.CommsPhonestatustypeUnconfirmed {
is_visible_to_llm = false
}
l, err := models.CommsTextLogs.Insert(&models.CommsTextLogSetter{ l, err := models.CommsTextLogs.Insert(&models.CommsTextLogSetter{
//ID: //ID:
Content: omit.From(content), Content: omit.From(content),

View file

@ -1,7 +1,6 @@
package platform package platform
import ( import (
"errors"
"fmt" "fmt"
"time" "time"
@ -257,7 +256,7 @@ func toTemplateTrapsNearby(locations []sql.TrapLocationBySourceIDRow, trap_data
for _, td := range trap_data { for _, td := range trap_data {
c, ok := count_by_trap_data_id[td.Globalid] c, ok := count_by_trap_data_id[td.Globalid]
if !ok { if !ok {
return results, errors.New(fmt.Sprintf("Failed to find trap count for %s", td.Globalid)) return results, fmt.Errorf("Failed to find trap count for %s", td.Globalid)
} }
loc_id := td.LocID loc_id := td.LocID
count := &TrapCount{ count := &TrapCount{
@ -278,7 +277,7 @@ func toTemplateTrapsNearby(locations []sql.TrapLocationBySourceIDRow, trap_data
for _, location := range locations { for _, location := range locations {
counts, ok := counts_by_location_id[location.TrapLocationGlobalid] counts, ok := counts_by_location_id[location.TrapLocationGlobalid]
if !ok { if !ok {
return results, errors.New(fmt.Sprintf("Failed to find counts for %s", location.TrapLocationGlobalid)) return results, fmt.Errorf("Failed to find counts for %s", location.TrapLocationGlobalid)
} }
trap := TrapNearby{ trap := TrapNearby{
Counts: counts, Counts: counts,

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/Gleipnir-Technology/nidus-sync/config" "github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db/gen/nidus-sync/publicreport/model"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http" nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform" "github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/google/uuid" "github.com/google/uuid"
@ -25,14 +26,10 @@ func Communication(r *router) *communicationR {
} }
type communication struct { type communication struct {
Created time.Time `json:"created"` Created time.Time `json:"created"`
ID string `json:"id"` ID int32 `json:"id"`
PublicReport string `json:"public_report"` Source string `json:"source"`
Source string `json:"source"` Type string `json:"type"`
Type string `json:"type"`
}
type communicationList struct {
Communications []communication `json:"communications"`
} }
func toImageURLs(m map[string][]uuid.UUID, id string) []string { func toImageURLs(m map[string][]uuid.UUID, id string) []string {
@ -46,24 +43,61 @@ func toImageURLs(m map[string][]uuid.UUID, id string) []string {
} }
return urls return urls
} }
func (res *communicationR) List(ctx context.Context, r *http.Request, user platform.User, query QueryParams) (*communicationList, *nhttp.ErrorWithStatus) { func (res *communicationR) List(ctx context.Context, r *http.Request, user platform.User, query QueryParams) ([]*communication, *nhttp.ErrorWithStatus) {
reports, err := platform.PublicReportsForOrganization(ctx, user.Organization.ID, false) comms, err := platform.CommunicationsForOrganization(ctx, int64(user.Organization.ID))
if err != nil { if err != nil {
return nil, nhttp.NewError("nuisance report query: %w", err) return nil, nhttp.NewError("nuisance report query: %w", err)
} }
comms := make([]communication, len(reports)) report_ids := make([]int64, 0)
for i, report := range reports { for _, comm := range comms {
populateDistrictURI(report, res.router) if comm.SourceReportID != nil {
populateReportURI(report, res.router, false) report_ids = append(report_ids, int64(*comm.SourceReportID))
comms[i] = communication{
Created: report.Created,
ID: report.PublicID,
PublicReport: report.URI,
Type: "publicreport." + string(report.Type),
} }
} }
_by_created := func(a, b communication) int { public_reports, err := platform.PublicReportsFromIDs(ctx, report_ids)
if a.Created == b.Created { if err != nil {
return nil, nhttp.NewError("public reports from IDs: %w", err)
}
public_report_id_to_report := make(map[int32]*model.Report, 0)
for _, pr := range public_reports {
public_report_id_to_report[pr.ID] = pr
}
result := make([]*communication, len(comms))
for i, comm := range comms {
source_uri := "unknown"
type_ := "unknown"
if comm.SourceReportID != nil {
public_report, ok := public_report_id_to_report[*comm.SourceReportID]
if !ok {
return nil, nhttp.NewError("lookup report id %d failed", comm.SourceReportID)
}
source_uri, err = reportURI(res.router, "", public_report.PublicID)
if err != nil {
return nil, nhttp.NewError("gen report URI: %w", err)
}
type_ = "publicreport." + public_report.ReportType.String()
} else if comm.SourceEmailLogID != nil {
source_uri, err = emailURI(res.router, *comm.SourceEmailLogID)
if err != nil {
return nil, nhttp.NewError("gen email URI: %w", err)
}
type_ = "email"
} else if comm.SourceTextLogID != nil {
source_uri, err = textURI(res.router, *comm.SourceTextLogID)
if err != nil {
return nil, nhttp.NewError("gen email URI: %w", err)
}
source_uri = "text"
}
result[i] = &communication{
Created: comm.Created,
ID: comm.ID,
Source: source_uri,
Type: type_,
}
}
_by_created := func(a, b *communication) int {
if a.Created.Equal(b.Created) {
return 0 return 0
} else if a.Created.Before(b.Created) { } else if a.Created.Before(b.Created) {
return 1 return 1
@ -71,8 +105,13 @@ func (res *communicationR) List(ctx context.Context, r *http.Request, user platf
return -1 return -1
} }
} }
slices.SortFunc(comms, _by_created) slices.SortFunc(result, _by_created)
return &communicationList{ return result, nil
Communications: comms, }
}, nil
func emailURI(r *router, id int32) (string, error) {
return "fake email uri", nil
}
func textURI(r *router, id int32) (string, error) {
return "fake text uri", nil
} }

View file

@ -118,7 +118,7 @@ func reportURI(r *router, report_type string, public_id string) (string, error)
case "water": case "water":
route_name = "publicreport.water.ByIDGet" route_name = "publicreport.water.ByIDGet"
default: default:
return "", fmt.Errorf("Unrecognized report type '%s'", report_type) route_name = "publicreport.ByIDGet"
} }
uri, err := r.IDStrToURI(route_name, public_id) uri, err := r.IDStrToURI(route_name, public_id)
if err != nil { if err != nil {

View file

@ -20,10 +20,8 @@ func (qp QueryParams) SortOrDefault(default_name string, ascending bool) (string
if s == "" { if s == "" {
return default_name, ascending return default_name, ascending
} }
a := true a := !(s[0] == '-')
if s[0] == '-' {
a = false
}
if s[0] == '+' || s[0] == '-' { if s[0] == '+' || s[0] == '-' {
s = s[1:] s = s[1:]
} }

View file

@ -98,7 +98,7 @@ func (res *uploadR) Discard(ctx context.Context, r *http.Request, u platform.Use
func (res *uploadR) PoolFlyoverCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) { func (res *uploadR) PoolFlyoverCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
// If the organization we're uploading to doesn't have a service area, we can't process the upload correctly // If the organization we're uploading to doesn't have a service area, we can't process the upload correctly
if !(u.Organization.HasServiceArea() || u.Organization.IsCatchall()) { if !u.Organization.HasServiceArea() && !u.Organization.IsCatchall() {
return "", nhttp.NewErrorStatus(http.StatusConflict, "Your organization does not yet have a service area") return "", nhttp.NewErrorStatus(http.StatusConflict, "Your organization does not yet have a service area")
} }
if len(uploads) == 0 { if len(uploads) == 0 {

View file

@ -94,7 +94,7 @@ func (res *userR) ByIDPut(ctx context.Context, r *http.Request, user platform.Us
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "user id conversion: %w", err) return "", nhttp.NewErrorStatus(http.StatusBadRequest, "user id conversion: %w", err)
} }
user_changes := &models.UserSetter{} user_changes := &models.UserSetter{}
if !(user.HasRoot() || user.IsAccountOwner() || user.ID == user_id) { if !user.HasRoot() && !user.IsAccountOwner() && user.ID != user_id {
return "", nhttp.NewForbidden("Only account owners can change other users") return "", nhttp.NewForbidden("Only account owners can change other users")
} }
if updates.Avatar.IsValue() { if updates.Avatar.IsValue() {

View file

@ -51,9 +51,7 @@ func main() {
log.Printf("%d: %d meters, %d seconds, %s traffic delay", i, s.LengthInMeters, s.TravelTimeInSeconds, s.TrafficDelayInSeconds) log.Printf("%d: %d meters, %d seconds, %s traffic delay", i, s.LengthInMeters, s.TravelTimeInSeconds, s.TrafficDelayInSeconds)
for _, leg := range route.Legs { for _, leg := range route.Legs {
all_stops = append(all_stops, leg.Points[0]) all_stops = append(all_stops, leg.Points[0])
for _, p := range leg.Points { all_points = append(all_points, leg.Points...)
all_points = append(all_points, p)
}
} }
} }
lines := tomtom.PolylineToGeoJSON(all_points) lines := tomtom.PolylineToGeoJSON(all_points)

View file

@ -10,10 +10,7 @@ type PointShort struct {
} }
func (ps PointShort) AsPoint() Point { func (ps PointShort) AsPoint() Point {
return Point{ return Point(ps)
Latitude: ps.Latitude,
Longitude: ps.Longitude,
}
} }
type GeocodeResult struct { type GeocodeResult struct {

View file

@ -74,7 +74,7 @@
}" }"
@click="handleClick(comm.id)" @click="handleClick(comm.id)"
> >
<ListCardPublicReport :comm="comm" /> <ListCardCommunication :comm="comm" />
</div> </div>
</div> </div>
</div> </div>
@ -91,7 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import ListCardPublicReport from "@/components/ListCardPublicReport.vue"; import ListCardCommunication from "@/components/ListCardCommunication.vue";
import { Communication, LogEntry, PublicReport } from "@/type/api"; import { Communication, LogEntry, PublicReport } from "@/type/api";
interface Props { interface Props {

View file

@ -0,0 +1,92 @@
<template>
<!-- First row: icon, type badge, and time -->
<div class="justify-content-between align-items-center">
<div class="row">
<div class="d-flex align-items-center">
<div class="col">
<Tooltip placement="top" :title="tooltipTitleForCommunicationType()">
<i class="bi fs-4 me-2" :class="iconForReportType()"></i>
</Tooltip>
<Tooltip placement="top" :title="tooltipTitleForReportType()">
<i class="bi fs-4 me-2" :class="iconForCommunicationType()"></i>
</Tooltip>
</div>
<div class="col-6 text-end">
<small>
<Tooltip placement="top" :title="tooltipTitleForCreated()">
<TimeRelative :time="comm.created" />
</Tooltip>
</small>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import TimeRelative from "@/components/TimeRelative.vue";
import Tooltip from "@/components/Tooltip.vue";
import { formatAddress, formatDate } from "@/format";
import { Communication } from "@/type/api";
interface Props {
comm: Communication;
}
const props = defineProps<Props>();
function iconForCommunicationType(): string {
switch (props.comm.type) {
case "publicreport.compliance":
return "bi-card-checklist";
case "publicreport.nuisance":
return "bi-mosquito";
case "publicreport.water":
return "bi-droplet-fill";
default:
return "";
}
}
function iconForReportType(): string {
switch (props.comm.type) {
case "publicreport.compliance":
case "publicreport.nuisance":
case "publicreport.water":
return "bi-postcard";
case "email":
return "bi-envelope";
case "text":
return "bi-chat-dots";
default:
return "";
}
}
function tooltipTitleForCommunicationType(): string {
switch (props.comm.type) {
case "publicreport.compliance":
case "publicreport.nuisance":
case "publicreport.water":
return "A report made from a member of the public to report.mosquitoes.online";
case "email":
return "An email received from a member of the public";
case "text":
return "An SMS/MMS text message received from a member of the public";
default:
return "I'm actually not sure what this is. How are you even seeing this?";
}
}
function tooltipTitleForReportType(): string {
switch (props.comm.type) {
case "publicreport.compliance":
case "publicreport.nuisance":
case "publicreport.water":
return "A compliance report either made by scanning a door hanger or by receiving a personal letter through the mail";
case "publicreport.nuisance":
return "A report of a mosquito nuisance";
case "publicreport.water":
return "A report of standing water";
default:
return "I'm actually not sure what this is. This shouldn't be possible.";
}
}
function tooltipTitleForCreated(): string {
return `or at exactly ${formatDate(props.comm.created)}`;
}
</script>

View file

@ -1,65 +0,0 @@
<template>
<!-- First row: icon, type badge, and time -->
<div class="justify-content-between align-items-center">
<div class="row">
<div class="d-flex align-items-center">
<div class="col">
<i class="bi fs-4 me-2" :class="iconForType()"></i>
</div>
<div class="col-6 text-end">
<span class="badge" :class="colorForType()">
{{ titleForType() }}
</span>
</div>
</div>
</div>
<div class="row">
<small>
<TimeRelative :time="comm.created" />
</small>
</div>
</div>
</template>
<script setup lang="ts">
import TimeRelative from "@/components/TimeRelative.vue";
import { formatAddress } from "@/format";
import { Communication } from "@/type/api";
interface Props {
comm: Communication;
}
const props = defineProps<Props>();
function colorForType(): string {
if (props.comm.type == "publicreport.compliance") {
return "bg-secondary";
} else if (props.comm.type == "publicreport.nuisance") {
return "bg-danger";
} else if (props.comm.type == "publicreport.water") {
return "bg-info";
} else {
return "";
}
}
function iconForType(): string {
if (props.comm.type == "publicreport.compliance") {
return "bi-postcard";
} else if (props.comm.type == "publicreport.nuisance") {
return "bi-mosquito";
} else if (props.comm.type == "publicreport.water") {
return "bi-droplet-fill";
} else {
return "";
}
}
function titleForType(): string {
if (props.comm.type == "publicreport.compliance") {
return "Compliance";
} else if (props.comm.type == "publicreport.nuisance") {
return "Nuisance";
} else if (props.comm.type == "publicreport.water") {
return "Standing Water";
} else {
return "Unknown";
}
}
</script>

View file

@ -29,12 +29,14 @@ export function formatBigNumber(n: number): string {
return result; return result;
} }
export function formatDate(date: Date): string { export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
hour12: false,
timeZoneName: "short",
}).format(date); }).format(date);
} }

View file

@ -33,12 +33,10 @@ export const useCommunicationStore = defineStore("communication", () => {
//if (typeFilter.value) params.append("type", typeFilter.value); //if (typeFilter.value) params.append("type", typeFilter.value);
const url = `${session.urls.api.communication}?${params}`; const url = `${session.urls.api.communication}?${params}`;
const data = await apiClient.JSONGet(url); const data = (await apiClient.JSONGet(url)) as CommunicationDTO[];
all.value = data.communications.map((c: CommunicationDTO) => all.value = data.map((c: CommunicationDTO) => Communication.fromJSON(c));
Communication.fromJSON(c), return all.value;
);
return data.communications;
} catch (err) { } catch (err) {
console.error("Error loading communications:", err); console.error("Error loading communications:", err);
throw err; throw err;

View file

@ -523,22 +523,22 @@ export class PublicReportWater extends PublicReport {
export interface CommunicationDTO { export interface CommunicationDTO {
created: string; created: string;
id: string; id: string;
public_report?: string; source: string;
type: string; type: string;
} }
export class Communication { export class Communication {
constructor( constructor(
public created: Date, public created: Date,
public id: string, public id: string,
public source: string,
public type: string, public type: string,
public public_report?: string,
) {} ) {}
static fromJSON(json: CommunicationDTO): Communication { static fromJSON(json: CommunicationDTO): Communication {
return new Communication( return new Communication(
new Date(json.created), new Date(json.created),
json.id, json.id,
json.source,
json.type, json.type,
json.public_report,
); );
} }
} }

View file

@ -97,7 +97,7 @@ const currentImage = computed(() => {
}); });
const currentImages = computed(() => { const currentImages = computed(() => {
const comm = selectedCommunication.value; const comm = selectedCommunication.value;
if (comm == null || comm.public_report == null) { if (comm == null) {
return []; return [];
} }
return selectedReport.value?.images ?? []; return selectedReport.value?.images ?? [];
@ -181,13 +181,12 @@ const selectedReport = computedAsync(
async (): Promise<PublicReport | undefined> => { async (): Promise<PublicReport | undefined> => {
if ( if (
!( !(
selectedCommunication.value && selectedCommunication.value.public_report selectedCommunication.value &&
selectedCommunication.value.type != "publicreport"
) )
) )
return; return;
return await storePublicReport.fetchByURI( return await storePublicReport.byURI(selectedCommunication.value.source);
selectedCommunication.value.public_report,
);
}, },
); );
const handleDeselect = (id: string) => { const handleDeselect = (id: string) => {