Compare commits

..

12 Commits

Author SHA1 Message Date
Hayden
dbaaf4ad0a fix import bug and add ref support (#88)
* fix import bug and add ref support

* fix calls

* add docs
2022-10-15 17:46:57 -08:00
Hayden
5596740cd2 fix: selector value binding (#87) 2022-10-15 13:56:08 -08:00
Hayden
bb86a51b05 feat: order labels and locations by name (#86)
* order labels and locations by name

* sort items
2022-10-15 13:29:33 -08:00
Hayden
72bdf524c2 chore: init dev container (#85) 2022-10-15 12:56:58 -08:00
Hayden
461be2afca feat: currency selection support (#72)
* initial UI for currency selection

* add task to purge invitation tokens

* group API contracts

* fix type import

* use auth middleware

* add currency setting support (UI)

* use group settings for format currency

* fix casing
2022-10-15 12:15:55 -08:00
Hayden
1cc38d6a5c Create FUNDING.yml 2022-10-15 11:24:09 -08:00
Motordom
50bd2ab86e fix: consistency changes to item pages (#82)
Changes to the item index.vue and edit.vue pages to resolve spelling differences between the two
2022-10-14 21:03:46 -08:00
Motordom
5adb8fbad7 chore: update issue templates (#78)
Update capitalisation on descriptions, and added corresponding labels.
2022-10-14 21:02:34 -08:00
Hayden
dea2dcfde8 feat: allow disable registration (#71) 2022-10-14 14:02:16 -08:00
Hayden
ba8367f637 fix: compose example (#70) 2022-10-14 13:40:27 -08:00
Motordom
b87cdc8164 fix(s): spelling mistakes, and external link handling on login page (#65)
* Fix spelling mistake

Fix spelling mistake, changed 'Mode Number' to 'Model Number'

* Update external links on login page

Updated the external links on the login page for Discord and application docs to open in new tabs/windows.

* Fix spelling mistake on items page

Fix spelling mistake, changed second incorrect occurrence of 'Labels' to 'Locations'
2022-10-14 08:30:23 -08:00
Hayden
0f51e51f63 set fly.io build args 2022-10-13 17:12:35 -08:00
61 changed files with 1196 additions and 445 deletions

5
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
ARG VARIANT=16-bullseye
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin

View File

@@ -0,0 +1,40 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node
{
"name": "Node.js & TypeScript",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local on arm64/Apple Silicon.
"args": {
"VARIANT": "18-bullseye"
}
},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
7745,
3000
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "go install github.com/go-task/task/v3/cmd/task@latest && npm install -g pnpm && task setup",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"features": {
"golang": "1.19"
}
}

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [hay-kot]

View File

@@ -1,6 +1,7 @@
--- ---
name: "Bug Report" name: "Bug Report"
description: "submit a bug report for the current release" description: "Submit a bug report for the current release"
labels: ["bug"]
body: body:
- type: checkboxes - type: checkboxes
id: checks id: checks

View File

@@ -1,6 +1,7 @@
--- ---
name: "Feature Request" name: "Feature Request"
description: "submit a feature request for the current release" description: "Submit a feature request for the current release"
labels: ["feature-request"]
body: body:
- type: textarea - type: textarea
id: problem-statement id: problem-statement

View File

@@ -4,6 +4,12 @@ env:
HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_fk=1 HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_fk=1
tasks: tasks:
setup:
desc: Install dependencies
cmds:
- go install github.com/swaggo/swag/cmd/swag@latest
- cd backend && go mod tidy
- cd frontend && pnpm install --shamefully-hoist
generate: generate:
desc: | desc: |
Generates collateral files from the backend project Generates collateral files from the backend project
@@ -27,6 +33,7 @@ tasks:
- "./scripts/process-types.py" - "./scripts/process-types.py"
generates: generates:
- "./frontend/lib/api/types/data-contracts.ts" - "./frontend/lib/api/types/data-contracts.ts"
- "./backend/ent/schema"
- "./backend/app/api/docs/swagger.json" - "./backend/app/api/docs/swagger.json"
- "./backend/app/api/docs/swagger.yaml" - "./backend/app/api/docs/swagger.yaml"

View File

@@ -52,7 +52,7 @@ func (a *app) SetupDemo() {
log.Fatal().Msg("Failed to setup demo") log.Fatal().Msg("Failed to setup demo")
} }
err = a.services.Items.CsvImport(context.Background(), self.GroupID, records) _, err = a.services.Items.CsvImport(context.Background(), self.GroupID, records)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to import CSV") log.Err(err).Msg("Failed to import CSV")
log.Fatal().Msg("Failed to setup demo") log.Fatal().Msg("Failed to setup demo")

View File

@@ -21,6 +21,63 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/v1/groups": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Updates some fields of the current users group",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.GroupUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
}
},
"/v1/groups/invitations": { "/v1/groups/invitations": {
"post": { "post": {
"security": [ "security": [
@@ -32,7 +89,7 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"User" "Group"
], ],
"summary": "Get the current user", "summary": "Get the current user",
"parameters": [ "parameters": [
@@ -1116,6 +1173,37 @@ const docTemplate = `{
} }
} }
}, },
"repo.Group": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"currency": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"repo.GroupUpdate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"repo.ItemAttachment": { "repo.ItemAttachment": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -13,6 +13,63 @@
}, },
"basePath": "/api", "basePath": "/api",
"paths": { "paths": {
"/v1/groups": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Updates some fields of the current users group",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.GroupUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
}
},
"/v1/groups/invitations": { "/v1/groups/invitations": {
"post": { "post": {
"security": [ "security": [
@@ -24,7 +81,7 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"User" "Group"
], ],
"summary": "Get the current user", "summary": "Get the current user",
"parameters": [ "parameters": [
@@ -1108,6 +1165,37 @@
} }
} }
}, },
"repo.Group": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"currency": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"repo.GroupUpdate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"repo.ItemAttachment": { "repo.ItemAttachment": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -9,6 +9,26 @@ definitions:
title: title:
type: string type: string
type: object type: object
repo.Group:
properties:
createdAt:
type: string
currency:
type: string
id:
type: string
name:
type: string
updatedAt:
type: string
type: object
repo.GroupUpdate:
properties:
currency:
type: string
name:
type: string
type: object
repo.ItemAttachment: repo.ItemAttachment:
properties: properties:
createdAt: createdAt:
@@ -415,6 +435,40 @@ info:
title: Go API Templates title: Go API Templates
version: "1.0" version: "1.0"
paths: paths:
/v1/groups:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Get the current user's group
tags:
- Group
put:
parameters:
- description: User Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/repo.GroupUpdate'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Updates some fields of the current users group
tags:
- Group
/v1/groups/invitations: /v1/groups/invitations:
post: post:
parameters: parameters:
@@ -435,7 +489,7 @@ paths:
- Bearer: [] - Bearer: []
summary: Get the current user summary: Get the current user
tags: tags:
- User - Group
/v1/items: /v1/items:
get: get:
parameters: parameters:

View File

@@ -110,7 +110,7 @@ func run(cfg *config.Config) error {
app.db = c app.db = c
app.repos = repo.New(c, cfg.Storage.Data) app.repos = repo.New(c, cfg.Storage.Data)
app.services = services.NewServices(app.repos) app.services = services.New(app.repos)
// ========================================================================= // =========================================================================
// Start Server // Start Server
@@ -138,6 +138,14 @@ func run(cfg *config.Config) error {
Msg("failed to purge expired tokens") Msg("failed to purge expired tokens")
} }
}) })
go app.startBgTask(time.Duration(24)*time.Hour, func() {
_, err := app.repos.Groups.InvitationPurge(context.Background())
if err != nil {
log.Error().
Err(err).
Msg("failed to purge expired invitations")
}
})
// TODO: Remove through external API that does setup // TODO: Remove through external API that does setup
if cfg.Demo { if cfg.Demo {

View File

@@ -44,6 +44,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
v1Base := v1.BaseUrlFunc(prefix) v1Base := v1.BaseUrlFunc(prefix)
v1Ctrl := v1.NewControllerV1(a.services, v1Ctrl := v1.NewControllerV1(a.services,
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize), v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
v1.WithRegistration(a.conf.AllowRegistration),
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
) )
r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
@@ -71,6 +72,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate()) r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate())
// TODO: I don't like /groups being the URL for users
r.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet())
r.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate())
r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll()) r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll())
r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate()) r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate())
r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet()) r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet())

View File

@@ -19,10 +19,17 @@ func WithDemoStatus(demoStatus bool) func(*V1Controller) {
} }
} }
func WithRegistration(allowRegistration bool) func(*V1Controller) {
return func(ctrl *V1Controller) {
ctrl.allowRegistration = allowRegistration
}
}
type V1Controller struct { type V1Controller struct {
svc *services.AllServices svc *services.AllServices
maxUploadSize int64 maxUploadSize int64
isDemo bool isDemo bool
allowRegistration bool
} }
type ( type (
@@ -53,7 +60,8 @@ func BaseUrlFunc(prefix string) func(s string) string {
func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller { func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller {
ctrl := &V1Controller{ ctrl := &V1Controller{
svc: svc, svc: svc,
allowRegistration: true,
} }
for _, opt := range options { for _, opt := range options {

View File

@@ -2,8 +2,10 @@ package v1
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services" "github.com/hay-kot/homebox/backend/internal/services"
"github.com/hay-kot/homebox/backend/pkgs/server" "github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -22,9 +24,63 @@ type (
} }
) )
// HandleUserSelf godoc // HandleGroupGet godoc
// @Summary Get the current user's group
// @Tags Group
// @Produce json
// @Success 200 {object} repo.Group
// @Router /v1/groups [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := services.NewContext(r.Context())
group, err := ctrl.svc.Group.Get(ctx)
if err != nil {
log.Err(err).Msg("failed to get group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower caseÍ
server.Respond(w, http.StatusOK, group)
}
}
// HandleGroupUpdate godoc
// @Summary Updates some fields of the current users group
// @Tags Group
// @Produce json
// @Param payload body repo.GroupUpdate true "User Data"
// @Success 200 {object} repo.Group
// @Router /v1/groups [Put]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := repo.GroupUpdate{}
if err := server.Decode(r, &data); err != nil {
server.RespondError(w, http.StatusBadRequest, err)
return
}
ctx := services.NewContext(r.Context())
group, err := ctrl.svc.Group.UpdateGroup(ctx, data)
if err != nil {
log.Err(err).Msg("failed to update group")
server.RespondError(w, http.StatusInternalServerError, err)
return
}
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case
server.Respond(w, http.StatusOK, group)
}
}
// HandleGroupInvitationsCreate godoc
// @Summary Get the current user // @Summary Get the current user
// @Tags User // @Tags Group
// @Produce json // @Produce json
// @Param payload body GroupInvitationCreate true "User Data" // @Param payload body GroupInvitationCreate true "User Data"
// @Success 200 {object} GroupInvitation // @Success 200 {object} GroupInvitation
@@ -36,7 +92,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
if err := server.Decode(r, &data); err != nil { if err := server.Decode(r, &data); err != nil {
log.Err(err).Msg("failed to decode user registration data") log.Err(err).Msg("failed to decode user registration data")
server.RespondError(w, http.StatusInternalServerError, err) server.RespondError(w, http.StatusBadRequest, err)
return return
} }
@@ -46,7 +102,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
ctx := services.NewContext(r.Context()) ctx := services.NewContext(r.Context())
token, err := ctrl.svc.User.NewInvitation(ctx, data.Uses, data.ExpiresAt) token, err := ctrl.svc.Group.NewInvitation(ctx, data.Uses, data.ExpiresAt)
if err != nil { if err != nil {
log.Err(err).Msg("failed to create new token") log.Err(err).Msg("failed to create new token")
server.RespondError(w, http.StatusInternalServerError, err) server.RespondError(w, http.StatusInternalServerError, err)

View File

@@ -220,7 +220,7 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc {
user := services.UseUserCtx(r.Context()) user := services.UseUserCtx(r.Context())
err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, data) _, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, data)
if err != nil { if err != nil {
log.Err(err).Msg("failed to import items") log.Err(err).Msg("failed to import items")
server.RespondServerError(w) server.RespondServerError(w)

View File

@@ -27,6 +27,11 @@ func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc {
return return
} }
if !ctrl.allowRegistration && regData.GroupToken == "" {
server.RespondError(w, http.StatusForbidden, nil)
return
}
_, err := ctrl.svc.User.RegisterUser(r.Context(), regData) _, err := ctrl.svc.User.RegisterUser(r.Context(), regData)
if err != nil { if err != nil {
log.Err(err).Msg("failed to register user") log.Err(err).Msg("failed to register user")

View File

@@ -121,6 +121,9 @@ const DefaultCurrency = CurrencyUsd
// Currency values. // Currency values.
const ( const (
CurrencyUsd Currency = "usd" CurrencyUsd Currency = "usd"
CurrencyEur Currency = "eur"
CurrencyGbp Currency = "gbp"
CurrencyJpy Currency = "jpy"
) )
func (c Currency) String() string { func (c Currency) String() string {
@@ -130,7 +133,7 @@ func (c Currency) String() string {
// CurrencyValidator is a validator for the "currency" field enum values. It is called by the builders before save. // CurrencyValidator is a validator for the "currency" field enum values. It is called by the builders before save.
func CurrencyValidator(c Currency) error { func CurrencyValidator(c Currency) error {
switch c { switch c {
case CurrencyUsd: case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy:
return nil return nil
default: default:
return fmt.Errorf("group: invalid enum value for currency field: %q", c) return fmt.Errorf("group: invalid enum value for currency field: %q", c)

View File

@@ -127,7 +127,7 @@ var (
{Name: "created_at", Type: field.TypeTime}, {Name: "created_at", Type: field.TypeTime},
{Name: "updated_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime},
{Name: "name", Type: field.TypeString, Size: 255}, {Name: "name", Type: field.TypeString, Size: 255},
{Name: "currency", Type: field.TypeEnum, Enums: []string{"usd"}, Default: "usd"}, {Name: "currency", Type: field.TypeEnum, Enums: []string{"usd", "eur", "gbp", "jpy"}, Default: "usd"},
} }
// GroupsTable holds the schema information for the "groups" table. // GroupsTable holds the schema information for the "groups" table.
GroupsTable = &schema.Table{ GroupsTable = &schema.Table{

View File

@@ -27,7 +27,7 @@ func (Group) Fields() []ent.Field {
NotEmpty(), NotEmpty(),
field.Enum("currency"). field.Enum("currency").
Default("usd"). Default("usd").
Values("usd"), // TODO: add more currencies Values("usd", "eur", "gbp", "jpy"), // TODO: add more currencies
} }
} }

View File

@@ -16,13 +16,14 @@ const (
) )
type Config struct { type Config struct {
Mode string `yaml:"mode" conf:"default:development"` // development or production Mode string `yaml:"mode" conf:"default:development"` // development or production
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Storage Storage `yaml:"storage"` Storage Storage `yaml:"storage"`
Log LoggerConf `yaml:"logger"` Log LoggerConf `yaml:"logger"`
Mailer MailerConf `yaml:"mailer"` Mailer MailerConf `yaml:"mailer"`
Swagger SwaggerConf `yaml:"swagger"` Swagger SwaggerConf `yaml:"swagger"`
Demo bool `yaml:"demo"` Demo bool `yaml:"demo"`
AllowRegistration bool `yaml:"disable_registration" conf:"default:true"`
} }
type SwaggerConf struct { type SwaggerConf struct {

View File

@@ -6,6 +6,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/ent"
"github.com/hay-kot/homebox/backend/ent/group"
"github.com/hay-kot/homebox/backend/ent/groupinvitationtoken" "github.com/hay-kot/homebox/backend/ent/groupinvitationtoken"
) )
@@ -15,11 +16,16 @@ type GroupRepository struct {
type ( type (
Group struct { Group struct {
ID uuid.UUID ID uuid.UUID `json:"id,omitempty"`
Name string Name string `json:"name,omitempty"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updatedAt,omitempty"`
Currency string Currency string `json:"currency,omitempty"`
}
GroupUpdate struct {
Name string `json:"name"`
Currency string `json:"currency"`
} }
GroupInvitationCreate struct { GroupInvitationCreate struct {
@@ -69,6 +75,17 @@ func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group,
Save(ctx)) Save(ctx))
} }
func (r *GroupRepository) GroupUpdate(ctx context.Context, ID uuid.UUID, data GroupUpdate) (Group, error) {
currency := group.Currency(data.Currency)
entity, err := r.db.Group.UpdateOneID(ID).
SetName(data.Name).
SetCurrency(currency).
Save(ctx)
return mapToGroupErr(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 mapToGroupErr(r.db.Group.Get(ctx, id))
} }

View File

@@ -18,3 +18,16 @@ func Test_Group_Create(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, g.ID, foundGroup.ID) assert.Equal(t, g.ID, foundGroup.ID)
} }
func Test_Group_Update(t *testing.T) {
g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
assert.NoError(t, err)
g, err = tRepos.Groups.GroupUpdate(context.Background(), g.ID, GroupUpdate{
Name: "test2",
Currency: "eur",
})
assert.NoError(t, err)
assert.Equal(t, "test2", g.Name)
assert.Equal(t, "eur", g.Currency)
}

View File

@@ -24,6 +24,7 @@ type (
Search string `json:"search"` Search string `json:"search"`
LocationIDs []uuid.UUID `json:"locationIds"` LocationIDs []uuid.UUID `json:"locationIds"`
LabelIDs []uuid.UUID `json:"labelIds"` LabelIDs []uuid.UUID `json:"labelIds"`
SortBy string `json:"sortBy"`
} }
ItemCreate struct { ItemCreate struct {
@@ -210,6 +211,11 @@ func (e *ItemsRepository) GetOne(ctx context.Context, id uuid.UUID) (ItemOut, er
return e.getOne(ctx, item.ID(id)) return e.getOne(ctx, item.ID(id))
} }
func (e *ItemsRepository) CheckRef(ctx context.Context, GID uuid.UUID, ref string) (bool, error) {
q := e.db.Item.Query().Where(item.HasGroupWith(group.ID(GID)))
return q.Where(item.ImportRef(ref)).Exist(ctx)
}
// GetOneByGroup returns a single item by ID. If the item does not exist, an error is returned. // GetOneByGroup returns a single item by ID. If the item does not exist, an error is returned.
// GetOneByGroup ensures that the item belongs to a specific group. // GetOneByGroup ensures that the item belongs to a specific group.
func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID) (ItemOut, error) { func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID) (ItemOut, error) {
@@ -252,7 +258,8 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
} }
items, err := mapItemsSummaryErr( items, err := mapItemsSummaryErr(
qb.WithLabel(). qb.Order(ent.Asc(item.FieldName)).
WithLabel().
WithLocation(). WithLocation().
All(ctx), All(ctx),
) )
@@ -285,6 +292,7 @@ func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSumm
func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) { func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) {
q := e.db.Item.Create(). q := e.db.Item.Create().
SetImportRef(data.ImportRef).
SetName(data.Name). SetName(data.Name).
SetDescription(data.Description). SetDescription(data.Description).
SetGroupID(gid). SetGroupID(gid).

View File

@@ -84,6 +84,7 @@ func (r *LabelRepository) GetOneByGroup(ctx context.Context, gid, ld uuid.UUID)
func (r *LabelRepository) GetAll(ctx context.Context, groupId uuid.UUID) ([]LabelSummary, error) { func (r *LabelRepository) GetAll(ctx context.Context, groupId uuid.UUID) ([]LabelSummary, error) {
return mapLabelsOut(r.db.Label.Query(). return mapLabelsOut(r.db.Label.Query().
Where(label.HasGroupWith(group.ID(groupId))). Where(label.HasGroupWith(group.ID(groupId))).
Order(ent.Asc(label.FieldName)).
WithGroup(). WithGroup().
All(ctx), All(ctx),
) )

View File

@@ -94,7 +94,9 @@ func (r *LocationRepository) GetAll(ctx context.Context, groupId uuid.UUID) ([]L
locations locations
WHERE WHERE
locations.group_locations = ? locations.group_locations = ?
` ORDER BY
locations.name ASC
`
rows, err := r.db.Sql().QueryContext(ctx, query, groupId) rows, err := r.db.Sql().QueryContext(ctx, query, groupId)
if err != nil { if err != nil {

View File

@@ -4,18 +4,20 @@ import "github.com/hay-kot/homebox/backend/internal/repo"
type AllServices struct { type AllServices struct {
User *UserService User *UserService
Group *GroupService
Location *LocationService Location *LocationService
Labels *LabelService Labels *LabelService
Items *ItemService Items *ItemService
} }
func NewServices(repos *repo.AllRepos) *AllServices { func New(repos *repo.AllRepos) *AllServices {
if repos == nil { if repos == nil {
panic("repos cannot be nil") panic("repos cannot be nil")
} }
return &AllServices{ return &AllServices{
User: &UserService{repos}, User: &UserService{repos},
Group: &GroupService{repos},
Location: &LocationService{repos}, Location: &LocationService{repos},
Labels: &LabelService{repos}, Labels: &LabelService{repos},
Items: &ItemService{ Items: &ItemService{

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
tClient = client tClient = client
tRepos = repo.New(tClient, os.TempDir()+"/homebox") tRepos = repo.New(tClient, os.TempDir()+"/homebox")
tSvc = NewServices(tRepos) tSvc = New(tRepos)
defer client.Close() defer client.Close()
bootstrap() bootstrap()

View File

@@ -0,0 +1,47 @@
package services
import (
"errors"
"strings"
"time"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/pkgs/hasher"
)
type GroupService struct {
repos *repo.AllRepos
}
func (svc *GroupService) Get(ctx Context) (repo.Group, error) {
return svc.repos.Groups.GroupByID(ctx.Context, ctx.GID)
}
func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) {
if data.Name == "" {
data.Name = ctx.User.GroupName
}
if data.Currency == "" {
return repo.Group{}, errors.New("currency cannot be empty")
}
data.Currency = strings.ToLower(data.Currency)
return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data)
}
func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
token := hasher.GenerateToken()
_, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
Token: token.Hash,
Uses: uses,
ExpiresAt: expiresAt,
})
if err != nil {
return "", err
}
return token.Raw, nil
}

View File

@@ -3,7 +3,6 @@ package services
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/repo"
@@ -48,7 +47,7 @@ func (svc *ItemService) Update(ctx context.Context, gid uuid.UUID, data repo.Ite
return svc.repo.Items.UpdateByGroup(ctx, gid, data) return svc.repo.Items.UpdateByGroup(ctx, gid, data)
} }
func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]string) error { func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]string) (int, error) {
loaded := []csvRow{} loaded := []csvRow{}
// Skip first row // Skip first row
@@ -59,18 +58,41 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
} }
if len(row) != NumOfCols { if len(row) != NumOfCols {
return ErrInvalidCsv return 0, ErrInvalidCsv
} }
r := newCsvRow(row) r := newCsvRow(row)
loaded = append(loaded, r) loaded = append(loaded, r)
} }
// validate rows
var errMap = map[int][]error{}
var hasErr bool
for i, r := range loaded {
errs := r.validate()
if len(errs) > 0 {
hasErr = true
lineNum := i + 2
errMap[lineNum] = errs
}
}
if hasErr {
for lineNum, errs := range errMap {
for _, err := range errs {
log.Error().Err(err).Int("line", lineNum).Msg("csv import error")
}
}
}
// Bootstrap the locations and labels so we can reuse the created IDs for the items // Bootstrap the locations and labels so we can reuse the created IDs for the items
locations := map[string]uuid.UUID{} locations := map[string]uuid.UUID{}
existingLocation, err := svc.repo.Locations.GetAll(ctx, gid) existingLocation, err := svc.repo.Locations.GetAll(ctx, gid)
if err != nil { if err != nil {
return err return 0, err
} }
for _, loc := range existingLocation { for _, loc := range existingLocation {
locations[loc.Name] = loc.ID locations[loc.Name] = loc.ID
@@ -79,7 +101,7 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
labels := map[string]uuid.UUID{} labels := map[string]uuid.UUID{}
existingLabels, err := svc.repo.Labels.GetAll(ctx, gid) existingLabels, err := svc.repo.Labels.GetAll(ctx, gid)
if err != nil { if err != nil {
return err return 0, err
} }
for _, label := range existingLabels { for _, label := range existingLabels {
labels[label.Name] = label.ID labels[label.Name] = label.ID
@@ -88,25 +110,21 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
for _, row := range loaded { for _, row := range loaded {
// Locations // Locations
if _, ok := locations[row.Location]; ok { if _, exists := locations[row.Location]; !exists {
continue result, err := svc.repo.Locations.Create(ctx, gid, repo.LocationCreate{
Name: row.Location,
Description: "",
})
if err != nil {
return 0, err
}
locations[row.Location] = result.ID
} }
fmt.Println("Creating Location: ", row.Location)
result, err := svc.repo.Locations.Create(ctx, gid, repo.LocationCreate{
Name: row.Location,
Description: "",
})
if err != nil {
return err
}
locations[row.Location] = result.ID
// Labels // Labels
for _, label := range row.getLabels() { for _, label := range row.getLabels() {
if _, ok := labels[label]; ok { if _, exists := labels[label]; exists {
continue continue
} }
result, err := svc.repo.Labels.Create(ctx, gid, repo.LabelCreate{ result, err := svc.repo.Labels.Create(ctx, gid, repo.LabelCreate{
@@ -114,14 +132,26 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
Description: "", Description: "",
}) })
if err != nil { if err != nil {
return err return 0, err
} }
labels[label] = result.ID labels[label] = result.ID
} }
} }
// Create the items // Create the items
var count int
for _, row := range loaded { for _, row := range loaded {
// Check Import Ref
if row.Item.ImportRef != "" {
exists, err := svc.repo.Items.CheckRef(ctx, gid, row.Item.ImportRef)
if exists {
continue
}
if err != nil {
log.Err(err).Msg("error checking import ref")
}
}
locationID := locations[row.Location] locationID := locations[row.Location]
labelIDs := []uuid.UUID{} labelIDs := []uuid.UUID{}
for _, label := range row.getLabels() { for _, label := range row.getLabels() {
@@ -131,8 +161,6 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
log.Info(). log.Info().
Str("name", row.Item.Name). Str("name", row.Item.Name).
Str("location", row.Location). Str("location", row.Location).
Strs("labels", row.getLabels()).
Str("locationId", locationID.String()).
Msgf("Creating Item: %s", row.Item.Name) Msgf("Creating Item: %s", row.Item.Name)
result, err := svc.repo.Items.Create(ctx, gid, repo.ItemCreate{ result, err := svc.repo.Items.Create(ctx, gid, repo.ItemCreate{
@@ -144,7 +172,7 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
}) })
if err != nil { if err != nil {
return err return count, err
} }
// Update the item with the rest of the data // Update the item with the rest of the data
@@ -183,8 +211,10 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
}) })
if err != nil { if err != nil {
return err return count, err
} }
count++
} }
return nil return count, nil
} }

View File

@@ -97,3 +97,22 @@ func (c csvRow) getLabels() []string {
return split return split
} }
func (c csvRow) validate() []error {
var errs []error
add := func(err error) {
errs = append(errs, err)
}
required := func(s string, name string) {
if s == "" {
add(errors.New(name + " is required"))
}
}
required(c.Location, "Location")
required(c.Item.Name, "Name")
return errs
}

View File

@@ -9,12 +9,12 @@ import (
const CSV_DATA = ` const CSV_DATA = `
Import Ref,Location,Labels,Quantity,Name,Description,Insured,Serial Number,Mode Number,Manufacturer,Notes,Purchase From,Purchased Price,Purchased Time,Lifetime Warranty,Warranty Expires,Warranty Details,Sold To,Sold Price,Sold Time,Sold Notes Import Ref,Location,Labels,Quantity,Name,Description,Insured,Serial Number,Mode Number,Manufacturer,Notes,Purchase From,Purchased Price,Purchased Time,Lifetime Warranty,Warranty Expires,Warranty Details,Sold To,Sold Price,Sold Time,Sold Notes
,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,, A,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,, B,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,,
,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,, C,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,,,,,, D,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,,,,,,
,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,,,,,, E,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,,,,,,
,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,,39351,Honeywell,,Amazon,65.98,09/30/0202,,,,,,,` F,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,,39351,Honeywell,,Amazon,65.98,09/30/0202,,,,,,,`
func loadcsv() [][]string { func loadcsv() [][]string {
reader := csv.NewReader(bytes.NewBuffer([]byte(CSV_DATA))) reader := csv.NewReader(bytes.NewBuffer([]byte(CSV_DATA)))

View File

@@ -13,7 +13,13 @@ func TestItemService_CsvImport(t *testing.T) {
svc := &ItemService{ svc := &ItemService{
repo: tRepos, repo: tRepos,
} }
err := svc.CsvImport(context.Background(), tGroup.ID, data) count, err := svc.CsvImport(context.Background(), tGroup.ID, data)
assert.Equal(t, 6, count)
assert.NoError(t, err)
// Check import refs are deduplicated
count, err = svc.CsvImport(context.Background(), tGroup.ID, data)
assert.Equal(t, 0, count)
assert.NoError(t, err) assert.NoError(t, err)
items, err := svc.GetAll(context.Background(), tGroup.ID) items, err := svc.GetAll(context.Background(), tGroup.ID)

View File

@@ -186,21 +186,6 @@ func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error {
return svc.repos.Users.Delete(ctx, ID) return svc.repos.Users.Delete(ctx, ID)
} }
func (svc *UserService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
token := hasher.GenerateToken()
_, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
Token: token.Hash,
Uses: uses,
ExpiresAt: expiresAt,
})
if err != nil {
return "", err
}
return token.Raw, nil
}
func (svc *UserService) ChangePassword(ctx Context, current string, new string) (ok bool) { func (svc *UserService) ChangePassword(ctx Context, current string, new string) (ok bool) {
usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID) usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID)
if err != nil { if err != nil {

View File

@@ -23,29 +23,29 @@ Import RefLocation Labels Quantity Name Description Insured Serial Number Model
## CSV Reference ## CSV Reference
| Column | Type | Description | | Column | Type | Description |
| ----------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | ----------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ImportRef | String (100) | Future | | ImportRef | String (100) | Import Refs are unique strings that can be used to deduplicate imports. Before an item is imported, we check the database for a matching ref. If the ref exists, we skip that item. |
| Location | String | This is the location of the item that will be created. These are de-duplicated and won't create another instance when reused. | | Location | String | This is the location of the item that will be created. These are de-duplicated and won't create another instance when reused. |
| Labels | `;` Separated String | List of labels to apply to the item separated by a `;`, can be existing or new | | Labels | `;` Separated String | List of labels to apply to the item separated by a `;`, can be existing or new |
| Quantity | Integer | The quantity of items to create | | Quantity | Integer | The quantity of items to create |
| Name | String | Name of the item | | Name | String | Name of the item |
| Description | String | Description of the item | | Description | String | Description of the item |
| Insured | Boolean | Whether or not the item is insured | | Insured | Boolean | Whether or not the item is insured |
| Serial Number | String | Serial number of the item | | Serial Number | String | Serial number of the item |
| Model Number | String | Model of the item | | Model Number | String | Model of the item |
| Manufacturer | String | Manufacturer of the item | | Manufacturer | String | Manufacturer of the item |
| Notes | String (1000) | General notes about the product | | Notes | String (1000) | General notes about the product |
| Purchase From | String | Name of the place the item was purchased from | | Purchase From | String | Name of the place the item was purchased from |
| Purchase Price | Float64 | | | Purchase Price | Float64 | |
| Purchase At | Date | Date the item was purchased | | Purchase At | Date | Date the item was purchased |
| Lifetime Warranty | Boolean | true or false - case insensitive | | Lifetime Warranty | Boolean | true or false - case insensitive |
| Warranty Expires | Date | Date in the format | | Warranty Expires | Date | Date in the format |
| Warranty Details | String | Details about the warranty | | Warranty Details | String | Details about the warranty |
| Sold To | String | Name of the person the item was sold to | | Sold To | String | Name of the person the item was sold to |
| Sold At | Date | Date the item was sold | | Sold At | Date | Date the item was sold |
| Sold Price | Float64 | | | Sold Price | Float64 | |
| Sold Notes | String (1000) | | | Sold Notes | String (1000) | |
**Type Key** **Type Key**

View File

@@ -15,19 +15,20 @@ docker run --name=homebox \
```yml ```yml
version: "3.4" version: "3.4"
services:
homebox: services:
image: ghcr.io/hay-kot/homebox:latest homebox:
container_name: homebox image: ghcr.io/hay-kot/homebox:latest
restart: always container_name: homebox
environment: restart: always
- HBOX_LOG_LEVEL=info environment:
- HBOX_LOG_FORMAT=text - HBOX_LOG_LEVEL=info
- HBOX_WEB_MAX_UPLOAD_SIZE=10 - HBOX_LOG_FORMAT=text
volumes: - HBOX_WEB_MAX_UPLOAD_SIZE=10
- homebox-data:/data/ volumes:
ports: - homebox-data:/data/
- 3100:7745 ports:
- 3100:7745
volumes: volumes:
homebox-data: homebox-data:
@@ -41,9 +42,10 @@ volumes:
| HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production | | HBOX_MODE | production | application mode used for runtime behavior can be one of: development, production |
| HBOX_WEB_PORT | 7745 | port to run the web server on, in you're using docker do not change this | | HBOX_WEB_PORT | 7745 | port to run the web server on, in you're using docker do not change this |
| HBOX_WEB_HOST | | host to run the web server on, in you're using docker do not change this | | HBOX_WEB_HOST | | host to run the web server on, in you're using docker do not change this |
| HBOX_ALLOW_REGISTRATION | true | allow users to register themselves |
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
| HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker | | HBOX_STORAGE_DATA | /data/ | path to the data directory, do not change this if you're using docker |
| HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this | | HBOX_STORAGE_SQLITE_URL | /data/homebox.db?_fk=1 | sqlite database url, in you're using docker do not change this |
| HBOX_WEB_MAX_UPLOAD_SIZE | 10 | maximum file upload size supported in MB |
| HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical | | HBOX_LOG_LEVEL | info | log level to use, can be one of: trace, debug, info, warn, error, critical |
| HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json | | HBOX_LOG_FORMAT | text | log format to use, can be one of: text, json |
| HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used | | HBOX_MAILER_HOST | | email host to use, if not set no email provider will be used |
@@ -76,6 +78,8 @@ volumes:
--mailer-from/$HBOX_MAILER_FROM <string> --mailer-from/$HBOX_MAILER_FROM <string>
--swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745) --swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745)
--swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http) --swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http)
--demo/$HBOX_DEMO <bool>
--allow-registration/$HBOX_ALLOW_REGISTRATION <bool> (default: true)
--help/-h --help/-h
display this help message display this help message
``` ```

View File

@@ -5,6 +5,9 @@ kill_signal = "SIGINT"
kill_timeout = 5 kill_timeout = 5
processes = [] processes = []
[build.args]
COMMIT = "HEAD"
VERSION = "nightly"
[env] [env]
PORT = "7745" PORT = "7745"

View File

@@ -17,7 +17,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue", "update:value"]);
const props = defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
@@ -25,7 +25,7 @@
}, },
modelValue: { modelValue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type: [Object, String, Boolean] as any, type: [Object, String] as any,
default: null, default: null,
}, },
items: { items: {
@@ -37,26 +37,41 @@
type: String, type: String,
default: "name", default: "name",
}, },
value: { valueKey: {
type: String, type: String,
default: null, default: null,
required: false, },
value: {
type: String,
default: "",
}, },
}); });
const selectedIdx = ref(-1); const selectedIdx = ref(-1);
const internalSelected = useVModel(props, "modelValue", emit); const internalSelected = useVModel(props, "modelValue", emit);
watch(selectedIdx, newVal => { watch(selectedIdx, newVal => {
internalSelected.value = props.items[newVal]; internalSelected.value = props.items[newVal];
}); });
watch(internalSelected, newVal => {
if (props.valueKey) {
emit("update:value", newVal[props.valueKey]);
}
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function compare(a: any, b: any): boolean { function compare(a: any, b: any): boolean {
if (props.value != null) { if (a === b) {
return a[props.value] === b[props.value]; return true;
} }
return a === b;
if (!a || !b) {
return false;
}
return JSON.stringify(a) === JSON.stringify(b);
} }
watch( watch(

View File

@@ -0,0 +1,22 @@
<template>
{{ value }}
</template>
<script setup lang="ts">
const props = defineProps({
amount: {
type: String,
required: true,
},
});
const fmt = await useFormatCurrency();
const value = computed(() => {
if (!props.amount || props.amount === "0") {
return "";
}
return fmt(props.amount);
});
</script>

View File

@@ -7,9 +7,8 @@
</dt> </dt>
<dd class="mt-1 text-sm text-base-content sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-base-content sm:col-span-2 sm:mt-0">
<slot :name="detail.slot || detail.name" v-bind="{ detail }"> <slot :name="detail.slot || detail.name" v-bind="{ detail }">
<template v-if="detail.type == 'date'"> <DateTime v-if="detail.type == 'date'" :date="detail.text" />
<DateTime :date="detail.text" /> <Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
</template>
<template v-else> <template v-else>
{{ detail.text }} {{ detail.text }}
</template> </template>
@@ -21,11 +20,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DateDetail, Detail } from "./types"; import type { CustomDetail, Detail } from "./types";
defineProps({ defineProps({
details: { details: {
type: Object as () => (Detail | DateDetail)[], type: Object as () => (Detail | CustomDetail)[],
required: true, required: true,
}, },
}); });

View File

@@ -1,15 +1,25 @@
export type StringLike = string | number | boolean; export type StringLike = string | number | boolean;
export type DateDetail = { type BaseDetail = {
name: string; name: string;
text: string | Date;
slot?: string; slot?: string;
type: "date";
}; };
export type Detail = { type DateDetail = BaseDetail & {
name: string; type: "date";
text: Date | string;
};
type CurrencyDetail = BaseDetail & {
type: "currency";
text: string;
};
export type CustomDetail = DateDetail | CurrencyDetail;
export type Detail = BaseDetail & {
text: StringLike; text: StringLike;
slot?: string;
type?: "text"; type?: "text";
}; };
export type Details = Array<Detail | CustomDetail>;

View File

@@ -0,0 +1,21 @@
const cache = {
currency: "",
};
export function ResetCurrency() {
cache.currency = "";
}
export async function useFormatCurrency() {
if (!cache.currency) {
const client = useUserApi();
const { data: group } = await client.group.get();
if (group) {
cache.currency = group.currency;
}
}
return (value: number | string) => fmtCurrency(value, cache.currency);
}

View File

@@ -1,35 +1,5 @@
import { Ref } from "vue"; import { Ref } from "vue";
import { DaisyTheme } from "~~/lib/data/themes";
export type DaisyTheme =
| "light"
| "dark"
| "cupcake"
| "bumblebee"
| "emerald"
| "corporate"
| "synthwave"
| "retro"
| "cyberpunk"
| "valentine"
| "halloween"
| "garden"
| "forest"
| "aqua"
| "lofi"
| "pastel"
| "fantasy"
| "wireframe"
| "black"
| "luxury"
| "dracula"
| "cmyk"
| "autumn"
| "business"
| "acid"
| "lemonade"
| "night"
| "coffee"
| "winter";
export type LocationViewPreferences = { export type LocationViewPreferences = {
showDetails: boolean; showDetails: boolean;

View File

@@ -1,5 +1,5 @@
import { ComputedRef } from "vue"; import { ComputedRef } from "vue";
import { DaisyTheme } from "./use-preferences"; import { DaisyTheme } from "~~/lib/data/themes";
export interface UseTheme { export interface UseTheme {
theme: ComputedRef<DaisyTheme>; theme: ComputedRef<DaisyTheme>;

View File

@@ -1,10 +1,70 @@
<script setup lang="ts"></script>
<template> <template>
<div> <div>
<AppToast /> <AppToast />
<AppHeader /> <AppHeader />
<main class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen"> <main>
<slot></slot> <slot></slot>
</main> </main>
</div> </div>
</template> </template>
<script lang="ts" setup>
import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
/**
* Store Provider Initialization
*/
const labelStore = useLabelStore();
const reLabel = /\/api\/v1\/labels\/.*/gm;
const rmLabelStoreObserver = defineObserver("labelStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLabel)) {
labelStore.refresh();
}
console.debug("labelStore handler called by observer");
},
});
const locationStore = useLocationStore();
const reLocation = /\/api\/v1\/locations\/.*/gm;
const rmLocationStoreObserver = defineObserver("locationStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLocation)) {
locationStore.refresh();
}
console.debug("locationStore handler called by observer");
},
});
const itemStore = useItemStore();
const reItem = /\/api\/v1\/items\/.*/gm;
const rmItemStoreObserver = defineObserver("itemStore", {
handler: r => {
if (r.status === 201 || r.url.match(reItem)) {
itemStore.refresh();
}
console.debug("itemStore handler called by observer");
},
});
const eventBus = useEventBus();
eventBus.on(
EventTypes.ClearStores,
() => {
labelStore.refresh();
itemStore.refresh();
locationStore.refresh();
},
"stores"
);
onUnmounted(() => {
rmLabelStoreObserver();
rmLocationStoreObserver();
rmItemStoreObserver();
eventBus.off(EventTypes.ClearStores, "stores");
});
</script>

View File

@@ -1,69 +0,0 @@
<template>
<div>
<AppToast />
<AppHeader />
<main>
<slot></slot>
</main>
</div>
</template>
<script lang="ts" setup>
import { useItemStore } from "~~/stores/items";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
/**
* Store Provider Initialization
*/
const labelStore = useLabelStore();
const reLabel = /\/api\/v1\/labels\/.*/gm;
const rmLabelStoreObserver = defineObserver("labelStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLabel)) {
labelStore.refresh();
}
console.debug("labelStore handler called by observer");
},
});
const locationStore = useLocationStore();
const reLocation = /\/api\/v1\/locations\/.*/gm;
const rmLocationStoreObserver = defineObserver("locationStore", {
handler: r => {
if (r.status === 201 || r.url.match(reLocation)) {
locationStore.refresh();
}
console.debug("locationStore handler called by observer");
},
});
const itemStore = useItemStore();
const reItem = /\/api\/v1\/items\/.*/gm;
const rmItemStoreObserver = defineObserver("itemStore", {
handler: r => {
if (r.status === 201 || r.url.match(reItem)) {
itemStore.refresh();
}
console.debug("itemStore handler called by observer");
},
});
const eventBus = useEventBus();
eventBus.on(
EventTypes.ClearStores,
() => {
labelStore.refresh();
itemStore.refresh();
locationStore.refresh();
},
"stores"
);
onUnmounted(() => {
rmLabelStoreObserver();
rmLocationStoreObserver();
rmItemStoreObserver();
eventBus.off(EventTypes.ClearStores, "stores");
});
</script>

View File

@@ -1,6 +1,5 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { factories } from "./factories"; import { factories } from "./factories";
import { sharedUserClient } from "./test-utils";
describe("[GET] /api/v1/status", () => { describe("[GET] /api/v1/status", () => {
test("server should respond", async () => { test("server should respond", async () => {
@@ -32,43 +31,4 @@ describe("first time user workflow (register, login, join group)", () => {
expect(response.status).toBe(204); expect(response.status).toBe(204);
} }
}); });
test("user should be able to join create join token and have user signup", async () => {
// Setup User 1 Token
const client = await sharedUserClient();
const { data: user1 } = await client.user.self();
const { response, data } = await client.group.createInvitation({
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
uses: 1,
});
expect(response.status).toBe(201);
expect(data.token).toBeTruthy();
// Create User 2 with token
const duplicateUser = factories.user();
duplicateUser.token = data.token;
const { response: registerResp } = await api.register(duplicateUser);
expect(registerResp.status).toBe(204);
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
expect(loginResp.status).toBe(200);
// Get Self and Assert
const client2 = factories.client.user(loginData.token);
const { data: user2 } = await client2.user.self();
user2.item.groupName = user1.item.groupName;
// Cleanup User 2
const { response: deleteResp } = await client2.user.delete();
expect(deleteResp.status).toBe(204);
});
}); });

