feat: use notifiers on schedule (#362)

* fix potential memory leak with time.After

* add new background service to manage scheduled notifications

* update docs

* remove old js reference

* closes #278

* tidy
This commit is contained in:
Hayden 2023-03-21 11:32:48 -08:00 committed by GitHub
parent 975e636fb6
commit 840d220d4f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 201 additions and 52 deletions

View file

@ -16,9 +16,12 @@
[Configuration & Docker Compose](https://hay-kot.github.io/homebox/quick-start) [Configuration & Docker Compose](https://hay-kot.github.io/homebox/quick-start)
```bash ```bash
docker run --name=homebox \ docker run -d \
--restart=always \ --name homebox \
--publish=3100:7745 \ --restart unless-stopped \
--publish 3100:7745 \
--env TZ=Europe/Bucharest \
--volume /path/to/data/folder/:/data \
ghcr.io/hay-kot/homebox:latest ghcr.io/hay-kot/homebox:latest
``` ```

View file

@ -37,8 +37,11 @@ func new(conf *config.Config) *app {
} }
func (a *app) startBgTask(t time.Duration, fn func()) { func (a *app) startBgTask(t time.Duration, fn func()) {
timer := time.NewTimer(t)
for { for {
timer.Reset(t)
a.server.Background(fn) a.server.Background(fn)
time.Sleep(t) <-timer.C
} }
} }

View file

@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -168,6 +169,19 @@ func run(cfg *config.Config) error {
Msg("failed to purge expired invitations") Msg("failed to purge expired invitations")
} }
}) })
go app.startBgTask(time.Duration(1)*time.Hour, func() {
now := time.Now()
if now.Hour() == 8 {
fmt.Println("run notifiers")
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
if err != nil {
log.Error().
Err(err).
Msg("failed to send notifiers")
}
}
})
// TODO: Remove through external API that does setup // TODO: Remove through external API that does setup
if cfg.Demo { if cfg.Demo {

View file

@ -18,7 +18,6 @@ require (
github.com/rs/zerolog v1.29.0 github.com/rs/zerolog v1.29.0
github.com/stretchr/testify v1.8.2 github.com/stretchr/testify v1.8.2
github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/http-swagger/v2 v2.0.0
github.com/swaggo/swag v1.8.11 github.com/swaggo/swag v1.8.11
github.com/yeqown/go-qrcode/v2 v2.2.1 github.com/yeqown/go-qrcode/v2 v2.2.1
github.com/yeqown/go-qrcode/writer/standard v1.2.1 github.com/yeqown/go-qrcode/writer/standard v1.2.1

View file

@ -183,8 +183,6 @@ cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuW
cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0=
cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
entgo.io/ent v0.11.8 h1:M/M0QL1CYCUSdqGRXUrXhFYSDRJPsOOrr+RLEej/gyQ=
entgo.io/ent v0.11.8/go.mod h1:ericBi6Q8l3wBH1wEIDfKxw7rcQEuRPyBfbIzjtxJ18=
entgo.io/ent v0.11.10 h1:iqn32ybY5HRW3xSAyMNdNKpZhKgMf1Zunsej9yPKUI8= entgo.io/ent v0.11.10 h1:iqn32ybY5HRW3xSAyMNdNKpZhKgMf1Zunsej9yPKUI8=
entgo.io/ent v0.11.10/go.mod h1:mzTZ0trE+jCQw/fnzijbm5Mck/l8Gbg7gC/+L1COyzM= entgo.io/ent v0.11.10/go.mod h1:mzTZ0trE+jCQw/fnzijbm5Mck/l8Gbg7gC/+L1COyzM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -620,9 +618,6 @@ github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc= github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/http-swagger/v2 v2.0.0/go.mod h1:XYhrQVIKz13CxuKD4p4kvpaRB4jJ1/MlfQXVOE+CX8Y=
github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
github.com/swaggo/swag v1.8.11 h1:Fp1dNNtDvbCf+8kvehZbHQnlF6AxHGjmw6H/xAMrZfY= github.com/swaggo/swag v1.8.11 h1:Fp1dNNtDvbCf+8kvehZbHQnlF6AxHGjmw6H/xAMrZfY=
github.com/swaggo/swag v1.8.11/go.mod h1:2GXgpNI9iy5OdsYWu8zXfRAGnOAPxYxTWTyM0XOTYZQ= github.com/swaggo/swag v1.8.11/go.mod h1:2GXgpNI9iy5OdsYWu8zXfRAGnOAPxYxTWTyM0XOTYZQ=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
@ -991,8 +986,6 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.6.1-0.20230222164832-25d2519c8696 h1:8985/C5IvACpd9DDXckSnjSBLKDgbxXiyODgi94zOPM= golang.org/x/tools v0.6.1-0.20230222164832-25d2519c8696 h1:8985/C5IvACpd9DDXckSnjSBLKDgbxXiyODgi94zOPM=
golang.org/x/tools v0.6.1-0.20230222164832-25d2519c8696/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.1-0.20230222164832-25d2519c8696/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -8,6 +8,7 @@ type AllServices struct {
User *UserService User *UserService
Group *GroupService Group *GroupService
Items *ItemService Items *ItemService
BackgroundService *BackgroundService
} }
type OptionsFunc func(*options) type OptionsFunc func(*options)
@ -42,5 +43,6 @@ func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices {
repo: repos, repo: repos,
autoIncrementAssetID: options.autoIncrementAssetID, autoIncrementAssetID: options.autoIncrementAssetID,
}, },
BackgroundService: &BackgroundService{repos},
} }
} }

