feat: notification loginname (#381)

* feat: add login names to notify user

* feat: add login names to initial mail

* feat: add login names to initial mail
This commit is contained in:
Fabi 2020-07-07 19:31:51 +02:00 committed by GitHub
parent 5081ff21b0
commit 1c40d5645e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 320 additions and 93 deletions

View File

@ -262,6 +262,7 @@ Console:
Notification:
Repository:
DefaultLanguage: 'de'
Domain: $ZITADEL_DEFAULT_DOMAIN
Eventstore:
ServiceName: 'Notification'
Repository:

View File

@ -31,4 +31,3 @@ cockroachdb/cockroach:v19.2.2 start --insecure
#### Should show eventstore, management, admin, auth
`show databases;`

View File

@ -9,6 +9,7 @@ import (
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/i18n"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/view"
org_event "github.com/caos/zitadel/internal/org/repository/eventsourcing"
usr_event "github.com/caos/zitadel/internal/user/repository/eventsourcing"
"net/http"
"time"
@ -29,6 +30,7 @@ type handler struct {
type EventstoreRepos struct {
UserEvents *usr_event.UserEventstore
OrgEvents *org_event.OrgEventstore
}
func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, eventstore eventstore.Eventstore, repos EventstoreRepos, systemDefaults sd.SystemDefaults, i18n *i18n.Translator, dir http.FileSystem) []spooler.Handler {
@ -37,7 +39,10 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, ev
logging.Log("HANDL-s90ew").WithError(err).Debug("error create new aes crypto")
}
return []spooler.Handler{
&NotifyUser{handler: handler{view, bulkLimit, configs.cycleDuration("User"), errorCount}},
&NotifyUser{
handler: handler{view, bulkLimit, configs.cycleDuration("User"), errorCount},
orgEvents: repos.OrgEvents,
},
&Notification{
handler: handler{view, bulkLimit, configs.cycleDuration("Notification"), errorCount},
eventstore: eventstore,

View File

@ -1,6 +1,11 @@
package handler
import (
"context"
es_models "github.com/caos/zitadel/internal/eventstore/models"
org_model "github.com/caos/zitadel/internal/org/model"
org_events "github.com/caos/zitadel/internal/org/repository/eventsourcing"
org_es_model "github.com/caos/zitadel/internal/org/repository/eventsourcing/model"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"time"
@ -9,13 +14,13 @@ import (
"github.com/caos/zitadel/internal/eventstore"
"github.com/caos/zitadel/internal/eventstore/models"
"github.com/caos/zitadel/internal/eventstore/spooler"
"github.com/caos/zitadel/internal/user/repository/eventsourcing"
view_model "github.com/caos/zitadel/internal/user/repository/view/model"
)
type NotifyUser struct {
handler
eventstore eventstore.Eventstore
orgEvents *org_events.OrgEventstore
}
const (
@ -33,35 +38,131 @@ func (p *NotifyUser) EventQuery() (*models.SearchQuery, error) {
if err != nil {
return nil, err
}
return eventsourcing.UserQuery(sequence), nil
return es_models.NewSearchQuery().
AggregateTypeFilter(es_model.UserAggregate, org_es_model.OrgAggregate).
LatestSequenceFilter(sequence), nil
}
func (p *NotifyUser) Reduce(event *models.Event) (err error) {
func (u *NotifyUser) Reduce(event *models.Event) (err error) {
switch event.AggregateType {
case es_model.UserAggregate:
return u.ProcessUser(event)
case org_es_model.OrgAggregate:
return u.ProcessOrg(event)
default:
return nil
}
}
func (u *NotifyUser) ProcessUser(event *models.Event) (err error) {
user := new(view_model.NotifyUser)
switch event.Type {
case es_model.UserAdded,
es_model.UserRegistered:
user.AppendEvent(event)
u.fillLoginNames(user)
case es_model.UserProfileChanged,
es_model.UserEmailChanged,
es_model.UserEmailVerified,
es_model.UserPhoneChanged,
es_model.UserPhoneVerified,
es_model.UserPhoneRemoved:
user, err = p.view.NotifyUserByID(event.AggregateID)
user, err = u.view.NotifyUserByID(event.AggregateID)
if err != nil {
return err
}
err = user.AppendEvent(event)
case es_model.UserRemoved:
err = p.view.DeleteNotifyUser(event.AggregateID, event.Sequence)
err = u.view.DeleteNotifyUser(event.AggregateID, event.Sequence)
default:
return p.view.ProcessedNotifyUserSequence(event.Sequence)
return u.view.ProcessedNotifyUserSequence(event.Sequence)
}
if err != nil {
return err
}
return p.view.PutNotifyUser(user)
return u.view.PutNotifyUser(user, user.Sequence)
}
func (u *NotifyUser) ProcessOrg(event *models.Event) (err error) {
switch event.Type {
case org_es_model.OrgDomainVerified,
org_es_model.OrgDomainRemoved,
org_es_model.OrgIamPolicyAdded,
org_es_model.OrgIamPolicyChanged,
org_es_model.OrgIamPolicyRemoved:
return u.fillLoginNamesOnOrgUsers(event)
case org_es_model.OrgDomainPrimarySet:
return u.fillPreferredLoginNamesOnOrgUsers(event)
default:
return u.view.ProcessedNotifyUserSequence(event.Sequence)
}
if err != nil {
return err
}
return nil
}
func (u *NotifyUser) fillLoginNamesOnOrgUsers(event *models.Event) error {
org, err := u.orgEvents.OrgByID(context.Background(), org_model.NewOrg(event.ResourceOwner))
if err != nil {
return err
}
policy, err := u.orgEvents.GetOrgIamPolicy(context.Background(), event.ResourceOwner)
if err != nil {
return err
}
users, err := u.view.NotifyUsersByOrgID(event.AggregateID)
if err != nil {
return err
}
for _, user := range users {
user.SetLoginNames(policy, org.Domains)
err := u.view.PutNotifyUser(user, 0)
if err != nil {
return err
}
}
return nil
}
func (u *NotifyUser) fillPreferredLoginNamesOnOrgUsers(event *models.Event) error {
org, err := u.orgEvents.OrgByID(context.Background(), org_model.NewOrg(event.ResourceOwner))
if err != nil {
return err
}
policy, err := u.orgEvents.GetOrgIamPolicy(context.Background(), event.ResourceOwner)
if err != nil {
return err
}
if !policy.UserLoginMustBeDomain {
return nil
}
users, err := u.view.NotifyUsersByOrgID(event.AggregateID)
if err != nil {
return err
}
for _, user := range users {
user.PreferredLoginName = user.GenerateLoginName(org.GetPrimaryDomain().Domain, policy.UserLoginMustBeDomain)
err := u.view.PutNotifyUser(user, 0)
if err != nil {
return err
}
}
return nil
}
func (u *NotifyUser) fillLoginNames(user *view_model.NotifyUser) (err error) {
org, err := u.orgEvents.OrgByID(context.Background(), org_model.NewOrg(user.ResourceOwner))
if err != nil {
return err
}
policy, err := u.orgEvents.GetOrgIamPolicy(context.Background(), user.ResourceOwner)
if err != nil {
return err
}
user.SetLoginNames(policy, org.Domains)
user.PreferredLoginName = user.GenerateLoginName(org.GetPrimaryDomain().Domain, policy.UserLoginMustBeDomain)
return nil
}
func (p *NotifyUser) OnError(event *models.Event, err error) error {

View File

@ -9,6 +9,7 @@ import (
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/handler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/spooler"
noti_view "github.com/caos/zitadel/internal/notification/repository/eventsourcing/view"
es_org "github.com/caos/zitadel/internal/org/repository/eventsourcing"
es_usr "github.com/caos/zitadel/internal/user/repository/eventsourcing"
"golang.org/x/text/language"
"net/http"
@ -19,6 +20,7 @@ type Config struct {
Eventstore es_int.Config
View types.SQL
Spooler spooler.SpoolerConfig
Domain string
}
type EsRepository struct {
@ -47,11 +49,13 @@ func Start(conf Config, dir http.FileSystem, systemDefaults sd.SystemDefaults) (
if err != nil {
return nil, err
}
org := es_org.StartOrg(es_org.OrgConfig{Eventstore: es, IAMDomain: conf.Domain}, systemDefaults)
i18n, err := i18n.NewTranslator(dir, i18n.TranslatorConfig{DefaultLanguage: conf.DefaultLanguage})
if err != nil {
return nil, err
}
eventstoreRepos := handler.EventstoreRepos{UserEvents: user}
eventstoreRepos := handler.EventstoreRepos{UserEvents: user, OrgEvents: org}
spool := spooler.StartSpooler(conf.Spooler, es, view, sqlClient, eventstoreRepos, systemDefaults, i18n, dir)
return &EsRepository{

View File

@ -8,7 +8,6 @@ import (
"github.com/caos/zitadel/internal/i18n"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/handler"
"github.com/caos/zitadel/internal/notification/repository/eventsourcing/view"
usr_event "github.com/caos/zitadel/internal/user/repository/eventsourcing"
"net/http"
)
@ -19,10 +18,6 @@ type SpoolerConfig struct {
Handlers handler.Configs
}
type EventstoreRepos struct {
UserEvents *usr_event.UserEventstore
}
func StartSpooler(c SpoolerConfig, es eventstore.Eventstore, view *view.View, sql *sql.DB, eventstoreRepos handler.EventstoreRepos, systemDefaults sd.SystemDefaults, i18n *i18n.Translator, dir http.FileSystem) *spooler.Spooler {
spoolerConfig := spooler.Config{
Eventstore: es,

View File

@ -14,12 +14,19 @@ func (v *View) NotifyUserByID(userID string) (*model.NotifyUser, error) {
return view.NotifyUserByID(v.Db, notifyUserTable, userID)
}
func (v *View) PutNotifyUser(user *model.NotifyUser) error {
func (v *View) PutNotifyUser(user *model.NotifyUser, sequence uint64) error {
err := view.PutNotifyUser(v.Db, notifyUserTable, user)
if err != nil {
return err
}
return v.ProcessedNotifyUserSequence(user.Sequence)
if sequence != 0 {
return v.ProcessedNotifyUserSequence(sequence)
}
return nil
}
func (v *View) NotifyUsersByOrgID(orgID string) ([]*model.NotifyUser, error) {
return view.NotifyUsersByOrgID(v.Db, notifyUserTable, orgID)
}
func (v *View) DeleteNotifyUser(userID string, eventSequence uint64) error {

View File

@ -3,7 +3,7 @@ InitCode:
PreHeader: User initialisieren
Subject: User initialisieren
Greeting: Hallo {{.FirstName}} {{.LastName}},
Text: Dieser Benutzer wurde soeben im Zitadel erstellt. Du kannst den Button unten verwenden, um die Initialisierung abzuschliesen. (Code {{.Code}}) Falls du dieses Mail nicht angefordert hast, kannst du es einfach ignorieren.
Text: Dieser Benutzer wurde soeben im Zitadel erstellt. Mit dem Benutzernamen {{.PreferredLoginName}} kannst du dich anmelden. Nutze den untenstehenden Button, um die Initialisierung abzuschliesen. (Code {{.Code}}) Falls du dieses Mail nicht angefordert hast, kannst du es einfach ignorieren.
ButtonText: Initialisierung abschliessen
PasswordReset:
Title: Zitadel - Passwort zurücksetzen

View File

@ -3,7 +3,7 @@ InitCode:
PreHeader: Initialize User
Subject: Initialize User
Greeting: Hello {{.FirstName}} {{.LastName}},
Text: This user was created in Zitadel. Please click the button below to finish the initialization process. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
Text: This user was created in Zitadel. Use the username {{.PreferredLoginName}} to login. Please click the button below to finish the initialization process. (Code {{.Code}}) If you didn't ask for this mail, please ignore it.
ButtonText: Finish initialization
PasswordReset:
Title: Zitadel - Reset password

View File

@ -31,9 +31,10 @@ func SendUserInitCode(dir http.FileSystem, i18n *i18n.Translator, user *view_mod
return err
}
var args = map[string]interface{}{
"FirstName": user.FirstName,
"LastName": user.LastName,
"Code": codeString,
"FirstName": user.FirstName,
"LastName": user.LastName,
"Code": codeString,
"PreferredLoginName": user.PreferredLoginName,
}
systemDefaults.Notifications.TemplateData.InitCode.Translate(i18n, args, user.PreferredLanguage)
initCodeData := &InitCodeEmailData{TemplateData: systemDefaults.Notifications.TemplateData.InitCode, URL: url}

View File

@ -6,23 +6,25 @@ import (
)
type NotifyUser struct {
ID string
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
UserName string
FirstName string
LastName string
NickName string
DisplayName string
PreferredLanguage string
Gender Gender
LastEmail string
VerifiedEmail string
LastPhone string
VerifiedPhone string
PasswordSet bool
Sequence uint64
ID string
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
UserName string
PreferredLoginName string
LoginNames []string
FirstName string
LastName string
NickName string
DisplayName string
PreferredLanguage string
Gender Gender
LastEmail string
VerifiedEmail string
LastPhone string
VerifiedPhone string
PasswordSet bool
Sequence uint64
}
type NotifyUserSearchRequest struct {
@ -36,8 +38,9 @@ type NotifyUserSearchRequest struct {
type NotifyUserSearchKey int32
const (
NotifyUserSearchKeyUnspecified UserSearchKey = iota
NotifyUserSearchKeyUnspecified NotifyUserSearchKey = iota
NotifyUserSearchKeyUserID
NotifyUserSearchKeyResourceOwner
)
type NotifyUserSearchQuery struct {

View File

@ -5,79 +5,108 @@ import (
"github.com/caos/logging"
caos_errs "github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore/models"
org_model "github.com/caos/zitadel/internal/org/model"
"github.com/caos/zitadel/internal/user/model"
es_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
"github.com/lib/pq"
"time"
)
const (
NotifyUserKeyUserID = "id"
NotifyUserKeyUserID = "id"
NotifyUserKeyResourceOwner = "id"
)
type NotifyUser struct {
ID string `json:"-" gorm:"column:id;primary_key"`
CreationDate time.Time `json:"-" gorm:"column:creation_date"`
ChangeDate time.Time `json:"-" gorm:"column:change_date"`
ResourceOwner string `json:"-" gorm:"column:resource_owner"`
UserName string `json:"userName" gorm:"column:user_name"`
FirstName string `json:"firstName" gorm:"column:first_name"`
LastName string `json:"lastName" gorm:"column:last_name"`
NickName string `json:"nickName" gorm:"column:nick_name"`
DisplayName string `json:"displayName" gorm:"column:display_name"`
PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"`
Gender int32 `json:"gender" gorm:"column:gender"`
LastEmail string `json:"email" gorm:"column:last_email"`
VerifiedEmail string `json:"-" gorm:"column:verified_email"`
LastPhone string `json:"phone" gorm:"column:last_phone"`
VerifiedPhone string `json:"-" gorm:"column:verified_phone"`
PasswordSet bool `json:"-" gorm:"column:password_set"`
Sequence uint64 `json:"-" gorm:"column:sequence"`
ID string `json:"-" gorm:"column:id;primary_key"`
CreationDate time.Time `json:"-" gorm:"column:creation_date"`
ChangeDate time.Time `json:"-" gorm:"column:change_date"`
ResourceOwner string `json:"-" gorm:"column:resource_owner"`
UserName string `json:"userName" gorm:"column:user_name"`
LoginNames pq.StringArray `json:"-" gorm:"column:login_names"`
PreferredLoginName string `json:"-" gorm:"column:preferred_login_name"`
FirstName string `json:"firstName" gorm:"column:first_name"`
LastName string `json:"lastName" gorm:"column:last_name"`
NickName string `json:"nickName" gorm:"column:nick_name"`
DisplayName string `json:"displayName" gorm:"column:display_name"`
PreferredLanguage string `json:"preferredLanguage" gorm:"column:preferred_language"`
Gender int32 `json:"gender" gorm:"column:gender"`
LastEmail string `json:"email" gorm:"column:last_email"`
VerifiedEmail string `json:"-" gorm:"column:verified_email"`
LastPhone string `json:"phone" gorm:"column:last_phone"`
VerifiedPhone string `json:"-" gorm:"column:verified_phone"`
PasswordSet bool `json:"-" gorm:"column:password_set"`
Sequence uint64 `json:"-" gorm:"column:sequence"`
}
func NotifyUserFromModel(user *model.NotifyUser) *NotifyUser {
return &NotifyUser{
ID: user.ID,
ChangeDate: user.ChangeDate,
CreationDate: user.CreationDate,
ResourceOwner: user.ResourceOwner,
UserName: user.UserName,
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage,
Gender: int32(user.Gender),
LastEmail: user.LastEmail,
VerifiedEmail: user.VerifiedEmail,
LastPhone: user.LastPhone,
VerifiedPhone: user.VerifiedPhone,
PasswordSet: user.PasswordSet,
Sequence: user.Sequence,
ID: user.ID,
ChangeDate: user.ChangeDate,
CreationDate: user.CreationDate,
ResourceOwner: user.ResourceOwner,
UserName: user.UserName,
LoginNames: user.LoginNames,
PreferredLoginName: user.PreferredLoginName,
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage,
Gender: int32(user.Gender),
LastEmail: user.LastEmail,
VerifiedEmail: user.VerifiedEmail,
LastPhone: user.LastPhone,
VerifiedPhone: user.VerifiedPhone,
PasswordSet: user.PasswordSet,
Sequence: user.Sequence,
}
}
func NotifyUserToModel(user *NotifyUser) *model.NotifyUser {
return &model.NotifyUser{
ID: user.ID,
ChangeDate: user.ChangeDate,
CreationDate: user.CreationDate,
ResourceOwner: user.ResourceOwner,
UserName: user.UserName,
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage,
Gender: model.Gender(user.Gender),
LastEmail: user.LastEmail,
VerifiedEmail: user.VerifiedEmail,
LastPhone: user.LastPhone,
VerifiedPhone: user.VerifiedPhone,
PasswordSet: user.PasswordSet,
Sequence: user.Sequence,
ID: user.ID,
ChangeDate: user.ChangeDate,
CreationDate: user.CreationDate,
ResourceOwner: user.ResourceOwner,
UserName: user.UserName,
LoginNames: user.LoginNames,
PreferredLoginName: user.PreferredLoginName,
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage,
Gender: model.Gender(user.Gender),
LastEmail: user.LastEmail,
VerifiedEmail: user.VerifiedEmail,
LastPhone: user.LastPhone,
VerifiedPhone: user.VerifiedPhone,
PasswordSet: user.PasswordSet,
Sequence: user.Sequence,
}
}
func (u *NotifyUser) GenerateLoginName(domain string, appendDomain bool) string {
if !appendDomain {
return u.UserName
}
return u.UserName + "@" + domain
}
func (u *NotifyUser) SetLoginNames(policy *org_model.OrgIamPolicy, domains []*org_model.OrgDomain) {
loginNames := make([]string, 0)
for _, d := range domains {
if d.Verified {
loginNames = append(loginNames, u.GenerateLoginName(d.Domain, true))
}
}
if !policy.UserLoginMustBeDomain {
loginNames = append(loginNames, u.UserName)
}
u.LoginNames = loginNames
}
func (u *NotifyUser) AppendEvent(event *models.Event) (err error) {
u.ChangeDate = event.CreationDate
u.Sequence = event.Sequence

View File

@ -0,0 +1,61 @@
package model
import (
global_model "github.com/caos/zitadel/internal/model"
usr_model "github.com/caos/zitadel/internal/user/model"
"github.com/caos/zitadel/internal/view/repository"
)
type NotifyUserSearchRequest usr_model.NotifyUserSearchRequest
type NotifyUserSearchQuery usr_model.NotifyUserSearchQuery
type NotifyUserSearchKey usr_model.NotifyUserSearchKey
func (req NotifyUserSearchRequest) GetLimit() uint64 {
return req.Limit
}
func (req NotifyUserSearchRequest) GetOffset() uint64 {
return req.Offset
}
func (req NotifyUserSearchRequest) GetSortingColumn() repository.ColumnKey {
if req.SortingColumn == usr_model.NotifyUserSearchKeyUnspecified {
return nil
}
return NotifyUserSearchKey(req.SortingColumn)
}
func (req NotifyUserSearchRequest) GetAsc() bool {
return req.Asc
}
func (req NotifyUserSearchRequest) GetQueries() []repository.SearchQuery {
result := make([]repository.SearchQuery, len(req.Queries))
for i, q := range req.Queries {
result[i] = NotifyUserSearchQuery{Key: q.Key, Value: q.Value, Method: q.Method}
}
return result
}
func (req NotifyUserSearchQuery) GetKey() repository.ColumnKey {
return NotifyUserSearchKey(req.Key)
}
func (req NotifyUserSearchQuery) GetMethod() global_model.SearchMethod {
return req.Method
}
func (req NotifyUserSearchQuery) GetValue() interface{} {
return req.Value
}
func (key NotifyUserSearchKey) ToColumnName() string {
switch usr_model.NotifyUserSearchKey(key) {
case usr_model.NotifyUserSearchKeyUserID:
return NotifyUserKeyUserID
case usr_model.NotifyUserSearchKeyResourceOwner:
return NotifyUserKeyResourceOwner
default:
return ""
}
}

View File

@ -2,6 +2,7 @@ package view
import (
caos_errs "github.com/caos/zitadel/internal/errors"
global_model "github.com/caos/zitadel/internal/model"
usr_model "github.com/caos/zitadel/internal/user/model"
"github.com/caos/zitadel/internal/user/repository/view/model"
"github.com/caos/zitadel/internal/view/repository"
@ -10,7 +11,7 @@ import (
func NotifyUserByID(db *gorm.DB, table, userID string) (*model.NotifyUser, error) {
user := new(model.NotifyUser)
query := repository.PrepareGetByKey(table, model.UserSearchKey(usr_model.NotifyUserSearchKeyUserID), userID)
query := repository.PrepareGetByKey(table, model.NotifyUserSearchKey(usr_model.NotifyUserSearchKeyUserID), userID)
err := query(db, user)
if caos_errs.IsNotFound(err) {
return nil, caos_errs.ThrowNotFound(nil, "VIEW-Gad31", "Errors.User.NotFound")
@ -18,6 +19,20 @@ func NotifyUserByID(db *gorm.DB, table, userID string) (*model.NotifyUser, error
return user, err
}
func NotifyUsersByOrgID(db *gorm.DB, table, orgID string) ([]*model.NotifyUser, error) {
users := make([]*model.NotifyUser, 0)
orgIDQuery := &usr_model.NotifyUserSearchQuery{
Key: usr_model.NotifyUserSearchKeyResourceOwner,
Method: global_model.SearchMethodEquals,
Value: orgID,
}
query := repository.PrepareSearchQuery(table, model.NotifyUserSearchRequest{
Queries: []*usr_model.NotifyUserSearchQuery{orgIDQuery},
})
_, err := query(db, &users)
return users, err
}
func PutNotifyUser(db *gorm.DB, table string, project *model.NotifyUser) error {
save := repository.PrepareSave(table)
return save(db, project)

View File

@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE notification.notify_users ADD COLUMN login_names TEXT ARRAY;
ALTER TABLE notification.notify_users ADD COLUMN preferred_login_name TEXT;
COMMIT;