View File

@@ -0,0 +1,66 @@
import { faker } from "@faker-js/faker";
import { describe, test, expect } from "vitest";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils";
describe("first time user workflow (register, login, join group)", () => {
test("user should be able to update group", async () => {
const { client } = await factories.client.singleUse();
const name = faker.name.firstName();
const { response, data: group } = await client.group.update({
name,
currency: "eur",
});
expect(response.status).toBe(200);
expect(group.name).toBe(name);
});
test("user should be able to get own group", async () => {
const { client } = await factories.client.singleUse();
const { response, data: group } = await client.group.get();
expect(response.status).toBe(200);
expect(group.name).toBeTruthy();
expect(group.currency).toBe("USD");
});
test("user should be able to join create join token and have user signup", async () => {
const api = factories.client.public();
// Setup User 1 Token
const client = await sharedUserClient();
const { data: user1 } = await client.user.self();
const { response, data } = await client.group.createInvitation({
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
uses: 1,
});
expect(response.status).toBe(201);
expect(data.token).toBeTruthy();
// Create User 2 with token
const duplicateUser = factories.user();
duplicateUser.token = data.token;
const { response: registerResp } = await api.register(duplicateUser);
expect(registerResp.status).toBe(204);
const { response: loginResp, data: loginData } = await api.login(duplicateUser.email, duplicateUser.password);
expect(loginResp.status).toBe(200);
// Get Self and Assert
const client2 = factories.client.user(loginData.token);
const { data: user2 } = await client2.user.self();
user2.item.groupName = user1.item.groupName;
// Cleanup User 2
const { response: deleteResp } = await client2.user.delete();
expect(deleteResp.status).toBe(204);
});
});

View File

@@ -1,5 +1,5 @@
import { BaseAPI, route } from "../base"; import { BaseAPI, route } from "../base";
import { GroupInvitation, GroupInvitationCreate } from "../types/data-contracts"; import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
export class GroupApi extends BaseAPI { export class GroupApi extends BaseAPI {
createInvitation(data: GroupInvitationCreate) { createInvitation(data: GroupInvitationCreate) {
@@ -8,4 +8,17 @@ export class GroupApi extends BaseAPI {
body: data, body: data,
}); });
} }
update(data: GroupUpdate) {
return this.http.put<GroupUpdate, Group>({
url: route("/groups"),
body: data,
});
}
get() {
return this.http.get<Group>({
url: route("/groups"),
});
}
} }

View File

@@ -16,6 +16,19 @@ export interface DocumentOut {
title: string; title: string;
} }
export interface Group {
createdAt: Date;
currency: string;
id: string;
name: string;
updatedAt: Date;
}
export interface GroupUpdate {
currency: string;
name: string;
}
export interface ItemAttachment { export interface ItemAttachment {
createdAt: Date; createdAt: Date;
document: DocumentOut; document: DocumentOut;
@@ -187,6 +200,12 @@ export interface LocationSummary {
updatedAt: Date; updatedAt: Date;
} }
export interface PaginationResultRepoItemSummary {
items: ItemSummary[];
page: number;
pageSize: number;
total: number;
}
export interface UserOut { export interface UserOut {
email: string; email: string;

View File

@@ -0,0 +1,35 @@
export type Codes = "USD" | "EUR" | "GBP" | "JPY";
export type Currency = {
code: Codes;
local: string;
symbol: string;
name: string;
};
export const currencies: Currency[] = [
{
code: "USD",
local: "en-US",
symbol: "$",
name: "US Dollar",
},
{
code: "EUR",
local: "de-DE",
symbol: "€",
name: "Euro",
},
{
code: "GBP",
local: "en-GB",
symbol: "£",
name: "British Pound",
},
{
code: "JPY",
local: "ja-JP",
symbol: "¥",
name: "Japanese Yen",
},
];

150
frontend/lib/data/themes.ts Normal file
View File

@@ -0,0 +1,150 @@
export type DaisyTheme =
| "light"
| "dark"
| "cupcake"
| "bumblebee"
| "emerald"
| "corporate"
| "synthwave"
| "retro"
| "cyberpunk"
| "valentine"
| "halloween"
| "garden"
| "forest"
| "aqua"
| "lofi"
| "pastel"
| "fantasy"
| "wireframe"
| "black"
| "luxury"
| "dracula"
| "cmyk"
| "autumn"
| "business"
| "acid"
| "lemonade"
| "night"
| "coffee"
| "winter";
export type ThemeOption = {
label: string;
value: DaisyTheme;
};
export const themes: ThemeOption[] = [
{
label: "Garden",
value: "garden",
},
{
label: "Light",
value: "light",
},
{
label: "Cupcake",
value: "cupcake",
},
{
label: "Bumblebee",
value: "bumblebee",
},
{
label: "Emerald",
value: "emerald",
},
{
label: "Corporate",
value: "corporate",
},
{
label: "Synthwave",
value: "synthwave",
},
{
label: "Retro",
value: "retro",
},
{
label: "Cyberpunk",
value: "cyberpunk",
},
{
label: "Valentine",
value: "valentine",
},
{
label: "Halloween",
value: "halloween",
},
{
label: "Forest",
value: "forest",
},
{
label: "Aqua",
value: "aqua",
},
{
label: "Lofi",
value: "lofi",
},
{
label: "Pastel",
value: "pastel",
},
{
label: "Fantasy",
value: "fantasy",
},
{
label: "Wireframe",
value: "wireframe",
},
{
label: "Black",
value: "black",
},
{
label: "Luxury",
value: "luxury",
},
{
label: "Dracula",
value: "dracula",
},
{
label: "Cmyk",
value: "cmyk",
},
{
label: "Autumn",
value: "autumn",
},
{
label: "Business",
value: "business",
},
{
label: "Acid",
value: "acid",
},
{
label: "Lemonade",
value: "lemonade",
},
{
label: "Night",
value: "night",
},
{
label: "Coffee",
value: "coffee",
},
{
label: "Winter",
value: "winter",
},
];

View File

@@ -0,0 +1,15 @@
import { useAuthStore } from "~~/stores/auth";
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore();
const api = useUserApi();
if (!auth.self) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}
auth.$patch({ self: data.item });
}
});

