diff --git a/README.md b/README.md index 691504f..611a530 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,13 @@ [Configuration & Docker Compose](https://hay-kot.github.io/homebox/quick-start) ```bash -docker run --name=homebox \ - --restart=always \ - --publish=3100:7745 \ - ghcr.io/hay-kot/homebox:latest +docker run -d \ + --name homebox \ + --restart unless-stopped \ + --publish 3100:7745 \ + --env TZ=Europe/Bucharest \ + --volume /path/to/data/folder/:/data \ + ghcr.io/hay-kot/homebox:latest ``` ## Credits diff --git a/backend/app/api/app.go b/backend/app/api/app.go index a84b9ae..0f44297 100644 --- a/backend/app/api/app.go +++ b/backend/app/api/app.go @@ -37,8 +37,11 @@ func new(conf *config.Config) *app { } func (a *app) startBgTask(t time.Duration, fn func()) { + timer := time.NewTimer(t) + for { + timer.Reset(t) a.server.Background(fn) - time.Sleep(t) + <-timer.C } } diff --git a/backend/app/api/main.go b/backend/app/api/main.go index c92572a..5ea4373 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "net/http" "os" "path/filepath" @@ -168,6 +169,19 @@ func run(cfg *config.Config) error { 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 if cfg.Demo { diff --git a/backend/go.mod b/backend/go.mod index 5871a44..44db1e2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -18,7 +18,6 @@ require ( github.com/rs/zerolog v1.29.0 github.com/stretchr/testify v1.8.2 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/yeqown/go-qrcode/v2 v2.2.1 github.com/yeqown/go-qrcode/writer/standard v1.2.1 diff --git a/backend/go.sum b/backend/go.sum index be22b37..0bad26e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= 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/go.mod h1:mzTZ0trE+jCQw/fnzijbm5Mck/l8Gbg7gC/+L1COyzM= 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/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/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/go.mod h1:2GXgpNI9iy5OdsYWu8zXfRAGnOAPxYxTWTyM0XOTYZQ= 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.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.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/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/core/services/all.go b/backend/internal/core/services/all.go index dab59ef..8cbe896 100644 --- a/backend/internal/core/services/all.go +++ b/backend/internal/core/services/all.go @@ -5,9 +5,10 @@ import ( ) type AllServices struct { - User *UserService - Group *GroupService - Items *ItemService + User *UserService + Group *GroupService + Items *ItemService + BackgroundService *BackgroundService } type OptionsFunc func(*options) @@ -42,5 +43,6 @@ func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices { repo: repos, autoIncrementAssetID: options.autoIncrementAssetID, }, + BackgroundService: &BackgroundService{repos}, } } diff --git a/backend/internal/core/services/service_background.go b/backend/internal/core/services/service_background.go new file mode 100644 index 0000000..21ae4c3 --- /dev/null +++ b/backend/internal/core/services/service_background.go @@ -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 +} diff --git a/backend/internal/data/ent/schema/maintenance_entry.go b/backend/internal/data/ent/schema/maintenance_entry.go index 1c623cf..52b905b 100644 --- a/backend/internal/data/ent/schema/maintenance_entry.go +++ b/backend/internal/data/ent/schema/maintenance_entry.go @@ -33,6 +33,8 @@ func (MaintenanceEntry) Fields() []ent.Field { Optional(), field.Float("cost"). Default(0.0), + field.Bool("reminders_enabled"). + Default(false), } } diff --git a/backend/internal/data/repo/repo_group.go b/backend/internal/data/repo/repo_group.go index 2b74071..fab543c 100644 --- a/backend/internal/data/repo/repo_group.go +++ b/backend/internal/data/repo/repo_group.go @@ -16,7 +16,36 @@ import ( ) 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 ( @@ -76,27 +105,8 @@ type ( } ) -var mapToGroupErr = mapTErrFunc(mapToGroup) - -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) GetAllGroups(ctx context.Context) ([]Group, error) { + return r.groupMapper.MapEachErr(r.db.Group.Query().All(ctx)) } 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) { - return mapToGroupErr(r.db.Group.Create(). + return r.groupMapper.MapErr(r.db.Group.Create(). SetName(name). Save(ctx)) } @@ -262,15 +272,15 @@ func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data Gr SetCurrency(currency). Save(ctx) - return mapToGroupErr(entity, err) + return r.groupMapper.MapErr(entity, err) } 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) { - return mapToGroupInvitationErr(r.db.GroupInvitationToken.Query(). + return r.invitationMapper.MapErr(r.db.GroupInvitationToken.Query(). Where(groupinvitationtoken.Token(token)). WithGroup(). Only(ctx)) diff --git a/backend/internal/data/repo/repo_maintenance_entry.go b/backend/internal/data/repo/repo_maintenance_entry.go index daf7887..b699d09 100644 --- a/backend/internal/data/repo/repo_maintenance_entry.go +++ b/backend/internal/data/repo/repo_maintenance_entry.go @@ -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) { item, err := r.db.MaintenanceEntry.Create(). SetItemID(itemID). diff --git a/backend/internal/data/repo/repo_notifier.go b/backend/internal/data/repo/repo_notifier.go index c99cad2..2ea27eb 100644 --- a/backend/internal/data/repo/repo_notifier.go +++ b/backend/internal/data/repo/repo_notifier.go @@ -73,6 +73,16 @@ func (r *NotifierRepository) GetByGroup(ctx context.Context, groupID uuid.UUID) Where(notifier.GroupID(groupID)). Order(ent.Asc(notifier.FieldName)). 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) } diff --git a/backend/internal/data/repo/repos_all.go b/backend/internal/data/repo/repos_all.go index 9a6d9c5..2a3cf27 100644 --- a/backend/internal/data/repo/repos_all.go +++ b/backend/internal/data/repo/repos_all.go @@ -20,7 +20,7 @@ func New(db *ent.Client, root string) *AllRepos { return &AllRepos{ Users: &UserRepository{db}, AuthTokens: &TokenRepository{db}, - Groups: &GroupRepository{db}, + Groups: NewGroupRepository(db), Locations: &LocationRepository{db}, Labels: &LabelRepository{db}, Items: &ItemsRepository{db}, diff --git a/docs/docs/index.md b/docs/docs/index.md index cc15a02..708e33a 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -35,7 +35,7 @@ Homebox is currently in early-active development and is currently in **beta** st - Item Identifications (Serial, Model, etc) - Categorized Attachments (Images, Manuals, General) - Arbitrary/Custom Fields -- Csv Import for quickly creating and managing items +- CSV Import/Export for quickly creating and managing items - Custom Reporting - Bill of Materials Export - QR Code Label Generator diff --git a/docs/docs/quick-start.md b/docs/docs/quick-start.md index 3a5fad3..e0ad87b 100644 --- a/docs/docs/quick-start.md +++ b/docs/docs/quick-start.md @@ -5,10 +5,13 @@ Great for testing out the application, but not recommended for stable use. Checkout the docker-compose for the recommended deployment. ```sh -docker run --name=homebox \ - --restart=always \ - --publish=3100:7745 \ - ghcr.io/hay-kot/homebox:latest +docker run -d \ + --name homebox \ + --restart unless-stopped \ + --publish 3100:7745 \ + --env TZ=Europe/Bucharest \ + --volume /path/to/data/folder/:/data \ + ghcr.io/hay-kot/homebox:latest ``` ## Docker-Compose diff --git a/docs/docs/tips-tricks.md b/docs/docs/tips-tricks.md index f7f47d9..d5c1207 100644 --- a/docs/docs/tips-tricks.md +++ b/docs/docs/tips-tricks.md @@ -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. -: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. -[Demo](https://homebox.fly.dev/reports/label-generator) \ No newline at end of file +[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. \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6ba8915..65bd2e1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -33,8 +33,6 @@ plugins: extra_css: - assets/stylesheets/extras.css -extra_javascript: - - assets/js/redoc.js markdown_extensions: - pymdownx.emoji: