feat: add lookup by asset ID (#208)

* add asset id redirecting

* dev env changes

* suggested changes from PR

* remove unnecessary proxy from nuxt config

* fix formatting

* change directory reference

* fix API key storage

* use /a/{id} as redirect

* run generators

* remove dependabot

Co-authored-by: Bradley Nelson <bradley@nel.family>
Co-authored-by: Bradley Nelson <BCNelson@users.noreply.github.com>
This commit is contained in:
Hayden 2023-01-14 10:24:11 -08:00 committed by GitHub
parent c78f10ba5d
commit 07441eec8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 302 additions and 33 deletions

View file

@ -1,31 +0,0 @@
version: 2
updates:
# Fetch and update latest `npm` packages
- package-ecosystem: npm
directory: "/frontend"
schedule:
interval: daily
time: "00:00"
open-pull-requests-limit: 10
reviewers:
- hay-kot
assignees:
- hay-kot
commit-message:
prefix: fix
prefix-development: chore
include: scope
- package-ecosystem: gomod
directory: backend
schedule:
interval: daily
time: "00:00"
open-pull-requests-limit: 10
reviewers:
- hay-kot
assignees:
- hay-kot
commit-message:
prefix: fix
prefix-development: chore
include: scope

4
.gitignore vendored
View file

@ -45,3 +45,7 @@ node_modules
.output .output
.env .env
dist dist
.pnpm-store
backend/app/api/app
backend/app/api/__debug_bin

47
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,47 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"compounds": [
{
"name": "Full Stack",
"configurations": ["Launch Backend", "Launch Frontend"],
"stopAll": true
}
],
"configurations": [
{
"name": "Launch Backend",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceRoot}/backend/app/api/",
"args": [],
"env": {
"HBOX_DEMO": "true",
"HBOX_LOG_LEVEL": "debug",
"HBOX_DEBUG_ENABLED": "true",
"HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
"HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1"
},
},
{
"name": "Launch Frontend",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend",
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "Local: http://localhost:([0-9]+)",
"uriFormat": "http://localhost:%s",
"webRoot": "${workspaceFolder}/frontend"
}
}
]
}

View file

@ -12,7 +12,7 @@
"debughandlers" "debughandlers"
], ],
// use ESLint to format code on save // use ESLint to format code on save
"editor.formatOnSave": true, "editor.formatOnSave": false,
"editor.defaultFormatter": "dbaeumer.vscode-eslint", "editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": true

View file

@ -0,0 +1,61 @@
package v1
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/hay-kot/homebox/backend/internal/core/services"
"github.com/hay-kot/homebox/backend/internal/data/repo"
"github.com/hay-kot/homebox/backend/internal/sys/validate"
"github.com/hay-kot/homebox/backend/pkgs/server"
"github.com/rs/zerolog/log"
)
// HandleItemGet godocs
// @Summary Gets an item by Asset ID
// @Tags Assets
// @Produce json
// @Param id path string true "Asset ID"
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
// @Router /v1/assets/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleAssetGet() server.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())
assetIdParam := chi.URLParam(r, "id")
assetIdParam = strings.ReplaceAll(assetIdParam, "-", "") // Remove dashes
// Convert the asset ID to an int64
assetId, err := strconv.ParseInt(assetIdParam, 10, 64)
if err != nil {
return err
}
pageParam := r.URL.Query().Get("page")
var page int64 = -1
if pageParam != "" {
page, err = strconv.ParseInt(pageParam, 10, 64)
if err != nil {
return server.Respond(w, http.StatusBadRequest, "Invalid page number")
}
}
pageSizeParam := r.URL.Query().Get("pageSize")
var pageSize int64 = -1
if pageSizeParam != "" {
pageSize, err = strconv.ParseInt(pageSizeParam, 10, 64)
if err != nil {
return server.Respond(w, http.StatusBadRequest, "Invalid page size")
}
}
items, err := ctrl.repo.Items.QueryByAssetID(r.Context(), ctx.GID, repo.AssetID(assetId), int(page), int(pageSize))
if err != nil {
log.Err(err).Msg("failed to get item")
return validate.NewRequestError(err, http.StatusInternalServerError)
}
return server.Respond(w, http.StatusOK, items)
}
}

View file