View File

@@ -5,8 +5,9 @@
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
useHead({ useHead({
title: "Homebox | Home", title: "Homebox | Home",
}); });
@@ -15,15 +16,6 @@
const auth = useAuthStore(); const auth = useAuthStore();
if (auth.self === null) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}
auth.$patch({ self: data.item });
}
const itemsStore = useItemStore(); const itemsStore = useItemStore();
const items = computed(() => itemsStore.items); const items = computed(() => itemsStore.items);

View File

@@ -147,10 +147,10 @@
<a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank"> <a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank">
<Icon name="mdi-twitter" class="h-8 w-8" /> <Icon name="mdi-twitter" class="h-8 w-8" />
</a> </a>
<a href="https://discord.gg/tuncmNrE4z" class="tooltip" data-tip="Join The Discord"> <a href="https://discord.gg/tuncmNrE4z" class="tooltip" data-tip="Join The Discord" target="_blank">
<Icon name="mdi-discord" class="h-8 w-8" /> <Icon name="mdi-discord" class="h-8 w-8" />
</a> </a>
<a href="https://hay-kot.github.io/homebox/" class="tooltip" data-tip="Read The Docs"> <a href="https://hay-kot.github.io/homebox/" class="tooltip" data-tip="Read The Docs" target="_blank">
<Icon name="mdi-folder" class="h-8 w-8" /> <Icon name="mdi-folder" class="h-8 w-8" />
</a> </a>
</div> </div>

