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 ContactID int32
ConfirmedMessageID *int32 E164 string
ContactID int32 IsSubscribed bool
E164 string ID int32 `sql:"primary_key"`
IsSubscribed bool
StopMessageID *int32
ID int32 `sql:"primary_key"`
} }

View file

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

View file

@ -17,13 +17,10 @@ type contactPhoneTable struct {
postgres.Table postgres.Table
// Columns // Columns
CanSms postgres.ColumnBool ContactID postgres.ColumnInteger
ConfirmedMessageID postgres.ColumnInteger E164 postgres.ColumnString
ContactID postgres.ColumnInteger IsSubscribed postgres.ColumnBool
E164 postgres.ColumnString ID postgres.ColumnInteger
IsSubscribed postgres.ColumnBool
StopMessageID postgres.ColumnInteger
ID postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@ -65,29 +62,23 @@ 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") ContactIDColumn = postgres.IntegerColumn("contact_id")
ConfirmedMessageIDColumn = postgres.IntegerColumn("confirmed_message_id") E164Column = postgres.StringColumn("e164")
ContactIDColumn = postgres.IntegerColumn("contact_id") IsSubscribedColumn = postgres.BoolColumn("is_subscribed")
E164Column = postgres.StringColumn("e164") IDColumn = postgres.IntegerColumn("id")
IsSubscribedColumn = postgres.BoolColumn("is_subscribed") allColumns = postgres.ColumnList{ContactIDColumn, E164Column, IsSubscribedColumn, IDColumn}
StopMessageIDColumn = postgres.IntegerColumn("stop_message_id") mutableColumns = postgres.ColumnList{ContactIDColumn, E164Column, IsSubscribedColumn}
IDColumn = postgres.IntegerColumn("id") defaultColumns = postgres.ColumnList{IDColumn}
allColumns = postgres.ColumnList{CanSmsColumn, ConfirmedMessageIDColumn, ContactIDColumn, E164Column, IsSubscribedColumn, StopMessageIDColumn, IDColumn}
mutableColumns = postgres.ColumnList{CanSmsColumn, ConfirmedMessageIDColumn, ContactIDColumn, E164Column, IsSubscribedColumn, StopMessageIDColumn}
defaultColumns = postgres.ColumnList{IDColumn}
) )
return contactPhoneTable{ return contactPhoneTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
CanSms: CanSmsColumn, ContactID: ContactIDColumn,
ConfirmedMessageID: ConfirmedMessageIDColumn, E164: E164Column,
ContactID: ContactIDColumn, IsSubscribed: IsSubscribedColumn,
E164: E164Column, ID: IDColumn,
IsSubscribed: IsSubscribedColumn,
StopMessageID: StopMessageIDColumn,
ID: IDColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,

View file

@ -17,10 +17,10 @@ type phoneTable struct {
postgres.Table postgres.Table
// Columns // Columns
E164 postgres.ColumnString E164 postgres.ColumnString
IsSubscribed postgres.ColumnBool CanSms postgres.ColumnBool
Status postgres.ColumnString ConfirmedMessageID postgres.ColumnInteger
CanSms postgres.ColumnBool StopMessageID postgres.ColumnInteger
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@ -62,23 +62,23 @@ 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") CanSmsColumn = postgres.BoolColumn("can_sms")
StatusColumn = postgres.StringColumn("status") ConfirmedMessageIDColumn = postgres.IntegerColumn("confirmed_message_id")
CanSmsColumn = postgres.BoolColumn("can_sms") StopMessageIDColumn = postgres.IntegerColumn("stop_message_id")
allColumns = postgres.ColumnList{E164Column, IsSubscribedColumn, StatusColumn, CanSmsColumn} allColumns = postgres.ColumnList{E164Column, CanSmsColumn, ConfirmedMessageIDColumn, StopMessageIDColumn}
mutableColumns = postgres.ColumnList{IsSubscribedColumn, StatusColumn, CanSmsColumn} mutableColumns = postgres.ColumnList{CanSmsColumn, ConfirmedMessageIDColumn, StopMessageIDColumn}
defaultColumns = postgres.ColumnList{} defaultColumns = postgres.ColumnList{}
) )
return phoneTable{ return phoneTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
E164: E164Column, E164: E164Column,
IsSubscribed: IsSubscribedColumn, CanSms: CanSmsColumn,
Status: StatusColumn, ConfirmedMessageID: ConfirmedMessageIDColumn,
CanSms: CanSmsColumn, 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,21 +15,17 @@ 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, ContactID: contact.ID,
ConfirmedMessageID: nil, E164: destination,
ContactID: contact.ID,
E164: destination,
IsSubscribed: false,
StopMessageID: nil,
} }
_, err = querycomms.ContactPhoneInsert(ctx, txn, contact_phone) _, err = querycomms.ContactPhoneInsert(ctx, txn, contact_phone)
return err return err
} }
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,
E164: number.PhoneString(), ConfirmedMessageID: nil,
IsSubscribed: false, E164: number.PhoneString(),
Status: modelcomms.Phonestatustype_Unconfirmed, StopMessageID: nil,
}) })
} }

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"`