View file

@ -0,0 +1,81 @@
package services
import (
"context"
"strings"
"time"
"github.com/containrrr/shoutrrr"
"github.com/hay-kot/homebox/backend/internal/data/repo"
"github.com/hay-kot/homebox/backend/internal/data/types"
"github.com/rs/zerolog/log"
)
type BackgroundService struct {
repos *repo.AllRepos
}
func (svc *BackgroundService) SendNotifiersToday(ctx context.Context) error {
// Get All Groups
groups, err := svc.repos.Groups.GetAllGroups(ctx)
if err != nil {
return err
}
today := types.DateFromTime(time.Now())
for i := range groups {
group := groups[i]
entries, err := svc.repos.MaintEntry.GetScheduled(ctx, group.ID, today)
if err != nil {
return err
}
if len(entries) == 0 {
log.Debug().
Str("group_name", group.Name).
Str("group_id", group.ID.String()).
Msg("No scheduled maintenance for today")
continue
}
notifiers, err := svc.repos.Notifiers.GetByGroup(ctx, group.ID)
if err != nil {
return err
}
urls := make([]string, len(notifiers))
for i := range notifiers {
urls[i] = notifiers[i].URL
}
bldr := strings.Builder{}
bldr.WriteString("Homebox Maintenance for (")
bldr.WriteString(today.String())
bldr.WriteString("):\n")
for i := range entries {
entry := entries[i]
bldr.WriteString(" - ")
bldr.WriteString(entry.Name)
bldr.WriteString("\n")
}
var sendErrs []error
for i := range urls {
err := shoutrrr.Send(urls[i], bldr.String())
if err != nil {
sendErrs = append(sendErrs, err)
}
}
if len(sendErrs) > 0 {
return sendErrs[0]
}
}
return nil
}

View file

@ -33,6 +33,8 @@ func (MaintenanceEntry) Fields() []ent.Field {
Optional(), Optional(),
field.Float("cost"). field.Float("cost").
Default(0.0), Default(0.0),
field.Bool("reminders_enabled").
Default(false),
} }
} }

View file

