Compare commits

...

39 Commits

Author SHA1 Message Date
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
Hayden
14f1b93d38 chore: remove yaml config option 2022-10-12 13:03:42 -08:00
Hayden
92368dabf8 feat: automate demo-site deployment and configuration (#42)
* add demo env variable

* setup initialization when demo

* disable password when in demo mode

* expose demo status to API

* improve UI for demo instance
2022-10-12 12:53:22 -08:00
Hayden
eca071f974 fix if check 2022-10-11 09:48:36 -08:00
Hayden
48a719d385 fix typo 2022-10-11 09:44:07 -08:00
Hayden
92c29a37e1 add description to issue form 2022-10-11 09:43:10 -08:00
Hayden
ea4e2f6da4 delete old feature request 2022-10-11 09:41:51 -08:00
Hayden
28a7adbffe update issue tracker templates 2022-10-11 09:41:10 -08:00
Hayden
9e19e89de4 finally fix syntax issues 2022-10-11 09:27:46 -08:00
Hayden
e48109e530 hardcode 2022-10-11 09:24:58 -08:00
Hayden
e62529b314 use = 2022-10-11 09:21:06 -08:00
Hayden
b0f184a1b1 single line it 2022-10-11 09:19:48 -08:00
Hayden
6960b235ea add no-cache 2022-10-11 09:16:51 -08:00
Hayden
3198800329 formatting 2022-10-11 09:15:57 -08:00
Hayden
1a69265601 ?!?!?!? 2022-10-11 09:15:10 -08:00
Hayden
2dc765efdf use > instead of | 2022-10-11 09:13:39 -08:00
Hayden
6a6e9e842e formatting 2022-10-11 09:11:17 -08:00
Hayden
cc78b19d6e temp: drop tests 2022-10-11 09:10:25 -08:00
Hayden
6b1110b3c5 reformat? 2022-10-11 09:09:17 -08:00
Hayden
3aae71ee18 revert 2022-10-11 09:01:30 -08:00
Hayden
340b9ac43d maybe? 2022-10-11 08:57:34 -08:00
Hayden
00c3e1bfc9 remove wrong build command 2022-10-10 19:51:15 -08:00
81 changed files with 2156 additions and 922 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,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

61
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
---
name: "Bug Report"
description: "Submit a bug report for the current release"
labels: ["bug"]
body:
- type: checkboxes
id: checks
attributes:
label: First Check
description: Please confirm and check all the following options.
options:
- label: This is not a feature request
required: true
- label: I added a very descriptive title to this issue.
required: true
- label: I used the GitHub search to find a similar issue and didn't find it.
required: true
- label: I searched the documentation, with the integrated search.
required: true
- label: I already read the docs and didn't find an answer.
required: true
- type: input
id: homebox-version
attributes:
label: Homebox Version
validations:
required: true
- type: textarea
id: description
attributes:
label: What is the issue you are experiencing?
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: reproduction
attributes:
description: If you do not provide a way to reproduce the issue, your issue will likely be closed.
label: How can the maintainer reproduce the issue?
placeholder: A clear step-by-step guide on how to reproduce the issue.
validations:
required: true
- type: dropdown
id: os
attributes:
label: Deployment
description: What Deployment system are you using?
multiple: true
options:
- Docker (Linux)
- Docker (Windows)
- Docker (Synology)
- Unraid
- Other
validations:
required: true
- type: textarea
id: os-details
attributes:
label: Deployment Details
description: You can add more details about your operating system here, in particular if you chose "Other". If you are experiencing issues with deployment, please provide your docker-compose or docker commands

View File

@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,37 @@
---
name: "Feature Request"
description: "Submit a feature request for the current release"
labels: ["feature-request"]
body:
- type: textarea
id: problem-statement
attributes:
label: What is the problem you are trying to solve with this feature?
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
- type: textarea
id: feature-solution
attributes:
label: What is the solution you are proposing?
placeholder: A clear and concise description of what you want to happen.
- type: textarea
id: feature-alternatives
attributes:
label: What alternatives have you considered?
placeholder: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: feature-details
attributes:
label: Additional context
placeholder: Add any other context or screenshots about the feature request here.
- type: checkboxes
id: checks
attributes:
label: Contributions
description: Please confirm the following
options:
- label: I have searched through existing issues and feature requests to see if my idea has already been proposed.
required: true
- label: If this feature is accepted, I would be willing to help implement and maintain this feature.
required: false
- label: If this feature is accepted, I'm willing to sponsor the development of this feature.
required: false

View File

@@ -44,28 +44,23 @@ jobs:
env: env:
CR_PAT: ${{ secrets.GH_TOKEN }} CR_PAT: ${{ secrets.GH_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
push: true
tags: user/app:latest
- name: build nightly the image - name: build nightly the image
if: ${{ inputs.release == false }} if: ${{ inputs.release == false }}
run: | run: |
docker build --push \ docker build --push --no-cache \
--tag ghcr.io/hay-kot/homebox:{{ inputs.tag }} \ --tag=ghcr.io/hay-kot/homebox:${{ inputs.tag }} \
--build-arg COMMIT=$(git rev-parse HEAD) \ --build-arg=COMMIT=$(git rev-parse HEAD) \
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ --build-arg=BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--platform linux/amd64,linux/arm64,linux/arm/v7 . --platform=linux/amd64,linux/arm64,linux/arm/v7 .
- name: build release tagged the image - name: build release tagged the image
if: ${{ inputs.release == true }} if: ${{ inputs.release == true }}
run: | run: |
docker build --push \ docker build --push --no-cache \
--tag ghcr.io/hay-kot/homebox:nightly \ --tag ghcr.io/hay-kot/homebox:nightly \
--tag ghcr.io/hay-kot/homebox:latest \ --tag ghcr.io/hay-kot/homebox:latest \
--tag ghcr.io/hay-kot/homebox:{{ inputs.tag }} \ --tag ghcr.io/hay-kot/homebox:${{ inputs.tag }} \
--build-arg VERSION=${{ inputs.tag }} \
--build-arg COMMIT=$(git rev-parse HEAD) \ --build-arg COMMIT=$(git rev-parse HEAD) \
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--platform linux/amd64,linux/arm64,linux/arm/v7 . --platform linux/amd64,linux/arm64,linux/arm/v7 .

View File

@@ -31,9 +31,21 @@ jobs:
- uses: superfly/flyctl-actions/setup-flyctl@master - uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only - run: flyctl deploy --remote-only
publish-nightly:
name: "Publish Nightly"
if: github.event_name != 'release'
needs:
- backend-tests
- frontend-tests
uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
with:
tag: nightly
secrets:
GH_TOKEN: ${{ secrets.CR_PAT }}
publish-tag: publish-tag:
if: github.event_name == 'release'
name: "Publish Tag" name: "Publish Tag"
if: github.event_name == 'release'
needs: needs:
- backend-tests - backend-tests
- frontend-tests - frontend-tests
@@ -44,17 +56,6 @@ jobs:
secrets: secrets:
GH_TOKEN: ${{ secrets.CR_PAT }} GH_TOKEN: ${{ secrets.CR_PAT }}
publish-nightly:
name: "Publish Nightly"
needs:
- backend-tests
- frontend-tests
uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
with:
tag: nightly
secrets:
GH_TOKEN: ${{ secrets.CR_PAT }}
deploy-docs: deploy-docs:
name: Deploy docs name: Deploy docs
needs: needs:

View File

@@ -12,6 +12,7 @@ RUN pnpm build
FROM golang:alpine AS builder FROM golang:alpine AS builder
ARG BUILD_TIME ARG BUILD_TIME
ARG COMMIT ARG COMMIT
ARG VERSION
RUN apk update && \ RUN apk update && \
apk upgrade && \ apk upgrade && \
apk add --update git build-base gcc g++ apk add --update git build-base gcc g++
@@ -22,7 +23,7 @@ RUN go get -d -v ./...
RUN rm -rf ./app/api/public RUN rm -rf ./app/api/public
COPY --from=frontend-builder /app/.output/public ./app/api/public COPY --from=frontend-builder /app/.output/public ./app/api/public
RUN CGO_ENABLED=1 GOOS=linux go build \ 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 \ -o /go/bin/api \
-v ./app/api/*.go -v ./app/api/*.go

View File

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

62
backend/app/api/demo.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"context"
"encoding/csv"
"strings"
"github.com/hay-kot/homebox/backend/internal/services"
"github.com/rs/zerolog/log"
)
func (a *app) SetupDemo() {
csvText := `Import Ref,Location,Labels,Quantity,Name,Description,Insured,Serial Number,Model 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,,,,,,,
`
var (
registration = services.UserRegistration{
Email: "demo@example.com",
Name: "Demo",
Password: "demo",
}
)
// First check if we've already setup a demo user and skip if so
_, err := a.services.User.Login(context.Background(), registration.Email, registration.Password)
if err == nil {
return
}
_, err = a.services.User.RegisterUser(context.Background(), registration)
if err != nil {
log.Err(err).Msg("Failed to register demo user")
log.Fatal().Msg("Failed to setup demo")
}
token, _ := a.services.User.Login(context.Background(), registration.Email, registration.Password)
self, _ := a.services.User.GetSelf(context.Background(), token.Raw)
// Read CSV Text
reader := csv.NewReader(strings.NewReader(csvText))
reader.Comma = ','
records, err := reader.ReadAll()
if err != nil {
log.Err(err).Msg("Failed to read CSV")
log.Fatal().Msg("Failed to setup demo")
}
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")
}
log.Info().Msg("Demo setup complete")
}

View File

@@ -21,6 +21,63 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/v1/groups": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Updates some fields of the current users group",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.GroupUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
}
},
"/v1/groups/invitations": { "/v1/groups/invitations": {
"post": { "post": {
"security": [ "security": [
@@ -32,7 +89,7 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"User" "Group"
], ],
"summary": "Get the current user", "summary": "Get the current user",
"parameters": [ "parameters": [
@@ -70,26 +127,51 @@ const docTemplate = `{
"Items" "Items"
], ],
"summary": "Get All 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": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"allOf": [ "$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
{
"$ref": "#/definitions/server.Results"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
}
}
}
]
} }
} }
} }
@@ -153,7 +235,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -254,7 +336,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -354,7 +436,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"200": { "200": {
"description": "" "description": "OK"
} }
} }
} }
@@ -470,7 +552,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -634,7 +716,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -798,7 +880,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -846,7 +928,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -903,7 +985,7 @@ const docTemplate = `{
"summary": "User Logout", "summary": "User Logout",
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -922,7 +1004,7 @@ const docTemplate = `{
"summary": "User Token Refresh", "summary": "User Token Refresh",
"responses": { "responses": {
"200": { "200": {
"description": "" "description": "OK"
} }
} }
} }
@@ -949,7 +1031,7 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -1049,7 +1131,7 @@ const docTemplate = `{
"summary": "Deletes the user account", "summary": "Deletes the user account",
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -1070,7 +1152,7 @@ const docTemplate = `{
"summary": "Update the current user's password // TODO:", "summary": "Update the current user's password // TODO:",
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -1091,6 +1173,37 @@ const docTemplate = `{
} }
} }
}, },
"repo.Group": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"currency": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"repo.GroupUpdate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"repo.ItemAttachment": { "repo.ItemAttachment": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1488,6 +1601,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": { "repo.UserOut": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1541,9 +1674,7 @@ const docTemplate = `{
"server.Results": { "server.Results": {
"type": "object", "type": "object",
"properties": { "properties": {
"items": { "items": {}
"type": "any"
}
} }
}, },
"server.ValidationError": { "server.ValidationError": {
@@ -1580,6 +1711,9 @@ const docTemplate = `{
"build": { "build": {
"$ref": "#/definitions/v1.Build" "$ref": "#/definitions/v1.Build"
}, },
"demo": {
"type": "boolean"
},
"health": { "health": {
"type": "boolean" "type": "boolean"
}, },

View File

@@ -13,6 +13,63 @@
}, },
"basePath": "/api", "basePath": "/api",
"paths": { "paths": {
"/v1/groups": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get the current user's group",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"put": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Updates some fields of the current users group",
"parameters": [
{
"description": "User Data",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.GroupUpdate"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
}
},
"/v1/groups/invitations": { "/v1/groups/invitations": {
"post": { "post": {
"security": [ "security": [
@@ -24,7 +81,7 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"User" "Group"
], ],
"summary": "Get the current user", "summary": "Get the current user",
"parameters": [ "parameters": [
@@ -62,26 +119,51 @@
"Items" "Items"
], ],
"summary": "Get All 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": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"allOf": [ "$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
{
"$ref": "#/definitions/server.Results"
},
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.ItemSummary"
}
}
}
}
]
} }
} }
} }
@@ -145,7 +227,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -246,7 +328,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -346,7 +428,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "" "description": "OK"
} }
} }
} }
@@ -462,7 +544,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -626,7 +708,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -790,7 +872,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -838,7 +920,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -895,7 +977,7 @@
"summary": "User Logout", "summary": "User Logout",
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -914,7 +996,7 @@
"summary": "User Token Refresh", "summary": "User Token Refresh",
"responses": { "responses": {
"200": { "200": {
"description": "" "description": "OK"
} }
} }
} }
@@ -941,7 +1023,7 @@
], ],
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -1041,7 +1123,7 @@
"summary": "Deletes the user account", "summary": "Deletes the user account",
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -1062,7 +1144,7 @@
"summary": "Update the current user's password // TODO:", "summary": "Update the current user's password // TODO:",
"responses": { "responses": {
"204": { "204": {
"description": "" "description": "No Content"
} }
} }
} }
@@ -1083,6 +1165,37 @@
} }
} }
}, },
"repo.Group": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"currency": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"repo.GroupUpdate": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"repo.ItemAttachment": { "repo.ItemAttachment": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1480,6 +1593,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": { "repo.UserOut": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1533,9 +1666,7 @@
"server.Results": { "server.Results": {
"type": "object", "type": "object",
"properties": { "properties": {
"items": { "items": {}
"type": "any"
}
} }
}, },
"server.ValidationError": { "server.ValidationError": {
@@ -1572,6 +1703,9 @@
"build": { "build": {
"$ref": "#/definitions/v1.Build" "$ref": "#/definitions/v1.Build"
}, },
"demo": {
"type": "boolean"
},
"health": { "health": {
"type": "boolean" "type": "boolean"
}, },

View File

@@ -9,6 +9,26 @@ definitions:
title: title:
type: string type: string
type: object type: object
repo.Group:
properties:
createdAt:
type: string
currency:
type: string
id:
type: string
name:
type: string
updatedAt:
type: string
type: object
repo.GroupUpdate:
properties:
currency:
type: string
name:
type: string
type: object
repo.ItemAttachment: repo.ItemAttachment:
properties: properties:
createdAt: createdAt:
@@ -275,6 +295,19 @@ definitions:
updatedAt: updatedAt:
type: string type: string
type: object 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: repo.UserOut:
properties: properties:
email: email:
@@ -310,8 +343,7 @@ definitions:
type: object type: object
server.Results: server.Results:
properties: properties:
items: items: {}
type: any
type: object type: object
server.ValidationError: server.ValidationError:
properties: properties:
@@ -335,6 +367,8 @@ definitions:
properties: properties:
build: build:
$ref: '#/definitions/v1.Build' $ref: '#/definitions/v1.Build'
demo:
type: boolean
health: health:
type: boolean type: boolean
message: message:
@@ -401,6 +435,40 @@ info:
title: Go API Templates title: Go API Templates
version: "1.0" version: "1.0"
paths: paths:
/v1/groups:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Get the current user's group
tags:
- Group
put:
parameters:
- description: User Data
in: body
name: payload
required: true
schema:
$ref: '#/definitions/repo.GroupUpdate'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Updates some fields of the current users group
tags:
- Group
/v1/groups/invitations: /v1/groups/invitations:
post: post:
parameters: parameters:
@@ -421,23 +489,43 @@ paths:
- Bearer: [] - Bearer: []
summary: Get the current user summary: Get the current user
tags: tags:
- User - Group
/v1/items: /v1/items:
get: 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: produces:
- application/json - application/json
responses: responses:
"200": "200":
description: OK description: OK
schema: schema:
allOf: $ref: '#/definitions/repo.PaginationResult-repo_ItemSummary'
- $ref: '#/definitions/server.Results'
- properties:
items:
items:
$ref: '#/definitions/repo.ItemSummary'
type: array
type: object
security: security:
- Bearer: [] - Bearer: []
summary: Get All Items summary: Get All Items
@@ -475,7 +563,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: deletes a item summary: deletes a item
@@ -581,7 +669,7 @@ paths:
type: string type: string
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
@@ -656,7 +744,7 @@ paths:
- application/octet-stream - application/octet-stream
responses: responses:
"200": "200":
description: "" description: OK
security: security:
- Bearer: [] - Bearer: []
summary: retrieves an attachment for an item summary: retrieves an attachment for an item
@@ -674,7 +762,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: imports items into the database summary: imports items into the database
@@ -733,7 +821,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: deletes a label summary: deletes a label
@@ -830,7 +918,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: deletes a location summary: deletes a location
@@ -897,7 +985,7 @@ paths:
$ref: '#/definitions/v1.ChangePassword' $ref: '#/definitions/v1.ChangePassword'
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: Updates the users password summary: Updates the users password
@@ -933,7 +1021,7 @@ paths:
post: post:
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: User Logout summary: User Logout
@@ -946,7 +1034,7 @@ paths:
This does not validate that the user still exists within the database. This does not validate that the user still exists within the database.
responses: responses:
"200": "200":
description: "" description: OK
security: security:
- Bearer: [] - Bearer: []
summary: User Token Refresh summary: User Token Refresh
@@ -965,7 +1053,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
summary: Get the current user summary: Get the current user
tags: tags:
- User - User
@@ -975,7 +1063,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: Deletes the user account summary: Deletes the user account
@@ -1030,7 +1118,7 @@ paths:
- application/json - application/json
responses: responses:
"204": "204":
description: "" description: No Content
security: security:
- Bearer: [] - Bearer: []
summary: 'Update the current user''s password // TODO:' summary: 'Update the current user''s password // TODO:'

View File

@@ -20,28 +20,23 @@ import (
) )
var ( var (
Version = "0.1.0" version = "nightly"
Commit = "HEAD" commit = "HEAD"
BuildTime = "now" buildTime = "now"
) )
// @title Go API Templates // @title Go API Templates
// @version 1.0 // @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!. // @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 // @contact.name Don't
// @license.name MIT // @license.name MIT
// @BasePath /api // @BasePath /api
// @securityDefinitions.apikey Bearer // @securityDefinitions.apikey Bearer
// @in header // @in header
// @name Authorization // @name Authorization
// @description "Type 'Bearer TOKEN' to correctly set the API Key" // @description "Type 'Bearer TOKEN' to correctly set the API Key"
func main() { func main() {
path := "" cfg, err := config.New()
if len(os.Args) > 1 {
path = os.Args[1]
}
cfg, err := config.NewConfig(path)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -115,7 +110,7 @@ func run(cfg *config.Config) error {
app.db = c app.db = c
app.repos = repo.New(c, cfg.Storage.Data) app.repos = repo.New(c, cfg.Storage.Data)
app.services = services.NewServices(app.repos) app.services = services.New(app.repos)
// ========================================================================= // =========================================================================
// Start Server // Start Server
@@ -143,6 +138,20 @@ func run(cfg *config.Config) error {
Msg("failed to purge expired tokens") Msg("failed to purge expired tokens")
} }
}) })
go app.startBgTask(time.Duration(24)*time.Hour, func() {
_, err := app.repos.Groups.InvitationPurge(context.Background())
if err != nil {
log.Error().
Err(err).
Msg("failed to purge expired invitations")
}
})
// TODO: Remove through external API that does setup
if cfg.Demo {
log.Info().Msg("Running in demo mode, creating demo data")
app.SetupDemo()
}
return app.server.Start(routes) return app.server.Start(routes)
} }

View File

@@ -42,58 +42,64 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
// API Version 1 // API Version 1
v1Base := v1.BaseUrlFunc(prefix) v1Base := v1.BaseUrlFunc(prefix)
v1Ctrl := v1.NewControllerV1(a.services, v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize)) v1Ctrl := v1.NewControllerV1(a.services,
{ v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{ v1.WithRegistration(a.conf.AllowRegistration),
Version: Version, v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
Commit: Commit, )
BuildTime: BuildTime, r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
})) Version: version,
Commit: commit,
BuildTime: buildTime,
}))
r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration()) r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin()) r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin())
// Attachment download URl needs a `token` query param to be passed in the request. // Attachment download URl needs a `token` query param to be passed in the request.
// and also needs to be outside of the `auth` middleware. // and also needs to be outside of the `auth` middleware.
r.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload()) r.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload())
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.mwAuthToken) r.Use(a.mwAuthToken)
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf()) r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate()) r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate())
r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete()) r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete())
r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword()) r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword())
r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout()) r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout())
r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh()) r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh())
r.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword()) r.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword())
r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate()) r.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate())
r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll()) // TODO: I don't like /groups being the URL for users
r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate()) r.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet())
r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet()) r.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate())
r.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate())
r.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete())
r.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll()) r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll())
r.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate()) r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate())
r.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet()) r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet())
r.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate()) r.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate())
r.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete()) r.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete())
r.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll()) r.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll())
r.Post(v1Base("/items/import"), v1Ctrl.HandleItemsImport()) r.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate())
r.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate()) r.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet())
r.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet()) r.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate())
r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate()) r.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete())
r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate()) r.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll())
r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken()) r.Post(v1Base("/items/import"), v1Ctrl.HandleItemsImport())
r.Put(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentUpdate()) r.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate())
r.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentDelete()) r.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet())
}) r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate())
} r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate())
r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken())
r.Put(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentUpdate())
r.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentDelete())
})
r.NotFound(notFoundHandler()) r.NotFound(notFoundHandler())
return r return r

View File

@@ -13,9 +13,23 @@ func WithMaxUploadSize(maxUploadSize int64) func(*V1Controller) {
} }
} }
func WithDemoStatus(demoStatus bool) func(*V1Controller) {
return func(ctrl *V1Controller) {
ctrl.isDemo = demoStatus
}
}
func WithRegistration(allowRegistration bool) func(*V1Controller) {
return func(ctrl *V1Controller) {
ctrl.allowRegistration = allowRegistration
}
}
type V1Controller struct { type V1Controller struct {
svc *services.AllServices svc *services.AllServices
maxUploadSize int64 maxUploadSize int64
isDemo bool
allowRegistration bool
} }
type ( type (
@@ -30,7 +44,8 @@ type (
Versions []string `json:"versions"` Versions []string `json:"versions"`
Title string `json:"title"` Title string `json:"title"`
Message string `json:"message"` Message string `json:"message"`
Build Build Build Build `json:"build"`
Demo bool `json:"demo"`
} }
) )
@@ -45,7 +60,12 @@ func BaseUrlFunc(prefix string) func(s string) string {
func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller { func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller)) *V1Controller {
ctrl := &V1Controller{ ctrl := &V1Controller{
svc: svc, svc: svc,
allowRegistration: true,
}
for _, opt := range options {
opt(ctrl)
} }
return ctrl return ctrl
@@ -54,11 +74,11 @@ func NewControllerV1(svc *services.AllServices, options ...func(*V1Controller))
type ReadyFunc func() bool type ReadyFunc func() bool
// HandleBase godoc // HandleBase godoc
// @Summary Retrieves the basic information about the API // @Summary Retrieves the basic information about the API
// @Tags Base // @Tags Base
// @Produce json // @Produce json
// @Success 200 {object} ApiSummary // @Success 200 {object} ApiSummary
// @Router /v1/status [GET] // @Router /v1/status [GET]
func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) http.HandlerFunc { func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
server.Respond(w, http.StatusOK, ApiSummary{ server.Respond(w, http.StatusOK, ApiSummary{
@@ -66,6 +86,7 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) http.HandlerF
Title: "Go API Template", Title: "Go API Template",
Message: "Welcome to the Go API Template Application!", Message: "Welcome to the Go API Template Application!",
Build: build, Build: build,
Demo: ctrl.isDemo,
}) })
} }
} }

View File

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

View File

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

View File

@@ -3,41 +3,84 @@ package v1
import ( import (
"encoding/csv" "encoding/csv"
"net/http" "net/http"
"net/url"
"strconv"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services" "github.com/hay-kot/homebox/backend/internal/services"
"github.com/hay-kot/homebox/backend/pkgs/server" "github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func uuidList(params url.Values, key string) []uuid.UUID {
var ids []uuid.UUID
for _, id := range params[key] {
uid, err := uuid.Parse(id)
if err != nil {
continue
}
ids = append(ids, uid)
}
return ids
}
func intOrNegativeOne(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
return -1
}
return i
}
func extractQuery(r *http.Request) repo.ItemQuery {
params := r.URL.Query()
page := intOrNegativeOne(params.Get("page"))
perPage := intOrNegativeOne(params.Get("perPage"))
return repo.ItemQuery{
Page: page,
PageSize: perPage,
Search: params.Get("q"),
LocationIDs: uuidList(params, "locations"),
LabelIDs: uuidList(params, "labels"),
}
}
// HandleItemsGetAll godoc // HandleItemsGetAll godoc
// @Summary Get All Items // @Summary Get All Items
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Success 200 {object} server.Results{items=[]repo.ItemSummary} // @Param q query string false "search string"
// @Router /v1/items [GET] // @Param page query int false "page number"
// @Security Bearer // @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 { func (ctrl *V1Controller) HandleItemsGetAll() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user := services.UseUserCtx(r.Context()) ctx := services.NewContext(r.Context())
items, err := ctrl.svc.Items.GetAll(r.Context(), user.GroupID) items, err := ctrl.svc.Items.Query(ctx, extractQuery(r))
if err != nil { if err != nil {
log.Err(err).Msg("failed to get items") log.Err(err).Msg("failed to get items")
server.RespondServerError(w) server.RespondServerError(w)
return return
} }
server.Respond(w, http.StatusOK, server.Results{Items: items}) server.Respond(w, http.StatusOK, items)
} }
} }
// HandleItemsCreate godoc // HandleItemsCreate godoc
// @Summary Create a new item // @Summary Create a new item
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Param payload body repo.ItemCreate true "Item Data" // @Param payload body repo.ItemCreate true "Item Data"
// @Success 200 {object} repo.ItemSummary // @Success 200 {object} repo.ItemSummary
// @Router /v1/items [POST] // @Router /v1/items [POST]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc { func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
createData := repo.ItemCreate{} createData := repo.ItemCreate{}
@@ -60,13 +103,13 @@ func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc {
} }
// HandleItemDelete godocs // HandleItemDelete godocs
// @Summary deletes a item // @Summary deletes a item
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Success 204 // @Success 204
// @Router /v1/items/{id} [DELETE] // @Router /v1/items/{id} [DELETE]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc { func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r) uid, user, err := ctrl.partialParseIdAndUser(w, r)
@@ -85,13 +128,13 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
} }
// HandleItemGet godocs // HandleItemGet godocs
// @Summary Gets a item and fields // @Summary Gets a item and fields
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Success 200 {object} repo.ItemOut // @Success 200 {object} repo.ItemOut
// @Router /v1/items/{id} [GET] // @Router /v1/items/{id} [GET]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc { func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r) uid, user, err := ctrl.partialParseIdAndUser(w, r)
@@ -110,14 +153,14 @@ func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
} }
// HandleItemUpdate godocs // HandleItemUpdate godocs
// @Summary updates a item // @Summary updates a item
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param payload body repo.ItemUpdate true "Item Data" // @Param payload body repo.ItemUpdate true "Item Data"
// @Success 200 {object} repo.ItemOut // @Success 200 {object} repo.ItemOut
// @Router /v1/items/{id} [PUT] // @Router /v1/items/{id} [PUT]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc { func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
body := repo.ItemUpdate{} body := repo.ItemUpdate{}
@@ -143,13 +186,13 @@ func (ctrl *V1Controller) HandleItemUpdate() http.HandlerFunc {
} }
// HandleItemsImport godocs // HandleItemsImport godocs
// @Summary imports items into the database // @Summary imports items into the database
// @Tags Items // @Tags Items
// @Produce json // @Produce json
// @Success 204 // @Success 204
// @Param csv formData file true "Image to upload" // @Param csv formData file true "Image to upload"
// @Router /v1/items/import [Post] // @Router /v1/items/import [Post]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,5 @@ require (
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.13-0.20220804200503-81c7dc4e4efa // indirect golang.org/x/tools v0.1.13-0.20220804200503-81c7dc4e4efa // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"github.com/ardanlabs/conf/v2" "github.com/ardanlabs/conf/v2"
"github.com/ardanlabs/conf/v2/yaml"
"os" "os"
) )
@@ -17,12 +16,14 @@ const (
) )
type Config struct { type Config struct {
Mode string `yaml:"mode" conf:"default:development"` // development or production Mode string `yaml:"mode" conf:"default:development"` // development or production
Web WebConfig `yaml:"web"` Web WebConfig `yaml:"web"`
Storage Storage `yaml:"storage"` Storage Storage `yaml:"storage"`
Log LoggerConf `yaml:"logger"` Log LoggerConf `yaml:"logger"`
Mailer MailerConf `yaml:"mailer"` Mailer MailerConf `yaml:"mailer"`
Swagger SwaggerConf `yaml:"swagger"` Swagger SwaggerConf `yaml:"swagger"`
Demo bool `yaml:"demo"`
AllowRegistration bool `yaml:"disable_registration" conf:"default:true"`
} }
type SwaggerConf struct { type SwaggerConf struct {
@@ -36,24 +37,13 @@ type WebConfig struct {
MaxUploadSize int64 `yaml:"max_file_upload" conf:"default:10"` MaxUploadSize int64 `yaml:"max_file_upload" conf:"default:10"`
} }
// NewConfig parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the // New parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the
// file is not read. If the file is not empty, the file is read and the Config struct is returned. // file is not read. If the file is not empty, the file is read and the Config struct is returned.
func NewConfig(file string) (*Config, error) { func New() (*Config, error) {
var cfg Config var cfg Config
const prefix = "HBOX" const prefix = "HBOX"
help, err := func() (string, error) { help, err := conf.Parse(prefix, &cfg)
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
return conf.Parse(prefix, &cfg)
} else {
yamlData, err := os.ReadFile(file)
if err != nil {
return "", err
}
return conf.Parse(prefix, &cfg, yaml.WithData(yamlData))
}
}()
if err != nil { if err != nil {
if errors.Is(err, conf.ErrHelpWanted) { if errors.Is(err, conf.ErrHelpWanted) {

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

View File

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

View File

@@ -8,6 +8,8 @@ import (
"github.com/hay-kot/homebox/backend/ent" "github.com/hay-kot/homebox/backend/ent"
"github.com/hay-kot/homebox/backend/ent/group" "github.com/hay-kot/homebox/backend/ent/group"
"github.com/hay-kot/homebox/backend/ent/item" "github.com/hay-kot/homebox/backend/ent/item"
"github.com/hay-kot/homebox/backend/ent/label"
"github.com/hay-kot/homebox/backend/ent/location"
"github.com/hay-kot/homebox/backend/ent/predicate" "github.com/hay-kot/homebox/backend/ent/predicate"
) )
@@ -16,6 +18,15 @@ type ItemsRepository struct {
} }
type ( 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"`
}
ItemCreate struct { ItemCreate struct {
ImportRef string `json:"-"` ImportRef string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
@@ -206,6 +217,65 @@ func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID)
return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid))) 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. // 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) { func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSummary, error) {
return mapItemsSummaryErr(e.db.Item.Query(). return mapItemsSummaryErr(e.db.Item.Query().

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,10 @@ func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID)
return svc.repo.Items.GetOneByGroup(ctx, gid, id) 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) { func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]repo.ItemSummary, error) {
return svc.repo.Items.GetAll(ctx, gid) return svc.repo.Items.GetAll(ctx, gid)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,8 @@
<template> <template>
<div ref="label" class="dropdown dropdown-end dropdown-top w-full"> <div ref="label" class="dropdown dropdown-end dropdown-top w-full">
<FormTextField v-model="dateText" tabindex="0" label="Date" :inline="inline" readonly /> <FormTextField v-model="dateText" tabindex="0" label="Date" :inline="inline" readonly />
<div tabindex="0" class="mt-1 card compact dropdown-content shadow bg-base-100 rounded-box w-64" @blur="resetTime"> <div tabindex="0" class="card compact dropdown-content shadow bg-base-100 rounded-box w-64" @blur="resetTime">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center">
<button class="btn btn-xs" @click="prevMonth">
<Icon class="h-5 w-5" name="mdi-arrow-left"></Icon>
</button>
<p class="text-center">{{ month }} {{ year }}</p>
<button class="btn btn-xs" @click="nextMonth">
<Icon class="h-5 w-5" name="mdi-arrow-right"></Icon>
</button>
</div>
<div class="grid grid-cols-7 gap-2"> <div class="grid grid-cols-7 gap-2">
<div v-for="d in daysIdx" :key="d"> <div v-for="d in daysIdx" :key="d">
<p class="text-center"> <p class="text-center">
@@ -30,6 +21,15 @@
<div v-else :key="`${day.number}-empty`"></div> <div v-else :key="`${day.number}-empty`"></div>
</template> </template>
</div> </div>
<div class="flex justify-between mt-1 items-center">
<button class="btn btn-xs" @click="prevMonth">
<Icon class="h-5 w-5" name="mdi-arrow-left"></Icon>
</button>
<p class="text-center">{{ month }} {{ year }}</p>
<button class="btn btn-xs" @click="nextMonth">
<Icon class="h-5 w-5" name="mdi-arrow-right"></Icon>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,8 @@
</div> </div>
<ul <ul
tabindex="0" 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 <li
v-for="(obj, idx) in items" v-for="(obj, idx) in items"

View File

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

View File

@@ -3,13 +3,13 @@
<label class="label"> <label class="label">
<span class="label-text">{{ label }}</span> <span class="label-text">{{ label }}</span>
</label> </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>
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4"> <div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label class="label"> <label class="label">
<span class="label-text">{{ label }}</span> <span class="label-text">{{ label }}</span>
</label> </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> </div>
</template> </template>
@@ -35,6 +35,10 @@
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholder: {
type: String,
default: "",
},
}); });
const input = ref<HTMLElement | null>(null); const input = ref<HTMLElement | null>(null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base"; import { BaseAPI, route } from "../base";
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts"; import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
import { Results } from "./types"; import { Results } from "../types/non-generated";
export type LocationUpdate = LocationCreate; 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; title: string;
} }
export interface Group {
createdAt: Date;
currency: string;
id: string;
name: string;
updatedAt: Date;
}
export interface GroupUpdate {
currency: string;
name: string;
}
export interface ItemAttachment { export interface ItemAttachment {
createdAt: Date; createdAt: Date;
document: DocumentOut; document: DocumentOut;
@@ -187,6 +200,13 @@ export interface LocationSummary {
updatedAt: Date; updatedAt: Date;
} }
export interface PaginationResultRepoItemSummary {
items: ItemSummary[];
page: number;
pageSize: number;
total: number;
}
export interface UserOut { export interface UserOut {
email: string; email: string;
groupId: string; groupId: string;
@@ -227,6 +247,7 @@ export interface UserRegistration {
export interface ApiSummary { export interface ApiSummary {
build: Build; build: Build;
demo: boolean;
health: boolean; health: boolean;
message: string; message: string;
title: string; title: string;

View File

@@ -8,3 +8,14 @@ export enum AttachmentTypes {
export type Result<T> = { export type Result<T> = {
item: 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"; import { useLocationStore } from "~~/stores/locations";
definePageMeta({ definePageMeta({
layout: "home", middleware: ["auth"],
}); });
useHead({ useHead({
title: "Homebox | Home", title: "Homebox | Home",
}); });
@@ -15,15 +16,6 @@
const auth = useAuthStore(); const auth = useAuthStore();
if (auth.self === null) {
const { data, error } = await api.user.self();
if (error) {
navigateTo("/");
}
auth.$patch({ self: data.item });
}
const itemsStore = useItemStore(); const itemsStore = useItemStore();
const items = computed(() => itemsStore.items); const items = computed(() => itemsStore.items);
@@ -157,13 +149,6 @@
</BaseCard> </BaseCard>
</section> </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> <section>
<BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Storage Locations </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 card md:grid-cols-3 gap-4">
@@ -172,9 +157,9 @@
</section> </section>
<section> <section>
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader> <BaseSectionHeader class="mb-5"> Labels </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="flex gap-2 flex-wrap">
<ItemCard v-for="item in items" :key="item.id" :item="item" /> <LabelChip v-for="label in labels" :key="label.id" size="lg" :label="label" />
</div> </div>
</section> </section>
</BaseContainer> </BaseContainer>

View File

@@ -11,6 +11,23 @@
const api = usePublicApi(); const api = usePublicApi();
const toast = useNotifier(); const toast = useNotifier();
const { data: status } = useAsyncData(async () => {
const { data } = await api.status();
if (data) {
username.value = "demo@example.com";
password.value = "demo";
}
return data;
});
whenever(status, status => {
if (status?.demo) {
email.value = "demo@example.com";
loginPassword.value = "demo";
}
});
const authStore = useAuthStore(); const authStore = useAuthStore();
if (!authStore.isTokenExpired) { if (!authStore.isTokenExpired) {
navigateTo("/home"); navigateTo("/home");
@@ -130,10 +147,10 @@
<a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank"> <a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank">
<Icon name="mdi-twitter" class="h-8 w-8" /> <Icon name="mdi-twitter" class="h-8 w-8" />
</a> </a>
<a href="https://discord.gg/tuncmNrE4z" class="tooltip" data-tip="Join The Discord"> <a href="https://discord.gg/tuncmNrE4z" class="tooltip" data-tip="Join The Discord" target="_blank">
<Icon name="mdi-discord" class="h-8 w-8" /> <Icon name="mdi-discord" class="h-8 w-8" />
</a> </a>
<a href="https://hay-kot.github.io/homebox/" class="tooltip" data-tip="Read The Docs"> <a href="https://hay-kot.github.io/homebox/" class="tooltip" data-tip="Read The Docs" target="_blank">
<Icon name="mdi-folder" class="h-8 w-8" /> <Icon name="mdi-folder" class="h-8 w-8" />
</a> </a>
</div> </div>
@@ -178,6 +195,11 @@
<Icon name="heroicons-user" class="mr-1 w-7 h-7" /> <Icon name="heroicons-user" class="mr-1 w-7 h-7" />
Login Login
</h2> </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@example.com</p>
<p class="text-xs text-center"><b>Password</b> demo</p>
</template>
<FormTextField v-model="email" label="Email" /> <FormTextField v-model="email" label="Email" />
<FormTextField v-model="loginPassword" label="Password" type="password" /> <FormTextField v-model="loginPassword" label="Password" type="password" />
<div class="card-actions justify-end mt-2"> <div class="card-actions justify-end mt-2">
@@ -200,6 +222,9 @@
</div> </div>
</div> </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> </div>
</template> </template>

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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