mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 06:28:34 +01:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d34557f69 | ||
|
|
97a34475c8 | ||
|
|
18488f5b15 | ||
|
|
434f1fa411 | ||
|
|
57f9372e49 | ||
|
|
dbaaf4ad0a |
@@ -2,14 +2,14 @@ version: "3"
|
||||
|
||||
env:
|
||||
HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_fk=1
|
||||
|
||||
UNSAFE_DISABLE_PASSWORD_PROJECTION: "yes_i_am_sure"
|
||||
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
|
||||
- go install github.com/swaggo/swag/cmd/swag@latest
|
||||
- cd backend && go mod tidy
|
||||
- cd frontend && pnpm install --shamefully-hoist
|
||||
generate:
|
||||
desc: |
|
||||
Generates collateral files from the backend project
|
||||
|
||||
@@ -52,7 +52,7 @@ func (a *app) SetupDemo() {
|
||||
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 {
|
||||
log.Err(err).Msg("Failed to import CSV")
|
||||
log.Fatal().Msg("Failed to setup demo")
|
||||
|
||||
@@ -352,7 +352,7 @@ const docTemplate = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "imports items into the database",
|
||||
"parameters": [
|
||||
@@ -415,7 +415,7 @@ const docTemplate = `{
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -452,7 +452,7 @@ const docTemplate = `{
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -487,7 +487,7 @@ const docTemplate = `{
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -531,7 +531,7 @@ const docTemplate = `{
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -1135,27 +1135,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/users/self/password": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"User"
|
||||
],
|
||||
"summary": "Update the current user's password // TODO:",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -1256,6 +1235,32 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ItemField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"booleanValue": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"numberValue": {
|
||||
"type": "integer"
|
||||
},
|
||||
"textValue": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeValue": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ItemOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1271,6 +1276,13 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"description": "Future",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.ItemField"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1388,6 +1400,12 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.ItemField"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "imports items into the database",
|
||||
"parameters": [
|
||||
@@ -407,7 +407,7 @@
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -444,7 +444,7 @@
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -479,7 +479,7 @@
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -523,7 +523,7 @@
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
"Items Attachments"
|
||||
],
|
||||
"summary": "retrieves an attachment for an item",
|
||||
"parameters": [
|
||||
@@ -1127,27 +1127,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/users/self/password": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"User"
|
||||
],
|
||||
"summary": "Update the current user's password // TODO:",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -1248,6 +1227,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ItemField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"booleanValue": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"numberValue": {
|
||||
"type": "integer"
|
||||
},
|
||||
"textValue": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeValue": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.ItemOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1263,6 +1268,13 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"description": "Future",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.ItemField"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1380,6 +1392,12 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/repo.ItemField"
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,23 @@ definitions:
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
repo.ItemField:
|
||||
properties:
|
||||
booleanValue:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
numberValue:
|
||||
type: integer
|
||||
textValue:
|
||||
type: string
|
||||
timeValue:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
repo.ItemOut:
|
||||
properties:
|
||||
attachments:
|
||||
@@ -73,6 +90,11 @@ definitions:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
fields:
|
||||
description: Future
|
||||
items:
|
||||
$ref: '#/definitions/repo.ItemField'
|
||||
type: array
|
||||
id:
|
||||
type: string
|
||||
insured:
|
||||
@@ -153,6 +175,10 @@ definitions:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/definitions/repo.ItemField'
|
||||
type: array
|
||||
id:
|
||||
type: string
|
||||
insured:
|
||||
@@ -653,7 +679,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: imports items into the database
|
||||
tags:
|
||||
- Items
|
||||
- Items Attachments
|
||||
/v1/items/{id}/attachments/{attachment_id}:
|
||||
delete:
|
||||
parameters:
|
||||
@@ -674,7 +700,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: retrieves an attachment for an item
|
||||
tags:
|
||||
- Items
|
||||
- Items Attachments
|
||||
get:
|
||||
parameters:
|
||||
- description: Item ID
|
||||
@@ -698,7 +724,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: retrieves an attachment for an item
|
||||
tags:
|
||||
- Items
|
||||
- Items Attachments
|
||||
put:
|
||||
parameters:
|
||||
- description: Item ID
|
||||
@@ -726,7 +752,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: retrieves an attachment for an item
|
||||
tags:
|
||||
- Items
|
||||
- Items Attachments
|
||||
/v1/items/{id}/attachments/download:
|
||||
get:
|
||||
parameters:
|
||||
@@ -749,7 +775,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: retrieves an attachment for an item
|
||||
tags:
|
||||
- Items
|
||||
- Items Attachments
|
||||
/v1/items/import:
|
||||
post:
|
||||
parameters:
|
||||
@@ -1112,18 +1138,6 @@ paths:
|
||||
summary: Update the current user
|
||||
tags:
|
||||
- User
|
||||
/v1/users/self/password:
|
||||
put:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: 'Update the current user''s password // TODO:'
|
||||
tags:
|
||||
- User
|
||||
securityDefinitions:
|
||||
Bearer:
|
||||
description: '"Type ''Bearer TOKEN'' to correctly set the API Key"'
|
||||
|
||||
@@ -63,22 +63,6 @@ func (a *app) mwAuthToken(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// mwAdminOnly is a middleware that extends the mwAuthToken middleware to only allow
|
||||
// requests from superusers.
|
||||
// func (a *app) mwAdminOnly(next http.Handler) http.Handler {
|
||||
// mw := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// usr := services.UseUserCtx(r.Context())
|
||||
|
||||
// if !usr.IsSuperuser {
|
||||
// server.RespondUnauthorized(w)
|
||||
// return
|
||||
// }
|
||||
|
||||
// next.ServeHTTP(w, r)
|
||||
// })
|
||||
// return a.mwAuthToken(mw)
|
||||
// }
|
||||
|
||||
// mqStripTrailingSlash is a middleware that will strip trailing slashes from the request path.
|
||||
func mwStripTrailingSlash(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -111,7 +95,6 @@ func (a *app) mwStructLogger(next http.Handler) http.Handler {
|
||||
|
||||
log.Info().
|
||||
Str("id", middleware.GetReqID(r.Context())).
|
||||
Str("url", url).
|
||||
Str("method", r.Method).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Int("status", record.Status).
|
||||
|
||||
@@ -65,7 +65,6 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
|
||||
r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate())
|
||||
r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete())
|
||||
r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword())
|
||||
r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout())
|
||||
r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh())
|
||||
r.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword())
|
||||
|
||||
@@ -33,6 +33,8 @@ type V1Controller struct {
|
||||
}
|
||||
|
||||
type (
|
||||
ReadyFunc func() bool
|
||||
|
||||
Build struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
@@ -50,12 +52,9 @@ type (
|
||||
)
|
||||
|
||||
func BaseUrlFunc(prefix string) func(s string) string {
|
||||
v1Base := prefix + "/v1"
|
||||
prefixFunc := func(s string) string {
|
||||
return v1Base + s
|
||||
return func(s string) string {
|
||||
return prefix + "/v1" + s
|
||||
}
|
||||
|
||||
return prefixFunc
|
||||
}
|
||||
|
||||
func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller {
|
||||
@@ -71,8 +70,6 @@ func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller))
|
||||
return ctrl
|
||||
}
|
||||
|
||||
type ReadyFunc func() bool
|
||||
|
||||
// HandleBase godoc
|
||||
// @Summary Retrieves the basic information about the API
|
||||
// @Tags Base
|
||||
|
||||
@@ -5,30 +5,17 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hay-kot/homebox/backend/internal/repo"
|
||||
"github.com/hay-kot/homebox/backend/internal/services"
|
||||
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
/*
|
||||
This is where we put partial snippets/functions for actions that are commonly
|
||||
used within the controller class. This _hopefully_ helps with code duplication
|
||||
and makes it a little more consistent when error handling and logging.
|
||||
*/
|
||||
|
||||
// partialParseIdAndUser parses the ID from the requests URL and pulls the user
|
||||
// from the context. If either of these fail, it will return an error. When an error
|
||||
// occurs it will also write the error to the response. As such, if an error is returned
|
||||
// from this function you can return immediately without writing to the response.
|
||||
func (ctrl *V1Controller) partialParseIdAndUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, *repo.UserOut, error) {
|
||||
uid, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
func (ctrl *V1Controller) routeID(w http.ResponseWriter, r *http.Request) (uuid.UUID, error) {
|
||||
ID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to parse id")
|
||||
server.RespondError(w, http.StatusBadRequest, err)
|
||||
return uuid.Nil, &repo.UserOut{}, err
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
user := services.UseUserCtx(r.Context())
|
||||
return uid, user, nil
|
||||
return ID, nil
|
||||
}
|
||||
|
||||
@@ -32,20 +32,7 @@ type (
|
||||
// @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)
|
||||
|
||||
}
|
||||
return ctrl.handleGroupGeneral()
|
||||
}
|
||||
|
||||
// HandleGroupUpdate godoc
|
||||
@@ -57,24 +44,41 @@ func (ctrl *V1Controller) HandleGroupGet() http.HandlerFunc {
|
||||
// @Router /v1/groups [Put]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
|
||||
return ctrl.handleGroupGeneral()
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) handleGroupGeneral() 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
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
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)
|
||||
|
||||
case http.MethodPut:
|
||||
data := repo.GroupUpdate{}
|
||||
if err := server.Decode(r, &data); err != nil {
|
||||
server.RespondError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
group.Currency = strings.ToUpper(group.Currency) // TODO: Hack to fix the currency enums being lower case
|
||||
server.Respond(w, http.StatusOK, group)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +93,6 @@ func (ctrl *V1Controller) HandleGroupUpdate() http.HandlerFunc {
|
||||
func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := GroupInvitationCreate{}
|
||||
|
||||
if err := server.Decode(r, &data); err != nil {
|
||||
log.Err(err).Msg("failed to decode user registration data")
|
||||
server.RespondError(w, http.StatusBadRequest, err)
|
||||
|
||||
@@ -13,41 +13,6 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func uuidList(params url.Values, key string) []uuid.UUID {
|
||||
var ids []uuid.UUID
|
||||
for _, id := range params[key] {
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func intOrNegativeOne(s string) int {
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func extractQuery(r *http.Request) repo.ItemQuery {
|
||||
params := r.URL.Query()
|
||||
|
||||
page := intOrNegativeOne(params.Get("page"))
|
||||
perPage := intOrNegativeOne(params.Get("perPage"))
|
||||
|
||||
return repo.ItemQuery{
|
||||
Page: page,
|
||||
PageSize: perPage,
|
||||
Search: params.Get("q"),
|
||||
LocationIDs: uuidList(params, "locations"),
|
||||
LabelIDs: uuidList(params, "labels"),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleItemsGetAll godoc
|
||||
// @Summary Get All Items
|
||||
// @Tags Items
|
||||
@@ -61,6 +26,38 @@ func extractQuery(r *http.Request) repo.ItemQuery {
|
||||
// @Router /v1/items [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
|
||||
uuidList := func(params url.Values, key string) []uuid.UUID {
|
||||
var ids []uuid.UUID
|
||||
for _, id := range params[key] {
|
||||
uid, err := uuid.Parse(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, uid)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
intOrNegativeOne := func(s string) int {
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
extractQuery := func(r *http.Request) repo.ItemQuery {
|
||||
params := r.URL.Query()
|
||||
|
||||
return repo.ItemQuery{
|
||||
Page: intOrNegativeOne(params.Get("page")),
|
||||
PageSize: intOrNegativeOne(params.Get("perPage")),
|
||||
Search: params.Get("q"),
|
||||
LocationIDs: uuidList(params, "locations"),
|
||||
LabelIDs: uuidList(params, "labels"),
|
||||
}
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := services.NewContext(r.Context())
|
||||
items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
|
||||
@@ -102,31 +99,6 @@ func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleItemDelete godocs
|
||||
// @Summary deletes a item
|
||||
// @Tags Items
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Success 204
|
||||
// @Router /v1/items/{id} [DELETE]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = ctrl.svc.Items.Delete(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete item")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleItemGet godocs
|
||||
// @Summary Gets a item and fields
|
||||
// @Tags Items
|
||||
@@ -136,20 +108,19 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
|
||||
// @Router /v1/items/{id} [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return ctrl.handleItemsGeneral()
|
||||
}
|
||||
|
||||
items, err := ctrl.svc.Items.GetOne(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to get item")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, items)
|
||||
}
|
||||
// HandleItemDelete godocs
|
||||
// @Summary deletes a item
|
||||
// @Tags Items
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Success 204
|
||||
// @Router /v1/items/{id} [DELETE]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
|
||||
return ctrl.handleItemsGeneral()
|
||||
}
|
||||
|
||||
// HandleItemUpdate godocs
|
||||
@@ -162,26 +133,53 @@ func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
|
||||
// @Router /v1/items/{id} [PUT]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc {
|
||||
return ctrl.handleItemsGeneral()
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) handleItemsGeneral() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body := repo.ItemUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
log.Err(err).Msg("failed to decode request body")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
ctx := services.NewContext(r.Context())
|
||||
ID, err := ctrl.routeID(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body.ID = uid
|
||||
result, err := ctrl.svc.Items.Update(r.Context(), user.GroupID, body)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to update item")
|
||||
server.RespondServerError(w)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items, err := ctrl.svc.Items.GetOne(r.Context(), ctx.GID, ID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to get item")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, items)
|
||||
return
|
||||
case http.MethodDelete:
|
||||
err = ctrl.svc.Items.Delete(r.Context(), ctx.GID, ID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete item")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
return
|
||||
case http.MethodPut:
|
||||
body := repo.ItemUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
log.Err(err).Msg("failed to decode request body")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
body.ID = ID
|
||||
result, err := ctrl.svc.Items.Update(r.Context(), ctx.GID, body)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to update item")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +218,7 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc {
|
||||
|
||||
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 {
|
||||
log.Err(err).Msg("failed to import items")
|
||||
server.RespondServerError(w)
|
||||
|
||||
@@ -22,7 +22,7 @@ type (
|
||||
|
||||
// HandleItemsImport godocs
|
||||
// @Summary imports items into the database
|
||||
// @Tags Items
|
||||
// @Tags Items Attachments
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param file formData file true "File attachment"
|
||||
@@ -72,7 +72,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
|
||||
attachmentType = attachment.TypeAttachment.String()
|
||||
}
|
||||
|
||||
id, _, err := ctrl.partialParseIdAndUser(w, r)
|
||||
id, err := ctrl.routeID(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
|
||||
|
||||
// HandleItemAttachmentGet godocs
|
||||
// @Summary retrieves an attachment for an item
|
||||
// @Tags Items
|
||||
// @Tags Items Attachments
|
||||
// @Produce application/octet-stream
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param token query string true "Attachment token"
|
||||
@@ -126,7 +126,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc {
|
||||
|
||||
// HandleItemAttachmentToken godocs
|
||||
// @Summary retrieves an attachment for an item
|
||||
// @Tags Items
|
||||
// @Tags Items Attachments
|
||||
// @Produce application/octet-stream
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param attachment_id path string true "Attachment ID"
|
||||
@@ -139,7 +139,7 @@ func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc {
|
||||
|
||||
// HandleItemAttachmentDelete godocs
|
||||
// @Summary retrieves an attachment for an item
|
||||
// @Tags Items
|
||||
// @Tags Items Attachments
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param attachment_id path string true "Attachment ID"
|
||||
// @Success 204
|
||||
@@ -151,7 +151,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc {
|
||||
|
||||
// HandleItemAttachmentUpdate godocs
|
||||
// @Summary retrieves an attachment for an item
|
||||
// @Tags Items
|
||||
// @Tags Items Attachments
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param attachment_id path string true "Attachment ID"
|
||||
// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update"
|
||||
@@ -163,7 +163,7 @@ func (ctrl *V1Controller) HandleItemAttachmentUpdate() http.HandlerFunc {
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
ID, err := ctrl.routeID(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
||||
|
||||
// Token Handler
|
||||
case http.MethodGet:
|
||||
token, err := ctrl.svc.Items.AttachmentToken(ctx, uid, attachmentId)
|
||||
token, err := ctrl.svc.Items.AttachmentToken(ctx, ID, attachmentId)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrNotFound:
|
||||
@@ -210,7 +210,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
||||
|
||||
// Delete Attachment Handler
|
||||
case http.MethodDelete:
|
||||
err = ctrl.svc.Items.AttachmentDelete(r.Context(), user.GroupID, uid, attachmentId)
|
||||
err = ctrl.svc.Items.AttachmentDelete(r.Context(), ctx.GID, ID, attachmentId)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete attachment")
|
||||
server.RespondServerError(w)
|
||||
@@ -230,7 +230,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
attachment.ID = attachmentId
|
||||
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, uid, &attachment)
|
||||
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ID, &attachment)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete attachment")
|
||||
server.RespondServerError(w)
|
||||
|
||||
@@ -56,7 +56,6 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc {
|
||||
}
|
||||
|
||||
server.Respond(w, http.StatusCreated, label)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,20 +68,7 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc {
|
||||
// @Router /v1/labels/{id} [DELETE]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = ctrl.svc.Labels.Delete(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error deleting label")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
}
|
||||
return ctrl.handleLabelsGeneral()
|
||||
}
|
||||
|
||||
// HandleLabelGet godocs
|
||||
@@ -94,27 +80,7 @@ func (ctrl *V1Controller) HandleLabelDelete() http.HandlerFunc {
|
||||
// @Router /v1/labels/{id} [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
labels, err := ctrl.svc.Labels.Get(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
log.Err(err).
|
||||
Str("id", uid.String()).
|
||||
Msg("label not found")
|
||||
server.RespondError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
log.Err(err).Msg("error getting label")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, labels)
|
||||
}
|
||||
return ctrl.handleLabelsGeneral()
|
||||
}
|
||||
|
||||
// HandleLabelUpdate godocs
|
||||
@@ -126,25 +92,59 @@ func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc {
|
||||
// @Router /v1/labels/{id} [PUT]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLabelUpdate() http.HandlerFunc {
|
||||
return ctrl.handleLabelsGeneral()
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) handleLabelsGeneral() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body := repo.LabelUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
log.Err(err).Msg("error decoding label update data")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
ctx := services.NewContext(r.Context())
|
||||
ID, err := ctrl.routeID(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body.ID = uid
|
||||
result, err := ctrl.svc.Labels.Update(r.Context(), user.GroupID, body)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error updating label")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
labels, err := ctrl.svc.Labels.Get(r.Context(), ctx.GID, ID)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
log.Err(err).
|
||||
Str("id", ID.String()).
|
||||
Msg("label not found")
|
||||
server.RespondError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
log.Err(err).Msg("error getting label")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, labels)
|
||||
|
||||
case http.MethodDelete:
|
||||
err = ctrl.svc.Labels.Delete(r.Context(), ctx.GID, ID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error deleting label")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
|
||||
case http.MethodPut:
|
||||
body := repo.LabelUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
log.Err(err).Msg("error decoding label update data")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
body.ID = ID
|
||||
result, err := ctrl.svc.Labels.Update(r.Context(), ctx.GID, body)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("error updating label")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,20 +69,7 @@ func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc {
|
||||
// @Router /v1/locations/{id} [DELETE]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = ctrl.svc.Location.Delete(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete location")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
}
|
||||
return ctrl.handleLocationGeneral()
|
||||
}
|
||||
|
||||
// HandleLocationGet godocs
|
||||
@@ -94,32 +81,7 @@ func (ctrl *V1Controller) HandleLocationDelete() http.HandlerFunc {
|
||||
// @Router /v1/locations/{id} [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
location, err := ctrl.svc.Location.GetOne(r.Context(), user.GroupID, uid)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
log.Err(err).
|
||||
Str("id", uid.String()).
|
||||
Str("gid", user.GroupID.String()).
|
||||
Msg("location not found")
|
||||
server.RespondError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Err(err).
|
||||
Str("id", uid.String()).
|
||||
Str("gid", user.GroupID.String()).
|
||||
Msg("failed to get location")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, location)
|
||||
}
|
||||
return ctrl.handleLocationGeneral()
|
||||
}
|
||||
|
||||
// HandleLocationUpdate godocs
|
||||
@@ -131,27 +93,61 @@ func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc {
|
||||
// @Router /v1/locations/{id} [PUT]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleLocationUpdate() http.HandlerFunc {
|
||||
return ctrl.handleLocationGeneral()
|
||||
}
|
||||
|
||||
func (ctrl *V1Controller) handleLocationGeneral() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body := repo.LocationUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
log.Err(err).Msg("failed to decode location update data")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
uid, user, err := ctrl.partialParseIdAndUser(w, r)
|
||||
ctx := services.NewContext(r.Context())
|
||||
ID, err := ctrl.routeID(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body.ID = uid
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
location, err := ctrl.svc.Location.GetOne(r.Context(), ctx.GID, ID)
|
||||
if err != nil {
|
||||
l := log.Err(err).
|
||||
Str("ID", ID.String()).
|
||||
Str("GID", ctx.GID.String())
|
||||
|
||||
result, err := ctrl.svc.Location.Update(r.Context(), user.GroupID, body)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to update location")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
if ent.IsNotFound(err) {
|
||||
l.Msg("location not found")
|
||||
server.RespondError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
l.Msg("failed to get location")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, location)
|
||||
case http.MethodPut:
|
||||
body := repo.LocationUpdate{}
|
||||
if err := server.Decode(r, &body); err != nil {
|
||||
log.Err(err).Msg("failed to decode location update data")
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
body.ID = ID
|
||||
|
||||
result, err := ctrl.svc.Location.Update(r.Context(), ctx.GID, body)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to update location")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
case http.MethodDelete:
|
||||
err = ctrl.svc.Location.Delete(r.Context(), ctx.GID, ID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete location")
|
||||
server.RespondServerError(w)
|
||||
return
|
||||
}
|
||||
server.Respond(w, http.StatusNoContent, nil)
|
||||
}
|
||||
server.Respond(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc {
|
||||
newData, err := ctrl.svc.User.UpdateSelf(r.Context(), actor.ID, updateData)
|
||||
|
||||
if err != nil {
|
||||
|
||||
server.RespondError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
@@ -94,18 +93,6 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserUpdatePassword godoc
|
||||
// @Summary Update the current user's password // TODO:
|
||||
// @Tags User
|
||||
// @Produce json
|
||||
// @Success 204
|
||||
// @Router /v1/users/self/password [PUT]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleUserUpdatePassword() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserSelfDelete godoc
|
||||
// @Summary Deletes the user account
|
||||
// @Tags User
|
||||
|
||||
@@ -95,6 +95,7 @@ const (
|
||||
TypeManual Type = "manual"
|
||||
TypeWarranty Type = "warranty"
|
||||
TypeAttachment Type = "attachment"
|
||||
TypeReceipt Type = "receipt"
|
||||
)
|
||||
|
||||
func (_type Type) String() string {
|
||||
@@ -104,7 +105,7 @@ func (_type Type) String() string {
|
||||
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
|
||||
func TypeValidator(_type Type) error {
|
||||
switch _type {
|
||||
case TypePhoto, TypeManual, TypeWarranty, TypeAttachment:
|
||||
case TypePhoto, TypeManual, TypeWarranty, TypeAttachment, TypeReceipt:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("attachment: invalid enum value for type field: %q", _type)
|
||||
|
||||
@@ -13,7 +13,7 @@ var (
|
||||
{Name: "id", Type: field.TypeUUID},
|
||||
{Name: "created_at", Type: field.TypeTime},
|
||||
{Name: "updated_at", Type: field.TypeTime},
|
||||
{Name: "type", Type: field.TypeEnum, Enums: []string{"photo", "manual", "warranty", "attachment"}, Default: "attachment"},
|
||||
{Name: "type", Type: field.TypeEnum, Enums: []string{"photo", "manual", "warranty", "attachment", "receipt"}, Default: "attachment"},
|
||||
{Name: "document_attachments", Type: field.TypeUUID},
|
||||
{Name: "item_attachments", Type: field.TypeUUID},
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (Attachment) Mixin() []ent.Mixin {
|
||||
func (Attachment) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Enum("type").
|
||||
Values("photo", "manual", "warranty", "attachment").
|
||||
Values("photo", "manual", "warranty", "attachment", "receipt").
|
||||
Default("attachment"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
github.com/rs/zerolog v1.28.0
|
||||
github.com/stretchr/testify v1.8.0
|
||||
github.com/swaggo/http-swagger v1.3.3
|
||||
github.com/swaggo/swag v1.8.6
|
||||
github.com/swaggo/swag v1.8.7
|
||||
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
|
||||
)
|
||||
|
||||
|
||||
@@ -90,8 +90,8 @@ github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowN
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
||||
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
|
||||
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
|
||||
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
|
||||
github.com/swaggo/swag v1.8.7 h1:2K9ivTD3teEO+2fXV6zrZKDqk5IuU2aJtBDo8U7omWU=
|
||||
github.com/swaggo/swag v1.8.7/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
|
||||
github.com/zclconf/go-cty v1.11.0 h1:726SxLdi2SDnjY+BStqB9J1hNp4+2WlzyXLuimibIe0=
|
||||
github.com/zclconf/go-cty v1.11.0/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA=
|
||||
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/hay-kot/homebox/backend/ent"
|
||||
"github.com/hay-kot/homebox/backend/ent/group"
|
||||
"github.com/hay-kot/homebox/backend/ent/item"
|
||||
"github.com/hay-kot/homebox/backend/ent/itemfield"
|
||||
"github.com/hay-kot/homebox/backend/ent/label"
|
||||
"github.com/hay-kot/homebox/backend/ent/location"
|
||||
"github.com/hay-kot/homebox/backend/ent/predicate"
|
||||
@@ -27,6 +28,16 @@ type (
|
||||
SortBy string `json:"sortBy"`
|
||||
}
|
||||
|
||||
ItemField struct {
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
TextValue string `json:"textValue"`
|
||||
NumberValue int `json:"numberValue"`
|
||||
BooleanValue bool `json:"booleanValue"`
|
||||
TimeValue time.Time `json:"timeValue,omitempty"`
|
||||
}
|
||||
|
||||
ItemCreate struct {
|
||||
ImportRef string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
@@ -69,8 +80,8 @@ type (
|
||||
SoldNotes string `json:"soldNotes"`
|
||||
|
||||
// Extras
|
||||
Notes string `json:"notes"`
|
||||
// Fields []*FieldSummary `json:"fields"`
|
||||
Notes string `json:"notes"`
|
||||
Fields []ItemField `json:"fields"`
|
||||
}
|
||||
|
||||
ItemSummary struct {
|
||||
@@ -116,7 +127,7 @@ type (
|
||||
|
||||
Attachments []ItemAttachment `json:"attachments"`
|
||||
// Future
|
||||
// Fields []*FieldSummary `json:"fields"`
|
||||
Fields []ItemField `json:"fields"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -156,12 +167,33 @@ var (
|
||||
mapItemOutErr = mapTErrFunc(mapItemOut)
|
||||
)
|
||||
|
||||
func mapFields(fields []*ent.ItemField) []ItemField {
|
||||
result := make([]ItemField, len(fields))
|
||||
for i, f := range fields {
|
||||
result[i] = ItemField{
|
||||
ID: f.ID,
|
||||
Type: f.Type.String(),
|
||||
Name: f.Name,
|
||||
TextValue: f.TextValue,
|
||||
NumberValue: f.NumberValue,
|
||||
BooleanValue: f.BooleanValue,
|
||||
TimeValue: f.TimeValue,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mapItemOut(item *ent.Item) ItemOut {
|
||||
var attachments []ItemAttachment
|
||||
if item.Edges.Attachments != nil {
|
||||
attachments = mapEach(item.Edges.Attachments, ToItemAttachment)
|
||||
}
|
||||
|
||||
var fields []ItemField
|
||||
if item.Edges.Fields != nil {
|
||||
fields = mapFields(item.Edges.Fields)
|
||||
}
|
||||
|
||||
return ItemOut{
|
||||
ItemSummary: mapItemSummary(item),
|
||||
LifetimeWarranty: item.LifetimeWarranty,
|
||||
@@ -187,6 +219,7 @@ func mapItemOut(item *ent.Item) ItemOut {
|
||||
// Extras
|
||||
Notes: item.Notes,
|
||||
Attachments: attachments,
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +244,11 @@ func (e *ItemsRepository) GetOne(ctx context.Context, id uuid.UUID) (ItemOut, er
|
||||
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 ensures that the item belongs to a specific group.
|
||||
func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID) (ItemOut, error) {
|
||||
@@ -287,6 +325,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) {
|
||||
q := e.db.Item.Create().
|
||||
SetImportRef(data.ImportRef).
|
||||
SetName(data.Name).
|
||||
SetDescription(data.Description).
|
||||
SetGroupID(gid).
|
||||
@@ -364,5 +403,63 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data
|
||||
return ItemOut{}, err
|
||||
}
|
||||
|
||||
fields, err := e.db.ItemField.Query().Where(itemfield.HasItemWith(item.ID(data.ID))).All(ctx)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
|
||||
fieldIds := newIDSet(fields)
|
||||
|
||||
// Update Existing Fields
|
||||
for _, f := range data.Fields {
|
||||
if f.ID == uuid.Nil {
|
||||
// Create New Field
|
||||
_, err = e.db.ItemField.Create().
|
||||
SetItemID(data.ID).
|
||||
SetType(itemfield.Type(f.Type)).
|
||||
SetName(f.Name).
|
||||
SetTextValue(f.TextValue).
|
||||
SetNumberValue(f.NumberValue).
|
||||
SetBooleanValue(f.BooleanValue).
|
||||
SetTimeValue(f.TimeValue).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
}
|
||||
|
||||
opt := e.db.ItemField.Update().
|
||||
Where(
|
||||
itemfield.ID(f.ID),
|
||||
itemfield.HasItemWith(item.ID(data.ID)),
|
||||
).
|
||||
SetType(itemfield.Type(f.Type)).
|
||||
SetName(f.Name).
|
||||
SetTextValue(f.TextValue).
|
||||
SetNumberValue(f.NumberValue).
|
||||
SetBooleanValue(f.BooleanValue).
|
||||
SetTimeValue(f.TimeValue)
|
||||
|
||||
_, err = opt.Save(ctx)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
|
||||
fieldIds.Remove(f.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete Fields that are no longer present
|
||||
if fieldIds.Len() > 0 {
|
||||
_, err = e.db.ItemField.Delete().
|
||||
Where(
|
||||
itemfield.IDIn(fieldIds.Slice()...),
|
||||
itemfield.HasItemWith(item.ID(data.ID)),
|
||||
).Exec(ctx)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return e.GetOne(ctx, data.ID)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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)
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
// Skip first row
|
||||
@@ -59,18 +58,41 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
|
||||
}
|
||||
|
||||
if len(row) != NumOfCols {
|
||||
return ErrInvalidCsv
|
||||
return 0, ErrInvalidCsv
|
||||
}
|
||||
|
||||
r := newCsvRow(row)
|
||||
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
|
||||
locations := map[string]uuid.UUID{}
|
||||
existingLocation, err := svc.repo.Locations.GetAll(ctx, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
for _, loc := range existingLocation {
|
||||
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{}
|
||||
existingLabels, err := svc.repo.Labels.GetAll(ctx, gid)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
for _, label := range existingLabels {
|
||||
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 {
|
||||
|
||||
// Locations
|
||||
if _, ok := locations[row.Location]; ok {
|
||||
continue
|
||||
if _, exists := locations[row.Location]; !exists {
|
||||
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
|
||||
|
||||
for _, label := range row.getLabels() {
|
||||
if _, ok := labels[label]; ok {
|
||||
if _, exists := labels[label]; exists {
|
||||
continue
|
||||
}
|
||||
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: "",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
labels[label] = result.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Create the items
|
||||
var count int
|
||||
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]
|
||||
labelIDs := []uuid.UUID{}
|
||||
for _, label := range row.getLabels() {
|
||||
@@ -131,8 +161,6 @@ func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]s
|
||||
log.Info().
|
||||
Str("name", row.Item.Name).
|
||||
Str("location", row.Location).
|
||||
Strs("labels", row.getLabels()).
|
||||
Str("locationId", locationID.String()).
|
||||
Msgf("Creating Item: %s", row.Item.Name)
|
||||
|
||||
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 {
|
||||
return err
|
||||
return count, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
return count, err
|
||||
}
|
||||
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -97,3 +97,22 @@ func (c csvRow) getLabels() []string {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
|
||||
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
|
||||
,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,,,,,,,
|
||||
,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,,,,,,,
|
||||
,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,,,,,,,`
|
||||
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,,,,,,,
|
||||
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,,,,,,,
|
||||
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,,,,,,,
|
||||
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,,,,,,,
|
||||
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,,,,,,,
|
||||
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 {
|
||||
reader := csv.NewReader(bytes.NewBuffer([]byte(CSV_DATA)))
|
||||
|
||||
@@ -13,7 +13,13 @@ func TestItemService_CsvImport(t *testing.T) {
|
||||
svc := &ItemService{
|
||||
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)
|
||||
|
||||
items, err := svc.GetAll(context.Background(), tGroup.ID)
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
package hasher
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var enabled = true
|
||||
|
||||
func init() {
|
||||
disableHas := os.Getenv("UNSAFE_DISABLE_PASSWORD_PROJECTION") == "yes_i_am_sure"
|
||||
|
||||
if disableHas {
|
||||
fmt.Println("WARNING: Password projection is disabled. This is unsafe in production.")
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if !enabled {
|
||||
return password, nil
|
||||
}
|
||||
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
if !enabled {
|
||||
return password == hash
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -23,29 +23,29 @@ Import RefLocation Labels Quantity Name Description Insured Serial Number Model
|
||||
|
||||
## CSV Reference
|
||||
|
||||
| Column | Type | Description |
|
||||
| ----------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ImportRef | String (100) | Future |
|
||||
| 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 |
|
||||
| Quantity | Integer | The quantity of items to create |
|
||||
| Name | String | Name of the item |
|
||||
| Description | String | Description of the item |
|
||||
| Insured | Boolean | Whether or not the item is insured |
|
||||
| Serial Number | String | Serial number of the item |
|
||||
| Model Number | String | Model of the item |
|
||||
| Manufacturer | String | Manufacturer of the item |
|
||||
| Notes | String (1000) | General notes about the product |
|
||||
| Purchase From | String | Name of the place the item was purchased from |
|
||||
| Purchase Price | Float64 | |
|
||||
| Purchase At | Date | Date the item was purchased |
|
||||
| Lifetime Warranty | Boolean | true or false - case insensitive |
|
||||
| Warranty Expires | Date | Date in the format |
|
||||
| Warranty Details | String | Details about the warranty |
|
||||
| Sold To | String | Name of the person the item was sold to |
|
||||
| Sold At | Date | Date the item was sold |
|
||||
| Sold Price | Float64 | |
|
||||
| Sold Notes | String (1000) | |
|
||||
| Column | Type | Description |
|
||||
| ----------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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. |
|
||||
| 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 |
|
||||
| Name | String | Name of the item |
|
||||
| Description | String | Description of the item |
|
||||
| Insured | Boolean | Whether or not the item is insured |
|
||||
| Serial Number | String | Serial number of the item |
|
||||
| Model Number | String | Model of the item |
|
||||
| Manufacturer | String | Manufacturer of the item |
|
||||
| Notes | String (1000) | General notes about the product |
|
||||
| Purchase From | String | Name of the place the item was purchased from |
|
||||
| Purchase Price | Float64 | |
|
||||
| Purchase At | Date | Date the item was purchased |
|
||||
| Lifetime Warranty | Boolean | true or false - case insensitive |
|
||||
| Warranty Expires | Date | Date in the format |
|
||||
| Warranty Details | String | Details about the warranty |
|
||||
| Sold To | String | Name of the person the item was sold to |
|
||||
| Sold At | Date | Date the item was sold |
|
||||
| Sold Price | Float64 | |
|
||||
| Sold Notes | String (1000) | |
|
||||
|
||||
**Type Key**
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ docker run --name=homebox \
|
||||
|
||||
## Docker-Compose
|
||||
|
||||
```yml
|
||||
```yaml
|
||||
version: "3.4"
|
||||
|
||||
services:
|
||||
|
||||
16
docs/docs/tips-tricks.md
Normal file
16
docs/docs/tips-tricks.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Tips and Tricks
|
||||
|
||||
## Custom Fields
|
||||
|
||||
Custom fields are a great way to add any extra information to your item. The following types are supported:
|
||||
|
||||
- [x] Text
|
||||
- [ ] Integer (Future)
|
||||
- [ ] Boolean (Future)
|
||||
- [ ] Timestamp (Future)
|
||||
|
||||
Custom fields are appended to the main details section of your item.
|
||||
|
||||
!!! tip
|
||||
Homebox Custom Fields also have special support for URLs. Provide a URL (`https://google.com`) and it will be automatically converted to a clickable link in the UI. Optionally, you can also use markdown syntax to add a custom text to the button. `[Google](https://google.com)`
|
||||
|
||||
@@ -47,5 +47,6 @@ markdown_extensions:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Quick Start: quick-start.md
|
||||
- Tips and Tricks: tips-tricks.md
|
||||
- Importing Data: import-csv.md
|
||||
- Building The Binary: build.md
|
||||
|
||||
@@ -50,17 +50,40 @@
|
||||
const selectedIdx = ref(-1);
|
||||
|
||||
const internalSelected = useVModel(props, "modelValue", emit);
|
||||
const internalValue = useVModel(props, "value", emit);
|
||||
|
||||
watch(selectedIdx, newVal => {
|
||||
internalSelected.value = props.items[newVal];
|
||||
});
|
||||
|
||||
watch(internalSelected, newVal => {
|
||||
watch(selectedIdx, newVal => {
|
||||
if (props.valueKey) {
|
||||
emit("update:value", newVal[props.valueKey]);
|
||||
internalValue.value = props.items[newVal][props.valueKey];
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
internalSelected,
|
||||
() => {
|
||||
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
|
||||
selectedIdx.value = idx;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
internalValue,
|
||||
() => {
|
||||
const idx = props.items.findIndex(item => compare(item[props.valueKey], internalValue.value));
|
||||
selectedIdx.value = idx;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function compare(a: any, b: any): boolean {
|
||||
if (a === b) {
|
||||
@@ -73,15 +96,4 @@
|
||||
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
watch(
|
||||
internalSelected,
|
||||
() => {
|
||||
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
|
||||
selectedIdx.value = idx;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
<slot :name="detail.slot || detail.name" v-bind="{ detail }">
|
||||
<DateTime v-if="detail.type == 'date'" :date="detail.text" />
|
||||
<Currency v-else-if="detail.type == 'currency'" :amount="detail.text" />
|
||||
<template v-else-if="detail.type === 'link'">
|
||||
<div class="tooltip tooltip-primary tooltip-right" :data-tip="detail.href">
|
||||
<a class="btn btn-primary btn-xs" :href="detail.href" target="_blank">
|
||||
<Icon name="mdi-open-in-new" class="mr-2 swap-on"></Icon>
|
||||
{{ detail.text }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ detail.text }}
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,13 @@ type CurrencyDetail = BaseDetail & {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type CustomDetail = DateDetail | CurrencyDetail;
|
||||
type LinkDetail = BaseDetail & {
|
||||
type: "link";
|
||||
text: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type CustomDetail = DateDetail | CurrencyDetail | LinkDetail;
|
||||
|
||||
export type Detail = BaseDetail & {
|
||||
text: StringLike;
|
||||
|
||||
32
frontend/composables/utils.test.ts
Normal file
32
frontend/composables/utils.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { maybeUrl } from "./utils";
|
||||
|
||||
describe("maybeURL works as expected", () => {
|
||||
test("basic valid URL case", () => {
|
||||
const result = maybeUrl("https://example.com");
|
||||
expect(result.isUrl).toBe(true);
|
||||
expect(result.url).toBe("https://example.com");
|
||||
expect(result.text).toBe("Link");
|
||||
});
|
||||
|
||||
test("special URL syntax", () => {
|
||||
const result = maybeUrl("[My Text](http://example.com)");
|
||||
expect(result.isUrl).toBe(true);
|
||||
expect(result.url).toBe("http://example.com");
|
||||
expect(result.text).toBe("My Text");
|
||||
});
|
||||
|
||||
test("not a url", () => {
|
||||
const result = maybeUrl("not a url");
|
||||
expect(result.isUrl).toBe(false);
|
||||
expect(result.url).toBe("");
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
test("malformed special syntax", () => {
|
||||
const result = maybeUrl("[My Text(http://example.com)");
|
||||
expect(result.isUrl).toBe(false);
|
||||
expect(result.url).toBe("");
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -33,3 +33,35 @@ export function fmtCurrency(value: number | string, currency = "USD", locale = "
|
||||
});
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
export type MaybeUrlResult = {
|
||||
isUrl: boolean;
|
||||
url: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function maybeUrl(str: string): MaybeUrlResult {
|
||||
const result: MaybeUrlResult = {
|
||||
isUrl: str.startsWith("http://") || str.startsWith("https://"),
|
||||
url: "",
|
||||
text: "",
|
||||
};
|
||||
|
||||
if (!result.isUrl && !str.startsWith("[")) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (str.startsWith("[")) {
|
||||
const match = str.match(/\[(.*)\]\((.*)\)/);
|
||||
if (match && match.length === 3) {
|
||||
result.isUrl = true;
|
||||
result.text = match[1];
|
||||
result.url = match[2];
|
||||
}
|
||||
} else {
|
||||
result.url = str;
|
||||
result.text = "Link";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,23 @@ import { faker } from "@faker-js/faker";
|
||||
import { expect } from "vitest";
|
||||
import { overrideParts } from "../../base/urls";
|
||||
import { PublicApi } from "../../public";
|
||||
import { LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts";
|
||||
import { ItemField, LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts";
|
||||
import * as config from "../../../../test/config";
|
||||
import { UserClient } from "../../user";
|
||||
import { Requests } from "../../../requests";
|
||||
|
||||
function itemField(id = null): ItemField {
|
||||
return {
|
||||
id,
|
||||
name: faker.lorem.word(),
|
||||
type: "text",
|
||||
textValue: faker.lorem.sentence(),
|
||||
booleanValue: false,
|
||||
numberValue: faker.datatype.number(),
|
||||
timeValue: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random user registration object that can be
|
||||
* used to signup a new user.
|
||||
@@ -72,6 +84,7 @@ export const factories = {
|
||||
user,
|
||||
location,
|
||||
label,
|
||||
itemField,
|
||||
client: {
|
||||
public: publicClient,
|
||||
user: userClient,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { LocationOut } from "../../types/data-contracts";
|
||||
import { ItemField, LocationOut } from "../../types/data-contracts";
|
||||
import { AttachmentTypes } from "../../types/non-generated";
|
||||
import { UserClient } from "../../user";
|
||||
import { factories } from "../factories";
|
||||
import { sharedUserClient } from "../test-utils";
|
||||
|
||||
describe("user should be able to create an item and add an attachment", () => {
|
||||
@@ -58,4 +60,57 @@ describe("user should be able to create an item and add an attachment", () => {
|
||||
api.items.delete(item.id);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("user should be able to create and delete fields on an item", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const [location, cleanup] = await useLocation(api);
|
||||
|
||||
const { response, data: item } = await api.items.create({
|
||||
name: faker.vehicle.model(),
|
||||
labelIds: [],
|
||||
description: faker.lorem.paragraph(1),
|
||||
locationId: location.id,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const fields: ItemField[] = [
|
||||
factories.itemField(),
|
||||
factories.itemField(),
|
||||
factories.itemField(),
|
||||
factories.itemField(),
|
||||
];
|
||||
|
||||
// Add fields
|
||||
const itemUpdate = {
|
||||
...item,
|
||||
locationId: item.location.id,
|
||||
labelIds: item.labels.map(l => l.id),
|
||||
fields,
|
||||
};
|
||||
|
||||
const { response: updateResponse, data: item2 } = await api.items.update(item.id, itemUpdate);
|
||||
expect(updateResponse.status).toBe(200);
|
||||
|
||||
expect(item2.fields).toHaveLength(fields.length);
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
expect(item2.fields[i].name).toBe(fields[i].name);
|
||||
expect(item2.fields[i].textValue).toBe(fields[i].textValue);
|
||||
expect(item2.fields[i].numberValue).toBe(fields[i].numberValue);
|
||||
}
|
||||
|
||||
itemUpdate.fields = [fields[0], fields[1]];
|
||||
|
||||
const { response: updateResponse2, data: item3 } = await api.items.update(item.id, itemUpdate);
|
||||
expect(updateResponse2.status).toBe(200);
|
||||
|
||||
expect(item3.fields).toHaveLength(2);
|
||||
for (let i = 0; i < item3.fields.length; i++) {
|
||||
expect(item3.fields[i].name).toBe(itemUpdate.fields[i].name);
|
||||
expect(item3.fields[i].textValue).toBe(itemUpdate.fields[i].textValue);
|
||||
expect(item3.fields[i].numberValue).toBe(itemUpdate.fields[i].numberValue);
|
||||
}
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,10 +51,23 @@ export interface ItemCreate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ItemField {
|
||||
booleanValue: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
numberValue: number;
|
||||
textValue: string;
|
||||
timeValue: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ItemOut {
|
||||
attachments: ItemAttachment[];
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
|
||||
/** Future */
|
||||
fields: ItemField[];
|
||||
id: string;
|
||||
insured: boolean;
|
||||
labels: LabelSummary[];
|
||||
@@ -108,6 +121,7 @@ export interface ItemSummary {
|
||||
|
||||
export interface ItemUpdate {
|
||||
description: string;
|
||||
fields: ItemField[];
|
||||
id: string;
|
||||
insured: boolean;
|
||||
labelIds: string[];
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum AttachmentTypes {
|
||||
Manual = "manual",
|
||||
Warranty = "warranty",
|
||||
Attachment = "attachment",
|
||||
Receipt = "receipt",
|
||||
}
|
||||
|
||||
export type Result<T> = {
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
const dropAttachment = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Attachment);
|
||||
const dropWarranty = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Warranty);
|
||||
const dropManual = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Manual);
|
||||
const dropReceipt = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Receipt);
|
||||
|
||||
async function uploadAttachment(files: File[] | null, type: AttachmentTypes) {
|
||||
if (!files && files.length === 0) {
|
||||
@@ -277,6 +278,34 @@
|
||||
|
||||
toast.success("Attachment updated");
|
||||
}
|
||||
|
||||
// Custom Fields
|
||||
// const fieldTypes = [
|
||||
// {
|
||||
// name: "Text",
|
||||
// value: "text",
|
||||
// },
|
||||
// {
|
||||
// name: "Number",
|
||||
// value: "number",
|
||||
// },
|
||||
// {
|
||||
// name: "Boolean",
|
||||
// value: "boolean",
|
||||
// },
|
||||
// ];
|
||||
|
||||
function addField() {
|
||||
item.value.fields.push({
|
||||
id: null,
|
||||
name: "Field Name",
|
||||
type: "text",
|
||||
textValue: "",
|
||||
numberValue: 0,
|
||||
booleanValue: false,
|
||||
timeValue: null,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -363,6 +392,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseCard>
|
||||
<template #title> Custom Fields </template>
|
||||
<div class="px-5 divide-y divide-gray-300 space-y-4">
|
||||
<div
|
||||
v-for="(field, idx) in item.fields"
|
||||
:key="`field-${idx}`"
|
||||
class="grid grid-cols-2 md:grid-cols-4 gap-2"
|
||||
>
|
||||
<!-- <FormSelect v-model:value="field.type" label="Field Type" :items="fieldTypes" value-key="value" /> -->
|
||||
<FormTextField v-model="field.name" label="Name" />
|
||||
<div class="flex items-end col-span-3">
|
||||
<FormTextField v-model="field.textValue" label="Value" />
|
||||
<div class="tooltip" data-tip="Delete">
|
||||
<button class="btn btn-sm btn-square mb-2 ml-2" @click="item.fields.splice(idx, 1)">
|
||||
<Icon name="mdi-delete" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 pb-4 mt-4 flex justify-end">
|
||||
<BaseButton size="sm" @click="addField"> Add </BaseButton>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<div
|
||||
v-if="!preferences.editorSimpleView"
|
||||
ref="attDropZone"
|
||||
@@ -378,6 +432,7 @@
|
||||
<DropZone @drop="dropWarranty"> Warranty </DropZone>
|
||||
<DropZone @drop="dropManual"> Manual </DropZone>
|
||||
<DropZone @drop="dropAttachment"> Attachment </DropZone>
|
||||
<DropZone @drop="dropReceipt"> Receipt </DropZone>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
attachments: ItemAttachment[];
|
||||
warranty: ItemAttachment[];
|
||||
manuals: ItemAttachment[];
|
||||
receipts: ItemAttachment[];
|
||||
};
|
||||
|
||||
const attachments = computed<FilteredAttachments>(() => {
|
||||
@@ -40,6 +41,7 @@
|
||||
attachments: [],
|
||||
manuals: [],
|
||||
warranty: [],
|
||||
receipts: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +53,8 @@
|
||||
acc.warranty.push(attachment);
|
||||
} else if (attachment.type === "manual") {
|
||||
acc.manuals.push(attachment);
|
||||
} else if (attachment.type === "receipt") {
|
||||
acc.receipts.push(attachment);
|
||||
} else {
|
||||
acc.attachments.push(attachment);
|
||||
}
|
||||
@@ -61,6 +65,7 @@
|
||||
attachments: [] as ItemAttachment[],
|
||||
warranty: [] as ItemAttachment[],
|
||||
manuals: [] as ItemAttachment[],
|
||||
receipts: [] as ItemAttachment[],
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -91,6 +96,25 @@
|
||||
name: "Notes",
|
||||
text: item.value?.notes,
|
||||
},
|
||||
...item.value.fields.map(field => {
|
||||
/**
|
||||
* Support Special URL Syntax
|
||||
*/
|
||||
const url = maybeUrl(field.textValue);
|
||||
if (url.isUrl) {
|
||||
return {
|
||||
name: field.name,
|
||||
text: url.text,
|
||||
type: "link",
|
||||
href: url.url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: field.name,
|
||||
text: field.textValue,
|
||||
};
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -103,7 +127,8 @@
|
||||
attachments.value.photos.length > 0 ||
|
||||
attachments.value.attachments.length > 0 ||
|
||||
attachments.value.warranty.length > 0 ||
|
||||
attachments.value.manuals.length > 0
|
||||
attachments.value.manuals.length > 0 ||
|
||||
attachments.value.receipts.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
@@ -134,6 +159,10 @@
|
||||
push("Manuals");
|
||||
}
|
||||
|
||||
if (attachments.value.receipts.length > 0) {
|
||||
push("Receipts");
|
||||
}
|
||||
|
||||
return details;
|
||||
});
|
||||
|
||||
@@ -323,6 +352,13 @@
|
||||
:item-id="item.id"
|
||||
/>
|
||||
</template>
|
||||
<template #receipts>
|
||||
<ItemAttachmentsList
|
||||
v-if="attachments.receipts.length > 0"
|
||||
:attachments="attachments.receipts"
|
||||
:item-id="item.id"
|
||||
/>
|
||||
</template>
|
||||
</DetailsSection>
|
||||
</BaseCard>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user