adds email notifications

This commit is contained in:
Christian Beutel
2025-01-04 14:24:00 +01:00
parent 72d2bf6062
commit 788690086e
15 changed files with 666 additions and 373 deletions

View File

@@ -114,7 +114,7 @@ func createTrailHandler(app *pocketbase.PocketBase, client meilisearch.ServiceMa
if e.Record.GetBool("public") { if e.Record.GetBool("public") {
notification := util.Notification{ notification := util.Notification{
Type: util.TrailCreate, Type: util.TrailCreate,
Metadata: map[string]interface{}{ Metadata: map[string]string{
"id": e.Record.Id, "id": e.Record.Id,
"trail": e.Record.GetString("name"), "trail": e.Record.GetString("name"),
}, },
@@ -163,7 +163,7 @@ func createTrailShareHandler(app *pocketbase.PocketBase, client meilisearch.Serv
notification := util.Notification{ notification := util.Notification{
Type: util.TrailShare, Type: util.TrailShare,
Metadata: map[string]interface{}{ Metadata: map[string]string{
"id": shareTrail.Id, "id": shareTrail.Id,
"trail": shareTrail.GetString("name"), "trail": shareTrail.GetString("name"),
"author": shareTrailAuthor.GetString("username"), "author": shareTrailAuthor.GetString("username"),
@@ -185,7 +185,7 @@ func createListShareHandler(app *pocketbase.PocketBase) func(e *core.RecordCreat
notification := util.Notification{ notification := util.Notification{
Type: util.ListShare, Type: util.ListShare,
Metadata: map[string]interface{}{ Metadata: map[string]string{
"id": shareList.Id, "id": shareList.Id,
"list": shareList.GetString("name"), "list": shareList.GetString("name"),
"author": shareListAuthor.GetString("username"), "author": shareListAuthor.GetString("username"),
@@ -211,7 +211,7 @@ func createListHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEven
} }
notification := util.Notification{ notification := util.Notification{
Type: util.ListCreate, Type: util.ListCreate,
Metadata: map[string]interface{}{ Metadata: map[string]string{
"id": e.Record.Id, "id": e.Record.Id,
"list": e.Record.GetString("name"), "list": e.Record.GetString("name"),
}, },
@@ -231,7 +231,7 @@ func createFollowHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEv
notification := util.Notification{ notification := util.Notification{
Type: util.NewFollower, Type: util.NewFollower,
Metadata: map[string]interface{}{ Metadata: map[string]string{
"follower": follower.GetString("username"), "follower": follower.GetString("username"),
}, },
Seen: false, Seen: false,
@@ -252,7 +252,7 @@ func createCommentHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateE
notification := util.Notification{ notification := util.Notification{
Type: util.TrailComment, Type: util.TrailComment,
Metadata: map[string]interface{}{ Metadata: map[string]string{
"id": commentTrail.Id, "id": commentTrail.Id,
"author": commentAuthor.GetString("username"), "author": commentAuthor.GetString("username"),
"trail": commentTrail.GetString("name"), "trail": commentTrail.GetString("name"),

View File

@@ -86,7 +86,7 @@ func init() {
return daos.New(db).SaveCollection(collection) return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error { }, func(db dbx.Builder) error {
dao := daos.New(db); dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId("urytyc428mwlbqq") collection, err := dao.FindCollectionByNameOrId("urytyc428mwlbqq")
if err != nil { if err != nil {

View File

@@ -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("r6gu2ajyidy1x69")
if err != nil {
return err
}
// update
edit_author := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "kwm6zdet",
"name": "author",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}`), edit_author); err != nil {
return err
}
collection.Schema.AddField(edit_author)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("r6gu2ajyidy1x69")
if err != nil {
return err
}
// update
edit_author := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "kwm6zdet",
"name": "author",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}`), edit_author); err != nil {
return err
}
collection.Schema.AddField(edit_author)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f9f9f9;
color: #333333;
}
.email-container {
margin: 20px auto;
padding: 20px;
max-width: 600px;
background-color: #ffffff;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}
.header {
background-color: #242734;
color: #ffffff;
text-align: center;
padding: 20px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.header img {
max-width: 150px;
margin-bottom: 10px;
}
.header h1 {
font-size: 24px;
margin: 0;
}
.greeting,
.goodbye {
margin-top: 20px;
font-size: 18px;
}
.content {
margin: 20px 0;
font-size: 16px;
line-height: 1.6;
background: #f1f1f1;
padding: 12px;
border-radius: 5px;
}
.button {
display: inline-block;
background-color: #242734;
color: #ffffff;
padding: 10px 20px;
border-radius: 5px;
text-decoration: none;
font-weight: bold;
margin: 8px 0;
}
.button:hover {
background-color: #333333;
}
.footer {
text-align: center;
font-size: 14px;
color: #777777;
margin-top: 20px;
}
.footer a {
color: #242734;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header Section -->
<div class="header">
<img src="{{.AppUrl}}/imgs/logo_text_light.png" alt="wanderer" />
</div>
<!-- Greeting -->
<div class="greeting">
<p>Hello {{.RecipientName}},</p>
<p>You have a new notification:</p>
</div>
<!-- Dynamic Content -->
<div class="content">
{{.Content}}
<div class="">
<a class="button" href="{{.AppUrl}}">
Check it out!
</a>
</div>
</div>
<div class="goodbye">
<p>Kind regards,</p>
<p>Your wanderer team</p>
</div>
<!-- Footer Section -->
<div class="footer">
<p>&copy; 2025 wanderer | <a href="{{.AppUrl}}/settings/notifications">Unsubscribe</a></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,67 @@
package util
import (
"bytes"
"errors"
t "html/template"
"github.com/pocketbase/pocketbase/tools/template"
)
type EmailData struct {
AppUrl string
RecipientName string
Content t.HTML
Link string
FollowerName string
}
var notificationTemplates = map[NotificationType]string{
TrailCreate: "{{.Author}} has created a new trail: {{.trail}}.",
TrailShare: "{{.Author}} has shared a trail with you: {{.trail}}.",
ListCreate: "{{.Author}} has created a new list: {{.list}}.",
ListShare: "{{.Author}} has shared a list with you: {{.list}}.",
NewFollower: "Good news! You have a new follower: {{.Author}}.",
TrailComment: "{{.Author}} commented on your trail '{{.trail}}': '{{.comment}}'.",
}
func GenerateHTML(appUrl string, recipientName string, authorName string, notificationType NotificationType, metadata map[string]string) (string, error) {
registry := template.NewRegistry()
// Get custom content template for the notification type
customTemplate, exists := notificationTemplates[notificationType]
if !exists {
return "", errors.New("unknown notification type")
}
// Create content template with dynamic data
contentTmpl, err := t.New("content").Parse(customTemplate)
if err != nil {
return "", err
}
// Render the custom content with data
metadata["Author"] = authorName
var contentBuffer bytes.Buffer
err = contentTmpl.Execute(&contentBuffer, metadata)
if err != nil {
return "", err
}
// Assemble the final email with boilerplate and custom content
content := EmailData{
AppUrl: appUrl,
RecipientName: recipientName,
Content: t.HTML(contentBuffer.String()),
}
html, err := registry.LoadFiles(
"templates/mail/notification.html",
).Render(content)
if err != nil {
return "", err
}
return html, nil
}

View File

@@ -2,10 +2,12 @@ package util
import ( import (
"fmt" "fmt"
"net/mail"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/mailer"
) )
type NotificationType string type NotificationType string
@@ -20,10 +22,10 @@ const (
) )
type Notification struct { type Notification struct {
Type NotificationType `json:"type"` Type NotificationType `json:"type"`
Metadata interface{} `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
Seen bool `json:"seen"` Seen bool `json:"seen"`
Author string `json:"author"` Author string `json:"author"`
} }
type NotificationSettings struct { type NotificationSettings struct {
@@ -59,22 +61,50 @@ func SendNotification(app *pocketbase.PocketBase, notification Notification, rec
if err != nil { if err != nil {
return err return err
} }
if !permissions.Web {
return nil
}
notifications, err := app.Dao().FindCollectionByNameOrId("notifications") notifications, err := app.Dao().FindCollectionByNameOrId("notifications")
if err != nil { if err != nil {
return err 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 { if permissions.Web {
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
}
}
if permissions.Email {
recipientUser, err := app.Dao().FindRecordById("users", recipient)
if err != nil {
return err
}
authorUser, err := app.Dao().FindRecordById("users", notification.Author)
if err != nil {
return err
}
html, err := GenerateHTML(app.Settings().Meta.AppUrl, recipientUser.Username(), authorUser.Username(), notification.Type, notification.Metadata)
if err != nil {
return err
}
message := &mailer.Message{
From: mail.Address{
Address: app.Settings().Meta.SenderAddress,
Name: app.Settings().Meta.SenderName,
},
To: []mail.Address{{Address: recipientUser.Email()}},
Subject: "wanderer - New Notification",
HTML: html,
}
app.NewMailClient().Send(message)
} }
return nil return nil
} }
@@ -85,32 +115,11 @@ func SendNotificationToFollowers(app *pocketbase.PocketBase, notification Notifi
if err != nil { if err != nil {
return err return err
} }
notifications, err := app.Dao().FindCollectionByNameOrId("notifications")
if err != nil {
return err
}
for _, f := range followers { for _, f := range followers {
recipient := f.GetString("follower") recipient := f.GetString("follower")
if notification.Author == recipient { SendNotification(app, notification, 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 return nil
} }

View File

@@ -115,9 +115,10 @@ To update all containers to a new version simply run `docker compose pull && doc
While not recommended it is absolutely possible to install wanderer from source. While not recommended it is absolutely possible to install wanderer from source.
### Prerequisites ### Prerequisites
1. git installed && `git clone https://github.com/Flomp/wanderer.git` 1. git installed && `git clone https://github.com/Flomp/wanderer.git --branch v0.12.0 --single-branch`
2. go >= 1.21.1 installed 2. go >= 1.21.1 installed
3. node >= 18.17.0 installed 3. node >= 18.17.0 installed
4. npm >= 8.15.0 installed
### meilisearch ### meilisearch
wanderer uses meilisearch without any further modifications. As a result, you can simply head over to [their website](https://www.meilisearch.com/docs/learn/getting_started/installation) and follow the instructions for your preferred platform. We assume that you put the binary in the `wanderer/search` directory. If you did not, adapt the launch script below accordingly. wanderer uses meilisearch without any further modifications. As a result, you can simply head over to [their website](https://www.meilisearch.com/docs/learn/getting_started/installation) and follow the instructions for your preferred platform. We assume that you put the binary in the `wanderer/search` directory. If you did not, adapt the launch script below accordingly.
@@ -157,15 +158,26 @@ Caution: Ensure that you change the `MEILI_MASTER_KEY` to a different value if y
```bash ```bash
trap "kill 0" EXIT trap "kill 0" EXIT
# learn more about the configuration:
# https://wanderer.to/getting-started/configuration/
# required
export ORIGIN=http://localhost:3000 export ORIGIN=http://localhost:3000
export MEILI_URL=http://127.0.0.1:7700 export MEILI_URL=http://127.0.0.1:7700
export MEILI_MASTER_KEY=CHANGE_ME! export MEILI_MASTER_KEY=YOU_SHOULD_DEFINITELY_CHANGE_ME
export PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090 export PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
export PUBLIC_VALHALLA_URL=https://valhalla1.openstreetmap.de export PUBLIC_VALHALLA_URL=https://valhalla1.openstreetmap.de
# optional
# export MEILI_NO_ANALYTICS=true
# export BODY_SIZE_LIMIT=Infinity
# export PUBLIC_DISABLE_SIGNUP=false
# export UPLOAD_FOLDER=/app/uploads
# export UPLOAD_USER=
# export UPLOAD_PASSWORD=
cd search && ./meilisearch --master-key $MEILI_MASTER_KEY & cd search && ./meilisearch --master-key $MEILI_MASTER_KEY &
cd db && ./pocketbase serve & cd db && ./pocketbase serve &
cd web/build && node build & cd web && node build &
wait wait
``` ```

File diff suppressed because it is too large Load Diff

View File

@@ -140,7 +140,7 @@
const r: GeoJSON[] = []; const r: GeoJSON[] = [];
trails.forEach((t) => { trails.forEach((t) => {
if (t.expand.gpx_data) { if (t.expand?.gpx_data) {
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON); r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
} else if (t.lat && t.lon) { } else if (t.lat && t.lon) {
r.push({ r.push({
@@ -165,7 +165,7 @@
if (data[activeTrail] && showElevation) { if (data[activeTrail] && showElevation) {
epc?.setData( epc?.setData(
data[activeTrail]!, data[activeTrail]!,
trails.at(activeTrail)!.expand.waypoints, trails.at(activeTrail)!.expand?.waypoints,
); );
epc?.showProfile(); epc?.showProfile();
} }
@@ -409,7 +409,7 @@
if (data[activeTrail] && showElevation) { if (data[activeTrail] && showElevation) {
epc?.setData( epc?.setData(
data[activeTrail]!, data[activeTrail]!,
trails.at(activeTrail)!.expand.waypoints, trails.at(activeTrail)!.expand?.waypoints,
); );
epc?.showProfile(); epc?.showProfile();
} }
@@ -490,7 +490,7 @@
if (!map) { if (!map) {
return; return;
} }
for (const waypoint of trails[activeTrail]?.expand.waypoints ?? []) { for (const waypoint of trails[activeTrail]?.expand?.waypoints ?? []) {
const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd); const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd);
marker.addTo(map); marker.addTo(map);
markers.push(marker); markers.push(marker);

View File

@@ -7,7 +7,7 @@ const RecordOptionsSchema = z.object({
}) })
const RecordListOptionsSchema = RecordOptionsSchema.extend({ const RecordListOptionsSchema = RecordOptionsSchema.extend({
page: z.number({ coerce: true }).int().positive().optional(), page: z.number({ coerce: true }).int().nonnegative().optional(),
perPage: z.number({ coerce: true }).int().optional(), perPage: z.number({ coerce: true }).int().optional(),
sort: z.string().optional(), sort: z.string().optional(),
filter: z.string().optional(), filter: z.string().optional(),

View File

@@ -132,6 +132,15 @@
function handleMapInit() { function handleMapInit() {
if ( if (
$page.url.searchParams.has("lat") &&
$page.url.searchParams.has("lon")
) {
const lat = $page.url.searchParams.get("lat");
const lon = $page.url.searchParams.get("lon");
map.setCenter([parseFloat(lon!), parseFloat(lat!)]);
map.setZoom(14);
console.log("Set map center to: " + lat + " " + lon);
} else if (
$page.url.searchParams.has("tl_lat") && $page.url.searchParams.has("tl_lat") &&
$page.url.searchParams.has("tl_lon") && $page.url.searchParams.has("tl_lon") &&
$page.url.searchParams.has("br_lat") && $page.url.searchParams.has("br_lat") &&
@@ -148,14 +157,6 @@
], ],
]; ];
map.fitBounds(boundingBox, { animate: false }); map.fitBounds(boundingBox, { animate: false });
} else if (
$page.url.searchParams.has("lat") &&
$page.url.searchParams.has("lon")
) {
const lat = $page.url.searchParams.get("lat");
const lon = $page.url.searchParams.get("lon");
map.setCenter([parseFloat(lon!), parseFloat(lat!)]);
map.setZoom(14);
} else if (settings && settings.mapFocus == "trails") { } else if (settings && settings.mapFocus == "trails") {
const boundingBox: M.LngLatBoundsLike = [ const boundingBox: M.LngLatBoundsLike = [
[maxBoundingBox.min_lon, maxBoundingBox.max_lat], [maxBoundingBox.min_lon, maxBoundingBox.max_lat],

View File

@@ -17,7 +17,7 @@
text: $_("trail", { values: { n: 2 } }), text: $_("trail", { values: { n: 2 } }),
value: `/profile/${$page.params.id}/trails`, value: `/profile/${$page.params.id}/trails`,
}, },
{ text: $_('statistics'), value: `/profile/${$page.params.id}/stats` }, { text: $_("statistics"), value: `/profile/${$page.params.id}/stats` },
]; ];
$: activeIndex = profileLinks.findIndex( $: activeIndex = profileLinks.findIndex(
@@ -80,14 +80,14 @@
href="/profile/{data.user.id}/users/followers" href="/profile/{data.user.id}/users/followers"
> >
<p class="font-semibold">{data.followers}</p> <p class="font-semibold">{data.followers}</p>
<p>{$_('followers')}</p> <p>{$_("followers")}</p>
</a> </a>
<a <a
class:font-bold={$page.url.pathname.endsWith("following")} class:font-bold={$page.url.pathname.endsWith("following")}
href="/profile/{data.user.id}/users/following" href="/profile/{data.user.id}/users/following"
> >
<p class="font-semibold">{data.following}</p> <p class="font-semibold">{data.following}</p>
<p>{$_('following')}</p> <p>{$_("following")}</p>
</a> </a>
</div> </div>
{#if !data.isOwnProfile} {#if !data.isOwnProfile}
@@ -113,6 +113,12 @@
> >
{/each} {/each}
</div> </div>
{#if data.isOwnProfile}
<div class="px-6 mb-4 flex flex-col gap-2">
<button class="btn-secondary basis-full">Share profile</button>
<a class="btn-secondary text-center basis-full" href="/settings/profile">{$_("settings")}</a>
</div>
{/if}
</div> </div>
<slot></slot> <slot></slot>
</div> </div>

View File

@@ -28,7 +28,7 @@
$_("must-be-at-least-n-characters-long", { $_("must-be-at-least-n-characters-long", {
values: { n: 3 }, values: { n: 3 },
}), }),
), ).regex(/^[\w][\w\.]*$/, $_("invalid-username")),
email: z email: z
.string() .string()
.min(1, "required") .min(1, "required")

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB