From c0b6398de283b15d069831b443fc2a309198a4b9 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Sun, 25 Jan 2026 18:47:22 +0000 Subject: [PATCH] Overhaul text messaging system to be like emails It's a better system for organization and makes it so we can have better logs about what gets sent. --- background/background.go | 3 +- background/text.go | 54 ++----- comms/text/db.go | 93 ++++++++++++ comms/text/job.go | 39 +++++ comms/text/report-subscription.go | 56 +++++++ comms/{ => text}/text.go | 19 ++- db/dberrors/comms.text_log.bob.go | 2 +- db/dberrors/organization.bob.go | 9 ++ db/dbinfo/comms.text_log.bob.go | 52 ++++--- db/dbinfo/organization.bob.go | 28 +++- db/enums/enums.bob.go | 57 ++++--- db/factory/bobfactory_main.bob.go | 4 +- db/factory/bobfactory_random.bob.go | 4 +- db/factory/comms.text_log.bob.go | 172 ++++++++++++++++------ db/migrations/00041_text_log_overhaul.sql | 34 +++++ db/models/comms.text_log.bob.go | 162 ++++++++++++-------- public-report/quick.go | 4 +- public-report/template/mock/nuisance.html | 2 +- public-report/template/mock/water.html | 2 +- 19 files changed, 577 insertions(+), 219 deletions(-) create mode 100644 comms/text/db.go create mode 100644 comms/text/job.go create mode 100644 comms/text/report-subscription.go rename comms/{ => text}/text.go (74%) create mode 100644 db/migrations/00041_text_log_overhaul.sql diff --git a/background/background.go b/background/background.go index 6afdabb4..a5c44584 100644 --- a/background/background.go +++ b/background/background.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/Gleipnir-Technology/nidus-sync/comms/email" + "github.com/Gleipnir-Technology/nidus-sync/comms/text" ) var waitGroup sync.WaitGroup @@ -14,7 +15,7 @@ func Start(ctx context.Context) { channelJobAudio = make(chan jobAudio, 100) // Buffered channel to prevent blocking channelJobEmail = make(chan email.Job, 100) // Buffered channel to prevent blocking - channelJobText = make(chan jobText, 100) // Buffered channel to prevent blocking + channelJobText = make(chan text.Job, 100) // Buffered channel to prevent blocking waitGroup.Add(1) go func() { diff --git a/background/text.go b/background/text.go index 853f7041..80de3b2b 100644 --- a/background/text.go +++ b/background/text.go @@ -2,34 +2,23 @@ package background import ( "context" - "errors" - "fmt" - "github.com/Gleipnir-Technology/nidus-sync/comms" + "github.com/Gleipnir-Technology/nidus-sync/comms/text" "github.com/Gleipnir-Technology/nidus-sync/config" - "github.com/Gleipnir-Technology/nidus-sync/db/enums" "github.com/rs/zerolog/log" ) -var channelJobText chan jobText +var channelJobText chan text.Job -func ReportSubscriptionConfirmationText(destination comms.E164, report_id string) { - enqueueJobText(jobText{ - Destination: destination, - ReportID: report_id, - Source: config.RMOPhoneNumber, - Type: enums.CommsMessagetypetextReportSubscriptionConfirmation, - }) +func ReportSubscriptionConfirmationText(destination text.E164, report_id string) { + enqueueJobText(text.NewJobReportSubscriptionConfirmation( + destination, + report_id, + config.RMOPhoneNumber, + )) } -type jobText struct { - Destination comms.E164 - ReportID string - Source comms.E164 - Type enums.CommsMessagetypetext -} - -func enqueueJobText(job jobText) { +func enqueueJobText(job text.Job) { select { case channelJobText <- job: log.Info().Msg("Enqueued text job") @@ -38,7 +27,7 @@ func enqueueJobText(job jobText) { } } -func startWorkerText(ctx context.Context, channel chan jobText) { +func startWorkerText(ctx context.Context, channel chan text.Job) { go func() { for { select { @@ -46,29 +35,8 @@ func startWorkerText(ctx context.Context, channel chan jobText) { log.Info().Msg("Email worker shutting down.") return case job := <-channel: - err := jobProcessText(job) - if err != nil { - log.Error().Err(err).Str("type", string(job.Type)).Msg("Error processing text message job") - } + text.Handle(ctx, job) } } }() } - -func jobProcessText(job jobText) error { - var message string - switch job.Type { - case enums.CommsMessagetypetextInitialContact: - message = "This is Report Mosquitoes Online. We just got your number. Text \"YES\" to get texts, or \"STOP\" to stap." - case enums.CommsMessagetypetextReportSubscriptionConfirmation: - message = "Thanks for submitting a mosquito report. Text for any questions. We'll send you updates as we get them." - default: - return errors.New("No idea what message to send") - } - err := comms.SendText(job.Source, job.Destination, message) - if err != nil { - log.Error().Err(err).Msg("Failed to send text message") - return fmt.Errorf("Failed to send message '%s' to '%s'", job.Type, job.Destination) - } - return nil -} diff --git a/comms/text/db.go b/comms/text/db.go new file mode 100644 index 00000000..5b609df9 --- /dev/null +++ b/comms/text/db.go @@ -0,0 +1,93 @@ +package text + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + + "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/rs/zerolog/log" + "github.com/stephenafamo/bob/types/pgtypes" +) + +func convertToPGData(data map[string]string) pgtypes.HStore { + result := pgtypes.HStore{} + for k, v := range data { + result[k] = sql.Null[string]{V: v, Valid: true} + } + return result +} + +func ensureInDB(ctx context.Context, destination string) (err error) { + _, err = models.FindCommsPhone(ctx, db.PGInstance.BobDB, destination) + if err != nil { + // doesn't exist + if err.Error() == "sql: no rows in result set" { + _, err = models.CommsPhones.Insert(&models.CommsPhoneSetter{ + E164: omit.From(destination), + IsSubscribed: omit.From(false), + }).One(ctx, db.PGInstance.BobDB) + if err != nil { + return fmt.Errorf("Failed to insert new phone contact: %w", err) + } + log.Info().Str("phone", destination).Msg("Added text to the comms database") + return nil + } + return fmt.Errorf("Unexpected error searching for phone contact: %w", err) + } + return nil +} + +func insertTextLog(ctx context.Context, content string, destination string, source string, origin enums.CommsTextorigin) (err error) { + _, err = models.CommsTextLogs.Insert(&models.CommsTextLogSetter{ + //ID: + Content: omit.From(content), + Created: omit.From(time.Now()), + Destination: omit.From(destination), + Origin: omit.From(origin), + Source: omit.From(source), + }).One(ctx, db.PGInstance.BobDB) + + return err +} +func generatePublicId(t enums.CommsMessagetypeemail, m map[string]string) string { + if m == nil || len(m) == 0 { + // Return hash of empty string for empty maps + emptyHash := sha256.Sum256([]byte("")) + return hex.EncodeToString(emptyHash[:]) + } + + // Get and sort keys for deterministic ordering + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build a string with all key-value pairs + var sb strings.Builder + // Add type first + sb.WriteString(fmt.Sprintf("type:%s,", t)) + for _, k := range keys { + sb.WriteString(k) + sb.WriteString(":") // Separator between key and value + sb.WriteString(m[k]) + sb.WriteString(",") // Separator between pairs + } + + // Compute SHA-256 hash + hasher := sha256.New() + hasher.Write([]byte(sb.String())) + hashBytes := hasher.Sum(nil) + + // Convert to hex string and return + return hex.EncodeToString(hashBytes) +} diff --git a/comms/text/job.go b/comms/text/job.go new file mode 100644 index 00000000..4d2bfdf3 --- /dev/null +++ b/comms/text/job.go @@ -0,0 +1,39 @@ +package text + +import ( + "context" + + "github.com/rs/zerolog/log" +) + +type MessageType int + +const ( + ReportSubscription MessageType = iota +) + +type Job interface { + content() string + destination() string + messageType() MessageType + messageTypeName() string + source() string +} + +func Handle(ctx context.Context, job Job) { + var err error + switch job.messageType() { + case ReportSubscription: + err = sendReportSubscription(ctx, job) + } + if err != nil { + log.Error().Err(err).Str("dest", job.destination()).Str("type", string(job.messageTypeName())).Msg("Error processing email") + return + } + /* + case enums.CommsMessagetypeemailReportStatusScheduled: + case enums.CommsMessagetypeemailReportStatusComplete: + + } + */ +} diff --git a/comms/text/report-subscription.go b/comms/text/report-subscription.go new file mode 100644 index 00000000..1dd1f193 --- /dev/null +++ b/comms/text/report-subscription.go @@ -0,0 +1,56 @@ +package text + +import ( + "context" + "fmt" + + "github.com/Gleipnir-Technology/nidus-sync/db/enums" + "github.com/nyaruka/phonenumbers" + //"github.com/rs/zerolog/log" +) + +func NewJobReportSubscriptionConfirmation( + destination E164, + report_id string, + source E164) jobReportSubscription { + return jobReportSubscription{ + dst: destination, + reportID: report_id, + src: source, + } +} + +type jobReportSubscription struct { + dst E164 + reportID string + src E164 +} + +func (j jobReportSubscription) content() string { + return fmt.Sprintf("Thanks for submitting mosquito report %s. Text for any questions. We'll send you updates as we get them.", j.reportID) +} +func (j jobReportSubscription) destination() string { + return phonenumbers.Format(&j.dst, phonenumbers.E164) +} +func (j jobReportSubscription) messageType() MessageType { + return ReportSubscription +} +func (j jobReportSubscription) messageTypeName() string { + return "report-subscription" +} +func (j jobReportSubscription) source() string { + return phonenumbers.Format(&j.src, phonenumbers.E164) +} + +func sendReportSubscription(ctx context.Context, job Job) error { + j, ok := job.(jobReportSubscription) + if !ok { + return fmt.Errorf("job is not for report subscription confirmation") + } + + err := sendText(ctx, j.src, j.dst, j.content(), enums.CommsTextoriginWebsiteAction) + if err != nil { + return fmt.Errorf("Failed to send report subscription confirmation: %w", err) + } + return nil +} diff --git a/comms/text.go b/comms/text/text.go similarity index 74% rename from comms/text.go rename to comms/text/text.go index 5dfce308..e0846d2b 100644 --- a/comms/text.go +++ b/comms/text/text.go @@ -1,10 +1,12 @@ -package comms +package text import ( + "context" "encoding/json" "fmt" "github.com/Gleipnir-Technology/nidus-sync/config" + "github.com/Gleipnir-Technology/nidus-sync/db/enums" "github.com/nyaruka/phonenumbers" "github.com/rs/zerolog/log" "github.com/twilio/twilio-go" @@ -17,16 +19,23 @@ func ParsePhoneNumber(input string) (*E164, error) { return phonenumbers.Parse(input, "US") } -func SendText(source E164, destination E164, message string) error { - log.Info().Msg("Sending text message...") - // Make sure TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN exists in your environment +func sendText(ctx context.Context, source E164, destination E164, message string, origin enums.CommsTextorigin) error { + src := phonenumbers.Format(&source, phonenumbers.E164) + dest := phonenumbers.Format(&destination, phonenumbers.E164) + err := ensureInDB(ctx, dest) + if err != nil { + return fmt.Errorf("Failed to ensure text message destination is in the DB: %w", err) + } + err = insertTextLog(ctx, message, dest, src, origin) + if err != nil { + return fmt.Errorf("Failed to insert text message in the DB: %w", err) + } client := twilio.NewRestClient() params := &twilioApi.CreateMessageParams{} params.SetMessagingServiceSid(config.TwilioMessagingServiceSID) params.SetBody(message) - dest := phonenumbers.Format(&destination, phonenumbers.E164) params.SetTo(dest) resp, err := client.Api.CreateMessage(params) diff --git a/db/dberrors/comms.text_log.bob.go b/db/dberrors/comms.text_log.bob.go index 4611eb7a..8bd6a9dc 100644 --- a/db/dberrors/comms.text_log.bob.go +++ b/db/dberrors/comms.text_log.bob.go @@ -7,7 +7,7 @@ var CommsTextLogErrors = &commsTextLogErrors{ ErrUniqueTextLogPkey: &UniqueConstraintError{ schema: "comms", table: "text_log", - columns: []string{"destination", "source", "type"}, + columns: []string{"id"}, s: "text_log_pkey", }, } diff --git a/db/dberrors/organization.bob.go b/db/dberrors/organization.bob.go index fee0219d..d3199621 100644 --- a/db/dberrors/organization.bob.go +++ b/db/dberrors/organization.bob.go @@ -18,6 +18,13 @@ var OrganizationErrors = &organizationErrors{ s: "organization_import_district_gid_key", }, + ErrUniqueOrganizationSlugKey: &UniqueConstraintError{ + schema: "", + table: "organization", + columns: []string{"slug"}, + s: "organization_slug_key", + }, + ErrUniqueOrganizationWebsiteKey: &UniqueConstraintError{ schema: "", table: "organization", @@ -31,5 +38,7 @@ type organizationErrors struct { ErrUniqueOrganizationImportDistrictGidKey *UniqueConstraintError + ErrUniqueOrganizationSlugKey *UniqueConstraintError + ErrUniqueOrganizationWebsiteKey *UniqueConstraintError } diff --git a/db/dbinfo/comms.text_log.bob.go b/db/dbinfo/comms.text_log.bob.go index 308ca842..7447d67d 100644 --- a/db/dbinfo/comms.text_log.bob.go +++ b/db/dbinfo/comms.text_log.bob.go @@ -15,6 +15,15 @@ var CommsTextLogs = Table[ Schema: "comms", Name: "text_log", Columns: commsTextLogColumns{ + Content: column{ + Name: "content", + DBType: "text", + Default: "", + Comment: "", + Nullable: false, + Generated: false, + AutoIncr: false, + }, Created: column{ Name: "created", DBType: "timestamp without time zone", @@ -33,18 +42,27 @@ var CommsTextLogs = Table[ Generated: false, AutoIncr: false, }, - Source: column{ - Name: "source", - DBType: "text", + ID: column{ + Name: "id", + DBType: "integer", + Default: "nextval('comms.text_log_id_seq'::regclass)", + Comment: "", + Nullable: false, + Generated: false, + AutoIncr: false, + }, + Origin: column{ + Name: "origin", + DBType: "comms.textorigin", Default: "", Comment: "", Nullable: false, Generated: false, AutoIncr: false, }, - Type: column{ - Name: "type", - DBType: "comms.messagetypetext", + Source: column{ + Name: "source", + DBType: "text", Default: "", Comment: "", Nullable: false, @@ -58,24 +76,14 @@ var CommsTextLogs = Table[ Name: "text_log_pkey", Columns: []indexColumn{ { - Name: "destination", - Desc: null.FromCond(false, true), - IsExpression: false, - }, - { - Name: "source", - Desc: null.FromCond(false, true), - IsExpression: false, - }, - { - Name: "type", + Name: "id", Desc: null.FromCond(false, true), IsExpression: false, }, }, Unique: true, Comment: "", - NullsFirst: []bool{false, false, false}, + NullsFirst: []bool{false}, NullsDistinct: false, Where: "", Include: []string{}, @@ -83,7 +91,7 @@ var CommsTextLogs = Table[ }, PrimaryKey: &constraint{ Name: "text_log_pkey", - Columns: []string{"destination", "source", "type"}, + Columns: []string{"id"}, Comment: "", }, ForeignKeys: commsTextLogForeignKeys{ @@ -111,15 +119,17 @@ var CommsTextLogs = Table[ } type commsTextLogColumns struct { + Content column Created column Destination column + ID column + Origin column Source column - Type column } func (c commsTextLogColumns) AsSlice() []column { return []column{ - c.Created, c.Destination, c.Source, c.Type, + c.Content, c.Created, c.Destination, c.ID, c.Origin, c.Source, } } diff --git a/db/dbinfo/organization.bob.go b/db/dbinfo/organization.bob.go index 23be6de2..da9cf320 100644 --- a/db/dbinfo/organization.bob.go +++ b/db/dbinfo/organization.bob.go @@ -132,6 +132,23 @@ var Organizations = Table[ Where: "", Include: []string{}, }, + OrganizationSlugKey: index{ + Type: "btree", + Name: "organization_slug_key", + Columns: []indexColumn{ + { + Name: "slug", + Desc: null.FromCond(false, true), + IsExpression: false, + }, + }, + Unique: true, + Comment: "", + NullsFirst: []bool{false}, + NullsDistinct: false, + Where: "", + Include: []string{}, + }, OrganizationWebsiteKey: index{ Type: "btree", Name: "organization_website_key", @@ -172,6 +189,11 @@ var Organizations = Table[ Columns: []string{"import_district_gid"}, Comment: "", }, + OrganizationSlugKey: constraint{ + Name: "organization_slug_key", + Columns: []string{"slug"}, + Comment: "", + }, OrganizationWebsiteKey: constraint{ Name: "organization_website_key", Columns: []string{"website"}, @@ -203,12 +225,13 @@ func (c organizationColumns) AsSlice() []column { type organizationIndexes struct { OrganizationPkey index OrganizationImportDistrictGidKey index + OrganizationSlugKey index OrganizationWebsiteKey index } func (i organizationIndexes) AsSlice() []index { return []index{ - i.OrganizationPkey, i.OrganizationImportDistrictGidKey, i.OrganizationWebsiteKey, + i.OrganizationPkey, i.OrganizationImportDistrictGidKey, i.OrganizationSlugKey, i.OrganizationWebsiteKey, } } @@ -224,12 +247,13 @@ func (f organizationForeignKeys) AsSlice() []foreignKey { type organizationUniques struct { OrganizationImportDistrictGidKey constraint + OrganizationSlugKey constraint OrganizationWebsiteKey constraint } func (u organizationUniques) AsSlice() []constraint { return []constraint{ - u.OrganizationImportDistrictGidKey, u.OrganizationWebsiteKey, + u.OrganizationImportDistrictGidKey, u.OrganizationSlugKey, u.OrganizationWebsiteKey, } } diff --git a/db/enums/enums.bob.go b/db/enums/enums.bob.go index 373cd454..2cccb414 100644 --- a/db/enums/enums.bob.go +++ b/db/enums/enums.bob.go @@ -272,35 +272,32 @@ func (e *CommsMessagetypeemail) Scan(value any) error { return nil } -// Enum values for CommsMessagetypetext +// Enum values for CommsTextorigin const ( - CommsMessagetypetextInitialContact CommsMessagetypetext = "initial-contact" - CommsMessagetypetextReportSubscriptionConfirmation CommsMessagetypetext = "report-subscription-confirmation" - CommsMessagetypetextReportStatusScheduled CommsMessagetypetext = "report-status-scheduled" - CommsMessagetypetextReportStatusComplete CommsMessagetypetext = "report-status-complete" + CommsTextoriginDistrict CommsTextorigin = "district" + CommsTextoriginLLM CommsTextorigin = "llm" + CommsTextoriginWebsiteAction CommsTextorigin = "website-action" ) -func AllCommsMessagetypetext() []CommsMessagetypetext { - return []CommsMessagetypetext{ - CommsMessagetypetextInitialContact, - CommsMessagetypetextReportSubscriptionConfirmation, - CommsMessagetypetextReportStatusScheduled, - CommsMessagetypetextReportStatusComplete, +func AllCommsTextorigin() []CommsTextorigin { + return []CommsTextorigin{ + CommsTextoriginDistrict, + CommsTextoriginLLM, + CommsTextoriginWebsiteAction, } } -type CommsMessagetypetext string +type CommsTextorigin string -func (e CommsMessagetypetext) String() string { +func (e CommsTextorigin) String() string { return string(e) } -func (e CommsMessagetypetext) Valid() bool { +func (e CommsTextorigin) Valid() bool { switch e { - case CommsMessagetypetextInitialContact, - CommsMessagetypetextReportSubscriptionConfirmation, - CommsMessagetypetextReportStatusScheduled, - CommsMessagetypetextReportStatusComplete: + case CommsTextoriginDistrict, + CommsTextoriginLLM, + CommsTextoriginWebsiteAction: return true default: return false @@ -308,44 +305,44 @@ func (e CommsMessagetypetext) Valid() bool { } // useful when testing in other packages -func (e CommsMessagetypetext) All() []CommsMessagetypetext { - return AllCommsMessagetypetext() +func (e CommsTextorigin) All() []CommsTextorigin { + return AllCommsTextorigin() } -func (e CommsMessagetypetext) MarshalText() ([]byte, error) { +func (e CommsTextorigin) MarshalText() ([]byte, error) { return []byte(e), nil } -func (e *CommsMessagetypetext) UnmarshalText(text []byte) error { +func (e *CommsTextorigin) UnmarshalText(text []byte) error { return e.Scan(text) } -func (e CommsMessagetypetext) MarshalBinary() ([]byte, error) { +func (e CommsTextorigin) MarshalBinary() ([]byte, error) { return []byte(e), nil } -func (e *CommsMessagetypetext) UnmarshalBinary(data []byte) error { +func (e *CommsTextorigin) UnmarshalBinary(data []byte) error { return e.Scan(data) } -func (e CommsMessagetypetext) Value() (driver.Value, error) { +func (e CommsTextorigin) Value() (driver.Value, error) { return string(e), nil } -func (e *CommsMessagetypetext) Scan(value any) error { +func (e *CommsTextorigin) Scan(value any) error { switch x := value.(type) { case string: - *e = CommsMessagetypetext(x) + *e = CommsTextorigin(x) case []byte: - *e = CommsMessagetypetext(x) + *e = CommsTextorigin(x) case nil: - return fmt.Errorf("cannot nil into CommsMessagetypetext") + return fmt.Errorf("cannot nil into CommsTextorigin") default: return fmt.Errorf("cannot scan type %T: %v", value, value) } if !e.Valid() { - return fmt.Errorf("invalid CommsMessagetypetext value: %s", *e) + return fmt.Errorf("invalid CommsTextorigin value: %s", *e) } return nil diff --git a/db/factory/bobfactory_main.bob.go b/db/factory/bobfactory_main.bob.go index 00bfe2bc..ab527cba 100644 --- a/db/factory/bobfactory_main.bob.go +++ b/db/factory/bobfactory_main.bob.go @@ -324,10 +324,12 @@ func (f *Factory) NewCommsTextLogWithContext(ctx context.Context, mods ...CommsT func (f *Factory) FromExistingCommsTextLog(m *models.CommsTextLog) *CommsTextLogTemplate { o := &CommsTextLogTemplate{f: f, alreadyPersisted: true} + o.Content = func() string { return m.Content } o.Created = func() time.Time { return m.Created } o.Destination = func() string { return m.Destination } + o.ID = func() int32 { return m.ID } + o.Origin = func() enums.CommsTextorigin { return m.Origin } o.Source = func() string { return m.Source } - o.Type = func() enums.CommsMessagetypetext { return m.Type } ctx := context.Background() if m.R.DestinationPhone != nil { diff --git a/db/factory/bobfactory_random.bob.go b/db/factory/bobfactory_random.bob.go index dfa5ebc5..23a5b12f 100644 --- a/db/factory/bobfactory_random.bob.go +++ b/db/factory/bobfactory_random.bob.go @@ -101,12 +101,12 @@ func random_enums_CommsMessagetypeemail(f *faker.Faker, limits ...string) enums. return all[f.IntBetween(0, len(all)-1)] } -func random_enums_CommsMessagetypetext(f *faker.Faker, limits ...string) enums.CommsMessagetypetext { +func random_enums_CommsTextorigin(f *faker.Faker, limits ...string) enums.CommsTextorigin { if f == nil { f = &defaultFaker } - var e enums.CommsMessagetypetext + var e enums.CommsTextorigin all := e.All() return all[f.IntBetween(0, len(all)-1)] } diff --git a/db/factory/comms.text_log.bob.go b/db/factory/comms.text_log.bob.go index 91d5a2f3..6b1cd6e0 100644 --- a/db/factory/comms.text_log.bob.go +++ b/db/factory/comms.text_log.bob.go @@ -36,10 +36,12 @@ func (mods CommsTextLogModSlice) Apply(ctx context.Context, n *CommsTextLogTempl // CommsTextLogTemplate is an object representing the database table. // all columns are optional and should be set by mods type CommsTextLogTemplate struct { + Content func() string Created func() time.Time Destination func() string + ID func() int32 + Origin func() enums.CommsTextorigin Source func() string - Type func() enums.CommsMessagetypetext r commsTextLogR f *Factory @@ -89,6 +91,10 @@ func (t CommsTextLogTemplate) setModelRels(o *models.CommsTextLog) { func (o CommsTextLogTemplate) BuildSetter() *models.CommsTextLogSetter { m := &models.CommsTextLogSetter{} + if o.Content != nil { + val := o.Content() + m.Content = omit.From(val) + } if o.Created != nil { val := o.Created() m.Created = omit.From(val) @@ -97,14 +103,18 @@ func (o CommsTextLogTemplate) BuildSetter() *models.CommsTextLogSetter { val := o.Destination() m.Destination = omit.From(val) } + if o.ID != nil { + val := o.ID() + m.ID = omit.From(val) + } + if o.Origin != nil { + val := o.Origin() + m.Origin = omit.From(val) + } if o.Source != nil { val := o.Source() m.Source = omit.From(val) } - if o.Type != nil { - val := o.Type() - m.Type = omit.From(val) - } return m } @@ -127,18 +137,24 @@ func (o CommsTextLogTemplate) BuildManySetter(number int) []*models.CommsTextLog func (o CommsTextLogTemplate) Build() *models.CommsTextLog { m := &models.CommsTextLog{} + if o.Content != nil { + m.Content = o.Content() + } if o.Created != nil { m.Created = o.Created() } if o.Destination != nil { m.Destination = o.Destination() } + if o.ID != nil { + m.ID = o.ID() + } + if o.Origin != nil { + m.Origin = o.Origin() + } if o.Source != nil { m.Source = o.Source() } - if o.Type != nil { - m.Type = o.Type() - } o.setModelRels(m) @@ -159,6 +175,10 @@ func (o CommsTextLogTemplate) BuildMany(number int) models.CommsTextLogSlice { } func ensureCreatableCommsTextLog(m *models.CommsTextLogSetter) { + if !(m.Content.IsValue()) { + val := random_string(nil) + m.Content = omit.From(val) + } if !(m.Created.IsValue()) { val := random_time_Time(nil) m.Created = omit.From(val) @@ -167,14 +187,14 @@ func ensureCreatableCommsTextLog(m *models.CommsTextLogSetter) { val := random_string(nil) m.Destination = omit.From(val) } + if !(m.Origin.IsValue()) { + val := random_enums_CommsTextorigin(nil) + m.Origin = omit.From(val) + } if !(m.Source.IsValue()) { val := random_string(nil) m.Source = omit.From(val) } - if !(m.Type.IsValue()) { - val := random_enums_CommsMessagetypetext(nil) - m.Type = omit.From(val) - } } // insertOptRels creates and inserts any optional the relationships on *models.CommsTextLog @@ -312,13 +332,46 @@ type commsTextLogMods struct{} func (m commsTextLogMods) RandomizeAllColumns(f *faker.Faker) CommsTextLogMod { return CommsTextLogModSlice{ + CommsTextLogMods.RandomContent(f), CommsTextLogMods.RandomCreated(f), CommsTextLogMods.RandomDestination(f), + CommsTextLogMods.RandomID(f), + CommsTextLogMods.RandomOrigin(f), CommsTextLogMods.RandomSource(f), - CommsTextLogMods.RandomType(f), } } +// Set the model columns to this value +func (m commsTextLogMods) Content(val string) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Content = func() string { return val } + }) +} + +// Set the Column from the function +func (m commsTextLogMods) ContentFunc(f func() string) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Content = f + }) +} + +// Clear any values for the column +func (m commsTextLogMods) UnsetContent() CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Content = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +func (m commsTextLogMods) RandomContent(f *faker.Faker) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Content = func() string { + return random_string(f) + } + }) +} + // Set the model columns to this value func (m commsTextLogMods) Created(val time.Time) CommsTextLogMod { return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { @@ -381,6 +434,68 @@ func (m commsTextLogMods) RandomDestination(f *faker.Faker) CommsTextLogMod { }) } +// Set the model columns to this value +func (m commsTextLogMods) ID(val int32) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.ID = func() int32 { return val } + }) +} + +// Set the Column from the function +func (m commsTextLogMods) IDFunc(f func() int32) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.ID = f + }) +} + +// Clear any values for the column +func (m commsTextLogMods) UnsetID() CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.ID = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +func (m commsTextLogMods) RandomID(f *faker.Faker) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.ID = func() int32 { + return random_int32(f) + } + }) +} + +// Set the model columns to this value +func (m commsTextLogMods) Origin(val enums.CommsTextorigin) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Origin = func() enums.CommsTextorigin { return val } + }) +} + +// Set the Column from the function +func (m commsTextLogMods) OriginFunc(f func() enums.CommsTextorigin) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Origin = f + }) +} + +// Clear any values for the column +func (m commsTextLogMods) UnsetOrigin() CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Origin = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +func (m commsTextLogMods) RandomOrigin(f *faker.Faker) CommsTextLogMod { + return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { + o.Origin = func() enums.CommsTextorigin { + return random_enums_CommsTextorigin(f) + } + }) +} + // Set the model columns to this value func (m commsTextLogMods) Source(val string) CommsTextLogMod { return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { @@ -412,37 +527,6 @@ func (m commsTextLogMods) RandomSource(f *faker.Faker) CommsTextLogMod { }) } -// Set the model columns to this value -func (m commsTextLogMods) Type(val enums.CommsMessagetypetext) CommsTextLogMod { - return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { - o.Type = func() enums.CommsMessagetypetext { return val } - }) -} - -// Set the Column from the function -func (m commsTextLogMods) TypeFunc(f func() enums.CommsMessagetypetext) CommsTextLogMod { - return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { - o.Type = f - }) -} - -// Clear any values for the column -func (m commsTextLogMods) UnsetType() CommsTextLogMod { - return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { - o.Type = nil - }) -} - -// Generates a random value for the column using the given faker -// if faker is nil, a default faker is used -func (m commsTextLogMods) RandomType(f *faker.Faker) CommsTextLogMod { - return CommsTextLogModFunc(func(_ context.Context, o *CommsTextLogTemplate) { - o.Type = func() enums.CommsMessagetypetext { - return random_enums_CommsMessagetypetext(f) - } - }) -} - func (m commsTextLogMods) WithParentsCascading() CommsTextLogMod { return CommsTextLogModFunc(func(ctx context.Context, o *CommsTextLogTemplate) { if isDone, _ := commsTextLogWithParentsCascadingCtx.Value(ctx); isDone { diff --git a/db/migrations/00041_text_log_overhaul.sql b/db/migrations/00041_text_log_overhaul.sql new file mode 100644 index 00000000..4e33d846 --- /dev/null +++ b/db/migrations/00041_text_log_overhaul.sql @@ -0,0 +1,34 @@ +-- +goose Up +DROP TABLE comms.text_log; +DROP TYPE comms.MessageTypeText; +CREATE TYPE comms.TextOrigin AS ENUM ( + 'district', + 'llm', + 'website-action' +); +CREATE TABLE comms.text_log ( + content TEXT NOT NULL, + created TIMESTAMP WITHOUT TIME ZONE NOT NULL, + destination TEXT NOT NULL REFERENCES comms.phone(e164), + id SERIAL, + origin comms.TextOrigin NOT NULL, + source TEXT NOT NULL REFERENCES comms.phone(e164), + PRIMARY KEY(id) +); + +-- +goose Down +DROP TABLE comms.text_log; +DROP TYPE comms.TextOrigin; +CREATE TYPE comms.MessageTypeText AS ENUM ( + 'initial-contact', + 'report-subscription-confirmation', + 'report-status-scheduled', + 'report-status-complete' +); +CREATE TABLE comms.text_log ( + created TIMESTAMP WITHOUT TIME ZONE NOT NULL, + destination TEXT NOT NULL REFERENCES comms.phone(e164), + source TEXT NOT NULL REFERENCES comms.phone(e164), + type comms.MessageTypeText NOT NULL, + PRIMARY KEY (destination, source, type) +); diff --git a/db/models/comms.text_log.bob.go b/db/models/comms.text_log.bob.go index d717c547..fc1054d3 100644 --- a/db/models/comms.text_log.bob.go +++ b/db/models/comms.text_log.bob.go @@ -25,10 +25,12 @@ import ( // CommsTextLog is an object representing the database table. type CommsTextLog struct { - Created time.Time `db:"created" ` - Destination string `db:"destination,pk" ` - Source string `db:"source,pk" ` - Type enums.CommsMessagetypetext `db:"type,pk" ` + Content string `db:"content" ` + Created time.Time `db:"created" ` + Destination string `db:"destination" ` + ID int32 `db:"id,pk" ` + Origin enums.CommsTextorigin `db:"origin" ` + Source string `db:"source" ` R commsTextLogR `db:"-" ` } @@ -52,23 +54,27 @@ type commsTextLogR struct { func buildCommsTextLogColumns(alias string) commsTextLogColumns { return commsTextLogColumns{ ColumnsExpr: expr.NewColumnsExpr( - "created", "destination", "source", "type", + "content", "created", "destination", "id", "origin", "source", ).WithParent("comms.text_log"), tableAlias: alias, + Content: psql.Quote(alias, "content"), Created: psql.Quote(alias, "created"), Destination: psql.Quote(alias, "destination"), + ID: psql.Quote(alias, "id"), + Origin: psql.Quote(alias, "origin"), Source: psql.Quote(alias, "source"), - Type: psql.Quote(alias, "type"), } } type commsTextLogColumns struct { expr.ColumnsExpr tableAlias string + Content psql.Expression Created psql.Expression Destination psql.Expression + ID psql.Expression + Origin psql.Expression Source psql.Expression - Type psql.Expression } func (c commsTextLogColumns) Alias() string { @@ -83,42 +89,56 @@ func (commsTextLogColumns) AliasedAs(alias string) commsTextLogColumns { // All values are optional, and do not have to be set // Generated columns are not included type CommsTextLogSetter struct { - Created omit.Val[time.Time] `db:"created" ` - Destination omit.Val[string] `db:"destination,pk" ` - Source omit.Val[string] `db:"source,pk" ` - Type omit.Val[enums.CommsMessagetypetext] `db:"type,pk" ` + Content omit.Val[string] `db:"content" ` + Created omit.Val[time.Time] `db:"created" ` + Destination omit.Val[string] `db:"destination" ` + ID omit.Val[int32] `db:"id,pk" ` + Origin omit.Val[enums.CommsTextorigin] `db:"origin" ` + Source omit.Val[string] `db:"source" ` } func (s CommsTextLogSetter) SetColumns() []string { - vals := make([]string, 0, 4) + vals := make([]string, 0, 6) + if s.Content.IsValue() { + vals = append(vals, "content") + } if s.Created.IsValue() { vals = append(vals, "created") } if s.Destination.IsValue() { vals = append(vals, "destination") } + if s.ID.IsValue() { + vals = append(vals, "id") + } + if s.Origin.IsValue() { + vals = append(vals, "origin") + } if s.Source.IsValue() { vals = append(vals, "source") } - if s.Type.IsValue() { - vals = append(vals, "type") - } return vals } func (s CommsTextLogSetter) Overwrite(t *CommsTextLog) { + if s.Content.IsValue() { + t.Content = s.Content.MustGet() + } if s.Created.IsValue() { t.Created = s.Created.MustGet() } if s.Destination.IsValue() { t.Destination = s.Destination.MustGet() } + if s.ID.IsValue() { + t.ID = s.ID.MustGet() + } + if s.Origin.IsValue() { + t.Origin = s.Origin.MustGet() + } if s.Source.IsValue() { t.Source = s.Source.MustGet() } - if s.Type.IsValue() { - t.Type = s.Type.MustGet() - } } func (s *CommsTextLogSetter) Apply(q *dialect.InsertQuery) { @@ -127,31 +147,43 @@ func (s *CommsTextLogSetter) Apply(q *dialect.InsertQuery) { }) q.AppendValues(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) { - vals := make([]bob.Expression, 4) - if s.Created.IsValue() { - vals[0] = psql.Arg(s.Created.MustGet()) + vals := make([]bob.Expression, 6) + if s.Content.IsValue() { + vals[0] = psql.Arg(s.Content.MustGet()) } else { vals[0] = psql.Raw("DEFAULT") } - if s.Destination.IsValue() { - vals[1] = psql.Arg(s.Destination.MustGet()) + if s.Created.IsValue() { + vals[1] = psql.Arg(s.Created.MustGet()) } else { vals[1] = psql.Raw("DEFAULT") } - if s.Source.IsValue() { - vals[2] = psql.Arg(s.Source.MustGet()) + if s.Destination.IsValue() { + vals[2] = psql.Arg(s.Destination.MustGet()) } else { vals[2] = psql.Raw("DEFAULT") } - if s.Type.IsValue() { - vals[3] = psql.Arg(s.Type.MustGet()) + if s.ID.IsValue() { + vals[3] = psql.Arg(s.ID.MustGet()) } else { vals[3] = psql.Raw("DEFAULT") } + if s.Origin.IsValue() { + vals[4] = psql.Arg(s.Origin.MustGet()) + } else { + vals[4] = psql.Raw("DEFAULT") + } + + if s.Source.IsValue() { + vals[5] = psql.Arg(s.Source.MustGet()) + } else { + vals[5] = psql.Raw("DEFAULT") + } + return bob.ExpressSlice(ctx, w, d, start, vals, "", ", ", "") })) } @@ -161,7 +193,14 @@ func (s CommsTextLogSetter) UpdateMod() bob.Mod[*dialect.UpdateQuery] { } func (s CommsTextLogSetter) Expressions(prefix ...string) []bob.Expression { - exprs := make([]bob.Expression, 0, 4) + exprs := make([]bob.Expression, 0, 6) + + if s.Content.IsValue() { + exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ + psql.Quote(append(prefix, "content")...), + psql.Arg(s.Content), + }}) + } if s.Created.IsValue() { exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ @@ -177,6 +216,20 @@ func (s CommsTextLogSetter) Expressions(prefix ...string) []bob.Expression { }}) } + if s.ID.IsValue() { + exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ + psql.Quote(append(prefix, "id")...), + psql.Arg(s.ID), + }}) + } + + if s.Origin.IsValue() { + exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ + psql.Quote(append(prefix, "origin")...), + psql.Arg(s.Origin), + }}) + } + if s.Source.IsValue() { exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ psql.Quote(append(prefix, "source")...), @@ -184,41 +237,28 @@ func (s CommsTextLogSetter) Expressions(prefix ...string) []bob.Expression { }}) } - if s.Type.IsValue() { - exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ - psql.Quote(append(prefix, "type")...), - psql.Arg(s.Type), - }}) - } - return exprs } // FindCommsTextLog retrieves a single record by primary key // If cols is empty Find will return all columns. -func FindCommsTextLog(ctx context.Context, exec bob.Executor, DestinationPK string, SourcePK string, TypePK enums.CommsMessagetypetext, cols ...string) (*CommsTextLog, error) { +func FindCommsTextLog(ctx context.Context, exec bob.Executor, IDPK int32, cols ...string) (*CommsTextLog, error) { if len(cols) == 0 { return CommsTextLogs.Query( - sm.Where(CommsTextLogs.Columns.Destination.EQ(psql.Arg(DestinationPK))), - sm.Where(CommsTextLogs.Columns.Source.EQ(psql.Arg(SourcePK))), - sm.Where(CommsTextLogs.Columns.Type.EQ(psql.Arg(TypePK))), + sm.Where(CommsTextLogs.Columns.ID.EQ(psql.Arg(IDPK))), ).One(ctx, exec) } return CommsTextLogs.Query( - sm.Where(CommsTextLogs.Columns.Destination.EQ(psql.Arg(DestinationPK))), - sm.Where(CommsTextLogs.Columns.Source.EQ(psql.Arg(SourcePK))), - sm.Where(CommsTextLogs.Columns.Type.EQ(psql.Arg(TypePK))), + sm.Where(CommsTextLogs.Columns.ID.EQ(psql.Arg(IDPK))), sm.Columns(CommsTextLogs.Columns.Only(cols...)), ).One(ctx, exec) } // CommsTextLogExists checks the presence of a single record by primary key -func CommsTextLogExists(ctx context.Context, exec bob.Executor, DestinationPK string, SourcePK string, TypePK enums.CommsMessagetypetext) (bool, error) { +func CommsTextLogExists(ctx context.Context, exec bob.Executor, IDPK int32) (bool, error) { return CommsTextLogs.Query( - sm.Where(CommsTextLogs.Columns.Destination.EQ(psql.Arg(DestinationPK))), - sm.Where(CommsTextLogs.Columns.Source.EQ(psql.Arg(SourcePK))), - sm.Where(CommsTextLogs.Columns.Type.EQ(psql.Arg(TypePK))), + sm.Where(CommsTextLogs.Columns.ID.EQ(psql.Arg(IDPK))), ).Exists(ctx, exec) } @@ -242,15 +282,11 @@ func (o *CommsTextLog) AfterQueryHook(ctx context.Context, exec bob.Executor, qu // primaryKeyVals returns the primary key values of the CommsTextLog func (o *CommsTextLog) primaryKeyVals() bob.Expression { - return psql.ArgGroup( - o.Destination, - o.Source, - o.Type, - ) + return psql.Arg(o.ID) } func (o *CommsTextLog) pkEQ() dialect.Expression { - return psql.Group(psql.Quote("comms.text_log", "destination"), psql.Quote("comms.text_log", "source"), psql.Quote("comms.text_log", "type")).EQ(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) { + return psql.Quote("comms.text_log", "id").EQ(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) { return o.primaryKeyVals().WriteSQL(ctx, w, d, start) })) } @@ -277,9 +313,7 @@ func (o *CommsTextLog) Delete(ctx context.Context, exec bob.Executor) error { // Reload refreshes the CommsTextLog using the executor func (o *CommsTextLog) Reload(ctx context.Context, exec bob.Executor) error { o2, err := CommsTextLogs.Query( - sm.Where(CommsTextLogs.Columns.Destination.EQ(psql.Arg(o.Destination))), - sm.Where(CommsTextLogs.Columns.Source.EQ(psql.Arg(o.Source))), - sm.Where(CommsTextLogs.Columns.Type.EQ(psql.Arg(o.Type))), + sm.Where(CommsTextLogs.Columns.ID.EQ(psql.Arg(o.ID))), ).One(ctx, exec) if err != nil { return err @@ -313,7 +347,7 @@ func (o CommsTextLogSlice) pkIN() dialect.Expression { return psql.Raw("NULL") } - return psql.Group(psql.Quote("comms.text_log", "destination"), psql.Quote("comms.text_log", "source"), psql.Quote("comms.text_log", "type")).In(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) { + return psql.Quote("comms.text_log", "id").In(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) { pkPairs := make([]bob.Expression, len(o)) for i, row := range o { pkPairs[i] = row.primaryKeyVals() @@ -328,13 +362,7 @@ func (o CommsTextLogSlice) pkIN() dialect.Expression { func (o CommsTextLogSlice) copyMatchingRows(from ...*CommsTextLog) { for i, old := range o { for _, new := range from { - if new.Destination != old.Destination { - continue - } - if new.Source != old.Source { - continue - } - if new.Type != old.Type { + if new.ID != old.ID { continue } new.R = old.R @@ -580,10 +608,12 @@ func (commsTextLog0 *CommsTextLog) AttachSourcePhone(ctx context.Context, exec b } type commsTextLogWhere[Q psql.Filterable] struct { + Content psql.WhereMod[Q, string] Created psql.WhereMod[Q, time.Time] Destination psql.WhereMod[Q, string] + ID psql.WhereMod[Q, int32] + Origin psql.WhereMod[Q, enums.CommsTextorigin] Source psql.WhereMod[Q, string] - Type psql.WhereMod[Q, enums.CommsMessagetypetext] } func (commsTextLogWhere[Q]) AliasedAs(alias string) commsTextLogWhere[Q] { @@ -592,10 +622,12 @@ func (commsTextLogWhere[Q]) AliasedAs(alias string) commsTextLogWhere[Q] { func buildCommsTextLogWhere[Q psql.Filterable](cols commsTextLogColumns) commsTextLogWhere[Q] { return commsTextLogWhere[Q]{ + Content: psql.Where[Q, string](cols.Content), Created: psql.Where[Q, time.Time](cols.Created), Destination: psql.Where[Q, string](cols.Destination), + ID: psql.Where[Q, int32](cols.ID), + Origin: psql.Where[Q, enums.CommsTextorigin](cols.Origin), Source: psql.Where[Q, string](cols.Source), - Type: psql.Where[Q, enums.CommsMessagetypetext](cols.Type), } } diff --git a/public-report/quick.go b/public-report/quick.go index cfb27a0b..beb95308 100644 --- a/public-report/quick.go +++ b/public-report/quick.go @@ -8,7 +8,7 @@ import ( "time" "github.com/Gleipnir-Technology/nidus-sync/background" - "github.com/Gleipnir-Technology/nidus-sync/comms" + "github.com/Gleipnir-Technology/nidus-sync/comms/text" "github.com/Gleipnir-Technology/nidus-sync/config" "github.com/Gleipnir-Technology/nidus-sync/db" "github.com/Gleipnir-Technology/nidus-sync/db/enums" @@ -241,7 +241,7 @@ func postRegisterNotifications(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", report_id), http.StatusFound) return } - phone, err := comms.ParsePhoneNumber(phone_str) + phone, err := text.ParsePhoneNumber(phone_str) result, err := psql.Update( um.Table("publicreport.quick"), um.SetCol("reporter_email").ToArg(email), diff --git a/public-report/template/mock/nuisance.html b/public-report/template/mock/nuisance.html index 0cb6e57b..85f3c6fb 100644 --- a/public-report/template/mock/nuisance.html +++ b/public-report/template/mock/nuisance.html @@ -385,7 +385,7 @@ document.addEventListener('DOMContentLoaded', function() { -
diff --git a/public-report/template/mock/water.html b/public-report/template/mock/water.html index dd5d244d..d37ebf2e 100644 --- a/public-report/template/mock/water.html +++ b/public-report/template/mock/water.html @@ -347,7 +347,7 @@ function displaySelectedCoordinates(lngLat) {
-