From 9140b523daa06f53524e998119304496de0751fc Mon Sep 17 00:00:00 2001 From: Christian Beutel <> Date: Sat, 21 Dec 2024 03:24:36 +0100 Subject: [PATCH] adds notifications --- db/main.go | 388 ++++++++++++------ .../1717613522_updated_user_tokens.go | 3 +- .../1734186355_created_activities.go | 2 +- db/migrations/1734196620_created_follows.go | 2 +- .../1734200418_created_follow_counts.go | 2 +- .../17347160210_created_notifications.go | 120 ++++++ .../1734726340_created_notifications.go | 120 ++++++ .../1734732096_updated_notifications.go | 82 ++++ db/migrations/1734732120_updated_settings.go | 53 +++ .../1734737296_updated_notifications.go | 34 ++ .../1734741586_updated_list_share.go | 76 ++++ .../1734745359_updated_notifications.go | 84 ++++ db/util/notification.go | 116 ++++++ .../base/skeleton_notification_card.svelte | 14 + web/src/lib/components/base/toggle.svelte | 9 + .../components/comment/comment_card.svelte | 8 +- web/src/lib/components/nav_bar.svelte | 6 +- .../notification/notification_card.svelte | 126 ++++++ .../notification/notification_dropdown.svelte | 141 +++++++ web/src/lib/i18n/locales/de.json | 15 +- web/src/lib/i18n/locales/en.json | 13 + web/src/lib/i18n/locales/fr.json | 13 + web/src/lib/i18n/locales/hu.json | 13 + web/src/lib/i18n/locales/it.json | 13 + web/src/lib/i18n/locales/nl.json | 13 + web/src/lib/i18n/locales/pl.json | 13 + web/src/lib/i18n/locales/pt.json | 13 + web/src/lib/i18n/locales/zh.json | 13 + web/src/lib/models/notification.ts | 26 ++ web/src/lib/models/settings.ts | 16 +- web/src/lib/stores/notification_store.ts | 38 ++ web/src/routes/+layout.server.ts | 10 +- web/src/routes/api/v1/follow/+server.ts | 6 +- web/src/routes/api/v1/notification/+server.ts | 34 ++ .../api/v1/notification/[id]/+server.ts | 15 + web/src/routes/login/+page.svelte | 4 +- web/src/routes/settings/+layout.svelte | 1 + .../settings/notifications/+page.svelte | 99 +++++ web/src/routes/trail/edit/[id]/+page.svelte | 1 + 39 files changed, 1613 insertions(+), 142 deletions(-) create mode 100644 db/migrations/17347160210_created_notifications.go create mode 100644 db/migrations/1734726340_created_notifications.go create mode 100644 db/migrations/1734732096_updated_notifications.go create mode 100644 db/migrations/1734732120_updated_settings.go create mode 100644 db/migrations/1734737296_updated_notifications.go create mode 100644 db/migrations/1734741586_updated_list_share.go create mode 100644 db/migrations/1734745359_updated_notifications.go create mode 100644 db/util/notification.go create mode 100644 web/src/lib/components/base/skeleton_notification_card.svelte create mode 100644 web/src/lib/components/notification/notification_card.svelte create mode 100644 web/src/lib/components/notification/notification_dropdown.svelte create mode 100644 web/src/lib/models/notification.ts create mode 100644 web/src/lib/stores/notification_store.ts create mode 100644 web/src/routes/api/v1/notification/+server.ts create mode 100644 web/src/routes/api/v1/notification/[id]/+server.ts create mode 100644 web/src/routes/settings/notifications/+page.svelte diff --git a/db/main.go b/db/main.go index ff32ab36..74028dd1 100644 --- a/db/main.go +++ b/db/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log" "net/http" "os" @@ -23,15 +24,52 @@ import ( func main() { app := pocketbase.New() + client := initializeMeiliSearch() + registerMigrations(app) + setupEventHandlers(app, client) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} + +func initializeMeiliSearch() meilisearch.ServiceManager { + return meilisearch.New( + os.Getenv("MEILI_URL"), + meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY")), + ) +} + +func registerMigrations(app *pocketbase.PocketBase) { migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ Dir: "migrations", Automigrate: true, }) +} - client := meilisearch.New(os.Getenv("MEILI_URL"), meilisearch.WithAPIKey(os.Getenv("MEILI_MASTER_KEY"))) +func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) { + app.OnModelAfterCreate("users").Add(createUserHandler(app, client)) - app.OnModelAfterCreate("users").Add(func(e *core.ModelEvent) error { + app.OnRecordAfterCreateRequest("trails").Add(createTrailHandler(app, client)) + app.OnRecordAfterUpdateRequest("trails").Add(updateTrailHandler(client)) + app.OnRecordAfterDeleteRequest("trails").Add(deleteTrailHandler(client)) + + app.OnRecordAfterCreateRequest("trail_share").Add(createTrailShareHandler(app, client)) + app.OnRecordAfterDeleteRequest("trail_share").Add(deleteTrailShareHandler(client)) + + app.OnRecordAfterCreateRequest("list_share").Add(createListShareHandler(app)) + + app.OnRecordAfterCreateRequest("lists").Add(createListHandler(app)) + app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app)) + app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(app)) + + app.OnRecordBeforeRequestEmailChangeRequest("users").Add(changeUserEmailHandler(app)) + app.OnBeforeServe().Add(onBeforeServeHandler(app, client)) +} + +func createUserHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ModelEvent) error { + return func(e *core.ModelEvent) error { record := e.Model.(*models.Record) userId := record.GetId() @@ -42,57 +80,68 @@ func main() { }, } - if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil { - return err - } else { - record.Set("token", token) - - if err := app.Dao().SaveRecord(record); err != nil { - return err - } - } - - collection, err := app.Dao().FindCollectionByNameOrId("settings") + token, err := util.GenerateMeilisearchToken(searchRules, client) if err != nil { return err } - - settings := models.NewRecord(collection) - - settings.Set("language", "en") - settings.Set("unit", "metric") - settings.Set("mapFocus", "trails") - settings.Set("user", record.Id) - - if err := app.Dao().SaveRecord(settings); err != nil { + record.Set("token", token) + if err := app.Dao().SaveRecord(record); err != nil { return err } - return nil - }) + return createDefaultUserSettings(app, record.Id) + } +} - app.OnRecordAfterCreateRequest("trails").Add(func(e *core.RecordCreateEvent) error { +func createDefaultUserSettings(app *pocketbase.PocketBase, userId string) error { + collection, err := app.Dao().FindCollectionByNameOrId("settings") + if err != nil { + return err + } + settings := models.NewRecord(collection) + settings.Set("language", "en") + settings.Set("unit", "metric") + settings.Set("mapFocus", "trails") + settings.Set("user", userId) + return app.Dao().SaveRecord(settings) +} + +func createTrailHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { if err := util.IndexTrail(e.Record, client); err != nil { return err } - return nil - }) - - app.OnRecordAfterUpdateRequest("trails").Add(func(e *core.RecordUpdateEvent) error { - if err := util.UpdateTrail(e.Record, client); err != nil { - return err + if e.Record.GetBool("public") { + notification := util.Notification{ + Type: util.TrailCreate, + Metadata: map[string]interface{}{ + "id": e.Record.Id, + "trail": e.Record.GetString("name"), + }, + Seen: false, + Author: e.Record.GetString("author"), + } + return util.SendNotificationToFollowers(app, notification) } return nil - }) + } +} - app.OnRecordAfterDeleteRequest("trails").Add(func(e *core.RecordDeleteEvent) error { - if _, err := client.Index("trails").DeleteDocument(e.Record.Id); err != nil { - return err - } - return nil - }) +func updateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordUpdateEvent) error { + return func(e *core.RecordUpdateEvent) error { + return util.UpdateTrail(e.Record, client) + } +} - app.OnRecordAfterCreateRequest("trail_share").Add(func(e *core.RecordCreateEvent) error { +func deleteTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error { + return func(e *core.RecordDeleteEvent) error { + _, err := client.Index("trails").DeleteDocument(e.Record.Id) + return err + } +} + +func createTrailShareHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { trailId := e.Record.GetString("trail") shares, err := app.Dao().FindRecordsByExpr("trail_share", dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}), @@ -100,100 +149,205 @@ func main() { if err != nil { return err } - userIds := []string{} - for _, r := range shares { - userIds = append(userIds, r.GetString("user")) + userIds := make([]string, len(shares)) + for i, r := range shares { + userIds[i] = r.GetString("user") } + util.UpdateTrailShares(trailId, userIds, client) - if err := util.UpdateTrailShares(trailId, userIds, client); err != nil { - return err + if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "trail.author"}, nil); len(errs) > 0 { + return fmt.Errorf("failed to expand: %v", errs) } - return nil - }) + shareTrail := e.Record.ExpandedOne("trail") + shareTrailAuthor := shareTrail.ExpandedOne("author") - app.OnRecordAfterDeleteRequest("trail_share").Add(func(e *core.RecordDeleteEvent) error { + notification := util.Notification{ + Type: util.TrailShare, + Metadata: map[string]interface{}{ + "id": shareTrail.Id, + "trail": shareTrail.GetString("name"), + "author": shareTrailAuthor.GetString("username"), + }, + Seen: false, + Author: shareTrailAuthor.Id, + } + return util.SendNotification(app, notification, e.Record.GetString("user")) + } +} + +func createListShareHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { + if errs := app.Dao().ExpandRecord(e.Record, []string{"list", "list.author"}, nil); len(errs) > 0 { + return fmt.Errorf("failed to expand: %v", errs) + } + shareList := e.Record.ExpandedOne("list") + shareListAuthor := shareList.ExpandedOne("author") + + notification := util.Notification{ + Type: util.ListShare, + Metadata: map[string]interface{}{ + "id": shareList.Id, + "list": shareList.GetString("name"), + "author": shareListAuthor.GetString("username"), + }, + Seen: false, + Author: shareListAuthor.Id, + } + return util.SendNotification(app, notification, e.Record.GetString("user")) + } +} + +func deleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordDeleteEvent) error { + return func(e *core.RecordDeleteEvent) error { trailId := e.Record.GetString("trail") + return util.UpdateTrailShares(trailId, []string{}, client) + } +} - if err := util.UpdateTrailShares(trailId, []string{}, client); err != nil { - return err +func createListHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { + if !e.Record.GetBool("public") { + return nil } - return nil - }) + notification := util.Notification{ + Type: util.ListCreate, + Metadata: map[string]interface{}{ + "id": e.Record.Id, + "list": e.Record.GetString("name"), + }, + Seen: false, + Author: e.Record.GetString("author"), + } + return util.SendNotificationToFollowers(app, notification) + } +} - app.OnRecordBeforeRequestEmailChangeRequest("users").Add(func(e *core.RecordRequestEmailChangeEvent) error { +func createFollowHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { + if errs := app.Dao().ExpandRecord(e.Record, []string{"follower"}, nil); len(errs) > 0 { + return fmt.Errorf("failed to expand: %v", errs) + } + follower := e.Record.ExpandedOne("follower") + + notification := util.Notification{ + Type: util.NewFollower, + Metadata: map[string]interface{}{ + "follower": follower.GetString("username"), + }, + Seen: false, + Author: e.Record.GetString("follower"), + } + return util.SendNotification(app, notification, e.Record.GetString("followee")) + } +} + +func createCommentHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { + + if errs := app.Dao().ExpandRecord(e.Record, []string{"trail", "author"}, nil); len(errs) > 0 { + return fmt.Errorf("failed to expand: %v", errs) + } + commentAuthor := e.Record.ExpandedOne("author") + commentTrail := e.Record.ExpandedOne("trail") + + notification := util.Notification{ + Type: util.TrailComment, + Metadata: map[string]interface{}{ + "id": commentTrail.Id, + "author": commentAuthor.GetString("username"), + "trail": commentTrail.GetString("name"), + "comment": e.Record.GetString("text")[:128], + }, + Seen: false, + Author: e.Record.GetString("author"), + } + return util.SendNotification(app, notification, commentTrail.GetString("author")) + } +} + +func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordRequestEmailChangeEvent) error { + return func(e *core.RecordRequestEmailChangeEvent) error { form := forms.NewRecordEmailChangeRequest(app, e.Record) if err := e.HttpContext.Bind(form); err != nil { return err } e.Record.Set("email", form.NewEmail) - if err := app.Dao().SaveRecord(e.Record); err != nil { return err } - return hook.StopPropagation - }) - - app.OnBeforeServe().Add(func(e *core.ServeEvent) error { - e.Router.GET("/public/search/token", func(c echo.Context) error { - searchRules := map[string]interface{}{ - "cities500": map[string]string{}, - "trails": map[string]string{ - "filter": "public = true", - }, - } - - if token, err := util.GenerateMeilisearchToken(searchRules, client); err != nil { - return err - } else { - return c.JSON(http.StatusOK, map[string]string{"token": token}) - } - - }) - - // bootstrap pocketbase - query := app.Dao().RecordQuery("categories") - records := []*models.Record{} - - if err := query.All(&records); err != nil { - return err - } - if len(records) == 0 { - collection, _ := app.Dao().FindCollectionByNameOrId("categories") - - categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"} - for _, element := range categories { - record := models.NewRecord(collection) - form := forms.NewRecordUpsert(app, record) - form.LoadData(map[string]any{ - "name": element, - }) - f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg") - form.AddFiles("img", f) - form.Submit() - } - } - - // bootstrap meilisearch - query = app.Dao().RecordQuery("trails") - trails := []*models.Record{} - - if err := query.All(&trails); err != nil { - return err - } - - for _, trail := range trails { - log.Println(trail) - - if err := util.UpdateTrail(trail, client); err != nil { - return err - } - } - - return nil - }) - - if err := app.Start(); err != nil { - log.Fatal(err) } } + +func onBeforeServeHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ServeEvent) error { + return func(e *core.ServeEvent) error { + registerRoutes(e, client) + return bootstrapData(app, client) + } + +} + +func registerRoutes(e *core.ServeEvent, client meilisearch.ServiceManager) { + e.Router.GET("/public/search/token", func(c echo.Context) error { + searchRules := map[string]interface{}{ + "cities500": map[string]string{}, + "trails": map[string]string{ + "filter": "public = true", + }, + } + token, err := util.GenerateMeilisearchToken(searchRules, client) + if err != nil { + return err + } + return c.JSON(http.StatusOK, map[string]string{"token": token}) + }) +} + +func bootstrapData(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error { + bootstrapCategories(app) + bootstrapMeilisearchTrails(app, client) + return nil +} + +func bootstrapCategories(app *pocketbase.PocketBase) error { + query := app.Dao().RecordQuery("categories") + records := []*models.Record{} + + if err := query.All(&records); err != nil { + return err + } + if len(records) == 0 { + collection, _ := app.Dao().FindCollectionByNameOrId("categories") + + categories := []string{"Hiking", "Walking", "Climbing", "Skiing", "Canoeing", "Biking"} + for _, element := range categories { + record := models.NewRecord(collection) + form := forms.NewRecordUpsert(app, record) + form.LoadData(map[string]any{ + "name": element, + }) + f, _ := filesystem.NewFileFromPath("migrations/initial_data/" + strings.ToLower(element) + ".jpg") + form.AddFiles("img", f) + form.Submit() + } + } + return nil +} + +func bootstrapMeilisearchTrails(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error { + query := app.Dao().RecordQuery("trails") + trails := []*models.Record{} + + if err := query.All(&trails); err != nil { + return err + } + + for _, trail := range trails { + log.Println(trail) + + if err := util.UpdateTrail(trail, client); err != nil { + return err + } + } + return nil +} diff --git a/db/migrations/1717613522_updated_user_tokens.go b/db/migrations/1717613522_updated_user_tokens.go index 45e9e131..77e8bd2c 100644 --- a/db/migrations/1717613522_updated_user_tokens.go +++ b/db/migrations/1717613522_updated_user_tokens.go @@ -2,14 +2,13 @@ package migrations import ( "os" + "pocketbase/util" "github.com/meilisearch/meilisearch-go" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/daos" m "github.com/pocketbase/pocketbase/migrations" - - "pocketbase/util" ) func init() { diff --git a/db/migrations/1734186355_created_activities.go b/db/migrations/1734186355_created_activities.go index f0851bea..ef09dda5 100644 --- a/db/migrations/1734186355_created_activities.go +++ b/db/migrations/1734186355_created_activities.go @@ -170,7 +170,7 @@ func init() { return daos.New(db).SaveCollection(collection) }, func(db dbx.Builder) error { - dao := daos.New(db); + dao := daos.New(db) collection, err := dao.FindCollectionByNameOrId("t9lphichi5xwyeu") if err != nil { diff --git a/db/migrations/1734196620_created_follows.go b/db/migrations/1734196620_created_follows.go index 498726af..27cbd4af 100644 --- a/db/migrations/1734196620_created_follows.go +++ b/db/migrations/1734196620_created_follows.go @@ -68,7 +68,7 @@ func init() { return daos.New(db).SaveCollection(collection) }, func(db dbx.Builder) error { - dao := daos.New(db); + dao := daos.New(db) collection, err := dao.FindCollectionByNameOrId("8obn1ukumze565i") if err != nil { diff --git a/db/migrations/1734200418_created_follow_counts.go b/db/migrations/1734200418_created_follow_counts.go index 28713d55..92bd503c 100644 --- a/db/migrations/1734200418_created_follow_counts.go +++ b/db/migrations/1734200418_created_follow_counts.go @@ -62,7 +62,7 @@ func init() { return daos.New(db).SaveCollection(collection) }, func(db dbx.Builder) error { - dao := daos.New(db); + dao := daos.New(db) collection, err := dao.FindCollectionByNameOrId("j6w72f0kb5ivd7x") if err != nil { diff --git a/db/migrations/17347160210_created_notifications.go b/db/migrations/17347160210_created_notifications.go new file mode 100644 index 00000000..73925333 --- /dev/null +++ b/db/migrations/17347160210_created_notifications.go @@ -0,0 +1,120 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := `{ + "id": "khrcci2uqknny8h", + "created": "2024-12-20 17:33:49.887Z", + "updated": "2024-12-20 17:33:49.887Z", + "name": "notifications", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "b57prsbu", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "trail_create", + "list_create", + "new_follower", + "trail_comment" + ] + } + }, + { + "system": false, + "id": "1i2ycgle", + "name": "metadata", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 2000000 + } + }, + { + "system": false, + "id": "pyimxu85", + "name": "seen", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "tmghd4vo", + "name": "recipient", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "exqo1whj", + "name": "author", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + } + ], + "indexes": [], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "options": {} + }` + + collection := &models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return daos.New(db).SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + return dao.DeleteCollection(collection) + }) +} diff --git a/db/migrations/1734726340_created_notifications.go b/db/migrations/1734726340_created_notifications.go new file mode 100644 index 00000000..29ed2657 --- /dev/null +++ b/db/migrations/1734726340_created_notifications.go @@ -0,0 +1,120 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := `{ + "id": "khrcci2uqknny8h", + "created": "2024-12-20 17:33:49.887Z", + "updated": "2024-12-20 20:25:40.071Z", + "name": "notifications", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "b57prsbu", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "trail_create", + "list_create", + "new_follower", + "trail_comment" + ] + } + }, + { + "system": false, + "id": "1i2ycgle", + "name": "metadata", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 2000000 + } + }, + { + "system": false, + "id": "pyimxu85", + "name": "seen", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "tmghd4vo", + "name": "recipient", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "exqo1whj", + "name": "author", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + } + ], + "indexes": [], + "listRule": "@request.auth.id = recipient", + "viewRule": "@request.auth.id = recipient", + "createRule": null, + "updateRule": "@request.auth.id = recipient && @request.data.type = type && @request.data.metadata = metadata && @request.data.recipient = recipient && @request.data.author = author", + "deleteRule": null, + "options": {} + }` + + collection := &models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return daos.New(db).SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + return dao.DeleteCollection(collection) + }) +} diff --git a/db/migrations/1734732096_updated_notifications.go b/db/migrations/1734732096_updated_notifications.go new file mode 100644 index 00000000..00f061dd --- /dev/null +++ b/db/migrations/1734732096_updated_notifications.go @@ -0,0 +1,82 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models/schema" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + // update + edit_type := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "b57prsbu", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "trail_create", + "t list_create", + "new_follower", + "trail_comment", + "trail_share", + "list_share" + ] + } + }`), edit_type); err != nil { + return err + } + collection.Schema.AddField(edit_type) + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + // update + edit_type := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "b57prsbu", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "trail_create", + "list_create", + "new_follower", + "trail_comment" + ] + } + }`), edit_type); err != nil { + return err + } + collection.Schema.AddField(edit_type) + + return dao.SaveCollection(collection) + }) +} diff --git a/db/migrations/1734732120_updated_settings.go b/db/migrations/1734732120_updated_settings.go new file mode 100644 index 00000000..f0e96644 --- /dev/null +++ b/db/migrations/1734732120_updated_settings.go @@ -0,0 +1,53 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models/schema" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13") + if err != nil { + return err + } + + // add + new_notifications := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "sbwmk0q2", + "name": "notifications", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 2000000 + } + }`), new_notifications); err != nil { + return err + } + collection.Schema.AddField(new_notifications) + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13") + if err != nil { + return err + } + + // remove + collection.Schema.RemoveField("sbwmk0q2") + + return dao.SaveCollection(collection) + }) +} diff --git a/db/migrations/1734737296_updated_notifications.go b/db/migrations/1734737296_updated_notifications.go new file mode 100644 index 00000000..2a6b00ac --- /dev/null +++ b/db/migrations/1734737296_updated_notifications.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/tools/types" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + collection.UpdateRule = types.Pointer("@request.auth.id = recipient && @request.data.type = type && @request.data.metadata = metadata && @request.data.recipient = recipient && @request.data.author = author && @request.data.seen = true") + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + collection.UpdateRule = types.Pointer("@request.auth.id = recipient && @request.data.type = type && @request.data.metadata = metadata && @request.data.recipient = recipient && @request.data.author = author") + + return dao.SaveCollection(collection) + }) +} diff --git a/db/migrations/1734741586_updated_list_share.go b/db/migrations/1734741586_updated_list_share.go new file mode 100644 index 00000000..4df6df95 --- /dev/null +++ b/db/migrations/1734741586_updated_list_share.go @@ -0,0 +1,76 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models/schema" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("1kot7t9na3hi0gl") + if err != nil { + return err + } + + // update + edit_list := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "luqrtipy", + "name": "list", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "r6gu2ajyidy1x69", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }`), edit_list); err != nil { + return err + } + collection.Schema.AddField(edit_list) + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("1kot7t9na3hi0gl") + if err != nil { + return err + } + + // update + edit_list := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "luqrtipy", + "name": "list", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "r6gu2ajyidy1x69", + "cascadeDelete": false, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }`), edit_list); err != nil { + return err + } + collection.Schema.AddField(edit_list) + + return dao.SaveCollection(collection) + }) +} diff --git a/db/migrations/1734745359_updated_notifications.go b/db/migrations/1734745359_updated_notifications.go new file mode 100644 index 00000000..e881e0ff --- /dev/null +++ b/db/migrations/1734745359_updated_notifications.go @@ -0,0 +1,84 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models/schema" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + // update + edit_type := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "b57prsbu", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "trail_create", + "list_create", + "new_follower", + "trail_comment", + "trail_share", + "list_share" + ] + } + }`), edit_type); err != nil { + return err + } + collection.Schema.AddField(edit_type) + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("khrcci2uqknny8h") + if err != nil { + return err + } + + // update + edit_type := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "b57prsbu", + "name": "type", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "trail_create", + "t list_create", + "new_follower", + "trail_comment", + "trail_share", + "list_share" + ] + } + }`), edit_type); err != nil { + return err + } + collection.Schema.AddField(edit_type) + + return dao.SaveCollection(collection) + }) +} diff --git a/db/util/notification.go b/db/util/notification.go new file mode 100644 index 00000000..0d552bb6 --- /dev/null +++ b/db/util/notification.go @@ -0,0 +1,116 @@ +package util + +import ( + "fmt" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/models" +) + +type NotificationType string + +const ( + TrailCreate NotificationType = "trail_create" + TrailShare NotificationType = "trail_share" + ListCreate NotificationType = "list_create" + ListShare NotificationType = "list_share" + NewFollower NotificationType = "new_follower" + TrailComment NotificationType = "trail_comment" +) + +type Notification struct { + Type NotificationType `json:"type"` + Metadata interface{} `json:"metadata,omitempty"` + Seen bool `json:"seen"` + Author string `json:"author"` +} + +type NotificationSettings struct { + Web bool `json:"web"` + Email bool `json:"email"` +} + +func getNotificationPermissions(app *pocketbase.PocketBase, user string, notificationType NotificationType) (*NotificationSettings, error) { + settings, err := app.Dao().FindFirstRecordByFilter("settings", "user={:user}", dbx.Params{"user": user}) + if err != nil { + return nil, err + } + var notificationPreferences map[NotificationType]NotificationSettings + + err = settings.UnmarshalJSONField("notifications", ¬ificationPreferences) + if err != nil { + return nil, err + } + + settingsForType, exists := notificationPreferences[notificationType] + if !exists { + return nil, fmt.Errorf("notification type '%s' not found", notificationType) + } + + return &settingsForType, nil +} + +func SendNotification(app *pocketbase.PocketBase, notification Notification, recipient string) error { + if notification.Author == recipient { + return nil + } + permissions, err := getNotificationPermissions(app, recipient, notification.Type) + if err != nil { + return err + } + if !permissions.Web { + return nil + } + notifications, err := app.Dao().FindCollectionByNameOrId("notifications") + if err != nil { + return err + } + n := models.NewRecord(notifications) + n.Set("type", string(notification.Type)) + n.Set("metadata", notification.Metadata) + n.Set("seen", notification.Seen) + n.Set("recipient", recipient) + n.Set("author", notification.Author) + + if err := app.Dao().SaveRecord(n); err != nil { + return err + } + return nil +} + +func SendNotificationToFollowers(app *pocketbase.PocketBase, notification Notification) error { + followers, err := app.Dao().FindRecordsByFilter("follows", "followee={:user}", "", -1, 0, dbx.Params{"user": notification.Author}) + + if err != nil { + return err + } + notifications, err := app.Dao().FindCollectionByNameOrId("notifications") + if err != nil { + return err + } + for _, f := range followers { + recipient := f.GetString("follower") + if notification.Author == recipient { + continue + } + permissions, err := getNotificationPermissions(app, recipient, notification.Type) + if err != nil { + continue + } + if !permissions.Web { + continue + } + n := models.NewRecord(notifications) + n.Set("type", string(notification.Type)) + n.Set("metadata", notification.Metadata) + n.Set("seen", notification.Seen) + n.Set("recipient", recipient) + n.Set("author", notification.Author) + + if err := app.Dao().SaveRecord(n); err != nil { + continue + } + } + return nil +} diff --git a/web/src/lib/components/base/skeleton_notification_card.svelte b/web/src/lib/components/base/skeleton_notification_card.svelte new file mode 100644 index 00000000..7ab01efc --- /dev/null +++ b/web/src/lib/components/base/skeleton_notification_card.svelte @@ -0,0 +1,14 @@ +
+ +
+ + +
+
+
+
+
diff --git a/web/src/lib/components/base/toggle.svelte b/web/src/lib/components/base/toggle.svelte index 3b0f46c6..220d1a4c 100644 --- a/web/src/lib/components/base/toggle.svelte +++ b/web/src/lib/components/base/toggle.svelte @@ -1,8 +1,16 @@