diff --git a/backend/app/api/v1/v1_ctrl_items.go b/backend/app/api/v1/v1_ctrl_items.go index bbf9a63..359ff31 100644 --- a/backend/app/api/v1/v1_ctrl_items.go +++ b/backend/app/api/v1/v1_ctrl_items.go @@ -234,7 +234,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { return } - ctx := services.NewServiceContext(r.Context()) + ctx := services.NewContext(r.Context()) item, err := ctrl.svc.Items.AttachmentAdd( ctx, diff --git a/backend/app/api/v1/v1_ctrl_items_attachments.go b/backend/app/api/v1/v1_ctrl_items_attachments.go index 700aa44..b3b2d10 100644 --- a/backend/app/api/v1/v1_ctrl_items_attachments.go +++ b/backend/app/api/v1/v1_ctrl_items_attachments.go @@ -91,7 +91,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r return } - ctx := services.NewServiceContext(r.Context()) + ctx := services.NewContext(r.Context()) switch r.Method { @@ -99,9 +99,27 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r case http.MethodGet: token, err := ctrl.svc.Items.AttachmentToken(ctx, uid, attachmentId) if err != nil { - log.Err(err).Msg("failed to get attachment") - server.RespondServerError(w) - return + switch err { + case services.ErrNotFound: + log.Err(err). + Str("id", attachmentId.String()). + Msg("failed to find attachment with id") + + server.RespondError(w, http.StatusNotFound, err) + + case services.ErrFileNotFound: + log.Err(err). + Str("id", attachmentId.String()). + Msg("failed to find file path for attachment with id") + log.Warn().Msg("attachment with no file path removed from database") + + server.RespondError(w, http.StatusNotFound, err) + + default: + log.Err(err).Msg("failed to get attachment") + server.RespondServerError(w) + return + } } server.Respond(w, http.StatusOK, types.ItemAttachmentToken{Token: token}) diff --git a/backend/internal/services/contexts.go b/backend/internal/services/contexts.go index c85c50e..94a2168 100644 --- a/backend/internal/services/contexts.go +++ b/backend/internal/services/contexts.go @@ -16,7 +16,7 @@ var ( ContextUserToken = &contextKeys{name: "UserToken"} ) -type ServiceContext struct { +type Context struct { context.Context // UID is a unique identifier for the acting user. @@ -29,11 +29,11 @@ type ServiceContext struct { User *types.UserOut } -// UseServiceCtx is a helper function that returns the service context from the context. +// NewContext is a helper function that returns the service context from the context. // This extracts the users from the context and embeds it into the ServiceContext struct -func NewServiceContext(ctx context.Context) ServiceContext { +func NewContext(ctx context.Context) Context { user := UseUserCtx(ctx) - return ServiceContext{ + return Context{ Context: ctx, UID: user.ID, GID: user.GroupID, diff --git a/backend/internal/services/service_items.go b/backend/internal/services/service_items.go index dcd721f..fc76838 100644 --- a/backend/internal/services/service_items.go +++ b/backend/internal/services/service_items.go @@ -13,7 +13,8 @@ import ( ) var ( - ErrNotFound = errors.New("not found") + ErrNotFound = errors.New("not found") + ErrFileNotFound = errors.New("file not found") ) type ItemService struct { diff --git a/backend/internal/services/service_items_attachments.go b/backend/internal/services/service_items_attachments.go index ea8d034..37b79d9 100644 --- a/backend/internal/services/service_items_attachments.go +++ b/backend/internal/services/service_items_attachments.go @@ -40,7 +40,7 @@ func (at attachmentTokens) Delete(token string) { delete(at, token) } -func (svc *ItemService) AttachmentToken(ctx ServiceContext, itemId, attachmentId uuid.UUID) (string, error) { +func (svc *ItemService) AttachmentToken(ctx Context, itemId, attachmentId uuid.UUID) (string, error) { item, err := svc.repo.Items.GetOne(ctx, itemId) if err != nil { return "", err @@ -51,6 +51,17 @@ func (svc *ItemService) AttachmentToken(ctx ServiceContext, itemId, attachmentId token := hasher.GenerateToken() + // Ensure that the file exists + attachment, err := svc.repo.Attachments.Get(ctx, attachmentId) + if err != nil { + return "", err + } + + if _, err := os.Stat(attachment.Edges.Document.Path); os.IsNotExist(err) { + svc.AttachmentDelete(ctx, ctx.GID, itemId, attachmentId) + return "", ErrNotFound + } + svc.at.Add(token.Raw, attachmentId) return token.Raw, nil @@ -77,7 +88,7 @@ func (svc *ItemService) AttachmentPath(ctx context.Context, token string) (strin return attachment.Edges.Document.Path, nil } -func (svc *ItemService) AttachmentUpdate(ctx ServiceContext, itemId uuid.UUID, data *types.ItemAttachmentUpdate) (*types.ItemOut, error) { +func (svc *ItemService) AttachmentUpdate(ctx Context, itemId uuid.UUID, data *types.ItemAttachmentUpdate) (*types.ItemOut, error) { // Update Properties attachment, err := svc.repo.Attachments.Update(ctx, data.ID, attachment.Type(data.Type)) if err != nil { @@ -110,7 +121,7 @@ func (svc *ItemService) AttachmentUpdate(ctx ServiceContext, itemId uuid.UUID, d // AttachmentAdd adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment // Table and Items table. The file provided via the reader is stored on the file system based on the provided // relative path during construction of the service. -func (svc *ItemService) AttachmentAdd(ctx ServiceContext, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (*types.ItemOut, error) { +func (svc *ItemService) AttachmentAdd(ctx Context, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (*types.ItemOut, error) { // Get the Item item, err := svc.repo.Items.GetOne(ctx, itemId) if err != nil { diff --git a/backend/internal/services/service_items_test.go b/backend/internal/services/service_items_test.go index c6235b1..0b7a787 100644 --- a/backend/internal/services/service_items_test.go +++ b/backend/internal/services/service_items_test.go @@ -125,7 +125,7 @@ func TestItemService_AddAttachment(t *testing.T) { reader := strings.NewReader(contents) // Setup - afterAttachment, err := svc.AttachmentAdd(ServiceContext{Context: context.Background(), GID: tGroup.ID}, itm.ID, "testfile.txt", "attachment", reader) + afterAttachment, err := svc.AttachmentAdd(Context{Context: context.Background(), GID: tGroup.ID}, itm.ID, "testfile.txt", "attachment", reader) assert.NoError(t, err) assert.NotNil(t, afterAttachment) diff --git a/frontend/components/Form/Select.vue b/frontend/components/Form/Select.vue index bfdc9ff..27e3683 100644 --- a/frontend/components/Form/Select.vue +++ b/frontend/components/Form/Select.vue @@ -25,7 +25,7 @@ }, modelValue: { // eslint-disable-next-line @typescript-eslint/no-explicit-any - type: Object as any, + type: [Object, String, Boolean] as any, default: null, }, items: { @@ -37,18 +37,47 @@ type: String, default: "name", }, + value: { + type: String, + default: null, + required: false, + }, selectFirst: { type: Boolean, default: false, }, }); - watchOnce( - () => props.items, - () => { - if (props.selectFirst && props.items.length > 0) { + function syncSelect() { + if (!props.modelValue) { + if (props.selectFirst) { selectedIdx.value = 0; } + return; + } + // Check if we're already synced + if (props.value) { + if (props.modelValue[props.value] === props.items[selectedIdx.value][props.value]) { + return; + } + } else if (props.modelValue === props.items[selectedIdx.value]) { + return; + } + + const idx = props.items.findIndex(item => { + if (props.value) { + return item[props.value] === props.modelValue; + } + return item === props.modelValue; + }); + + selectedIdx.value = idx; + } + + watch( + () => props.modelValue, + () => { + syncSelect(); } ); @@ -56,6 +85,10 @@ watch( () => selectedIdx.value, () => { + if (props.value) { + emit("update:modelValue", props.items[selectedIdx.value][props.value]); + return; + } emit("update:modelValue", props.items[selectedIdx.value]); } ); diff --git a/frontend/lib/api/__test__/user/items.test.ts b/frontend/lib/api/__test__/user/items.test.ts new file mode 100644 index 0000000..75ca5cf --- /dev/null +++ b/frontend/lib/api/__test__/user/items.test.ts @@ -0,0 +1,61 @@ +import { describe, test, expect } from "vitest"; +import { LocationOut } from "../../types/data-contracts"; +import { AttachmentTypes } from "../../types/non-generated"; +import { UserApi } from "../../user"; +import { sharedUserClient } from "../test-utils"; + +describe("user should be able to create an item and add an attachment", () => { + let increment = 0; + /** + * useLocatio sets up a location resource for testing, and returns a function + * that can be used to delete the location from the backend server. + */ + async function useLocation(api: UserApi): Promise<[LocationOut, () => Promise]> { + const { response, data } = await api.locations.create({ + name: `__test__.location.name_${increment}`, + description: `__test__.location.description_${increment}`, + }); + expect(response.status).toBe(201); + increment++; + + const cleanup = async () => { + const { response } = await api.locations.delete(data.id); + expect(response.status).toBe(204); + }; + + return [data, cleanup]; + } + + test("user should be able to create an item and add an attachment", async () => { + const api = await sharedUserClient(); + const [location, cleanup] = await useLocation(api); + + const { response, data: item } = await api.items.create({ + name: "test-item", + labelIds: [], + description: "test-description", + locationId: location.id, + }); + expect(response.status).toBe(201); + + // Add attachment + { + const testFile = new Blob(["test"], { type: "text/plain" }); + const { response } = await api.items.addAttachment(item.id, testFile, "test.txt", AttachmentTypes.Attachment); + expect(response.status).toBe(201); + } + + // Get Attachment + const { response: itmResp, data } = await api.items.get(item.id); + expect(itmResp.status).toBe(200); + + expect(data.attachments).toHaveLength(1); + expect(data.attachments[0].document.title).toBe("test.txt"); + + const resp = await api.items.deleteAttachment(data.id, data.attachments[0].id); + expect(resp.response.status).toBe(204); + + api.items.delete(item.id); + await cleanup(); + }); +}); diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index 8257eaf..e98c785 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -1,6 +1,13 @@ import { BaseAPI, route } from "../base"; import { parseDate } from "../base/base-api"; -import { ItemAttachmentToken, ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts"; +import { + ItemAttachmentToken, + ItemAttachmentUpdate, + ItemCreate, + ItemOut, + ItemSummary, + ItemUpdate, +} from "../types/data-contracts"; import { AttachmentTypes } from "../types/non-generated"; import { Results } from "./types"; @@ -76,7 +83,14 @@ export class ItemsApi extends BaseAPI { return route(`/items/${id}/attachments/download`, { token: payload.data.token }); } - deleteAttachment(id: string, attachmentId: string) { - return this.http.delete({ url: route(`/items/${id}/attachments/${attachmentId}`) }); + async deleteAttachment(id: string, attachmentId: string) { + return await this.http.delete({ url: route(`/items/${id}/attachments/${attachmentId}`) }); + } + + async updateAttachment(id: string, attachmentId: string, data: ItemAttachmentUpdate) { + return await this.http.put({ + url: route(`/items/${id}/attachments/${attachmentId}`), + body: data, + }); } } diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 1e8013b..d6c1d98 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -53,6 +53,11 @@ export interface ItemAttachmentToken { token: string; } +export interface ItemAttachmentUpdate { + title: string; + type: string; +} + export interface ItemCreate { description: string; labelIds: string[]; diff --git a/frontend/lib/api/types/non-generated.ts b/frontend/lib/api/types/non-generated.ts new file mode 100644 index 0000000..6928784 --- /dev/null +++ b/frontend/lib/api/types/non-generated.ts @@ -0,0 +1,6 @@ +export enum AttachmentTypes { + Photo = "photo", + Manual = "manual", + Warranty = "warranty", + Attachment = "attachment", +} diff --git a/frontend/pages/home.vue b/frontend/pages/home.vue index ae6d8c1..f39eb6b 100644 --- a/frontend/pages/home.vue +++ b/frontend/pages/home.vue @@ -121,7 +121,7 @@

Welcome back,

-

Hayden Kotelman

+

Username

User

diff --git a/frontend/pages/item/[id]/edit.vue b/frontend/pages/item/[id]/edit.vue index afc5d88..59ab0a4 100644 --- a/frontend/pages/item/[id]/edit.vue +++ b/frontend/pages/item/[id]/edit.vue @@ -1,7 +1,9 @@