@ -17,6 +17,35 @@ import (
type GroupRepository struct { type GroupRepository struct {
db *ent.Client db *ent.Client
groupMapper MapFunc[*ent.Group, Group]
invitationMapper MapFunc[*ent.GroupInvitationToken, GroupInvitation]
}
func NewGroupRepository(db *ent.Client) *GroupRepository {
gmap := func(g *ent.Group) Group {
return Group{
ID: g.ID,
Name: g.Name,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
Currency: strings.ToUpper(g.Currency.String()),
}
}
imap := func(i *ent.GroupInvitationToken) GroupInvitation {
return GroupInvitation{
ID: i.ID,
ExpiresAt: i.ExpiresAt,
Uses: i.Uses,
Group: gmap(i.Edges.Group),
}
}
return &GroupRepository{
db: db,
groupMapper: gmap,
invitationMapper: imap,
}
} }
type ( type (
@ -76,27 +105,8 @@ type (
} }
) )
var mapToGroupErr = mapTErrFunc(mapToGroup) func (r *GroupRepository) GetAllGroups(ctx context.Context) ([]Group, error) {
return r.groupMapper.MapEachErr(r.db.Group.Query().All(ctx))
func mapToGroup(g *ent.Group) Group {
return Group{
ID: g.ID,
Name: g.Name,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
Currency: strings.ToUpper(g.Currency.String()),
}
}
var mapToGroupInvitationErr = mapTErrFunc(mapToGroupInvitation)
func mapToGroupInvitation(g *ent.GroupInvitationToken) GroupInvitation {
return GroupInvitation{
ID: g.ID,
ExpiresAt: g.ExpiresAt,
Uses: g.Uses,
Group: mapToGroup(g.Edges.Group),
}
} }
func (r *GroupRepository) StatsLocationsByPurchasePrice(ctx context.Context, GID uuid.UUID) ([]TotalsByOrganizer, error) { func (r *GroupRepository) StatsLocationsByPurchasePrice(ctx context.Context, GID uuid.UUID) ([]TotalsByOrganizer, error) {
@ -249,7 +259,7 @@ func (r *GroupRepository) StatsGroup(ctx context.Context, GID uuid.UUID) (GroupS
} }
func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, error) { func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, error) {
return mapToGroupErr(r.db.Group.Create(). return r.groupMapper.MapErr(r.db.Group.Create().
SetName(name). SetName(name).
Save(ctx)) Save(ctx))
} }
@ -262,15 +272,15 @@ func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data Gr
SetCurrency(currency). SetCurrency(currency).
Save(ctx) Save(ctx)
return mapToGroupErr(entity, err) return r.groupMapper.MapErr(entity, err)
} }
func (r *GroupRepository) GroupByID(ctx context.Context, id uuid.UUID) (Group, error) { func (r *GroupRepository) GroupByID(ctx context.Context, id uuid.UUID) (Group, error) {
return mapToGroupErr(r.db.Group.Get(ctx, id)) return r.groupMapper.MapErr(r.db.Group.Get(ctx, id))
} }
func (r *GroupRepository) InvitationGet(ctx context.Context, token []byte) (GroupInvitation, error) { func (r *GroupRepository) InvitationGet(ctx context.Context, token []byte) (GroupInvitation, error) {
return mapToGroupInvitationErr(r.db.GroupInvitationToken.Query(). return r.invitationMapper.MapErr(r.db.GroupInvitationToken.Query().
Where(groupinvitationtoken.Token(token)). Where(groupinvitationtoken.Token(token)).
WithGroup(). WithGroup().
Only(ctx)) Only(ctx))

View file

@ -84,6 +84,27 @@ func mapMaintenanceEntry(entry *ent.MaintenanceEntry) MaintenanceEntry {
} }
} }
func (r *MaintenanceEntryRepository) GetScheduled(ctx context.Context, GID uuid.UUID, dt types.Date) ([]MaintenanceEntry, error) {
entries, err := r.db.MaintenanceEntry.Query().
Where(
maintenanceentry.HasItemWith(
item.HasGroupWith(group.ID(GID)),
),
maintenanceentry.ScheduledDate(dt.Time()),
maintenanceentry.Or(
maintenanceentry.DateIsNil(),
maintenanceentry.DateEQ(time.Time{}),
),
).
All(ctx)
if err != nil {
return nil, err
}
return mapEachMaintenanceEntry(entries), nil
}
func (r *MaintenanceEntryRepository) Create(ctx context.Context, itemID uuid.UUID, input MaintenanceEntryCreate) (MaintenanceEntry, error) { func (r *MaintenanceEntryRepository) Create(ctx context.Context, itemID uuid.UUID, input MaintenanceEntryCreate) (MaintenanceEntry, error) {
item, err := r.db.MaintenanceEntry.Create(). item, err := r.db.MaintenanceEntry.Create().
SetItemID(itemID). SetItemID(itemID).

View file

@ -73,6 +73,16 @@ func (r *NotifierRepository) GetByGroup(ctx context.Context, groupID uuid.UUID)
Where(notifier.GroupID(groupID)). Where(notifier.GroupID(groupID)).
Order(ent.Asc(notifier.FieldName)). Order(ent.Asc(notifier.FieldName)).
All(ctx) All(ctx)
return r.mapper.MapEachErr(notifier, err)
}
func (r *NotifierRepository) GetActiveByGroup(ctx context.Context, groupID uuid.UUID) ([]NotifierOut, error) {
notifier, err := r.db.Notifier.Query().
Where(notifier.GroupID(groupID), notifier.IsActive(true)).
Order(ent.Asc(notifier.FieldName)).
All(ctx)
return r.mapper.MapEachErr(notifier, err) return r.mapper.MapEachErr(notifier, err)
} }

