Move properties of phones to the phone, not contact_phone

This makes sense because there will naturally be cases where multiple
districts have the same phone number mapped to different contacts.
This commit is contained in:
Eli Ribble 2026-05-22 20:56:22 +00:00
parent f957dc6982
commit 7b04822a9b
No known key found for this signature in database
14 changed files with 143 additions and 104 deletions

View file

@ -8,11 +8,8 @@
package model package model
type ContactPhone struct { type ContactPhone struct {
CanSms bool
ConfirmedMessageID *int32
ContactID int32 ContactID int32
E164 string E164 string
IsSubscribed bool IsSubscribed bool
StopMessageID *int32
ID int32 `sql:"primary_key"` ID int32 `sql:"primary_key"`
} }

View file

@ -9,7 +9,7 @@ package model
type Phone struct { type Phone struct {
E164 string `sql:"primary_key"` E164 string `sql:"primary_key"`
IsSubscribed bool
Status Phonestatustype
CanSms bool CanSms bool
ConfirmedMessageID *int32
StopMessageID *int32
} }

View file

@ -17,12 +17,9 @@ type contactPhoneTable struct {
postgres.Table postgres.Table
// Columns // Columns
CanSms postgres.ColumnBool
ConfirmedMessageID postgres.ColumnInteger
ContactID postgres.ColumnInteger ContactID postgres.ColumnInteger
E164 postgres.ColumnString E164 postgres.ColumnString
IsSubscribed postgres.ColumnBool IsSubscribed postgres.ColumnBool
StopMessageID postgres.ColumnInteger
ID postgres.ColumnInteger ID postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
@ -65,15 +62,12 @@ func newContactPhoneTable(schemaName, tableName, alias string) *ContactPhoneTabl
func newContactPhoneTableImpl(schemaName, tableName, alias string) contactPhoneTable { func newContactPhoneTableImpl(schemaName, tableName, alias string) contactPhoneTable {
var ( var (
CanSmsColumn = postgres.BoolColumn("can_sms")
ConfirmedMessageIDColumn = postgres.IntegerColumn("confirmed_message_id")
ContactIDColumn = postgres.IntegerColumn("contact_id") ContactIDColumn = postgres.IntegerColumn("contact_id")
E164Column = postgres.StringColumn("e164") E164Column = postgres.StringColumn("e164")
IsSubscribedColumn = postgres.BoolColumn("is_subscribed") IsSubscribedColumn = postgres.BoolColumn("is_subscribed")
StopMessageIDColumn = postgres.IntegerColumn("stop_message_id")
IDColumn = postgres.IntegerColumn("id") IDColumn = postgres.IntegerColumn("id")
allColumns = postgres.ColumnList{CanSmsColumn, ConfirmedMessageIDColumn, ContactIDColumn, E164Column, IsSubscribedColumn, StopMessageIDColumn, IDColumn} allColumns = postgres.ColumnList{ContactIDColumn, E164Column, IsSubscribedColumn, IDColumn}
mutableColumns = postgres.ColumnList{CanSmsColumn, ConfirmedMessageIDColumn, ContactIDColumn, E164Column, IsSubscribedColumn, StopMessageIDColumn} mutableColumns = postgres.ColumnList{ContactIDColumn, E164Column, IsSubscribedColumn}
defaultColumns = postgres.ColumnList{IDColumn} defaultColumns = postgres.ColumnList{IDColumn}
) )
@ -81,12 +75,9 @@ func newContactPhoneTableImpl(schemaName, tableName, alias string) contactPhoneT
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
CanSms: CanSmsColumn,
ConfirmedMessageID: ConfirmedMessageIDColumn,
ContactID: ContactIDColumn, ContactID: ContactIDColumn,
E164: E164Column, E164: E164Column,
IsSubscribed: IsSubscribedColumn, IsSubscribed: IsSubscribedColumn,
StopMessageID: StopMessageIDColumn,
ID: IDColumn, ID: IDColumn,
AllColumns: allColumns, AllColumns: allColumns,

View file

@ -18,9 +18,9 @@ type phoneTable struct {
// Columns // Columns
E164 postgres.ColumnString E164 postgres.ColumnString
IsSubscribed postgres.ColumnBool
Status postgres.ColumnString
CanSms postgres.ColumnBool CanSms postgres.ColumnBool
ConfirmedMessageID postgres.ColumnInteger
StopMessageID postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@ -63,11 +63,11 @@ func newPhoneTable(schemaName, tableName, alias string) *PhoneTable {
func newPhoneTableImpl(schemaName, tableName, alias string) phoneTable { func newPhoneTableImpl(schemaName, tableName, alias string) phoneTable {
var ( var (
E164Column = postgres.StringColumn("e164") E164Column = postgres.StringColumn("e164")
IsSubscribedColumn = postgres.BoolColumn("is_subscribed")
StatusColumn = postgres.StringColumn("status")
CanSmsColumn = postgres.BoolColumn("can_sms") CanSmsColumn = postgres.BoolColumn("can_sms")
allColumns = postgres.ColumnList{E164Column, IsSubscribedColumn, StatusColumn, CanSmsColumn} ConfirmedMessageIDColumn = postgres.IntegerColumn("confirmed_message_id")
mutableColumns = postgres.ColumnList{IsSubscribedColumn, StatusColumn, CanSmsColumn} StopMessageIDColumn = postgres.IntegerColumn("stop_message_id")
allColumns = postgres.ColumnList{E164Column, CanSmsColumn, ConfirmedMessageIDColumn, StopMessageIDColumn}
mutableColumns = postgres.ColumnList{CanSmsColumn, ConfirmedMessageIDColumn, StopMessageIDColumn}
defaultColumns = postgres.ColumnList{} defaultColumns = postgres.ColumnList{}
) )
@ -76,9 +76,9 @@ func newPhoneTableImpl(schemaName, tableName, alias string) phoneTable {
//Columns //Columns
E164: E164Column, E164: E164Column,
IsSubscribed: IsSubscribedColumn,
Status: StatusColumn,
CanSms: CanSmsColumn, CanSms: CanSmsColumn,
ConfirmedMessageID: ConfirmedMessageIDColumn,
StopMessageID: StopMessageIDColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,

View file

@ -0,0 +1,10 @@
-- +goose Up
ALTER TABLE comms.contact_phone
DROP COLUMN can_sms,
DROP COLUMN confirmed_message_id,
DROP COLUMN stop_message_id;
ALTER TABLE comms.phone
ADD COLUMN confirmed_message_id INTEGER REFERENCES comms.text_log(id),
ADD COLUMN stop_message_id INTEGER REFERENCES comms.text_log(id),
DROP COLUMN is_subscribed,
DROP COLUMN status;

View file

@ -27,26 +27,6 @@ func ContactPhoneFromE164(ctx context.Context, txn db.Ex, e164 string) (model.Co
return db.ExecuteOneTx[model.ContactPhone](ctx, txn, statement) return db.ExecuteOneTx[model.ContactPhone](ctx, txn, statement)
} }
func ContactPhoneUpdateConfirmedMessageID(ctx context.Context, txn db.Ex, e164 string, message_id *int32) error {
statement := table.ContactPhone.UPDATE().
SET(table.ContactPhone.ConfirmedMessageID.SET(postgres.IntExp(postgres.NULL))).
WHERE(table.ContactPhone.E164.EQ(postgres.String(e164)))
return db.ExecuteNoneTx(ctx, txn, statement)
}
func ContactPhoneUpdateStopMessageID(ctx context.Context, txn db.Ex, e164 string, message_id *int32) error {
/*
m := model.ContactPhone{}
m.StopMessageID = message_id
statement := table.ContactPhone.UPDATE(
table.ContactPhone.StopMessageID,
).MODEL(m).
WHERE(table.ContactPhone.E164.EQ(postgres.String(e164)))
*/
statement := table.ContactPhone.UPDATE().
SET(table.ContactPhone.StopMessageID.SET(postgres.IntExp(postgres.NULL))).
WHERE(table.ContactPhone.E164.EQ(postgres.String(e164)))
return db.ExecuteNoneTx(ctx, txn, statement)
}
func ContactPhoneByContactIDs(ctx context.Context, txn db.Ex, contact_ids []int64) (result map[int64][]model.ContactPhone, err error) { func ContactPhoneByContactIDs(ctx context.Context, txn db.Ex, contact_ids []int64) (result map[int64][]model.ContactPhone, err error) {
sql_ids := make([]postgres.Expression, len(contact_ids)) sql_ids := make([]postgres.Expression, len(contact_ids))
for i, contact_id := range contact_ids { for i, contact_id := range contact_ids {

View file

@ -40,3 +40,34 @@ func PhoneFromE164(ctx context.Context, txn db.Ex, e164 string) (model.Phone, er
WHERE(table.Phone.E164.EQ(postgres.String(e164))) WHERE(table.Phone.E164.EQ(postgres.String(e164)))
return db.ExecuteOneTx[model.Phone](ctx, txn, statement) return db.ExecuteOneTx[model.Phone](ctx, txn, statement)
} }
func PhonesFromE164s(ctx context.Context, txn db.Ex, e164s []string) ([]model.Phone, error) {
sql_ids := make([]postgres.Expression, len(e164s))
for i, e164 := range e164s {
sql_ids[i] = postgres.String(e164)
}
statement := table.Phone.SELECT(
table.Phone.AllColumns,
).FROM(table.Phone).
WHERE(table.Phone.E164.IN(sql_ids...))
return db.ExecuteManyTx[model.Phone](ctx, txn, statement)
}
func PhoneUpdateConfirmedMessageID(ctx context.Context, txn db.Ex, e164 string, message_id *int32) error {
statement := table.Phone.UPDATE().
SET(table.Phone.ConfirmedMessageID.SET(postgres.IntExp(postgres.NULL))).
WHERE(table.Phone.E164.EQ(postgres.String(e164)))
return db.ExecuteNoneTx(ctx, txn, statement)
}
func PhoneUpdateStopMessageID(ctx context.Context, txn db.Ex, e164 string, message_id *int32) error {
/*
m := model.Phone{}
m.StopMessageID = message_id
statement := table.Phone.UPDATE(
table.Phone.StopMessageID,
).MODEL(m).
WHERE(table.Phone.E164.EQ(postgres.String(e164)))
*/
statement := table.Phone.UPDATE().
SET(table.Phone.StopMessageID.SET(postgres.IntExp(postgres.NULL))).
WHERE(table.Phone.E164.EQ(postgres.String(e164)))
return db.ExecuteNoneTx(ctx, txn, statement)
}

View file

@ -29,7 +29,20 @@ func ContactsForOrganization(ctx context.Context, org_id int32) (results []types
if err != nil { if err != nil {
return results, fmt.Errorf("by contact ids: %w", err) return results, fmt.Errorf("by contact ids: %w", err)
} }
e164s := make([]string, 0)
for _, v := range contact_phones_by_contact_id {
for _, p := range v {
e164s = append(e164s, p.E164)
}
}
phones, err := querycomms.PhonesFromE164s(ctx, txn, e164s)
if err != nil {
return results, fmt.Errorf("phones from e164: %w", err)
}
phones_by_e164 := make(map[string]modelcomms.Phone, len(phones))
for _, p := range phones {
phones_by_e164[p.E164] = p
}
results = make([]types.Contact, 0) results = make([]types.Contact, 0)
for _, row := range rows { for _, row := range rows {
// Exclude the magic Nidus contact // Exclude the magic Nidus contact
@ -44,9 +57,10 @@ func ContactsForOrganization(ctx context.Context, org_id int32) (results []types
contact_phones := contact_phones_by_contact_id[int64(row.ID)] contact_phones := contact_phones_by_contact_id[int64(row.ID)]
phones := make([]types.Phone, len(contact_phones)) phones := make([]types.Phone, len(contact_phones))
for i, p := range contact_phones { for i, p := range contact_phones {
phone := phones_by_e164[p.E164]
phones[i] = types.Phone{ phones[i] = types.Phone{
E164: p.E164, E164: phone.E164,
CanSMS: p.CanSms, CanSMS: phone.CanSms,
} }
} }
if row.Name != "" || len(contact_phones) > 0 || len(contact_emails) > 0 { if row.Name != "" || len(contact_phones) > 0 || len(contact_emails) > 0 {

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"source.gleipnir.technology/Gleipnir/nidus-sync/db" "source.gleipnir.technology/Gleipnir/nidus-sync/db"
modelcomms "source.gleipnir.technology/Gleipnir/nidus-sync/db/gen/nidus-sync/comms/model"
querycomms "source.gleipnir.technology/Gleipnir/nidus-sync/db/query/comms" querycomms "source.gleipnir.technology/Gleipnir/nidus-sync/db/query/comms"
"source.gleipnir.technology/Gleipnir/nidus-sync/platform/types" "source.gleipnir.technology/Gleipnir/nidus-sync/platform/types"
) )
@ -22,6 +23,20 @@ func ContactSimplesFromIDs(ctx context.Context, txn db.Ex, contact_ids []int64)
if err != nil { if err != nil {
return nil, fmt.Errorf("contact phones from ids: %w", err) return nil, fmt.Errorf("contact phones from ids: %w", err)
} }
e164s := make([]string, 0)
for _, v := range contact_phones {
for _, p := range v {
e164s = append(e164s, p.E164)
}
}
phones, err := querycomms.PhonesFromE164s(ctx, txn, e164s)
if err != nil {
return nil, fmt.Errorf("contact phones from ids: %w", err)
}
phones_by_e164 := make(map[string]modelcomms.Phone, 0)
for _, p := range phones {
phones_by_e164[p.E164] = p
}
results := make([]types.ContactSimple, len(contact_ids)) results := make([]types.ContactSimple, len(contact_ids))
for i, contact := range contacts { for i, contact := range contacts {
@ -29,7 +44,7 @@ func ContactSimplesFromIDs(ctx context.Context, txn db.Ex, contact_ids []int64)
if !ok { if !ok {
return nil, fmt.Errorf("no emails for contact %d", contact.ID) return nil, fmt.Errorf("no emails for contact %d", contact.ID)
} }
phones, ok := contact_phones[int64(contact.ID)] cps, ok := contact_phones[int64(contact.ID)]
if !ok { if !ok {
return nil, fmt.Errorf("no phones for contact %d", contact.ID) return nil, fmt.Errorf("no phones for contact %d", contact.ID)
} }
@ -38,8 +53,9 @@ func ContactSimplesFromIDs(ctx context.Context, txn db.Ex, contact_ids []int64)
email_string = emails[0].Address email_string = emails[0].Address
} }
phone_simple := types.PhoneSimple{} phone_simple := types.PhoneSimple{}
if len(phones) > 0 { if len(cps) > 0 {
phone := phones[0] contact_phone := cps[0]
phone := phones_by_e164[contact_phone.E164]
phone_simple = types.PhoneSimple{ phone_simple = types.PhoneSimple{
CanSMS: phone.CanSms, CanSMS: phone.CanSms,
Number: phone.E164, Number: phone.E164,

View file

@ -159,18 +159,24 @@ func saveReporterEmail(ctx context.Context, txn db.Ex, contact_id int32, email_a
return nil return nil
} }
func saveReporterPhone(ctx context.Context, txn db.Ex, contact_id int32, phone *types.E164, can_sms bool) error { func saveReporterPhone(ctx context.Context, txn db.Ex, contact_id int32, number *types.E164, can_sms bool) error {
if phone == nil { if number == nil {
return nil return nil
} }
p, err := querycomms.ContactPhoneInsert(ctx, txn, modelcomms.ContactPhone{ _, err := querycomms.PhoneInsertIfNotExists(ctx, txn, modelcomms.Phone{
E164: number.PhoneString(),
CanSms: can_sms, CanSms: can_sms,
ConfirmedMessageID: nil, ConfirmedMessageID: nil,
ContactID: contact_id,
E164: phone.PhoneString(),
IsSubscribed: false,
StopMessageID: nil, StopMessageID: nil,
}) })
if err != nil {
return fmt.Errorf("insert phone if not exists: %w", err)
}
p, err := querycomms.ContactPhoneInsert(ctx, txn, modelcomms.ContactPhone{
ContactID: contact_id,
E164: number.PhoneString(),
IsSubscribed: false,
})
if err != nil { if err != nil {
return fmt.Errorf("contact add phone: %w", err) return fmt.Errorf("contact add phone: %w", err)
} }

View file

@ -15,12 +15,8 @@ func EnsureInDB(ctx context.Context, txn db.Ex, contact modelcomms.Contact, dst
} }
func ensureInDB(ctx context.Context, txn db.Ex, contact modelcomms.Contact, destination string) (err error) { func ensureInDB(ctx context.Context, txn db.Ex, contact modelcomms.Contact, destination string) (err error) {
contact_phone := modelcomms.ContactPhone{ contact_phone := modelcomms.ContactPhone{
CanSms: true,
ConfirmedMessageID: nil,
ContactID: contact.ID, ContactID: contact.ID,
E164: destination, E164: destination,
IsSubscribed: false,
StopMessageID: nil,
} }
_, err = querycomms.ContactPhoneInsert(ctx, txn, contact_phone) _, err = querycomms.ContactPhoneInsert(ctx, txn, contact_phone)
return err return err
@ -28,8 +24,8 @@ func ensureInDB(ctx context.Context, txn db.Ex, contact modelcomms.Contact, dest
func ensurePhoneInDB(ctx context.Context, txn db.Ex, number *types.E164) (modelcomms.Phone, error) { func ensurePhoneInDB(ctx context.Context, txn db.Ex, number *types.E164) (modelcomms.Phone, error) {
return querycomms.PhoneInsertIfNotExists(ctx, txn, modelcomms.Phone{ return querycomms.PhoneInsertIfNotExists(ctx, txn, modelcomms.Phone{
CanSms: false, CanSms: false,
ConfirmedMessageID: nil,
E164: number.PhoneString(), E164: number.PhoneString(),
IsSubscribed: false, StopMessageID: nil,
Status: modelcomms.Phonestatustype_Unconfirmed,
}) })
} }

View file

@ -36,7 +36,7 @@ func resendInitialText(ctx context.Context, txn db.Ex, dst types.E164) error {
if err != nil { if err != nil {
return fmt.Errorf("Failed to find phone %s: %w", dst, err) return fmt.Errorf("Failed to find phone %s: %w", dst, err)
} }
err = querycomms.ContactPhoneUpdateStopMessageID(ctx, txn, phone.E164, nil) err = querycomms.PhoneUpdateStopMessageID(ctx, txn, phone.E164, nil)
if err != nil { if err != nil {
return fmt.Errorf("Failed to clear subscription on phone %s: %w", dst, err) return fmt.Errorf("Failed to clear subscription on phone %s: %w", dst, err)
} }
@ -100,17 +100,14 @@ func sendTextComplete(ctx context.Context, job modelcomms.TextJob) error {
if err != nil { if err != nil {
return fmt.Errorf("destination phone from e164: %w", err) return fmt.Errorf("destination phone from e164: %w", err)
} }
log.Debug().Str("phone status", string(destination.Status)).Str("destination", job.Destination).Send() if destination.ConfirmedMessageID == nil {
switch destination.Status {
case modelcomms.Phonestatustype_Unconfirmed:
err := ensureInitialText(ctx, txn, *dst) err := ensureInitialText(ctx, txn, *dst)
if err != nil { if err != nil {
return fmt.Errorf("Failed to ensure initial text has been sent: %w", err) return fmt.Errorf("Failed to ensure initial text has been sent: %w", err)
} }
return nil return nil
//case enums.CommsPhonestatustypeOkToSend: }
// allow to drop through if destination.StopMessageID != nil {
case modelcomms.Phonestatustype_Stopped:
lint.LogOnErrCtx(func(ctx context.Context) error { lint.LogOnErrCtx(func(ctx context.Context) error {
return resendInitialText(ctx, txn, *dst) return resendInitialText(ctx, txn, *dst)
}, ctx, "resend initial text") }, ctx, "resend initial text")

View file

@ -41,7 +41,7 @@ func HandleTextMessage(ctx context.Context, source string, destination string, c
if err != nil { if err != nil {
return fmt.Errorf("ensure source in DB: %w", err) return fmt.Errorf("ensure source in DB: %w", err)
} }
is_visible_to_llm := s.Status != modelcomms.Phonestatustype_Unconfirmed is_visible_to_llm := s.ConfirmedMessageID != nil
l, err := querycomms.TextLogInsert(ctx, txn, modelcomms.TextLog{ l, err := querycomms.TextLogInsert(ctx, txn, modelcomms.TextLog{
Content: content, Content: content,
@ -83,17 +83,17 @@ func respondText(ctx context.Context, log_id int32) error {
return fmt.Errorf("parse source: %w", err) return fmt.Errorf("parse source: %w", err)
} }
contact_phone, err := querycomms.ContactPhoneFromE164(ctx, txn, src.PhoneString()) phone, err := querycomms.PhoneFromE164(ctx, txn, src.PhoneString())
if err != nil { if err != nil {
return fmt.Errorf("Failed to get contact phone") return fmt.Errorf("Failed to get contact phone")
} }
body_l := strings.TrimSpace(strings.ToLower(l.Content)) body_l := strings.TrimSpace(strings.ToLower(l.Content))
// If the user isn't confirmed for sending regular texts ensure they get a reprompt // If the user isn't confirmed for sending regular texts ensure they get a reprompt
if contact_phone.ConfirmedMessageID == nil { if phone.ConfirmedMessageID == nil {
switch body_l { switch body_l {
case "yes": case "yes":
err = querycomms.ContactPhoneUpdateConfirmedMessageID(ctx, txn, src.PhoneString(), &l.ID) err = querycomms.PhoneUpdateConfirmedMessageID(ctx, txn, src.PhoneString(), &l.ID)
if err != nil { if err != nil {
return fmt.Errorf("set phone confirmed message ID: %w", err) return fmt.Errorf("set phone confirmed message ID: %w", err)
} }
@ -119,7 +119,7 @@ func respondText(ctx context.Context, log_id int32) error {
} }
switch body_l { switch body_l {
case "stop": case "stop":
err = querycomms.ContactPhoneUpdateStopMessageID(ctx, txn, src.PhoneString(), &l.ID) err = querycomms.PhoneUpdateStopMessageID(ctx, txn, src.PhoneString(), &l.ID)
if err != nil { if err != nil {
return fmt.Errorf("set phone stop message ID: %w", err) return fmt.Errorf("set phone stop message ID: %w", err)
} }

View file

@ -19,6 +19,7 @@ type ContactSimple struct {
type Phone struct { type Phone struct {
E164 string `json:"e164"` E164 string `json:"e164"`
CanSMS bool `json:"can_sms"` CanSMS bool `json:"can_sms"`
Status string `json:"status"`
} }
type PhoneSimple struct { type PhoneSimple struct {
CanSMS bool `json:"can_sms"` CanSMS bool `json:"can_sms"`