diff --git a/backend/app/api/handlers/v1/v1_ctrl_actions.go b/backend/app/api/handlers/v1/v1_ctrl_actions.go new file mode 100644 index 0000000..37e2b72 --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_actions.go @@ -0,0 +1,35 @@ +package v1 + +import ( + "net/http" + + "github.com/hay-kot/homebox/backend/internal/core/services" + "github.com/hay-kot/homebox/backend/internal/sys/validate" + "github.com/hay-kot/homebox/backend/pkgs/server" + "github.com/rs/zerolog/log" +) + +type EnsureAssetIDResult struct { + Completed int `json:"completed"` +} + +// HandleGroupInvitationsCreate godoc +// @Summary Get the current user +// @Tags Group +// @Produce json +// @Success 200 {object} EnsureAssetIDResult +// @Router /v1/actions/ensure-asset-ids [Post] +// @Security Bearer +func (ctrl *V1Controller) HandleEnsureAssetID() server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + ctx := services.NewContext(r.Context()) + + totalCompleted, err := ctrl.svc.Items.EnsureAssetID(ctx, ctx.GID) + if err != nil { + log.Err(err).Msg("failed to ensure asset id") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + return server.Respond(w, http.StatusOK, EnsureAssetIDResult{Completed: totalCompleted}) + } +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 992e70e..beec706 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -82,6 +82,8 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), a.mwAuthToken) a.server.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate(), a.mwAuthToken) + a.server.Post(v1Base("/actions/ensure-asset-ids"), v1Ctrl.HandleEnsureAssetID(), a.mwAuthToken) + a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), a.mwAuthToken) a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), a.mwAuthToken) a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), a.mwAuthToken) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index f2c877e..6e02411 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -21,6 +21,30 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/v1/actions/ensure-asset-ids": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Get the current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.EnsureAssetIDResult" + } + } + } + } + }, "/v1/groups": { "get": { "security": [ @@ -1326,6 +1350,10 @@ const docTemplate = `{ "archived": { "type": "boolean" }, + "assetId": { + "type": "string", + "example": "0" + }, "attachments": { "type": "array", "items": { @@ -1479,6 +1507,9 @@ const docTemplate = `{ "archived": { "type": "boolean" }, + "assetId": { + "type": "string" + }, "description": { "type": "string" }, @@ -1891,6 +1922,14 @@ const docTemplate = `{ } } }, + "v1.EnsureAssetIDResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.GroupInvitation": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 7215fae..3be73bd 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -13,6 +13,30 @@ }, "basePath": "/api", "paths": { + "/v1/actions/ensure-asset-ids": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Get the current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.EnsureAssetIDResult" + } + } + } + } + }, "/v1/groups": { "get": { "security": [ @@ -1318,6 +1342,10 @@ "archived": { "type": "boolean" }, + "assetId": { + "type": "string", + "example": "0" + }, "attachments": { "type": "array", "items": { @@ -1471,6 +1499,9 @@ "archived": { "type": "boolean" }, + "assetId": { + "type": "string" + }, "description": { "type": "string" }, @@ -1883,6 +1914,14 @@ } } }, + "v1.EnsureAssetIDResult": { + "type": "object", + "properties": { + "completed": { + "type": "integer" + } + } + }, "v1.GroupInvitation": { "type": "object", "properties": { diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index e479583..72563b9 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -98,6 +98,9 @@ definitions: properties: archived: type: boolean + assetId: + example: "0" + type: string attachments: items: $ref: '#/definitions/repo.ItemAttachment' @@ -204,6 +207,8 @@ definitions: properties: archived: type: boolean + assetId: + type: string description: type: string fields: @@ -477,6 +482,11 @@ definitions: new: type: string type: object + v1.EnsureAssetIDResult: + properties: + completed: + type: integer + type: object v1.GroupInvitation: properties: expiresAt: @@ -516,6 +526,20 @@ info: title: Go API Templates version: "1.0" paths: + /v1/actions/ensure-asset-ids: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnsureAssetIDResult' + security: + - Bearer: [] + summary: Get the current user + tags: + - Group /v1/groups: get: produces: diff --git a/backend/internal/core/services/service_items.go b/backend/internal/core/services/service_items.go index 4af80c8..0541577 100644 --- a/backend/internal/core/services/service_items.go +++ b/backend/internal/core/services/service_items.go @@ -23,6 +23,32 @@ type ItemService struct { at attachmentTokens } +func (svc *ItemService) EnsureAssetID(ctx context.Context, GID uuid.UUID) (int, error) { + items, err := svc.repo.Items.GetAllZeroAssetID(ctx, GID) + + if err != nil { + return 0, err + } + + highest, err := svc.repo.Items.GetHighestAssetID(ctx, GID) + if err != nil { + return 0, err + } + + finished := 0 + for _, item := range items { + highest++ + + err = svc.repo.Items.SetAssetID(ctx, GID, item.ID, repo.AssetID(highest)) + if err != nil { + return 0, err + } + + finished++ + } + + return finished, nil +} func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]string) (int, error) { loaded := []csvRow{} diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 71619fa..e983abf 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -2,6 +2,9 @@ package repo import ( "context" + "fmt" + "strconv" + "strings" "time" "github.com/google/uuid" @@ -18,6 +21,30 @@ type ItemsRepository struct { db *ent.Client } +type AssetID int + +func (aid AssetID) MarshalJSON() ([]byte, error) { + str := fmt.Sprintf("%d", aid) + + for len(str) < 6 { + str = "0" + str + } + + return []byte(fmt.Sprintf(`"%s"`, str)), nil +} + +func (aid *AssetID) UnmarshalJSON(data []byte) error { + str := string(strings.Replace(string(data), `"`, "", -1)) + aidInt, err := strconv.Atoi(str) + if err != nil { + return err + } + + *aid = AssetID(aidInt) + return nil + +} + type ( ItemQuery struct { Page int @@ -52,6 +79,7 @@ type ( ItemUpdate struct { ParentID uuid.UUID `json:"parentId" extensions:"x-nullable,x-omitempty"` ID uuid.UUID `json:"id"` + AssetID AssetID `json:"assetId"` Name string `json:"name"` Description string `json:"description"` Quantity int `json:"quantity"` @@ -107,6 +135,7 @@ type ( ItemOut struct { Parent *ItemSummary `json:"parent,omitempty" extensions:"x-nullable,x-omitempty"` ItemSummary + AssetID AssetID `json:"assetId,string"` SerialNumber string `json:"serialNumber"` ModelNumber string `json:"modelNumber"` @@ -215,6 +244,7 @@ func mapItemOut(item *ent.Item) ItemOut { return ItemOut{ Parent: parent, + AssetID: AssetID(item.AssetID), ItemSummary: mapItemSummary(item), LifetimeWarranty: item.LifetimeWarranty, WarrantyExpires: item.WarrantyExpires, @@ -359,6 +389,42 @@ func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemSumm All(ctx)) } +func (e *ItemsRepository) GetAllZeroAssetID(ctx context.Context, GID uuid.UUID) ([]ItemSummary, error) { + q := e.db.Item.Query().Where( + item.HasGroupWith(group.ID(GID)), + item.AssetID(0), + ).Order( + ent.Asc(item.FieldCreatedAt), + ) + + return mapItemsSummaryErr(q.All(ctx)) +} + +func (e *ItemsRepository) GetHighestAssetID(ctx context.Context, GID uuid.UUID) (AssetID, error) { + q := e.db.Item.Query().Where( + item.HasGroupWith(group.ID(GID)), + ).Order( + ent.Desc(item.FieldAssetID), + ).Limit(1) + + result, err := q.First(ctx) + if err != nil { + return 0, err + } + + return AssetID(result.AssetID), nil +} + +func (e *ItemsRepository) SetAssetID(ctx context.Context, GID uuid.UUID, ID uuid.UUID, assetID AssetID) error { + q := e.db.Item.Update().Where( + item.HasGroupWith(group.ID(GID)), + item.ID(ID), + ) + + _, err := q.SetAssetID(int(assetID)).Save(ctx) + return err +} + func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) { q := e.db.Item.Create(). SetImportRef(data.ImportRef). @@ -414,7 +480,8 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data SetInsured(data.Insured). SetWarrantyExpires(data.WarrantyExpires). SetWarrantyDetails(data.WarrantyDetails). - SetQuantity(data.Quantity) + SetQuantity(data.Quantity). + SetAssetID(int(data.AssetID)) currentLabels, err := e.db.Item.Query().Where(item.ID(data.ID)).QueryLabel().All(ctx) if err != nil { diff --git a/backend/internal/data/repo/repo_items_test.go b/backend/internal/data/repo/repo_items_test.go index 4b958b0..bc9c91d 100644 --- a/backend/internal/data/repo/repo_items_test.go +++ b/backend/internal/data/repo/repo_items_test.go @@ -2,6 +2,7 @@ package repo import ( "context" + "encoding/json" "testing" "time" @@ -9,6 +10,33 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAssetID_UnmarshalJSON(t *testing.T) { + rawjson := `{"aid":"000123"}` + + st := struct { + AID AssetID `json:"aid"` + }{ + AID: AssetID(0), + } + + err := json.Unmarshal([]byte(rawjson), &st) + assert.NoError(t, err) + assert.Equal(t, AssetID(123), st.AID) +} + +func TestAssetID_MarshalJSON(t *testing.T) { + st := struct { + AID AssetID `json:"aid"` + }{ + AID: AssetID(123), + } + + b, err := json.Marshal(st) + + assert.NoError(t, err) + assert.JSONEq(t, `{"aid":"000123"}`, string(b)) +} + func itemFactory() ItemCreate { return ItemCreate{ Name: fk.Str(10), diff --git a/frontend/lib/api/classes/actions.ts b/frontend/lib/api/classes/actions.ts new file mode 100644 index 0000000..be892b3 --- /dev/null +++ b/frontend/lib/api/classes/actions.ts @@ -0,0 +1,10 @@ +import { BaseAPI, route } from "../base"; +import { EnsureAssetIDResult } from "../types/data-contracts"; + +export class ActionsAPI extends BaseAPI { + ensureAssetIDs() { + return this.http.post({ + url: route("/actions/ensure-asset-ids"), + }); + } +} diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 7fd055c..9829f14 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -71,6 +71,9 @@ export interface ItemField { export interface ItemOut { archived: boolean; + + /** @example 0 */ + assetId: string; attachments: ItemAttachment[]; children: ItemSummary[]; createdAt: Date; @@ -131,6 +134,7 @@ export interface ItemSummary { export interface ItemUpdate { archived: boolean; + assetId: string; description: string; fields: ItemField[]; id: string; @@ -300,6 +304,10 @@ export interface ChangePassword { new: string; } +export interface EnsureAssetIDResult { + completed: number; +} + export interface GroupInvitation { expiresAt: Date; token: string; diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index d714bef..5c4940f 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -4,6 +4,7 @@ import { LabelsApi } from "./classes/labels"; import { LocationsApi } from "./classes/locations"; import { GroupApi } from "./classes/group"; import { UserApi } from "./classes/users"; +import { ActionsAPI } from "./classes/actions"; import { Requests } from "~~/lib/requests"; export class UserClient extends BaseAPI { @@ -12,6 +13,7 @@ export class UserClient extends BaseAPI { items: ItemsApi; group: GroupApi; user: UserApi; + actions: ActionsAPI; constructor(requests: Requests) { super(requests); @@ -21,6 +23,7 @@ export class UserClient extends BaseAPI { this.items = new ItemsApi(requests); this.group = new GroupApi(requests); this.user = new UserApi(requests); + this.actions = new ActionsAPI(requests); Object.freeze(this); } diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 03ddfc5..c92508e 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -100,6 +100,10 @@ name: "Notes", text: item.value?.notes, }, + { + name: "Asset ID", + text: item.value?.assetId, + }, ...item.value.fields.map(field => { /** * Support Special URL Syntax diff --git a/frontend/pages/profile.vue b/frontend/pages/profile.vue index 5abb0f0..f2f32fe 100644 --- a/frontend/pages/profile.vue +++ b/frontend/pages/profile.vue @@ -163,6 +163,25 @@ passwordChange.current = ""; passwordChange.loading = false; } + + async function ensureAssetIDs() { + const { isCanceled } = await confirm.open( + "Are you sure you want to ensure all assets have an ID? This will take a while and cannot be undone." + ); + + if (isCanceled) { + return; + } + + const result = await api.actions.ensureAssetIDs(); + + if (result.error) { + notify.error("Failed to ensure asset IDs."); + return; + } + + notify.success(`${result.data.completed} assets have been updated.`); + }