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.
This commit is contained in:
Eli Ribble 2026-01-25 18:47:22 +00:00
parent 5e9c0d9f11
commit c0b6398de2
No known key found for this signature in database
19 changed files with 577 additions and 219 deletions

View file

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

View file

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

93
comms/text/db.go Normal file
View file

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

39
comms/text/job.go Normal file
View file

@ -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:
}
*/
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -385,7 +385,7 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<button class="btn btn-primary" type="button" onClick="toggleCollapse('collapse-additional-fields')">
<button class="btn btn-warning" type="button" onClick="toggleCollapse('collapse-additional-fields')">
Answer a few more questions to better help us solve your mosquito problem
</button>
<div class="collapse" id="collapse-additional-fields">

View file

@ -347,7 +347,7 @@ function displaySelectedCoordinates(lngLat) {
<input type="hidden" id="map-zoom" name="map-zoom"/>
</div>
<button class="btn btn-primary" type="button" onClick="toggleCollapse('collapse-additional-fields')">
<button class="btn btn-warning" type="button" onClick="toggleCollapse('collapse-additional-fields')">
Answer a few more questions to better help us solve your mosquito problem
</button>
<div class="collapse" id="collapse-additional-fields">