@ -117,6 +117,8 @@ func (a *app) mountRoutes(repos *repo.AllRepos) {
a.server.Put(v1Base("/items/{id}/maintenance/{entry_id}"), v1Ctrl.HandleMaintenanceEntryUpdate(), userMW...) a.server.Put(v1Base("/items/{id}/maintenance/{entry_id}"), v1Ctrl.HandleMaintenanceEntryUpdate(), userMW...)
a.server.Delete(v1Base("/items/{id}/maintenance/{entry_id}"), v1Ctrl.HandleMaintenanceEntryDelete(), userMW...) a.server.Delete(v1Base("/items/{id}/maintenance/{entry_id}"), v1Ctrl.HandleMaintenanceEntryDelete(), userMW...)
a.server.Get(v1Base("/asset/{id}"), v1Ctrl.HandleAssetGet(), userMW...)
a.server.Get( a.server.Get(
v1Base("/items/{id}/attachments/{attachment_id}"), v1Base("/items/{id}/attachments/{attachment_id}"),
v1Ctrl.HandleItemAttachmentGet(), v1Ctrl.HandleItemAttachmentGet(),

View file

@ -45,6 +45,39 @@ const docTemplate = `{
} }
} }
}, },
"/v1/assets/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Assets"
],
"summary": "Gets an item by Asset ID",
"parameters": [
{
"type": "string",
"description": "Asset ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
}
}
}
}
},
"/v1/groups": { "/v1/groups": {
"get": { "get": {
"security": [ "security": [

View file

@ -37,6 +37,39 @@
} }
} }
}, },
"/v1/assets/{id}": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Assets"
],
"summary": "Gets an item by Asset ID",
"parameters": [
{
"type": "string",
"description": "Asset ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/repo.PaginationResult-repo_ItemSummary"
}
}
}
}
},
"/v1/groups": { "/v1/groups": {
"get": { "get": {
"security": [ "security": [

View file

@ -633,6 +633,26 @@ paths:
summary: Get the current user summary: Get the current user
tags: tags:
- Group - Group
/v1/assets/{id}:
get:
parameters:
- description: Asset ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/repo.PaginationResult-repo_ItemSummary'
security:
- Bearer: []
summary: Gets an item by Asset ID
tags:
- Assets
/v1/groups: /v1/groups:
get: get:
produces: produces:

View file

@ -356,6 +356,41 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
} }
// QueryByAssetID returns items by asset ID. If the item does not exist, an error is returned.
func (e *ItemsRepository) QueryByAssetID(ctx context.Context, gid uuid.UUID, assetID AssetID, page int, pageSize int) (PaginationResult[ItemSummary], error) {
qb := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(gid)),
item.AssetID(int(assetID)),
)
if page != -1 || pageSize != -1 {
qb.Offset(calculateOffset(page, pageSize)).
Limit(pageSize)
} else {
page = -1
pageSize = -1
}
items, err := mapItemsSummaryErr(
qb.Order(ent.Asc(item.FieldName)).
WithLabel().
WithLocation().
All(ctx),
)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
return PaginationResult[ItemSummary]{
Page: page,
PageSize: pageSize,
Total: len(items),
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

@ -0,0 +1,11 @@
import { BaseAPI, route } from "../base";
import { ItemSummary } from "../types/data-contracts";
import { PaginationResult } from "../types/non-generated";
export class AssetsApi extends BaseAPI {
async get(id: string, page = 1, pageSize = 50) {
return await this.http.get<PaginationResult<ItemSummary>>({
url: route(`/asset/${id}`, { page, pageSize }),
});
}
}

View file

@ -6,6 +6,7 @@ import { GroupApi } from "./classes/group";
import { UserApi } from "./classes/users"; import { UserApi } from "./classes/users";
import { ActionsAPI } from "./classes/actions"; import { ActionsAPI } from "./classes/actions";
import { StatsAPI } from "./classes/stats"; import { StatsAPI } from "./classes/stats";
import { AssetsApi } from "./classes/assets";
import { Requests } from "~~/lib/requests"; import { Requests } from "~~/lib/requests";
export class UserClient extends BaseAPI { export class UserClient extends BaseAPI {
@ -16,17 +17,19 @@ export class UserClient extends BaseAPI {
user: UserApi; user: UserApi;
actions: ActionsAPI; actions: ActionsAPI;
stats: StatsAPI; stats: StatsAPI;
assets: AssetsApi;
constructor(requests: Requests, attachmentToken: string) { constructor(requests: Requests, attachmentToken: string) {
super(requests, attachmentToken); super(requests, attachmentToken);
this.locations = new LocationsApi(requests); this.locations = new LocationsApi(requests);
this.labels = new LabelsApi(requests); this.labels = new LabelsApi(requests);
this.items = new ItemsApi(requests); this.items = new ItemsApi(requests, attachmentToken);
this.group = new GroupApi(requests); this.group = new GroupApi(requests);
this.user = new UserApi(requests); this.user = new UserApi(requests);
this.actions = new ActionsAPI(requests); this.actions = new ActionsAPI(requests);
this.stats = new StatsAPI(requests); this.stats = new StatsAPI(requests);
this.assets = new AssetsApi(requests);
Object.freeze(this); Object.freeze(this);
} }

View file

@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
middleware: ["auth"],
});
const route = useRoute();
const assetId = computed<string>(() => route.params.id as string);
await navigateTo("/assets/" + assetId.value, { replace: true, redirectCode: 301 });
</script>

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
definePageMeta({
middleware: ["auth"],
});
const route = useRoute();
const api = useUserApi();
const toast = useNotifier();
const assetId = computed<string>(() => route.params.id as string);
const { pending, data: items } = useLazyAsyncData(`asset/${assetId.value}`, async () => {
const { data, error } = await api.assets.get(assetId.value);
if (error) {
toast.error("Failed to load asset");
navigateTo("/home");
return;
}
switch (data.total) {
case 0:
toast.error("Asset not found");
navigateTo("/home");
break;
case 1:
navigateTo(`/item/${data.items[0].id}`, { replace: true, redirectCode: 302 });
break;
default:
return data.items;
}
});
</script>
<template>
<BaseContainer>
<section v-if="!pending">
<BaseSectionHeader class="mb-5"> This Asset Id is associated with multiple items</BaseSectionHeader>
<div class="grid gap-2 grid-cols-1 sm:grid-cols-2">
<ItemCard v-for="item in items" :key="item.id" :item="item" />
</div>
</section>
</BaseContainer>
</template>