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) {
go func() {
for envelope := range chan_envelopes {
for conn, _ := range connectionsSSE {
for conn := range connectionsSSE {
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")
conn.chanEvent <- envelope.Event

View file

@ -345,19 +345,20 @@ func parseRequest[RequestType any](r *http.Request) (*RequestType, *nhttp.ErrorW
var err error
var req RequestType
content_type := r.Header.Get("Content-Type")
if content_type == "application/json" {
switch content_type {
case "application/json":
body, e := io.ReadAll(r.Body)
if e != nil {
return nil, nhttp.NewError("Failed to read body: %w", err)
}
err = json.Unmarshal(body, &req)
} else if content_type == "application/x-www-form-urlencoded" {
case "application/x-www-form-urlencoded":
e := r.ParseForm()
if err != nil {
return nil, nhttp.NewBadRequest("parsing form: %w", e)
}
err = decoder.Decode(&req, r.PostForm)
} else {
default:
return nil, nhttp.NewBadRequest("unrecognized content type '%s'", content_type)
}
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("/client/ios", auth.NewEnsureAuth(handleClientIos)).Methods("GET")
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.HandleFunc("/compliance-request/image/pool/{public_id}", getComplianceRequestImagePool).Methods("GET")
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/signal", authenticatedHandlerJSONPost(postPublicreportSignal)).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/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")

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
requested_with := r.Header.Get("X-Requested-With")
//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"`)
// Separate return codes for different authentication failures
if _, ok := err.(*NoCredentialsError); ok {

View file

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

View file

@ -98,7 +98,7 @@ func Parse() (err error) {
if 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")
}
FieldseekerSchemaDirectory = os.Getenv("FIELDSEEKER_SCHEMA_DIRECTORY")

View file

@ -149,10 +149,6 @@ func InitializeDatabase(ctx context.Context, uri string) error {
if err != nil {
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
}

View file

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

View file

@ -8,6 +8,7 @@ CREATE TABLE communication (
invalidated_by INTEGER REFERENCES user_(id),
opened TIMESTAMP WITHOUT TIME ZONE,
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_text_log_id INTEGER REFERENCES comms.text_log(id),
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"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
@ -21,52 +20,6 @@ import (
//go:embed prepared_functions/*.sql
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 {
type Skn struct {
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 {
var err error
//log.Debug().Msg("Adding subdir templates")
err = fs.WalkDir(ts.sourceFS, subdir, func(path string, d fs.DirEntry, err error) error {
var err error = fs.WalkDir(ts.sourceFS, subdir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}

View file

@ -42,7 +42,7 @@ type Draft struct {
func NewDraft(projectID int) *DraftRequest {
return &DraftRequest{
DraftID: 0,
Project: string(projectID),
Project: fmt.Sprint(rune(projectID)),
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() == "" {
//log.Debug().Msg("Tool called")
} else {
var toSay string = m.String()
var toSay = m.String()
toSay = strings.Replace(toSay, "report-mosquitoes-online: ", "", 1)
return Message{
Content: toSay,

View file

@ -164,9 +164,7 @@ func (l *defaultLogEntry) Panic(v interface{}, stack []byte) {
}
func init() {
color := true
if runtime.GOOS == "windows" {
color = false
}
color := !(runtime.GOOS == "windows")
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() {
f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl := f.ResponseWriter.(http.Flusher)
fl.Flush()
}
@ -158,7 +158,7 @@ type hijackWriter struct {
}
func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
hj := f.ResponseWriter.(http.Hijacker)
return hj.Hijack()
}
@ -171,12 +171,12 @@ type flushHijackWriter struct {
func (f *flushHijackWriter) Flush() {
f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl := f.ResponseWriter.(http.Flusher)
fl.Flush()
}
func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
hj := f.ResponseWriter.(http.Hijacker)
return hj.Hijack()
}
@ -193,12 +193,12 @@ type httpFancyWriter struct {
func (f *httpFancyWriter) Flush() {
f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl := f.ResponseWriter.(http.Flusher)
fl.Flush()
}
func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
hj := f.ResponseWriter.(http.Hijacker)
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) {
if f.basicWriter.tee != nil {
if f.tee != nil {
n, err := io.Copy(&f.basicWriter, r)
f.basicWriter.bytes += int(n)
f.bytes += int(n)
return n, err
}
rf := f.basicWriter.ResponseWriter.(io.ReaderFrom)
f.basicWriter.maybeWriteHeader()
rf := f.ResponseWriter.(io.ReaderFrom)
f.maybeWriteHeader()
n, err := rf.ReadFrom(r)
f.basicWriter.bytes += int(n)
f.bytes += int(n)
return n, err
}
@ -234,7 +234,7 @@ type http2FancyWriter struct {
func (f *http2FancyWriter) Flush() {
f.wroteHeader = true
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl := f.ResponseWriter.(http.Flusher)
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
columnNames := make([]string, len(sorted_columns)+2)
for i, c := range sorted_columns {
columnNames[i] = c
}
copy(columnNames, sorted_columns)
columnNames[len(sorted_columns)] = "geometry_x"
columnNames[len(sorted_columns)+1] = "geometry_y"
@ -1031,7 +1029,7 @@ func selectAllFromQueryResult(table string, sorted_columns []string) string {
return sb.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 {
@ -1618,7 +1616,7 @@ func aggregateAtResolution(ctx context.Context, resolution int, org_id int32, ty
if err != nil {
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"))
for cell, count := range cellToCount {
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
}
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
}
geom_query := geom.PostgisPointQuery(*geo.Address.Location)
@ -329,7 +329,7 @@ func parseHeaders(row []string) ([]headerPoolEnum, []string) {
ht := strings.TrimSpace(h)
hl := strings.ToLower(ht)
log.Debug().Str("header", hl).Msg("Saw CSV header")
var type_ headerPoolEnum = headerPoolTag
var type_ = headerPoolTag
switch hl {
case "city":
type_ = headerPoolAddressLocality

View file

@ -64,7 +64,7 @@ func createLabelStudioClient() (*labelstudio.Client, error) {
// Get and store the access token
err := labelStudioClient.GetAccessToken()
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")
@ -116,7 +116,7 @@ func createTask(client *labelstudio.Client, project *labelstudio.Project, minioC
return fmt.Errorf("Failed to upload audio: %v", err)
}
}
var transcription string = ""
var transcription = ""
//if note.Transcription.IsValue() {
//transcription = note.Transcription.MustGet()
//}

View file

@ -15,7 +15,11 @@ import (
"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/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"
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/omitnull"
//"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) {
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) {
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)
@ -382,6 +389,15 @@ func publicReportCreate(ctx context.Context, setter_report models.PublicreportRe
UserID: omitnull.FromPtr[int32](nil),
}).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)
event.Created(

View file

@ -59,9 +59,7 @@ func logEntriesByReportID(ctx context.Context, report_ids []int32, is_public boo
if !ok {
return results, fmt.Errorf("no text logs for %d", report_id)
}
for _, l := range logs {
cur_logs = append(cur_logs, l)
}
cur_logs = append(cur_logs, 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 {
return fmt.Errorf("Failed to get phone status")
}
is_visible_to_llm := true
if status == enums.CommsPhonestatustypeUnconfirmed {
is_visible_to_llm = false
}
is_visible_to_llm := !(status == enums.CommsPhonestatustypeUnconfirmed)
l, err := models.CommsTextLogs.Insert(&models.CommsTextLogSetter{
//ID:
Content: omit.From(content),

View file

@ -1,7 +1,6 @@
package platform
import (
"errors"
"fmt"
"time"
@ -257,7 +256,7 @@ func toTemplateTrapsNearby(locations []sql.TrapLocationBySourceIDRow, trap_data
for _, td := range trap_data {
c, ok := count_by_trap_data_id[td.Globalid]
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
count := &TrapCount{
@ -278,7 +277,7 @@ func toTemplateTrapsNearby(locations []sql.TrapLocationBySourceIDRow, trap_data
for _, location := range locations {
counts, ok := counts_by_location_id[location.TrapLocationGlobalid]
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{
Counts: counts,

View file

@ -7,6 +7,7 @@ import (
"time"
"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"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/google/uuid"
@ -26,14 +27,10 @@ func Communication(r *router) *communicationR {
type communication struct {
Created time.Time `json:"created"`
ID string `json:"id"`
PublicReport string `json:"public_report"`
ID int32 `json:"id"`
Source string `json:"source"`
Type string `json:"type"`
}
type communicationList struct {
Communications []communication `json:"communications"`
}
func toImageURLs(m map[string][]uuid.UUID, id string) []string {
uuids, ok := m[id]
@ -46,24 +43,61 @@ func toImageURLs(m map[string][]uuid.UUID, id string) []string {
}
return urls
}
func (res *communicationR) List(ctx context.Context, r *http.Request, user platform.User, query QueryParams) (*communicationList, *nhttp.ErrorWithStatus) {
reports, err := platform.PublicReportsForOrganization(ctx, user.Organization.ID, false)
func (res *communicationR) List(ctx context.Context, r *http.Request, user platform.User, query QueryParams) ([]*communication, *nhttp.ErrorWithStatus) {
comms, err := platform.CommunicationsForOrganization(ctx, int64(user.Organization.ID))
if err != nil {
return nil, nhttp.NewError("nuisance report query: %w", err)
}
comms := make([]communication, len(reports))
for i, report := range reports {
populateDistrictURI(report, res.router)
populateReportURI(report, res.router, false)
comms[i] = communication{
Created: report.Created,
ID: report.PublicID,
PublicReport: report.URI,
Type: "publicreport." + string(report.Type),
report_ids := make([]int64, 0)
for _, comm := range comms {
if comm.SourceReportID != nil {
report_ids = append(report_ids, int64(*comm.SourceReportID))
}
}
_by_created := func(a, b communication) int {
if a.Created == b.Created {
public_reports, err := platform.PublicReportsFromIDs(ctx, report_ids)
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
} else if a.Created.Before(b.Created) {
return 1
@ -71,8 +105,13 @@ func (res *communicationR) List(ctx context.Context, r *http.Request, user platf
return -1
}
}
slices.SortFunc(comms, _by_created)
return &communicationList{
Communications: comms,
}, nil
slices.SortFunc(result, _by_created)
return result, 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":
route_name = "publicreport.water.ByIDGet"
default:
return "", fmt.Errorf("Unrecognized report type '%s'", report_type)
route_name = "publicreport.ByIDGet"
}
uri, err := r.IDStrToURI(route_name, public_id)
if err != nil {

View file

@ -20,10 +20,8 @@ func (qp QueryParams) SortOrDefault(default_name string, ascending bool) (string
if s == "" {
return default_name, ascending
}
a := true
if s[0] == '-' {
a = false
}
a := !(s[0] == '-')
if s[0] == '+' || s[0] == '-' {
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) {
// 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")
}
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)
}
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")
}
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)
for _, leg := range route.Legs {
all_stops = append(all_stops, leg.Points[0])
for _, p := range leg.Points {
all_points = append(all_points, p)
}
all_points = append(all_points, leg.Points...)
}
}
lines := tomtom.PolylineToGeoJSON(all_points)

View file

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

View file

@ -74,7 +74,7 @@
}"
@click="handleClick(comm.id)"
>
<ListCardPublicReport :comm="comm" />
<ListCardCommunication :comm="comm" />
</div>
</div>
</div>
@ -91,7 +91,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import ListCardPublicReport from "@/components/ListCardPublicReport.vue";
import ListCardCommunication from "@/components/ListCardCommunication.vue";
import { Communication, LogEntry, PublicReport } from "@/type/api";
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;
}
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZoneName: "short",
}).format(date);
}

View file

@ -33,12 +33,10 @@ export const useCommunicationStore = defineStore("communication", () => {
//if (typeFilter.value) params.append("type", typeFilter.value);
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) =>
Communication.fromJSON(c),
);
return data.communications;
all.value = data.map((c: CommunicationDTO) => Communication.fromJSON(c));
return all.value;
} catch (err) {
console.error("Error loading communications:", err);
throw err;

View file

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

View file

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