From fc40309dd00ee9d653a8bff1875fc759d5261a8c Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 13 Nov 2025 16:46:30 +0000 Subject: [PATCH] Make it possible to resolve notifications --- arcgis.go | 26 ++---- dbinfo/notification.bob.go | 51 ++++++++++-- factory/bobfactory_main.bob.go | 1 + factory/notification.bob.go | 76 +++++++++++++++-- html.go | 33 -------- migrations/00012_notification_unique_link.sql | 9 ++ models/notification.bob.go | 83 ++++++++++++------- notification.go | 83 +++++++++++++++++++ 8 files changed, 266 insertions(+), 96 deletions(-) create mode 100644 migrations/00012_notification_unique_link.sql create mode 100644 notification.go diff --git a/arcgis.go b/arcgis.go index 3777868d..1f61fbb1 100644 --- a/arcgis.go +++ b/arcgis.go @@ -22,7 +22,6 @@ import ( "github.com/Gleipnir-Technology/arcgis-go" "github.com/Gleipnir-Technology/arcgis-go/fieldseeker" - enums "github.com/Gleipnir-Technology/nidus-sync/enums" "github.com/Gleipnir-Technology/nidus-sync/models" "github.com/Gleipnir-Technology/nidus-sync/sql" "github.com/aarondl/opt/omit" @@ -194,26 +193,23 @@ func updateArcgisUserData(ctx context.Context, user *models.User, access_token s slog.Error("Cannot get webhooks - ArcGIS ID is null", slog.Int("org.id", int(org.ID))) } client.Context = &arcgis_id - err = maybeCreateWebhook(ctx, fieldseekerClient) - if err != nil { - slog.Error("Failed to manage webhooks", slog.String("err", err.Error())) - } + maybeCreateWebhook(ctx, fieldseekerClient) + clearNotificationsOauth(ctx, user) NewOAuthTokenChannel <- struct{}{} } -func maybeCreateWebhook(ctx context.Context, client *fieldseeker.FieldSeeker) error { +func maybeCreateWebhook(ctx context.Context, client *fieldseeker.FieldSeeker) { webhooks, err := client.WebhookList() if err != nil { - return fmt.Errorf("Failed to get webhooks: %v", err) + slog.Error("Failed to get webhooks", slog.String("err", err.Error())) } for _, hook := range webhooks { if hook.Name == "Nidus Sync" { - return nil + slog.Info("Found nidus sync hook") } else { slog.Info("Found webhook", slog.String("name", hook.Name)) } } - return errors.New("Not implemented") } func handleOauthAccessCode(ctx context.Context, user *models.User, code string) error { @@ -513,17 +509,7 @@ func markTokenFailed(ctx context.Context, oauth *models.OauthToken) { slog.Error("Failed to get oauth user", slog.String("err", err.Error())) return } - notificationSetter := models.NotificationSetter{ - Created: omit.From(time.Now()), - Message: omit.From("Oauth token invalidated"), - Link: omit.From("/oauth/refresh"), - Type: omit.From(enums.NotificationtypeOauthTokenInvalidated), - } - err = user.InsertUserNotifications(ctx, PGInstance.BobDB, ¬ificationSetter) - if err != nil { - slog.Error("Failed to get oauth user", slog.String("err", err.Error())) - return - } + notifyOauthInvalid(ctx, user) slog.Info("Marked oauth token invalid", slog.Int("id", int(oauth.ID))) } diff --git a/dbinfo/notification.bob.go b/dbinfo/notification.bob.go index 741c9a34..08c313c3 100644 --- a/dbinfo/notification.bob.go +++ b/dbinfo/notification.bob.go @@ -69,6 +69,15 @@ var Notifications = Table[ Generated: false, AutoIncr: false, }, + ResolvedAt: column{ + Name: "resolved_at", + DBType: "timestamp without time zone", + Default: "NULL", + Comment: "", + Nullable: true, + Generated: false, + AutoIncr: false, + }, }, Indexes: notificationIndexes{ NotificationPkey: index{ @@ -88,6 +97,28 @@ var Notifications = Table[ Where: "", Include: []string{}, }, + UniqueUserLinkNotResolved: index{ + Type: "btree", + Name: "unique_user_link_not_resolved", + Columns: []indexColumn{ + { + Name: "user_id", + Desc: null.FromCond(false, true), + IsExpression: false, + }, + { + Name: "link", + Desc: null.FromCond(false, true), + IsExpression: false, + }, + }, + Unique: true, + Comment: "", + NullsFirst: []bool{false, false}, + NullsDistinct: false, + Where: "(resolved_at IS NULL)", + Include: []string{}, + }, }, PrimaryKey: &constraint{ Name: "notification_pkey", @@ -110,27 +141,29 @@ var Notifications = Table[ } type notificationColumns struct { - ID column - Created column - Link column - Message column - Type column - UserID column + ID column + Created column + Link column + Message column + Type column + UserID column + ResolvedAt column } func (c notificationColumns) AsSlice() []column { return []column{ - c.ID, c.Created, c.Link, c.Message, c.Type, c.UserID, + c.ID, c.Created, c.Link, c.Message, c.Type, c.UserID, c.ResolvedAt, } } type notificationIndexes struct { - NotificationPkey index + NotificationPkey index + UniqueUserLinkNotResolved index } func (i notificationIndexes) AsSlice() []index { return []index{ - i.NotificationPkey, + i.NotificationPkey, i.UniqueUserLinkNotResolved, } } diff --git a/factory/bobfactory_main.bob.go b/factory/bobfactory_main.bob.go index 39f24a00..b3ce9566 100644 --- a/factory/bobfactory_main.bob.go +++ b/factory/bobfactory_main.bob.go @@ -3619,6 +3619,7 @@ func (f *Factory) FromExistingNotification(m *models.Notification) *Notification o.Message = func() string { return m.Message } o.Type = func() enums.Notificationtype { return m.Type } o.UserID = func() int32 { return m.UserID } + o.ResolvedAt = func() null.Val[time.Time] { return m.ResolvedAt } ctx := context.Background() if m.R.UserUser != nil { diff --git a/factory/notification.bob.go b/factory/notification.bob.go index c3c53070..0b4403ef 100644 --- a/factory/notification.bob.go +++ b/factory/notification.bob.go @@ -10,7 +10,9 @@ import ( enums "github.com/Gleipnir-Technology/nidus-sync/enums" models "github.com/Gleipnir-Technology/nidus-sync/models" + "github.com/aarondl/opt/null" "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" "github.com/jaswdr/faker/v2" "github.com/stephenafamo/bob" ) @@ -36,12 +38,13 @@ func (mods NotificationModSlice) Apply(ctx context.Context, n *NotificationTempl // NotificationTemplate is an object representing the database table. // all columns are optional and should be set by mods type NotificationTemplate struct { - ID func() int32 - Created func() time.Time - Link func() string - Message func() string - Type func() enums.Notificationtype - UserID func() int32 + ID func() int32 + Created func() time.Time + Link func() string + Message func() string + Type func() enums.Notificationtype + UserID func() int32 + ResolvedAt func() null.Val[time.Time] r notificationR f *Factory @@ -104,6 +107,10 @@ func (o NotificationTemplate) BuildSetter() *models.NotificationSetter { val := o.UserID() m.UserID = omit.From(val) } + if o.ResolvedAt != nil { + val := o.ResolvedAt() + m.ResolvedAt = omitnull.FromNull(val) + } return m } @@ -144,6 +151,9 @@ func (o NotificationTemplate) Build() *models.Notification { if o.UserID != nil { m.UserID = o.UserID() } + if o.ResolvedAt != nil { + m.ResolvedAt = o.ResolvedAt() + } o.setModelRels(m) @@ -309,6 +319,7 @@ func (m notificationMods) RandomizeAllColumns(f *faker.Faker) NotificationMod { NotificationMods.RandomMessage(f), NotificationMods.RandomType(f), NotificationMods.RandomUserID(f), + NotificationMods.RandomResolvedAt(f), } } @@ -498,6 +509,59 @@ func (m notificationMods) RandomUserID(f *faker.Faker) NotificationMod { }) } +// Set the model columns to this value +func (m notificationMods) ResolvedAt(val null.Val[time.Time]) NotificationMod { + return NotificationModFunc(func(_ context.Context, o *NotificationTemplate) { + o.ResolvedAt = func() null.Val[time.Time] { return val } + }) +} + +// Set the Column from the function +func (m notificationMods) ResolvedAtFunc(f func() null.Val[time.Time]) NotificationMod { + return NotificationModFunc(func(_ context.Context, o *NotificationTemplate) { + o.ResolvedAt = f + }) +} + +// Clear any values for the column +func (m notificationMods) UnsetResolvedAt() NotificationMod { + return NotificationModFunc(func(_ context.Context, o *NotificationTemplate) { + o.ResolvedAt = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +// The generated value is sometimes null +func (m notificationMods) RandomResolvedAt(f *faker.Faker) NotificationMod { + return NotificationModFunc(func(_ context.Context, o *NotificationTemplate) { + o.ResolvedAt = func() null.Val[time.Time] { + if f == nil { + f = &defaultFaker + } + + val := random_time_Time(f) + return null.From(val) + } + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +// The generated value is never null +func (m notificationMods) RandomResolvedAtNotNull(f *faker.Faker) NotificationMod { + return NotificationModFunc(func(_ context.Context, o *NotificationTemplate) { + o.ResolvedAt = func() null.Val[time.Time] { + if f == nil { + f = &defaultFaker + } + + val := random_time_Time(f) + return null.From(val) + } + }) +} + func (m notificationMods) WithParentsCascading() NotificationMod { return NotificationModFunc(func(ctx context.Context, o *NotificationTemplate) { if isDone, _ := notificationWithParentsCascadingCtx.Value(ctx); isDone { diff --git a/html.go b/html.go index 941811ab..a97df36f 100644 --- a/html.go +++ b/html.go @@ -15,7 +15,6 @@ import ( "strings" "time" - enums "github.com/Gleipnir-Technology/nidus-sync/enums" "github.com/Gleipnir-Technology/nidus-sync/models" "github.com/aarondl/opt/null" //"github.com/riverqueue/river/rivershared/util/slogutil" @@ -464,38 +463,6 @@ func timeSince(t *time.Time) string { } } -type Notification struct { - Link string - Message string - Time time.Time - Type string -} - -func notificationsForUser(ctx context.Context, u *models.User) ([]Notification, error) { - results := make([]Notification, 0) - notifications, err := u.UserNotifications().All(ctx, PGInstance.BobDB) - if err != nil { - return results, fmt.Errorf("Failed to get notifications: %v", err) - } - for _, n := range notifications { - results = append(results, Notification{ - Link: n.Link, - Message: n.Message, - Time: n.Created, - Type: notificationTypeName(n.Type), - }) - } - return results, nil -} - -func notificationTypeName(t enums.Notificationtype) string { - switch t { - case enums.NotificationtypeOauthTokenInvalidated: - return "alert" - default: - return "unknown-type" - } -} func renderOrError(w http.ResponseWriter, template BuiltTemplate, context interface{}) { buf := &bytes.Buffer{} err := template.ExecuteTemplate(buf, context) diff --git a/migrations/00012_notification_unique_link.sql b/migrations/00012_notification_unique_link.sql new file mode 100644 index 00000000..523a26b4 --- /dev/null +++ b/migrations/00012_notification_unique_link.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE notification ADD COLUMN resolved_at TIMESTAMP WITHOUT TIME ZONE; +CREATE UNIQUE INDEX unique_user_link_not_resolved +ON notification (user_id, link) +WHERE resolved_at IS NULL; + +-- +goose Down +DROP INDEX unique_user_link_not_resolved; +ALTER TABLE notification DROP COLUMN resolved_at; diff --git a/models/notification.bob.go b/models/notification.bob.go index 55b05a16..47ff1199 100644 --- a/models/notification.bob.go +++ b/models/notification.bob.go @@ -10,7 +10,9 @@ import ( "time" enums "github.com/Gleipnir-Technology/nidus-sync/enums" + "github.com/aarondl/opt/null" "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" "github.com/stephenafamo/bob" "github.com/stephenafamo/bob/dialect/psql" "github.com/stephenafamo/bob/dialect/psql/dialect" @@ -25,12 +27,13 @@ import ( // Notification is an object representing the database table. type Notification struct { - ID int32 `db:"id,pk" ` - Created time.Time `db:"created" ` - Link string `db:"link" ` - Message string `db:"message" ` - Type enums.Notificationtype `db:"type" ` - UserID int32 `db:"user_id" ` + ID int32 `db:"id,pk" ` + Created time.Time `db:"created" ` + Link string `db:"link" ` + Message string `db:"message" ` + Type enums.Notificationtype `db:"type" ` + UserID int32 `db:"user_id" ` + ResolvedAt null.Val[time.Time] `db:"resolved_at" ` R notificationR `db:"-" ` } @@ -53,7 +56,7 @@ type notificationR struct { func buildNotificationColumns(alias string) notificationColumns { return notificationColumns{ ColumnsExpr: expr.NewColumnsExpr( - "id", "created", "link", "message", "type", "user_id", + "id", "created", "link", "message", "type", "user_id", "resolved_at", ).WithParent("notification"), tableAlias: alias, ID: psql.Quote(alias, "id"), @@ -62,6 +65,7 @@ func buildNotificationColumns(alias string) notificationColumns { Message: psql.Quote(alias, "message"), Type: psql.Quote(alias, "type"), UserID: psql.Quote(alias, "user_id"), + ResolvedAt: psql.Quote(alias, "resolved_at"), } } @@ -74,6 +78,7 @@ type notificationColumns struct { Message psql.Expression Type psql.Expression UserID psql.Expression + ResolvedAt psql.Expression } func (c notificationColumns) Alias() string { @@ -88,16 +93,17 @@ func (notificationColumns) AliasedAs(alias string) notificationColumns { // All values are optional, and do not have to be set // Generated columns are not included type NotificationSetter struct { - ID omit.Val[int32] `db:"id,pk" ` - Created omit.Val[time.Time] `db:"created" ` - Link omit.Val[string] `db:"link" ` - Message omit.Val[string] `db:"message" ` - Type omit.Val[enums.Notificationtype] `db:"type" ` - UserID omit.Val[int32] `db:"user_id" ` + ID omit.Val[int32] `db:"id,pk" ` + Created omit.Val[time.Time] `db:"created" ` + Link omit.Val[string] `db:"link" ` + Message omit.Val[string] `db:"message" ` + Type omit.Val[enums.Notificationtype] `db:"type" ` + UserID omit.Val[int32] `db:"user_id" ` + ResolvedAt omitnull.Val[time.Time] `db:"resolved_at" ` } func (s NotificationSetter) SetColumns() []string { - vals := make([]string, 0, 6) + vals := make([]string, 0, 7) if s.ID.IsValue() { vals = append(vals, "id") } @@ -116,6 +122,9 @@ func (s NotificationSetter) SetColumns() []string { if s.UserID.IsValue() { vals = append(vals, "user_id") } + if !s.ResolvedAt.IsUnset() { + vals = append(vals, "resolved_at") + } return vals } @@ -138,6 +147,9 @@ func (s NotificationSetter) Overwrite(t *Notification) { if s.UserID.IsValue() { t.UserID = s.UserID.MustGet() } + if !s.ResolvedAt.IsUnset() { + t.ResolvedAt = s.ResolvedAt.MustGetNull() + } } func (s *NotificationSetter) Apply(q *dialect.InsertQuery) { @@ -146,7 +158,7 @@ func (s *NotificationSetter) Apply(q *dialect.InsertQuery) { }) q.AppendValues(bob.ExpressionFunc(func(ctx context.Context, w io.Writer, d bob.Dialect, start int) ([]any, error) { - vals := make([]bob.Expression, 6) + vals := make([]bob.Expression, 7) if s.ID.IsValue() { vals[0] = psql.Arg(s.ID.MustGet()) } else { @@ -183,6 +195,12 @@ func (s *NotificationSetter) Apply(q *dialect.InsertQuery) { vals[5] = psql.Raw("DEFAULT") } + if !s.ResolvedAt.IsUnset() { + vals[6] = psql.Arg(s.ResolvedAt.MustGetNull()) + } else { + vals[6] = psql.Raw("DEFAULT") + } + return bob.ExpressSlice(ctx, w, d, start, vals, "", ", ", "") })) } @@ -192,7 +210,7 @@ func (s NotificationSetter) UpdateMod() bob.Mod[*dialect.UpdateQuery] { } func (s NotificationSetter) Expressions(prefix ...string) []bob.Expression { - exprs := make([]bob.Expression, 0, 6) + exprs := make([]bob.Expression, 0, 7) if s.ID.IsValue() { exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ @@ -236,6 +254,13 @@ func (s NotificationSetter) Expressions(prefix ...string) []bob.Expression { }}) } + if !s.ResolvedAt.IsUnset() { + exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{ + psql.Quote(append(prefix, "resolved_at")...), + psql.Arg(s.ResolvedAt), + }}) + } + return exprs } @@ -535,12 +560,13 @@ func (notification0 *Notification) AttachUserUser(ctx context.Context, exec bob. } type notificationWhere[Q psql.Filterable] struct { - ID psql.WhereMod[Q, int32] - Created psql.WhereMod[Q, time.Time] - Link psql.WhereMod[Q, string] - Message psql.WhereMod[Q, string] - Type psql.WhereMod[Q, enums.Notificationtype] - UserID psql.WhereMod[Q, int32] + ID psql.WhereMod[Q, int32] + Created psql.WhereMod[Q, time.Time] + Link psql.WhereMod[Q, string] + Message psql.WhereMod[Q, string] + Type psql.WhereMod[Q, enums.Notificationtype] + UserID psql.WhereMod[Q, int32] + ResolvedAt psql.WhereNullMod[Q, time.Time] } func (notificationWhere[Q]) AliasedAs(alias string) notificationWhere[Q] { @@ -549,12 +575,13 @@ func (notificationWhere[Q]) AliasedAs(alias string) notificationWhere[Q] { func buildNotificationWhere[Q psql.Filterable](cols notificationColumns) notificationWhere[Q] { return notificationWhere[Q]{ - ID: psql.Where[Q, int32](cols.ID), - Created: psql.Where[Q, time.Time](cols.Created), - Link: psql.Where[Q, string](cols.Link), - Message: psql.Where[Q, string](cols.Message), - Type: psql.Where[Q, enums.Notificationtype](cols.Type), - UserID: psql.Where[Q, int32](cols.UserID), + ID: psql.Where[Q, int32](cols.ID), + Created: psql.Where[Q, time.Time](cols.Created), + Link: psql.Where[Q, string](cols.Link), + Message: psql.Where[Q, string](cols.Message), + Type: psql.Where[Q, enums.Notificationtype](cols.Type), + UserID: psql.Where[Q, int32](cols.UserID), + ResolvedAt: psql.WhereNull[Q, time.Time](cols.ResolvedAt), } } diff --git a/notification.go b/notification.go new file mode 100644 index 00000000..52ac3dfe --- /dev/null +++ b/notification.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "time" + + enums "github.com/Gleipnir-Technology/nidus-sync/enums" + "github.com/Gleipnir-Technology/nidus-sync/models" + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" +) + +var ( + NotificationPathOauthReset string = "/oauth/refresh" +) + +type Notification struct { + Link string + Message string + Time time.Time + Type string +} + +// Clear all notifications for a given user with the given path +func clearNotificationsOauth(ctx context.Context, user *models.User) { + setter := models.NotificationSetter{ + ResolvedAt: omitnull.From(time.Now()), + } + updater := models.Notifications.Update( + //models.SelectWhere.Notifications.Link.EQ(NotificationPathOauthReset), + models.UpdateWhere.Notifications.Link.EQ(NotificationPathOauthReset), + models.UpdateWhere.Notifications.UserID.EQ(user.ID), + setter.UpdateMod(), + ) + updater.Exec(ctx, PGInstance.BobDB) + //user.UserNotifications( + //models.SelectWhere.Notifications.Link.EQ(NotificationPathOauthReset), + //).UpdateAll() +} + +func notifyOauthInvalid(ctx context.Context, user *models.User) { + notificationSetter := models.NotificationSetter{ + Created: omit.From(time.Now()), + Message: omit.From("Oauth token invalidated"), + Link: omit.From(NotificationPathOauthReset), + Type: omit.From(enums.NotificationtypeOauthTokenInvalidated), + } + err := user.InsertUserNotifications(ctx, PGInstance.BobDB, ¬ificationSetter) + if err != nil { + slog.Error("Failed to get oauth user", slog.String("err", err.Error())) + return + } +} + +func notificationsForUser(ctx context.Context, u *models.User) ([]Notification, error) { + results := make([]Notification, 0) + notifications, err := u.UserNotifications( + models.SelectWhere.Notifications.ResolvedAt.IsNull(), + ).All(ctx, PGInstance.BobDB) + if err != nil { + return results, fmt.Errorf("Failed to get notifications: %v", err) + } + for _, n := range notifications { + results = append(results, Notification{ + Link: n.Link, + Message: n.Message, + Time: n.Created, + Type: notificationTypeName(n.Type), + }) + } + return results, nil +} + +func notificationTypeName(t enums.Notificationtype) string { + switch t { + case enums.NotificationtypeOauthTokenInvalidated: + return "alert" + default: + return "unknown-type" + } +}