View File

@@ -6,7 +6,7 @@
import { capitalize } from "~~/lib/strings"; import { capitalize } from "~~/lib/strings";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
const route = useRoute(); const route = useRoute();
@@ -118,12 +118,12 @@
}, },
{ {
type: "text", type: "text",
label: "Purchased Price", label: "Purchase Price",
ref: "purchasePrice", ref: "purchasePrice",
}, },
{ {
type: "date", type: "date",
label: "Purchased At", label: "Purchase Date",
ref: "purchaseTime", ref: "purchaseTime",
}, },
]; ];
@@ -233,6 +233,7 @@
loading: false, loading: false,
// Values // Values
obj: {},
id: "", id: "",
title: "", title: "",
type: "", type: "",
@@ -248,11 +249,13 @@
editState.title = attachment.document.title; editState.title = attachment.document.title;
editState.type = attachment.type; editState.type = attachment.type;
editState.modal = true; editState.modal = true;
editState.obj = attachmentOpts.find(o => o.value === attachment.type);
} }
async function updateAttachment() { async function updateAttachment() {
editState.loading = true; editState.loading = true;
console.log(editState.type);
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, { const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
title: editState.title, title: editState.title,
type: editState.type, type: editState.type,
@@ -282,7 +285,14 @@
<template #title> Attachment Edit </template> <template #title> Attachment Edit </template>
<FormTextField v-model="editState.title" label="Attachment Title" /> <FormTextField v-model="editState.title" label="Attachment Title" />
<FormSelect v-model="editState.type" label="Attachment Type" value="value" name="text" :items="attachmentOpts" /> <FormSelect
v-model="editState.obj"
v-model:value="editState.type"
label="Attachment Type"
value-key="value"
name="text"
:items="attachmentOpts"
/>
<div class="modal-action"> <div class="modal-action">
<BaseButton :loading="editState.loading" @click="updateAttachment"> Update </BaseButton> <BaseButton :loading="editState.loading" @click="updateAttachment"> Update </BaseButton>
</div> </div>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { DateDetail, Detail } from "~~/components/global/DetailsSection/types"; import { Detail, Details } from "~~/components/global/DetailsSection/types";
import { ItemAttachment } from "~~/lib/api/types/data-contracts"; import { ItemAttachment } from "~~/lib/api/types/data-contracts";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
const route = useRoute(); const route = useRoute();
@@ -76,7 +76,7 @@
text: item.value?.serialNumber, text: item.value?.serialNumber,
}, },
{ {
name: "Mode Number", name: "Model Number",
text: item.value?.modelNumber, text: item.value?.modelNumber,
}, },
{ {
@@ -145,7 +145,7 @@
}); });
const warrantyDetails = computed(() => { const warrantyDetails = computed(() => {
const details: (Detail | DateDetail)[] = [ const details: Details = [
{ {
name: "Lifetime Warranty", name: "Lifetime Warranty",
text: item.value?.lifetimeWarranty ? "Yes" : "No", text: item.value?.lifetimeWarranty ? "Yes" : "No",
@@ -180,15 +180,16 @@
return item.value?.purchaseFrom || item.value?.purchasePrice !== "0"; return item.value?.purchaseFrom || item.value?.purchasePrice !== "0";
}); });
const purchaseDetails = computed<Array<Detail | DateDetail>>(() => { const purchaseDetails = computed<Details>(() => {
return [ return [
{ {
name: "Purchase From", name: "Purchased From",
text: item.value?.purchaseFrom || "", text: item.value?.purchaseFrom || "",
}, },
{ {
name: "Purchase Price", name: "Purchase Price",
text: item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "", text: item.value?.purchasePrice || "",
type: "currency",
}, },
{ {
name: "Purchase Date", name: "Purchase Date",
@@ -205,7 +206,7 @@
return item.value?.soldTo || item.value?.soldPrice !== "0"; return item.value?.soldTo || item.value?.soldPrice !== "0";
}); });
const soldDetails = computed<Array<Detail | DateDetail>>(() => { const soldDetails = computed<Details>(() => {
return [ return [
{ {
name: "Sold To", name: "Sold To",
@@ -213,7 +214,8 @@
}, },
{ {
name: "Sold Price", name: "Sold Price",
text: item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "", text: item.value?.soldPrice || "",
type: "currency",
}, },
{ {
name: "Sold At", name: "Sold At",
@@ -325,17 +327,17 @@
</BaseCard> </BaseCard>
<BaseCard v-if="showPurchase"> <BaseCard v-if="showPurchase">
<template #title> Purchase </template> <template #title> Purchase Details </template>
<DetailsSection :details="purchaseDetails" /> <DetailsSection :details="purchaseDetails" />
</BaseCard> </BaseCard>
<BaseCard v-if="showWarranty"> <BaseCard v-if="showWarranty">
<template #title> Warranty </template> <template #title> Warranty Details </template>
<DetailsSection :details="warrantyDetails" /> <DetailsSection :details="warrantyDetails" />
</BaseCard> </BaseCard>
<BaseCard v-if="showSold"> <BaseCard v-if="showSold">
<template #title> Sold </template> <template #title> Sold Details </template>
<DetailsSection :details="soldDetails" /> <DetailsSection :details="soldDetails" />
</BaseCard> </BaseCard>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
const show = reactive({ const show = reactive({

View File

@@ -4,8 +4,9 @@
import { useLocationStore } from "~~/stores/locations"; import { useLocationStore } from "~~/stores/locations";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
useHead({ useHead({
title: "Homebox | Home", title: "Homebox | Home",
}); });
@@ -75,7 +76,7 @@
</template> </template>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
<FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" /> <FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" />
<FormMultiselect v-model="selectedLocations" label="Labels" :items="locations ?? []" /> <FormMultiselect v-model="selectedLocations" label="Locations" :items="locations ?? []" />
</div> </div>
</BaseCard> </BaseCard>
<section class="mt-10"> <section class="mt-10">

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DateDetail, Detail } from "~~/components/global/DetailsSection/types"; import type { CustomDetail, Detail } from "~~/components/global/DetailsSection/types";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
const route = useRoute(); const route = useRoute();
@@ -23,7 +23,7 @@
return data; return data;
}); });
const details = computed<(Detail | DateDetail)[]>(() => { const details = computed<(Detail | CustomDetail)[]>(() => {
const details = [ const details = [
{ {
name: "Name", name: "Name",

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Detail, DateDetail } from "~~/components/global/DetailsSection/types"; import { Detail, CustomDetail } from "~~/components/global/DetailsSection/types";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
const route = useRoute(); const route = useRoute();
@@ -23,7 +23,7 @@
return data; return data;
}); });
const details = computed<(Detail | DateDetail)[]>(() => { const details = computed<(Detail | CustomDetail)[]>(() => {
const details = [ const details = [
{ {
name: "Name", name: "Name",

View File

@@ -1,15 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { Detail } from "~~/components/global/DetailsSection/types"; import { Detail } from "~~/components/global/DetailsSection/types";
import { DaisyTheme } from "~~/composables/use-preferences";
import { useAuthStore } from "~~/stores/auth"; import { useAuthStore } from "~~/stores/auth";
import { themes } from "~~/lib/data/themes";
import { currencies, Currency } from "~~/lib/data/currency";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
useHead({ useHead({
title: "Homebox | Profile", title: "Homebox | Profile",
}); });
const api = useUserApi();
const confirm = useConfirm();
const notify = useNotifier();
// Currency Selection
const currency = ref<Currency>(currencies[0]);
watch(currency, () => {
if (group.value) {
group.value.currency = currency.value.code;
}
console.log(group.value);
});
const currencyExample = computed(() => {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.value ? currency.value.code : "USD",
});
return formatter.format(1000);
});
const { data: group } = useAsyncData(async () => {
const { data } = await api.group.get();
return data;
});
// Sync Initial Currency
watch(group, () => {
if (group.value) {
const found = currencies.find(c => c.code === group.value.currency);
if (found) {
currency.value = found;
}
}
});
async function updateGroup() {
const { data, error } = await api.group.update({
name: group.value.name,
currency: group.value.currency,
});
if (error) {
notify.error("Failed to update group");
return;
}
group.value = data;
notify.success("Group updated");
}
const pubApi = usePublicApi(); const pubApi = usePublicApi();
const { data: status } = useAsyncData(async () => { const { data: status } = useAsyncData(async () => {
const { data } = await pubApi.status(); const { data } = await pubApi.status();
@@ -19,126 +74,6 @@
const { setTheme } = useTheme(); const { setTheme } = useTheme();
type ThemeOption = {
label: string;
value: DaisyTheme;
};
const themes: ThemeOption[] = [
{
label: "Garden",
value: "garden",
},
{
label: "Light",
value: "light",
},
{
label: "Cupcake",
value: "cupcake",
},
{
label: "Bumblebee",
value: "bumblebee",
},
{
label: "Emerald",
value: "emerald",
},
{
label: "Corporate",
value: "corporate",
},
{
label: "Synthwave",
value: "synthwave",
},
{
label: "Retro",
value: "retro",
},
{
label: "Cyberpunk",
value: "cyberpunk",
},
{
label: "Valentine",
value: "valentine",
},
{
label: "Halloween",
value: "halloween",
},
{
label: "Forest",
value: "forest",
},
{
label: "Aqua",
value: "aqua",
},
{
label: "Lofi",
value: "lofi",
},
{
label: "Pastel",
value: "pastel",
},
{
label: "Fantasy",
value: "fantasy",
},
{
label: "Wireframe",
value: "wireframe",
},
{
label: "Black",
value: "black",
},
{
label: "Luxury",
value: "luxury",
},
{
label: "Dracula",
value: "dracula",
},
{
label: "Cmyk",
value: "cmyk",
},
{
label: "Autumn",
value: "autumn",
},
{
label: "Business",
value: "business",
},
{
label: "Acid",
value: "acid",
},
{
label: "Lemonade",
value: "lemonade",
},
{
label: "Night",
value: "night",
},
{
label: "Coffee",
value: "coffee",
},
{
label: "Winter",
value: "winter",
},
];
const auth = useAuthStore(); const auth = useAuthStore();
const details = computed(() => { const details = computed(() => {
@@ -154,10 +89,6 @@
] as Detail[]; ] as Detail[];
}); });
const api = useUserApi();
const confirm = useConfirm();
const notify = useNotifier();
async function deleteProfile() { async function deleteProfile() {
const result = await confirm.open( const result = await confirm.open(
"Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone." "Are you sure you want to delete your account? If you are the last member in your group all your data will be deleted. This action cannot be undone."
@@ -283,6 +214,27 @@
</div> </div>
</BaseCard> </BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader class="pb-0">
<Icon name="mdi-accounts" class="mr-2 -mt-1 text-base-600" />
<span class="text-base-600"> Group Settings </span>
<template #description>
Shared Group Settings. You may need to refresh your browser for some settings to apply.
</template>
</BaseSectionHeader>
</template>
<div v-if="group" class="p-5 pt-0">
<FormSelect v-model="currency" label="Currency Format" :items="currencies" />
<p class="m-2 text-sm">Example: {{ currencyExample }}</p>
<div class="mt-4 flex justify-end">
<BaseButton @click="updateGroup"> Update Group </BaseButton>
</div>
</div>
</BaseCard>
<BaseCard> <BaseCard>
<template #title> <template #title>
<BaseSectionHeader> <BaseSectionHeader>