Make it possible to resolve notifications

This commit is contained in:
Eli Ribble 2025-11-13 16:46:30 +00:00
parent 20186f65bf
commit fc40309dd0
No known key found for this signature in database
8 changed files with 266 additions and 96 deletions

View file

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

View file

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

View file

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

View file

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

33
html.go
View file

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

View file

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

View file

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

83
notification.go Normal file
View file

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