Compare commits

..

24 Commits

Author SHA1 Message Date
Hayden
2d34557f69 feat: link implementation (#100)
* link implementation

* add docs for links

* use btn instead of badge
2022-10-19 21:31:08 -08:00
dependabot[bot]
97a34475c8 fix(deps): bump github.com/swaggo/swag from 1.8.6 to 1.8.7 in /backend (#97)
Bumps [github.com/swaggo/swag](https://github.com/swaggo/swag) from 1.8.6 to 1.8.7.
- [Release notes](https://github.com/swaggo/swag/releases)
- [Changelog](https://github.com/swaggo/swag/blob/master/.goreleaser.yml)
- [Commits](https://github.com/swaggo/swag/compare/v1.8.6...v1.8.7)

---
updated-dependencies:
- dependency-name: github.com/swaggo/swag
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-18 21:47:46 -08:00
Hayden
18488f5b15 refactor: cleanup-api-functions (#94)
* cleanup items endpoints

* refactor group routes

* refactor labels routes

* remove old partial

* refactor location routes

* formatting

* update names

* cleanup func

* speedup test runner with disable hasher

* remove duplicate code
2022-10-16 18:50:44 -08:00
Hayden
434f1fa411 add support for custom text fields 2022-10-15 21:41:27 -08:00
Hayden
57f9372e49 feat: add receipt support for attachments (#89)
* add receipt support for attachments

* fix show logic
2022-10-15 19:45:36 -08:00
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
Hayden
1279028d07 fix build injection 2022-10-13 17:01:18 -08:00
Hayden
84bf67079b feat: show app version (#60)
* add version and build to homepage view

* add version to profile
2022-10-13 16:49:29 -08:00
Hayden
889197994b fix: auto-select bug (#59) 2022-10-13 16:45:18 -08:00
Hayden
ae73b194c4 fix: block self-delete on demo site (#57) 2022-10-13 09:37:29 -08:00
Hayden
30014a77ca feat: expanded search for items (#46)
* expanded search for items

* range domain from email to example

* implement pagination for items
2022-10-12 21:13:07 -08:00
Hayden
1b20a69c5e fix: select-first to update on items change (#45) 2022-10-12 14:07:22 -08:00
Hayden
a8e1d2c447 fix: move datepicker buttons to bottom (#44) 2022-10-12 14:07:11 -08:00
93 changed files with 2677 additions and 1121 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"
description: "submit a bug report for the current release"
description: "Submit a bug report for the current release"
labels: ["bug"]
body:
- type: checkboxes
id: checks

View File

@@ -1,6 +1,7 @@
---
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:
- type: textarea
id: problem-statement

View File

@@ -60,6 +60,7 @@ jobs:
--tag ghcr.io/hay-kot/homebox:nightly \
--tag ghcr.io/hay-kot/homebox:latest \
--tag ghcr.io/hay-kot/homebox:${{ inputs.tag }} \
--build-arg VERSION=${{ inputs.tag }} \
--build-arg COMMIT=$(git rev-parse HEAD) \
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--platform linux/amd64,linux/arm64,linux/arm/v7 .

View File

@@ -12,6 +12,7 @@ RUN pnpm build
FROM golang:alpine AS builder
ARG BUILD_TIME
ARG COMMIT
ARG VERSION
RUN apk update && \
apk upgrade && \
apk add --update git build-base gcc g++
@@ -22,7 +23,7 @@ RUN go get -d -v ./...
RUN rm -rf ./app/api/public
COPY --from=frontend-builder /app/.output/public ./app/api/public
RUN CGO_ENABLED=1 GOOS=linux go build \
-ldflags "-s -w -X main.Commit=$COMMIT -X main.BuildTime=$BUILD_TIME" \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-o /go/bin/api \
-v ./app/api/*.go

View File

@@ -2,8 +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
generate:
desc: |
Generates collateral files from the backend project
@@ -27,6 +33,7 @@ tasks:
- "./scripts/process-types.py"
generates:
- "./frontend/lib/api/types/data-contracts.ts"
- "./backend/ent/schema"
- "./backend/app/api/docs/swagger.json"
- "./backend/app/api/docs/swagger.yaml"

View File

@@ -21,7 +21,7 @@ func (a *app) SetupDemo() {
var (
registration = services.UserRegistration{
Email: "demo@email.com",
Email: "demo@example.com",
Name: "Demo",
Password: "demo",
}
@@ -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")

View File

@@ -21,6 +21,63 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"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": {
"post": {
"security": [
@@ -32,7 +89,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"User"
"Group"
],
"summary": "Get the current user",
"parameters": [
@@ -70,26 +127,51 @@ const docTemplate = `{
"Items"
],
"summary": "Get All Items",
"parameters": [
{
"type": "string",
"description": "search string",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "items per page",
"name": "pageSize",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "label Ids",
"name": "labels",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "location Ids",
"name": "locations",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Results"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
}
}
}
]
"$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
}
}
}
@@ -153,7 +235,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -254,7 +336,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -270,7 +352,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "imports items into the database",
"parameters": [
@@ -333,7 +415,7 @@ const docTemplate = `{
"application/octet-stream"
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -354,7 +436,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -370,7 +452,7 @@ const docTemplate = `{
"application/octet-stream"
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -405,7 +487,7 @@ const docTemplate = `{
}
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -449,7 +531,7 @@ const docTemplate = `{
}
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -470,7 +552,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -634,7 +716,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -798,7 +880,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -846,7 +928,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -903,7 +985,7 @@ const docTemplate = `{
"summary": "User Logout",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -922,7 +1004,7 @@ const docTemplate = `{
"summary": "User Token Refresh",
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -949,7 +1031,7 @@ const docTemplate = `{
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1049,28 +1131,7 @@ const docTemplate = `{
"summary": "Deletes the user account",
"responses": {
"204": {
"description": ""
}
}
}
},
"/v1/users/self/password": {
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1091,6 +1152,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": {
"type": "object",
"properties": {
@@ -1143,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": {
@@ -1158,6 +1276,13 @@ const docTemplate = `{
"description": {
"type": "string"
},
"fields": {
"description": "Future",
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},
@@ -1275,6 +1400,12 @@ const docTemplate = `{
"description": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},
@@ -1488,6 +1619,26 @@ const docTemplate = `{
}
}
},
"repo.PaginationResult-repo_ItemSummary": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"repo.UserOut": {
"type": "object",
"properties": {
@@ -1541,9 +1692,7 @@ const docTemplate = `{
"server.Results": {
"type": "object",
"properties": {
"items": {
"type": "any"
}
"items": {}
}
},
"server.ValidationError": {

View File

@@ -13,6 +13,63 @@
},
"basePath": "/api",
"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": {
"post": {
"security": [
@@ -24,7 +81,7 @@
"application/json"
],
"tags": [
"User"
"Group"
],
"summary": "Get the current user",
"parameters": [
@@ -62,26 +119,51 @@
"Items"
],
"summary": "Get All Items",
"parameters": [
{
"type": "string",
"description": "search string",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "items per page",
"name": "pageSize",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "label Ids",
"name": "labels",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "location Ids",
"name": "locations",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/server.Results"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
}
}
}
]
"$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
}
}
}
@@ -145,7 +227,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -246,7 +328,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -262,7 +344,7 @@
"application/json"
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "imports items into the database",
"parameters": [
@@ -325,7 +407,7 @@
"application/octet-stream"
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -346,7 +428,7 @@
],
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -362,7 +444,7 @@
"application/octet-stream"
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -397,7 +479,7 @@
}
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -441,7 +523,7 @@
}
],
"tags": [
"Items"
"Items Attachments"
],
"summary": "retrieves an attachment for an item",
"parameters": [
@@ -462,7 +544,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -626,7 +708,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -790,7 +872,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -838,7 +920,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -895,7 +977,7 @@
"summary": "User Logout",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -914,7 +996,7 @@
"summary": "User Token Refresh",
"responses": {
"200": {
"description": ""
"description": "OK"
}
}
}
@@ -941,7 +1023,7 @@
],
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1041,28 +1123,7 @@
"summary": "Deletes the user account",
"responses": {
"204": {
"description": ""
}
}
}
},
"/v1/users/self/password": {
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "Update the current user's password // TODO:",
"responses": {
"204": {
"description": ""
"description": "No Content"
}
}
}
@@ -1083,6 +1144,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": {
"type": "object",
"properties": {
@@ -1135,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": {
@@ -1150,6 +1268,13 @@
"description": {
"type": "string"
},
"fields": {
"description": "Future",
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},
@@ -1267,6 +1392,12 @@
"description": {
"type": "string"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemField"
}
},
"id": {
"type": "string"
},
@@ -1480,6 +1611,26 @@
}
}
},
"repo.PaginationResult-repo_ItemSummary": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
},
"page": {
"type": "integer"
},
"pageSize": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"repo.UserOut": {
"type": "object",
"properties": {
@@ -1533,9 +1684,7 @@
"server.Results": {
"type": "object",
"properties": {
"items": {
"type": "any"
}
"items": {}
}
},
"server.ValidationError": {

View File

@@ -9,6 +9,26 @@ definitions:
title:
type: string
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:
properties:
createdAt:
@@ -43,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:
@@ -53,6 +90,11 @@ definitions:
type: string
description:
type: string
fields:
description: Future
items:
$ref: '#/definitions/repo.ItemField'
type: array
id:
type: string
insured:
@@ -133,6 +175,10 @@ definitions:
properties:
description:
type: string
fields:
items:
$ref: '#/definitions/repo.ItemField'
type: array
id:
type: string
insured:
@@ -275,6 +321,19 @@ definitions:
updatedAt:
type: string
type: object
repo.PaginationResult-repo_ItemSummary:
properties:
items:
items:
$ref: '#/definitions/repo.ItemSummary'
type: array
page:
type: integer
pageSize:
type: integer
total:
type: integer
type: object
repo.UserOut:
properties:
email:
@@ -310,8 +369,7 @@ definitions:
type: object
server.Results:
properties:
items:
type: any
items: {}
type: object
server.ValidationError:
properties:
@@ -403,6 +461,40 @@ info:
title: Go API Templates
version: "1.0"
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:
post:
parameters:
@@ -423,23 +515,43 @@ paths:
- Bearer: []
summary: Get the current user
tags:
- User
- Group
/v1/items:
get:
parameters:
- description: search string
in: query
name: q
type: string
- description: page number
in: query
name: page
type: integer
- description: items per page
in: query
name: pageSize
type: integer
- collectionFormat: multi
description: label Ids
in: query
items:
type: string
name: labels
type: array
- collectionFormat: multi
description: location Ids
in: query
items:
type: string
name: locations
type: array
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/server.Results'
- properties:
items:
items:
$ref: '#/definitions/repo.ItemSummary'
type: array
type: object
$ref: '#/definitions/repo.PaginationResult-repo_ItemSummary'
security:
- Bearer: []
summary: Get All Items
@@ -477,7 +589,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: deletes a item
@@ -567,7 +679,7 @@ paths:
- Bearer: []
summary: imports items into the database
tags:
- Items
- Items Attachments
/v1/items/{id}/attachments/{attachment_id}:
delete:
parameters:
@@ -583,12 +695,12 @@ paths:
type: string
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: retrieves an attachment for an item
tags:
- Items
- Items Attachments
get:
parameters:
- description: Item ID
@@ -612,7 +724,7 @@ paths:
- Bearer: []
summary: retrieves an attachment for an item
tags:
- Items
- Items Attachments
put:
parameters:
- description: Item ID
@@ -640,7 +752,7 @@ paths:
- Bearer: []
summary: retrieves an attachment for an item
tags:
- Items
- Items Attachments
/v1/items/{id}/attachments/download:
get:
parameters:
@@ -658,12 +770,12 @@ paths:
- application/octet-stream
responses:
"200":
description: ""
description: OK
security:
- Bearer: []
summary: retrieves an attachment for an item
tags:
- Items
- Items Attachments
/v1/items/import:
post:
parameters:
@@ -676,7 +788,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: imports items into the database
@@ -735,7 +847,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: deletes a label
@@ -832,7 +944,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: deletes a location
@@ -899,7 +1011,7 @@ paths:
$ref: '#/definitions/v1.ChangePassword'
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: Updates the users password
@@ -935,7 +1047,7 @@ paths:
post:
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: User Logout
@@ -948,7 +1060,7 @@ paths:
This does not validate that the user still exists within the database.
responses:
"200":
description: ""
description: OK
security:
- Bearer: []
summary: User Token Refresh
@@ -967,7 +1079,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
summary: Get the current user
tags:
- User
@@ -977,7 +1089,7 @@ paths:
- application/json
responses:
"204":
description: ""
description: No Content
security:
- Bearer: []
summary: Deletes the user account
@@ -1026,18 +1138,6 @@ paths:
summary: Update the current user
tags:
- User
/v1/users/self/password:
put:
produces:
- application/json
responses:
"204":
description: ""
security:
- Bearer: []
summary: 'Update the current user''s password // TODO:'
tags:
- User
securityDefinitions:
Bearer:
description: '"Type ''Bearer TOKEN'' to correctly set the API Key"'

View File

@@ -20,21 +20,21 @@ import (
)
var (
Version = "0.1.0"
Commit = "HEAD"
BuildTime = "now"
version = "nightly"
commit = "HEAD"
buildTime = "now"
)
// @title Go API Templates
// @version 1.0
// @description This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!.
// @contact.name Don't
// @license.name MIT
// @BasePath /api
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
// @title Go API Templates
// @version 1.0
// @description This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!.
// @contact.name Don't
// @license.name MIT
// @BasePath /api
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
func main() {
cfg, err := config.New()
if err != nil {
@@ -110,7 +110,7 @@ func run(cfg *config.Config) error {
app.db = c
app.repos = repo.New(c, cfg.Storage.Data)
app.services = services.NewServices(app.repos)
app.services = services.New(app.repos)
// =========================================================================
// Start Server
@@ -138,6 +138,14 @@ func run(cfg *config.Config) error {
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
if cfg.Demo {

View File

@@ -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).

View File

@@ -44,12 +44,13 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
v1Base := v1.BaseUrlFunc(prefix)
v1Ctrl := v1.NewControllerV1(a.services,
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
v1.WithRegistration(a.conf.AllowRegistration),
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
)
r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
Version: Version,
Commit: Commit,
BuildTime: BuildTime,
Version: version,
Commit: commit,
BuildTime: buildTime,
}))
r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
@@ -64,13 +65,16 @@ 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())
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.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate())
r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet())

View File

@@ -19,13 +19,22 @@ func WithDemoStatus(demoStatus bool) func(*V1Controller) {
}
}
func WithRegistration(allowRegistration bool) func(*V1Controller) {
return func(ctrl *V1Controller) {
ctrl.allowRegistration = allowRegistration
}
}
type V1Controller struct {
svc *services.AllServices
maxUploadSize int64
isDemo bool
svc *services.AllServices
maxUploadSize int64
isDemo bool
allowRegistration bool
}
type (
ReadyFunc func() bool
Build struct {
Version string `json:"version"`
Commit string `json:"commit"`
@@ -43,17 +52,15 @@ 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 {
ctrl := &V1Controller{
svc: svc,
svc: svc,
allowRegistration: true,
}
for _, opt := range options {
@@ -63,14 +70,12 @@ 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
// @Produce json
// @Success 200 {object} ApiSummary
// @Router /v1/status [GET]
// @Summary Retrieves the basic information about the API
// @Tags Base
// @Produce json
// @Success 200 {object} ApiSummary
// @Router /v1/status [GET]
func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
server.Respond(w, http.StatusOK, ApiSummary{

View File

@@ -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
}

View File

@@ -23,15 +23,15 @@ type (
)
// HandleAuthLogin godoc
// @Summary User Login
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Accept application/json
// @Param username formData string false "string" example(admin@admin.com)
// @Param password formData string false "string" example(admin)
// @Produce json
// @Success 200 {object} TokenResponse
// @Router /v1/users/login [POST]
// @Summary User Login
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Accept application/json
// @Param username formData string false "string" example(admin@admin.com)
// @Param password formData string false "string" example(admin)
// @Produce json
// @Success 200 {object} TokenResponse
// @Router /v1/users/login [POST]
func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
loginForm := &LoginForm{}
@@ -80,11 +80,11 @@ func (ctrl *V1Controller) HandleAuthLogin() http.HandlerFunc {
}
// HandleAuthLogout godoc
// @Summary User Logout
// @Tags Authentication
// @Success 204
// @Router /v1/users/logout [POST]
// @Security Bearer
// @Summary User Logout
// @Tags Authentication
// @Success 204
// @Router /v1/users/logout [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := services.UseTokenCtx(r.Context())
@@ -106,13 +106,13 @@ func (ctrl *V1Controller) HandleAuthLogout() http.HandlerFunc {
}
// HandleAuthLogout godoc
// @Summary User Token Refresh
// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token.
// @Description This does not validate that the user still exists within the database.
// @Tags Authentication
// @Success 200
// @Router /v1/users/refresh [GET]
// @Security Bearer
// @Summary User Token Refresh
// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token.
// @Description This does not validate that the user still exists within the database.
// @Tags Authentication
// @Success 200
// @Router /v1/users/refresh [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleAuthRefresh() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requestToken := services.UseTokenCtx(r.Context())

View File

@@ -2,8 +2,10 @@ package v1
import (
"net/http"
"strings"
"time"
"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"
@@ -22,21 +24,78 @@ type (
}
)
// HandleUserSelf godoc
// @Summary Get the current user
// @Tags User
// @Produce json
// @Param payload body GroupInvitationCreate true "User Data"
// @Success 200 {object} GroupInvitation
// @Router /v1/groups/invitations [Post]
// @Security Bearer
// 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 ctrl.handleGroupGeneral()
}
// 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 ctrl.handleGroupGeneral()
}
func (ctrl *V1Controller) handleGroupGeneral() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := services.NewContext(r.Context())
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)
}
}
}
// HandleGroupInvitationsCreate godoc
// @Summary Get the current user
// @Tags Group
// @Produce json
// @Param payload body GroupInvitationCreate true "User Data"
// @Success 200 {object} GroupInvitation
// @Router /v1/groups/invitations [Post]
// @Security Bearer
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.StatusInternalServerError, err)
server.RespondError(w, http.StatusBadRequest, err)
return
}
@@ -46,7 +105,7 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() http.HandlerFunc {
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 {
log.Err(err).Msg("failed to create new token")
server.RespondError(w, http.StatusInternalServerError, err)

View File

@@ -3,7 +3,10 @@ package v1
import (
"encoding/csv"
"net/http"
"net/url"
"strconv"
"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"
@@ -11,33 +14,70 @@ import (
)
// HandleItemsGetAll godoc
// @Summary Get All Items
// @Tags Items
// @Produce json
// @Success 200 {object} server.Results{items=[]repo.ItemSummary}
// @Router /v1/items [GET]
// @Security Bearer
// @Summary Get All Items
// @Tags Items
// @Produce json
// @Param q query string false "search string"
// @Param page query int false "page number"
// @Param pageSize query int false "items per page"
// @Param labels query []string false "label Ids" collectionFormat(multi)
// @Param locations query []string false "location Ids" collectionFormat(multi)
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
// @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) {
user := services.UseUserCtx(r.Context())
items, err := ctrl.svc.Items.GetAll(r.Context(), user.GroupID)
ctx := services.NewContext(r.Context())
items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
if err != nil {
log.Err(err).Msg("failed to get items")
server.RespondServerError(w)
return
}
server.Respond(w, http.StatusOK, server.Results{Items: items})
server.Respond(w, http.StatusOK, items)
}
}
// HandleItemsCreate godoc
// @Summary Create a new item
// @Tags Items
// @Produce json
// @Param payload body repo.ItemCreate true "Item Data"
// @Success 200 {object} repo.ItemSummary
// @Router /v1/items [POST]
// @Security Bearer
// @Summary Create a new item
// @Tags Items
// @Produce json
// @Param payload body repo.ItemCreate true "Item Data"
// @Success 200 {object} repo.ItemSummary
// @Router /v1/items [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
createData := repo.ItemCreate{}
@@ -59,97 +99,98 @@ 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
// @Produce json
// @Param id path string true "Item ID"
// @Success 200 {object} repo.ItemOut
// @Router /v1/items/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
return ctrl.handleItemsGeneral()
}
// HandleItemGet godocs
// @Summary Gets a item and fields
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Success 200 {object} repo.ItemOut
// @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
}
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
// @Summary updates a item
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param payload body repo.ItemUpdate true "Item Data"
// @Success 200 {object} repo.ItemOut
// @Router /v1/items/{id} [PUT]
// @Security Bearer
// @Summary updates a item
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param payload body repo.ItemUpdate true "Item Data"
// @Success 200 {object} repo.ItemOut
// @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)
}
}
// HandleItemsImport godocs
// @Summary imports items into the database
// @Tags Items
// @Produce json
// @Success 204
// @Param csv formData file true "Image to upload"
// @Router /v1/items/import [Post]
// @Security Bearer
// @Summary imports items into the database
// @Tags Items
// @Produce json
// @Success 204
// @Param csv formData file true "Image to upload"
// @Router /v1/items/import [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -177,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)

View File

@@ -21,17 +21,17 @@ type (
)
// HandleItemsImport godocs
// @Summary imports items into the database
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param file formData file true "File attachment"
// @Param type formData string true "Type of file"
// @Param name formData string true "name of the file including extension"
// @Success 200 {object} repo.ItemOut
// @Failure 422 {object} []server.ValidationError
// @Router /v1/items/{id}/attachments [POST]
// @Security Bearer
// @Summary imports items into the database
// @Tags Items Attachments
// @Produce json
// @Param id path string true "Item ID"
// @Param file formData file true "File attachment"
// @Param type formData string true "Type of file"
// @Param name formData string true "name of the file including extension"
// @Success 200 {object} repo.ItemOut
// @Failure 422 {object} []server.ValidationError
// @Router /v1/items/{id}/attachments [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)
@@ -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
}
@@ -98,14 +98,14 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
}
// HandleItemAttachmentGet godocs
// @Summary retrieves an attachment for an item
// @Tags Items
// @Produce application/octet-stream
// @Param id path string true "Item ID"
// @Param token query string true "Attachment token"
// @Success 200
// @Router /v1/items/{id}/attachments/download [GET]
// @Security Bearer
// @Summary retrieves an attachment for an item
// @Tags Items Attachments
// @Produce application/octet-stream
// @Param id path string true "Item ID"
// @Param token query string true "Attachment token"
// @Success 200
// @Router /v1/items/{id}/attachments/download [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := server.GetParam(r, "token", "")
@@ -125,45 +125,45 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc {
}
// HandleItemAttachmentToken godocs
// @Summary retrieves an attachment for an item
// @Tags Items
// @Produce application/octet-stream
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Success 200 {object} ItemAttachmentToken
// @Router /v1/items/{id}/attachments/{attachment_id} [GET]
// @Security Bearer
// @Summary retrieves an attachment for an item
// @Tags Items Attachments
// @Produce application/octet-stream
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Success 200 {object} ItemAttachmentToken
// @Router /v1/items/{id}/attachments/{attachment_id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc {
return ctrl.handleItemAttachmentsHandler
}
// HandleItemAttachmentDelete godocs
// @Summary retrieves an attachment for an item
// @Tags Items
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Success 204
// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE]
// @Security Bearer
// @Summary retrieves an attachment for an item
// @Tags Items Attachments
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Success 204
// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc {
return ctrl.handleItemAttachmentsHandler
}
// HandleItemAttachmentUpdate godocs
// @Summary retrieves an attachment for an item
// @Tags Items
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update"
// @Success 200 {object} repo.ItemOut
// @Router /v1/items/{id}/attachments/{attachment_id} [PUT]
// @Security Bearer
// @Summary retrieves an attachment for an item
// @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"
// @Success 200 {object} repo.ItemOut
// @Router /v1/items/{id}/attachments/{attachment_id} [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentUpdate() http.HandlerFunc {
return ctrl.handleItemAttachmentsHandler
}
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)

View File

@@ -11,12 +11,12 @@ import (
)
// HandleLabelsGetAll godoc
// @Summary Get All Labels
// @Tags Labels
// @Produce json
// @Success 200 {object} server.Results{items=[]repo.LabelOut}
// @Router /v1/labels [GET]
// @Security Bearer
// @Summary Get All Labels
// @Tags Labels
// @Produce json
// @Success 200 {object} server.Results{items=[]repo.LabelOut}
// @Router /v1/labels [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleLabelsGetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := services.UseUserCtx(r.Context())
@@ -31,13 +31,13 @@ func (ctrl *V1Controller) HandleLabelsGetAll() http.HandlerFunc {
}
// HandleLabelsCreate godoc
// @Summary Create a new label
// @Tags Labels
// @Produce json
// @Param payload body repo.LabelCreate true "Label Data"
// @Success 200 {object} repo.LabelSummary
// @Router /v1/labels [POST]
// @Security Bearer
// @Summary Create a new label
// @Tags Labels
// @Produce json
// @Param payload body repo.LabelCreate true "Label Data"
// @Success 200 {object} repo.LabelSummary
// @Router /v1/labels [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
createData := repo.LabelCreate{}
@@ -56,95 +56,95 @@ func (ctrl *V1Controller) HandleLabelsCreate() http.HandlerFunc {
}
server.Respond(w, http.StatusCreated, label)
}
}
// HandleLabelDelete godocs
// @Summary deletes a label
// @Tags Labels
// @Produce json
// @Param id path string true "Label ID"
// @Success 204
// @Router /v1/labels/{id} [DELETE]
// @Security Bearer
// @Summary deletes a label
// @Tags Labels
// @Produce json
// @Param id path string true "Label ID"
// @Success 204
// @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
// @Summary Gets a label and fields
// @Tags Labels
// @Produce json
// @Param id path string true "Label ID"
// @Success 200 {object} repo.LabelOut
// @Router /v1/labels/{id} [GET]
// @Security Bearer
// @Summary Gets a label and fields
// @Tags Labels
// @Produce json
// @Param id path string true "Label ID"
// @Success 200 {object} repo.LabelOut
// @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
// @Summary updates a label
// @Tags Labels
// @Produce json
// @Param id path string true "Label ID"
// @Success 200 {object} repo.LabelOut
// @Router /v1/labels/{id} [PUT]
// @Security Bearer
// @Summary updates a label
// @Tags Labels
// @Produce json
// @Param id path string true "Label ID"
// @Success 200 {object} repo.LabelOut
// @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)
}
}

View File

@@ -11,12 +11,12 @@ import (
)
// HandleLocationGetAll godoc
// @Summary Get All Locations
// @Tags Locations
// @Produce json
// @Success 200 {object} server.Results{items=[]repo.LocationOutCount}
// @Router /v1/locations [GET]
// @Security Bearer
// @Summary Get All Locations
// @Tags Locations
// @Produce json
// @Success 200 {object} server.Results{items=[]repo.LocationOutCount}
// @Router /v1/locations [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := services.UseUserCtx(r.Context())
@@ -32,13 +32,13 @@ func (ctrl *V1Controller) HandleLocationGetAll() http.HandlerFunc {
}
// HandleLocationCreate godoc
// @Summary Create a new location
// @Tags Locations
// @Produce json
// @Param payload body repo.LocationCreate true "Location Data"
// @Success 200 {object} repo.LocationSummary
// @Router /v1/locations [POST]
// @Security Bearer
// @Summary Create a new location
// @Tags Locations
// @Produce json
// @Param payload body repo.LocationCreate true "Location Data"
// @Success 200 {object} repo.LocationSummary
// @Router /v1/locations [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
createData := repo.LocationCreate{}
@@ -61,97 +61,93 @@ func (ctrl *V1Controller) HandleLocationCreate() http.HandlerFunc {
}
// HandleLocationDelete godocs
// @Summary deletes a location
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Success 204
// @Router /v1/locations/{id} [DELETE]
// @Security Bearer
// @Summary deletes a location
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Success 204
// @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
// @Summary Gets a location and fields
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Success 200 {object} repo.LocationOut
// @Router /v1/locations/{id} [GET]
// @Security Bearer
// @Summary Gets a location and fields
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Success 200 {object} repo.LocationOut
// @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
// @Summary updates a location
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Success 200 {object} repo.LocationOut
// @Router /v1/locations/{id} [PUT]
// @Security Bearer
// @Summary updates a location
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Success 200 {object} repo.LocationOut
// @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)
}
}

View File

@@ -11,12 +11,12 @@ import (
)
// HandleUserSelf godoc
// @Summary Get the current user
// @Tags User
// @Produce json
// @Param payload body services.UserRegistration true "User Data"
// @Success 204
// @Router /v1/users/register [Post]
// @Summary Get the current user
// @Tags User
// @Produce json
// @Param payload body services.UserRegistration true "User Data"
// @Success 204
// @Router /v1/users/register [Post]
func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
regData := services.UserRegistration{}
@@ -27,6 +27,11 @@ func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc {
return
}
if !ctrl.allowRegistration && regData.GroupToken == "" {
server.RespondError(w, http.StatusForbidden, nil)
return
}
_, err := ctrl.svc.User.RegisterUser(r.Context(), regData)
if err != nil {
log.Err(err).Msg("failed to register user")
@@ -39,12 +44,12 @@ func (ctrl *V1Controller) HandleUserRegistration() http.HandlerFunc {
}
// HandleUserSelf godoc
// @Summary Get the current user
// @Tags User
// @Produce json
// @Success 200 {object} server.Result{item=repo.UserOut}
// @Router /v1/users/self [GET]
// @Security Bearer
// @Summary Get the current user
// @Tags User
// @Produce json
// @Success 200 {object} server.Result{item=repo.UserOut}
// @Router /v1/users/self [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := services.UseTokenCtx(r.Context())
@@ -60,13 +65,13 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc {
}
// HandleUserSelfUpdate godoc
// @Summary Update the current user
// @Tags User
// @Produce json
// @Param payload body repo.UserUpdate true "User Data"
// @Success 200 {object} server.Result{item=repo.UserUpdate}
// @Router /v1/users/self [PUT]
// @Security Bearer
// @Summary Update the current user
// @Tags User
// @Produce json
// @Param payload body repo.UserUpdate true "User Data"
// @Success 200 {object} server.Result{item=repo.UserUpdate}
// @Router /v1/users/self [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updateData := repo.UserUpdate{}
@@ -80,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
}
@@ -89,27 +93,20 @@ 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
// @Produce json
// @Success 204
// @Router /v1/users/self [DELETE]
// @Security Bearer
// @Summary Deletes the user account
// @Tags User
// @Produce json
// @Success 204
// @Router /v1/users/self [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if ctrl.isDemo {
server.RespondError(w, http.StatusForbidden, nil)
return
}
actor := services.UseUserCtx(r.Context())
if err := ctrl.svc.User.DeleteSelf(r.Context(), actor.ID); err != nil {
server.RespondError(w, http.StatusInternalServerError, err)
@@ -128,12 +125,12 @@ type (
)
// HandleUserSelfChangePassword godoc
// @Summary Updates the users password
// @Tags User
// @Success 204
// @Param payload body ChangePassword true "Password Payload"
// @Router /v1/users/change-password [PUT]
// @Security Bearer
// @Summary Updates the users password
// @Tags User
// @Success 204
// @Param payload body ChangePassword true "Password Payload"
// @Router /v1/users/change-password [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleUserSelfChangePassword() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if ctrl.isDemo {

View File

@@ -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)

View File

@@ -121,6 +121,9 @@ const DefaultCurrency = CurrencyUsd
// Currency values.
const (
CurrencyUsd Currency = "usd"
CurrencyEur Currency = "eur"
CurrencyGbp Currency = "gbp"
CurrencyJpy Currency = "jpy"
)
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.
func CurrencyValidator(c Currency) error {
switch c {
case CurrencyUsd:
case CurrencyUsd, CurrencyEur, CurrencyGbp, CurrencyJpy:
return nil
default:
return fmt.Errorf("group: invalid enum value for currency field: %q", c)

View File

@@ -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},
}
@@ -127,7 +127,7 @@ var (
{Name: "created_at", Type: field.TypeTime},
{Name: "updated_at", Type: field.TypeTime},
{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 = &schema.Table{

View File

@@ -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"),
}
}

View File

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

View File

@@ -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
)

View File

@@ -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=

View File

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

View File

@@ -0,0 +1,12 @@
package repo
type PaginationResult[T any] struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
Items []T `json:"items"`
}
func calculateOffset(page, pageSize int) int {
return (page - 1) * pageSize
}

View File

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

@@ -8,6 +8,9 @@ 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"
)
@@ -16,6 +19,25 @@ type ItemsRepository struct {
}
type (
ItemQuery struct {
Page int
PageSize int
Search string `json:"search"`
LocationIDs []uuid.UUID `json:"locationIds"`
LabelIDs []uuid.UUID `json:"labelIds"`
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"`
@@ -58,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 {
@@ -105,7 +127,7 @@ type (
Attachments []ItemAttachment `json:"attachments"`
// Future
// Fields []*FieldSummary `json:"fields"`
Fields []ItemField `json:"fields"`
}
)
@@ -145,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,
@@ -176,6 +219,7 @@ func mapItemOut(item *ent.Item) ItemOut {
// Extras
Notes: item.Notes,
Attachments: attachments,
Fields: fields,
}
}
@@ -200,12 +244,76 @@ 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) {
return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
}
// QueryByGroup returns a list of items that belong to a specific group based on the provided query.
func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q ItemQuery) (PaginationResult[ItemSummary], error) {
qb := e.db.Item.Query().Where(item.HasGroupWith(group.ID(gid)))
if len(q.LabelIDs) > 0 {
labels := make([]predicate.Item, 0, len(q.LabelIDs))
for _, l := range q.LabelIDs {
labels = append(labels, item.HasLabelWith(label.ID(l)))
}
qb = qb.Where(item.Or(labels...))
}
if len(q.LocationIDs) > 0 {
locations := make([]predicate.Item, 0, len(q.LocationIDs))
for _, l := range q.LocationIDs {
locations = append(locations, item.HasLocationWith(location.ID(l)))
}
qb = qb.Where(item.Or(locations...))
}
if q.Search != "" {
qb.Where(
item.Or(
item.NameContainsFold(q.Search),
item.DescriptionContainsFold(q.Search),
),
)
}
if q.Page != -1 || q.PageSize != -1 {
qb = qb.
Offset(calculateOffset(q.Page, q.PageSize)).
Limit(q.PageSize)
}
items, err := mapItemsSummaryErr(
qb.Order(ent.Asc(item.FieldName)).
WithLabel().
WithLocation().
All(ctx),
)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
count, err := qb.Count(ctx)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
return PaginationResult[ItemSummary]{
Page: q.Page,
PageSize: q.PageSize,
Total: count,
Items: items,
}, nil
}
// GetAll returns all the items in the database with the Labels and Locations eager loaded.
func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) {
return mapItemsSummaryErr(e.db.Item.Query().
@@ -217,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).
@@ -294,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)
}

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) {
return mapLabelsOut(r.db.Label.Query().
Where(label.HasGroupWith(group.ID(groupId))).
Order(ent.Asc(label.FieldName)).
WithGroup().
All(ctx),
)

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
tClient = client
tRepos = repo.New(tClient, os.TempDir()+"/homebox")
tSvc = NewServices(tRepos)
tSvc = New(tRepos)
defer client.Close()
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 (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/repo"
@@ -28,6 +27,10 @@ func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID)
return svc.repo.Items.GetOneByGroup(ctx, gid, id)
}
func (svc *ItemService) Query(ctx Context, q repo.ItemQuery) (repo.PaginationResult[repo.ItemSummary], error) {
return svc.repo.Items.QueryByGroup(ctx, ctx.GID, q)
}
func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) {
return svc.repo.Items.GetAll(ctx, gid)
}
@@ -44,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
@@ -55,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
@@ -75,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
@@ -84,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{
@@ -110,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() {
@@ -127,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{
@@ -140,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
@@ -179,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
}

View File

@@ -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
}

View File

@@ -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)))

View File

@@ -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)

View File

@@ -186,21 +186,6 @@ func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error {
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) {
usr, err := svc.repos.Users.GetOneId(ctx, ctx.UID)
if err != nil {

View File

@@ -33,7 +33,7 @@ func (f *Faker) Path() string {
}
func (f *Faker) Email() string {
return f.Str(10) + "@email.com"
return f.Str(10) + "@example.com"
}
func (f *Faker) Bool() bool {

View File

@@ -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
}

View File

@@ -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**

View File

@@ -13,21 +13,22 @@ docker run --name=homebox \
## Docker-Compose
```yml
```yaml
version: "3.4"
services:
homebox:
image: ghcr.io/hay-kot/homebox:latest
container_name: homebox
restart: always
environment:
- HBOX_LOG_LEVEL=info
- HBOX_LOG_FORMAT=text
- HBOX_WEB_MAX_UPLOAD_SIZE=10
volumes:
- homebox-data:/data/
ports:
- 3100:7745
services:
homebox:
image: ghcr.io/hay-kot/homebox:latest
container_name: homebox
restart: always
environment:
- HBOX_LOG_LEVEL=info
- HBOX_LOG_FORMAT=text
- HBOX_WEB_MAX_UPLOAD_SIZE=10
volumes:
- homebox-data:/data/
ports:
- 3100:7745
volumes:
homebox-data:
@@ -41,9 +42,10 @@ volumes:
| 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_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_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_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 |
@@ -76,6 +78,8 @@ volumes:
--mailer-from/$HBOX_MAILER_FROM <string>
--swagger-host/$HBOX_SWAGGER_HOST <string> (default: localhost:7745)
--swagger-scheme/$HBOX_SWAGGER_SCHEME <string> (default: http)
--demo/$HBOX_DEMO <bool>
--allow-registration/$HBOX_ALLOW_REGISTRATION <bool> (default: true)
--help/-h
display this help message
```

16
docs/docs/tips-tricks.md Normal file
View 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)`

View File

@@ -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

View File

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

View File

@@ -18,6 +18,10 @@
name: "Home",
href: "/home",
},
{
name: "Items",
href: "/items",
},
{
name: "Logout",
action: logout,

View File

@@ -11,7 +11,8 @@
</div>
<ul
tabindex="0"
class="dropdown-content mb-1 menu shadow border border-gray-400 rounded bg-base-100 w-full z-[9999] max-h-60 overflow-y-scroll scroll-bar"
style="display: inline"
class="dropdown-content mb-1 menu shadow border border-gray-400 rounded bg-base-100 w-full z-[9999] max-h-60 overflow-y-scroll"
>
<li
v-for="(obj, idx) in items"

View File

@@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
const emit = defineEmits(["update:modelValue"]);
const emit = defineEmits(["update:modelValue", "update:value"]);
const props = defineProps({
label: {
type: String,
@@ -25,7 +25,7 @@
},
modelValue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: [Object, String, Boolean] as any,
type: [Object, String] as any,
default: null,
},
items: {
@@ -37,59 +37,63 @@
type: String,
default: "name",
},
value: {
valueKey: {
type: String,
default: null,
required: false,
},
selectFirst: {
type: Boolean,
default: false,
value: {
type: String,
default: "",
},
});
function syncSelect() {
if (!props.modelValue) {
if (props.selectFirst) {
selectedIdx.value = 0;
}
return;
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(selectedIdx, newVal => {
if (props.valueKey) {
internalValue.value = props.items[newVal][props.valueKey];
}
// Check if we're already synced
if (props.value) {
if (props.modelValue[props.value] === props.items[selectedIdx.value][props.value]) {
return;
}
} else if (props.modelValue === props.items[selectedIdx.value]) {
return;
});
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) {
return true;
}
const idx = props.items.findIndex(item => {
if (props.value) {
return item[props.value] === props.modelValue;
}
return item === props.modelValue;
});
if (!a || !b) {
return false;
}
selectedIdx.value = idx;
return JSON.stringify(a) === JSON.stringify(b);
}
watch(
() => props.modelValue,
() => {
syncSelect();
}
);
const selectedIdx = ref(0);
watch(
() => selectedIdx.value,
() => {
if (props.value) {
emit("update:modelValue", props.items[selectedIdx.value][props.value]);
return;
}
emit("update:modelValue", props.items[selectedIdx.value]);
}
);
</script>

View File

@@ -3,13 +3,13 @@
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<input ref="input" v-model="value" :type="type" class="input input-bordered w-full" />
<input ref="input" v-model="value" :placeholder="placeholder" :type="type" class="input input-bordered w-full" />
</div>
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<input v-model="value" class="input input-bordered col-span-3 w-full mt-2" />
<input v-model="value" :placeholder="placeholder" class="input input-bordered col-span-3 w-full mt-2" />
</div>
</template>
@@ -35,6 +35,10 @@
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: "",
},
});
const input = ref<HTMLElement | null>(null);

View File

@@ -2,7 +2,7 @@
<BaseModal v-model="modal">
<template #title> Create Item </template>
<form @submit.prevent="create">
<FormSelect v-model="form.location" label="Location" :items="locations ?? []" select-first />
<FormSelect v-model="form.location" label="Location" :items="locations ?? []" />
<FormTextField
ref="locationNameRef"
v-model="form.name"

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,8 +7,15 @@
</dt>
<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 }">
<template v-if="detail.type == 'date'">
<DateTime :date="detail.text" />
<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 }}
@@ -21,11 +28,11 @@
</template>
<script setup lang="ts">
import type { DateDetail, Detail } from "./types";
import type { CustomDetail, Detail } from "./types";
defineProps({
details: {
type: Object as () => (Detail | DateDetail)[],
type: Object as () => (Detail | CustomDetail)[],
required: true,
},
});

View File

@@ -1,15 +1,31 @@
export type StringLike = string | number | boolean;
export type DateDetail = {
type BaseDetail = {
name: string;
text: string | Date;
slot?: string;
type: "date";
};
export type Detail = {
name: string;
type DateDetail = BaseDetail & {
type: "date";
text: Date | string;
};
type CurrencyDetail = BaseDetail & {
type: "currency";
text: string;
};
type LinkDetail = BaseDetail & {
type: "link";
text: string;
href: string;
};
export type CustomDetail = DateDetail | CurrencyDetail | LinkDetail;
export type Detail = BaseDetail & {
text: StringLike;
slot?: string;
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

@@ -0,0 +1,32 @@
import { WritableComputedRef } from "vue";
export function useMinLoader(ms = 500): WritableComputedRef<boolean> {
const loading = ref(false);
const locked = ref(false);
const minLoading = computed({
get: () => loading.value,
set: value => {
if (value) {
loading.value = true;
if (!locked.value) {
locked.value = true;
setTimeout(() => {
locked.value = false;
}, ms);
}
}
if (!value && !locked.value) {
loading.value = false;
} else if (!value && locked.value) {
setTimeout(() => {
loading.value = false;
}, ms);
}
},
});
return minLoading;
}

View File

@@ -1,35 +1,5 @@
import { Ref } from "vue";
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";
import { DaisyTheme } from "~~/lib/data/themes";
export type LocationViewPreferences = {
showDetails: boolean;

View File

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

View 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("");
});
});

View File

@@ -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;
}

View File

@@ -1,10 +1,70 @@
<script setup lang="ts"></script>
<template>
<div>
<AppToast />
<AppHeader />
<main class="p-8 dark:bg-gray-800 dark:text-white bg-white text-gray-800 min-h-screen">
<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,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

@@ -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,

View File

@@ -1,6 +1,5 @@
import { describe, test, expect } from "vitest";
import { factories } from "./factories";
import { sharedUserClient } from "./test-utils";
describe("[GET] /api/v1/status", () => {
test("server should respond", async () => {
@@ -32,43 +31,4 @@ describe("first time user workflow (register, login, join group)", () => {
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,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();
});
});

View File

@@ -1,5 +1,5 @@
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 {
createInvitation(data: GroupInvitationCreate) {
@@ -8,4 +8,17 @@ export class GroupApi extends BaseAPI {
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

@@ -8,12 +8,19 @@ import {
ItemSummary,
ItemUpdate,
} from "../types/data-contracts";
import { AttachmentTypes } from "../types/non-generated";
import { Results } from "./types";
import { AttachmentTypes, PaginationResult } from "../types/non-generated";
export type ItemsQuery = {
page?: number;
pageSize?: number;
locations?: string[];
labels?: string[];
q?: string;
};
export class ItemsApi extends BaseAPI {
getAll() {
return this.http.get<Results<ItemSummary>>({ url: route("/items") });
getAll(q: ItemsQuery = {}) {
return this.http.get<PaginationResult<ItemSummary>>({ url: route("/items", q) });
}
create(item: ItemCreate) {

View File

@@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base";
import { LabelCreate, LabelOut } from "../types/data-contracts";
import { Results } from "./types";
import { Results } from "../types/non-generated";
export class LabelsApi extends BaseAPI {
getAll() {

View File

@@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base";
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
import { Results } from "./types";
import { Results } from "../types/non-generated";
export type LocationUpdate = LocationCreate;

View File

@@ -1,3 +0,0 @@
export type Results<T> = {
items: T[];
};

View File

@@ -16,6 +16,19 @@ export interface DocumentOut {
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 {
createdAt: Date;
document: DocumentOut;
@@ -38,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[];
@@ -95,6 +121,7 @@ export interface ItemSummary {
export interface ItemUpdate {
description: string;
fields: ItemField[];
id: string;
insured: boolean;
labelIds: string[];
@@ -187,6 +214,13 @@ export interface LocationSummary {
updatedAt: Date;
}
export interface PaginationResultRepoItemSummary {
items: ItemSummary[];
page: number;
pageSize: number;
total: number;
}
export interface UserOut {
email: string;
groupId: string;

View File

@@ -3,8 +3,20 @@ export enum AttachmentTypes {
Manual = "manual",
Warranty = "warranty",
Attachment = "attachment",
Receipt = "receipt",
}
export type Result<T> = {
item: T;
};
export type Results<T> = {
items: T[];
};
export interface PaginationResult<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}

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";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
});
@@ -15,15 +16,6 @@
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 items = computed(() => itemsStore.items);
@@ -157,13 +149,6 @@
</BaseCard>
</section>
<section>
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="flex gap-2 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div>
</section>
<section>
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
@@ -172,9 +157,9 @@
</section>
<section>
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ItemCard v-for="item in items" :key="item.id" :item="item" />
<BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="flex gap-2 flex-wrap">
<LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div>
</section>
</BaseContainer>

View File

@@ -15,8 +15,7 @@
const { data } = await api.status();
if (data) {
console.log(data);
username.value = "demo@email.com";
username.value = "demo@example.com";
password.value = "demo";
}
return data;
@@ -24,7 +23,7 @@
whenever(status, status => {
if (status?.demo) {
email.value = "demo@email.com";
email.value = "demo@example.com";
loginPassword.value = "demo";
}
});
@@ -148,10 +147,10 @@
<a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank">
<Icon name="mdi-twitter" class="h-8 w-8" />
</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" />
</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" />
</a>
</div>
@@ -198,7 +197,7 @@
</h2>
<template v-if="status && status.demo">
<p class="text-xs italic text-center">This is a demo instance</p>
<p class="text-xs text-center"><b>Email</b> demo@email.com</p>
<p class="text-xs text-center"><b>Email</b> demo@example.com</p>
<p class="text-xs text-center"><b>Password</b> demo</p>
</template>
<FormTextField v-model="email" label="Email" />
@@ -223,6 +222,9 @@
</div>
</div>
</div>
<footer v-if="status" class="absolute text-center w-full bottom-0 pb-4">
<p class="text-center text-sm">Version: {{ status.build.version }} ~ Build: {{ status.build.commit }}</p>
</footer>
</div>
</template>

View File

@@ -6,7 +6,7 @@
import { capitalize } from "~~/lib/strings";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const route = useRoute();
@@ -30,6 +30,13 @@
return;
}
if (locations) {
const location = locations.value.find(l => l.id === data.location.id);
if (location) {
data.location = location;
}
}
return data;
});
onMounted(() => {
@@ -111,12 +118,12 @@
},
{
type: "text",
label: "Purchased Price",
label: "Purchase Price",
ref: "purchasePrice",
},
{
type: "date",
label: "Purchased At",
label: "Purchase Date",
ref: "purchaseTime",
},
];
@@ -183,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) {
@@ -226,6 +234,7 @@
loading: false,
// Values
obj: {},
id: "",
title: "",
type: "",
@@ -241,11 +250,13 @@
editState.title = attachment.document.title;
editState.type = attachment.type;
editState.modal = true;
editState.obj = attachmentOpts.find(o => o.value === attachment.type);
}
async function updateAttachment() {
editState.loading = true;
console.log(editState.type);
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
title: editState.title,
type: editState.type,
@@ -267,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>
@@ -275,7 +314,14 @@
<template #title> Attachment Edit </template>
<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">
<BaseButton :loading="editState.loading" @click="updateAttachment"> Update </BaseButton>
</div>
@@ -308,7 +354,7 @@
</template>
</BaseSectionHeader>
<div class="px-5 mb-6 grid md:grid-cols-2 gap-4">
<FormSelect v-model="item.location" label="Location" :items="locations ?? []" select-first />
<FormSelect v-if="item" v-model="item.location" label="Location" :items="locations ?? []" />
<FormMultiselect v-model="item.labels" label="Labels" :items="labels ?? []" />
</div>
@@ -346,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"
@@ -361,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

View File

@@ -1,9 +1,9 @@
<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";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
const route = useRoute();
@@ -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[],
}
);
});
@@ -76,7 +81,7 @@
text: item.value?.serialNumber,
},
{
name: "Mode Number",
name: "Model Number",
text: item.value?.modelNumber,
},
{
@@ -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;
});
@@ -145,7 +174,7 @@
});
const warrantyDetails = computed(() => {
const details: (Detail | DateDetail)[] = [
const details: Details = [
{
name: "Lifetime Warranty",
text: item.value?.lifetimeWarranty ? "Yes" : "No",
@@ -180,15 +209,16 @@
return item.value?.purchaseFrom || item.value?.purchasePrice !== "0";
});
const purchaseDetails = computed<Array<Detail | DateDetail>>(() => {
const purchaseDetails = computed<Details>(() => {
return [
{
name: "Purchase From",
name: "Purchased From",
text: item.value?.purchaseFrom || "",
},
{
name: "Purchase Price",
text: item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "",
text: item.value?.purchasePrice || "",
type: "currency",
},
{
name: "Purchase Date",
@@ -205,7 +235,7 @@
return item.value?.soldTo || item.value?.soldPrice !== "0";
});
const soldDetails = computed<Array<Detail | DateDetail>>(() => {
const soldDetails = computed<Details>(() => {
return [
{
name: "Sold To",
@@ -213,7 +243,8 @@
},
{
name: "Sold Price",
text: item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "",
text: item.value?.soldPrice || "",
type: "currency",
},
{
name: "Sold At",
@@ -321,21 +352,28 @@
:item-id="item.id"
/>
</template>
<template #receipts>
<ItemAttachmentsList
v-if="attachments.receipts.length > 0"
:attachments="attachments.receipts"
:item-id="item.id"
/>
</template>
</DetailsSection>
</BaseCard>
<BaseCard v-if="showPurchase">
<template #title> Purchase </template>
<template #title> Purchase Details </template>
<DetailsSection :details="purchaseDetails" />
</BaseCard>
<BaseCard v-if="showWarranty">
<template #title> Warranty </template>
<template #title> Warranty Details </template>
<DetailsSection :details="warrantyDetails" />
</BaseCard>
<BaseCard v-if="showSold">
<template #title> Sold </template>
<template #title> Sold Details </template>
<DetailsSection :details="soldDetails" />
</BaseCard>
</div>

View File

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

110
frontend/pages/items.vue Normal file
View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ItemSummary } from "~~/lib/api/types/data-contracts";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "Homebox | Home",
});
const api = useUserApi();
const query = ref("");
const loading = useMinLoader(2000);
const results = ref<ItemSummary[]>([]);
async function search() {
loading.value = true;
const locations = selectedLocations.value.map(l => l.id);
const labels = selectedLabels.value.map(l => l.id);
const { data, error } = await api.items.getAll({ q: query.value, locations, labels });
if (error) {
loading.value = false;
return;
}
results.value = data.items;
loading.value = false;
}
onMounted(() => {
search();
});
const locationsStore = useLocationStore();
const locations = computed(() => locationsStore.locations);
const labelStore = useLabelStore();
const labels = computed(() => labelStore.labels);
const advanced = ref(false);
const selectedLocations = ref([]);
const selectedLabels = ref([]);
watchEffect(() => {
if (!advanced.value) {
selectedLocations.value = [];
selectedLabels.value = [];
}
});
watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
watchDebounced(selectedLocations, search, { debounce: 250, maxWait: 1000 });
watchDebounced(selectedLabels, search, { debounce: 250, maxWait: 1000 });
</script>
<template>
<BaseContainer class="mb-16">
<FormTextField v-model="query" placeholder="Search" />
<div class="flex mt-1">
<label class="ml-auto label cursor-pointer">
<input v-model="advanced" type="checkbox" class="toggle toggle-primary" />
<span class="label-text text-neutral-content ml-2"> Filters </span>
</label>
</div>
<BaseCard v-if="advanced" class="my-1 overflow-visible">
<template #title> Filters </template>
<template #subtitle>
Location and label filters use the 'OR' operation. If more than one is selected only one will be required for a
match
</template>
<div class="px-4 pb-4">
<FormMultiselect v-model="selectedLabels" label="Labels" :items="labels ?? []" />
<FormMultiselect v-model="selectedLocations" label="Locations" :items="locations ?? []" />
</div>
</BaseCard>
<section class="mt-10">
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TransitionGroup name="list">
<ItemCard v-for="item in results" :key="item.id" :item="item" />
</TransitionGroup>
<div class="hidden first:inline text-xl">No Items Found</div>
</div>
</section>
</BaseContainer>
</template>
<style lang="css">
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.25s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-leave-active {
position: absolute;
}
</style>

View File

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

View File

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

View File

@@ -1,137 +1,79 @@
<script setup lang="ts">
import { Detail } from "~~/components/global/DetailsSection/types";
import { DaisyTheme } from "~~/composables/use-preferences";
import { useAuthStore } from "~~/stores/auth";
import { themes } from "~~/lib/data/themes";
import { currencies, Currency } from "~~/lib/data/currency";
definePageMeta({
layout: "home",
middleware: ["auth"],
});
useHead({
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 { data: status } = useAsyncData(async () => {
const { data } = await pubApi.status();
return data;
});
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 details = computed(() => {
@@ -147,10 +89,6 @@
] as Detail[];
});
const api = useUserApi();
const confirm = useConfirm();
const notify = useNotifier();
async function deleteProfile() {
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."
@@ -276,6 +214,27 @@
</div>
</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>
<template #title>
<BaseSectionHeader>
@@ -341,6 +300,9 @@
</template>
</BaseCard>
</BaseContainer>
<footer v-if="status" class="text-center w-full bottom-0 pb-4">
<p class="text-center text-sm">Version: {{ status.build.version }} ~ Build: {{ status.build.commit }}</p>
</footer>
</div>
</template>

View File

@@ -22,6 +22,7 @@ def date_types(*names: list[str]) -> dict[re.Pattern, str]:
regex_replace: dict[re.Pattern, str] = {
re.compile(r" PaginationResultRepo"): "PaginationResult",
re.compile(r" Repo"): " ",
re.compile(r" Services"): " ",
re.compile(r" V1"): " ",