View file

@ -20,7 +20,7 @@ func New(db *ent.Client, root string) *AllRepos {
return &AllRepos{ return &AllRepos{
Users: &UserRepository{db}, Users: &UserRepository{db},
AuthTokens: &TokenRepository{db}, AuthTokens: &TokenRepository{db},
Groups: &GroupRepository{db}, Groups: NewGroupRepository(db),
Locations: &LocationRepository{db}, Locations: &LocationRepository{db},
Labels: &LabelRepository{db}, Labels: &LabelRepository{db},
Items: &ItemsRepository{db}, Items: &ItemsRepository{db},

View file

@ -35,7 +35,7 @@ Homebox is currently in early-active development and is currently in **beta** st
- Item Identifications (Serial, Model, etc) - Item Identifications (Serial, Model, etc)
- Categorized Attachments (Images, Manuals, General) - Categorized Attachments (Images, Manuals, General)
- Arbitrary/Custom Fields - Arbitrary/Custom Fields
- Csv Import for quickly creating and managing items - CSV Import/Export for quickly creating and managing items
- Custom Reporting - Custom Reporting
- Bill of Materials Export - Bill of Materials Export
- QR Code Label Generator - QR Code Label Generator

View file

@ -5,9 +5,12 @@
Great for testing out the application, but not recommended for stable use. Checkout the docker-compose for the recommended deployment. Great for testing out the application, but not recommended for stable use. Checkout the docker-compose for the recommended deployment.
```sh ```sh
docker run --name=homebox \ docker run -d \
--restart=always \ --name homebox \
--publish=3100:7745 \ --restart unless-stopped \
--publish 3100:7745 \
--env TZ=Europe/Bucharest \
--volume /path/to/data/folder/:/data \
ghcr.io/hay-kot/homebox:latest ghcr.io/hay-kot/homebox:latest
``` ```

View file

@ -41,8 +41,18 @@ Homebox has a built-in QR code generator that can be used to generate QR codes f
However, the API endpoint is available for generating QR codes on the fly for any item (or any other data) if you provide a valid API key in the query parameters. An example url would look like `/api/v1/qrcode?data=https://homebox.fly.dev/item/{uuid}&access_token={api_key}`. Currently the easiest way to get an API token is to use one from an existing URL of the QR Code in the API key, but this will be improved in the future. However, the API endpoint is available for generating QR codes on the fly for any item (or any other data) if you provide a valid API key in the query parameters. An example url would look like `/api/v1/qrcode?data=https://homebox.fly.dev/item/{uuid}&access_token={api_key}`. Currently the easiest way to get an API token is to use one from an existing URL of the QR Code in the API key, but this will be improved in the future.
:octicons-tag-24: 0.8.0 :octicons-tag-24: v0.8.0
In version 0.8.0 We've added a custom label generation. On the tools page, there is now a link to the label-generator page where you can generate labels based on Asset ID for your inventory. These are still in early development, so please provide feedback. There's also more information on the implementation on the label generator page. In version 0.8.0 We've added a custom label generation. On the tools page, there is now a link to the label-generator page where you can generate labels based on Asset ID for your inventory. These are still in early development, so please provide feedback. There's also more information on the implementation on the label generator page.
[Demo](https://homebox.fly.dev/reports/label-generator) [Demo](https://homebox.fly.dev/reports/label-generator)
## Scheduled Maintenance Notifications
:octicons-tag-24: v0.9.0
Homebox uses [shoutrrr](https://containrrr.dev/shoutrrr/0.7/) to send notifications. This allows you to send notifications to a variety of services. On your profile page, you can add notification URLs to your profile which will be used to send notifications when a maintenance event is scheduled.
**Notifications are sent on the day the maintenance is scheduled at or around 8am.**
As of `v0.9.0` we have limited support for complex scheduling of maintenance events. If you have requests for extended functionality, please open an issue on GitHub or reach out on Discord. We're still gauging the demand for this feature.

View file

@ -33,8 +33,6 @@ plugins:
extra_css: extra_css:
- assets/stylesheets/extras.css - assets/stylesheets/extras.css
extra_javascript:
- assets/js/redoc.js
markdown_extensions: markdown_extensions:
- pymdownx.emoji: - pymdownx.emoji: