diff --git a/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx b/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx index 3ff039d7ce..f4fa92d09d 100644 --- a/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx +++ b/apps/webapp/app/components/assets/assets-index/advanced-asset-columns.tsx @@ -219,6 +219,9 @@ export function AdvancedIndexColumn({ /> ); + case "qrLabelApplied": + return ; + case "status": return ( + {appliedAt ? : } + + ); +} + /** * Displays a truncated plain-text preview of the asset description and shows * the full markdown-rendered content inside a tooltip on hover. diff --git a/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.test.ts b/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.test.ts index f712d33f03..851e551f83 100644 --- a/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.test.ts +++ b/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.test.ts @@ -5,6 +5,19 @@ import type { Column } from "~/modules/asset-index-settings/helpers"; import { getUIFieldType } from "./helpers"; describe("getUIFieldType", () => { + it("treats qrLabelApplied as a boolean field", () => { + const qrLabelColumn = { + name: "qrLabelApplied", + visible: true, + position: 0, + } as unknown as Column; + + expect(getUIFieldType({ column: qrLabelColumn })).toBe("boolean"); + expect(getUIFieldType({ column: qrLabelColumn, friendlyName: true })).toBe( + "Yes/No" + ); + }); + it("treats updatedAt as a date field", () => { const updatedColumn = { name: "updatedAt", diff --git a/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.ts b/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.ts index a2675189d4..0ec0d41ec1 100644 --- a/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.ts +++ b/apps/webapp/app/components/assets/assets-index/advanced-filters/helpers.ts @@ -75,6 +75,7 @@ export function getUIFieldType({ fieldType = "number"; break; case "availableToBook": + case "qrLabelApplied": fieldType = "boolean"; break; case "createdAt": diff --git a/apps/webapp/app/modules/asset-index-settings/helpers.test.ts b/apps/webapp/app/modules/asset-index-settings/helpers.test.ts index 9a872a2935..80cd21d350 100644 --- a/apps/webapp/app/modules/asset-index-settings/helpers.test.ts +++ b/apps/webapp/app/modules/asset-index-settings/helpers.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it } from "vitest"; -import { columnsLabelsMap, defaultFields, fixedFields } from "./helpers"; +import { + appendMissingDefaultFields, + columnsLabelsMap, + defaultFields, + fixedFields, + type Column, +} from "./helpers"; describe("asset index column metadata", () => { + it("registers QR label assignment as a fixed field with a user-facing label", () => { + expect(fixedFields).toContain("qrLabelApplied"); + expect(columnsLabelsMap.qrLabelApplied).toBe("Has ID Assigned"); + }); + it("registers last updated as a fixed field with a label", () => { expect(fixedFields).toContain("updatedAt"); expect(columnsLabelsMap.updatedAt).toBe("Updated at"); @@ -15,9 +26,13 @@ describe("asset index column metadata", () => { const updatedColumn = defaultFields.find( (column) => column.name === "updatedAt" ); + const qrLabelColumn = defaultFields.find( + (column) => column.name === "qrLabelApplied" + ); expect(createdColumn?.visible).toBe(true); expect(updatedColumn).toEqual(expect.objectContaining({ visible: true })); + expect(qrLabelColumn).toEqual(expect.objectContaining({ visible: false })); expect(updatedColumn && createdColumn).toBeTruthy(); expect(updatedColumn?.position).toBe((createdColumn?.position ?? -1) + 1); @@ -26,3 +41,25 @@ describe("asset index column metadata", () => { expect(positions).toEqual(expectedPositions); }); }); + +describe("appendMissingDefaultFields", () => { + it("preserves legacy positions and appends new defaults without collisions", () => { + const legacyColumns: Column[] = defaultFields + .filter(({ name }) => name !== "qrLabelApplied") + .map((column, position) => ({ ...column, position })); + + const result = appendMissingDefaultFields(legacyColumns, [ + "qrLabelApplied", + ]); + + expect(result.slice(0, legacyColumns.length)).toEqual(legacyColumns); + expect(result.at(-1)).toEqual({ + name: "qrLabelApplied", + visible: false, + position: legacyColumns.length, + }); + expect(new Set(result.map(({ position }) => position)).size).toBe( + result.length + ); + }); +}); diff --git a/apps/webapp/app/modules/asset-index-settings/helpers.ts b/apps/webapp/app/modules/asset-index-settings/helpers.ts index 2693feecfe..f9ab7d9c16 100644 --- a/apps/webapp/app/modules/asset-index-settings/helpers.ts +++ b/apps/webapp/app/modules/asset-index-settings/helpers.ts @@ -12,6 +12,7 @@ export const fixedFields = [ "id", "sequentialId", "qrId", + "qrLabelApplied", "status", "description", "valuation", @@ -67,6 +68,7 @@ export const columnsLabelsMap: { [key in ColumnLabelKey]: string } = { id: "ID", sequentialId: "Asset ID", qrId: "QR ID", + qrLabelApplied: "Has ID Assigned", name: "Name", status: "Status", description: "Description", @@ -98,25 +100,40 @@ export const defaultFields: Column[] = [ { name: "id", visible: false, position: 0 }, { name: "sequentialId", visible: true, position: 1 }, { name: "qrId", visible: true, position: 2 }, - { name: "status", visible: true, position: 3 }, - { name: "description", visible: true, position: 4 }, - { name: "valuation", visible: true, position: 5 }, - { name: "availableToBook", visible: true, position: 6 }, - { name: "createdAt", visible: true, position: 7 }, - { name: "updatedAt", visible: true, position: 8 }, - { name: "category", visible: true, position: 9 }, - { name: "tags", visible: true, position: 10 }, - { name: "location", visible: true, position: 11 }, - { name: "kit", visible: true, position: 12 }, - { name: "custody", visible: true, position: 13 }, - { name: "upcomingReminder", visible: true, position: 14 }, - { name: "actions", visible: true, position: 15 }, - { name: "upcomingBookings", visible: true, position: 16 }, - { name: "quantity", visible: false, position: 17 }, - { name: "type", visible: false, position: 18 }, - { name: "assetModel", visible: false, position: 19 }, + { name: "qrLabelApplied", visible: false, position: 3 }, + { name: "status", visible: true, position: 4 }, + { name: "description", visible: true, position: 5 }, + { name: "valuation", visible: true, position: 6 }, + { name: "availableToBook", visible: true, position: 7 }, + { name: "createdAt", visible: true, position: 8 }, + { name: "updatedAt", visible: true, position: 9 }, + { name: "category", visible: true, position: 10 }, + { name: "tags", visible: true, position: 11 }, + { name: "location", visible: true, position: 12 }, + { name: "kit", visible: true, position: 13 }, + { name: "custody", visible: true, position: 14 }, + { name: "upcomingReminder", visible: true, position: 15 }, + { name: "actions", visible: true, position: 16 }, + { name: "upcomingBookings", visible: true, position: 17 }, + { name: "quantity", visible: false, position: 18 }, + { name: "type", visible: false, position: 19 }, + { name: "assetModel", visible: false, position: 20 }, ]; +export function appendMissingDefaultFields( + columns: Column[], + missingFields: ColumnLabelKey[] +) { + let nextPosition = + Math.max(-1, ...columns.map((column) => column.position)) + 1; + + const fieldsToAdd = defaultFields + .filter((field) => missingFields.includes(field.name)) + .map((field) => ({ ...field, position: nextPosition++ })); + + return [...columns, ...fieldsToAdd]; +} + // Generate barcode columns when barcodes are enabled export const generateBarcodeColumns = (): Column[] => barcodeFields.map((field, index) => ({ diff --git a/apps/webapp/app/modules/asset-index-settings/service.server.ts b/apps/webapp/app/modules/asset-index-settings/service.server.ts index 0af24e8df4..6ea48cee0a 100644 --- a/apps/webapp/app/modules/asset-index-settings/service.server.ts +++ b/apps/webapp/app/modules/asset-index-settings/service.server.ts @@ -10,6 +10,7 @@ import { db } from "~/database/db.server"; import { ShelfError, type ErrorLabel } from "~/utils/error"; import type { Column, ColumnLabelKey } from "./helpers"; import { + appendMissingDefaultFields, barcodeFields, defaultFields, fixedFields, @@ -460,10 +461,10 @@ async function validateColumns({ // If default fields are missing, add them from our static defaults if (missingDefaultFields.length > 0) { - const fieldsToAdd = defaultFields.filter((field) => - missingDefaultFields.includes(field.name) + updatedColumns = appendMissingDefaultFields( + updatedColumns, + missingDefaultFields ); - updatedColumns = [...updatedColumns, ...fieldsToAdd]; needsUpdate = true; } diff --git a/apps/webapp/app/modules/asset/field-type-mapping.ts b/apps/webapp/app/modules/asset/field-type-mapping.ts index 28eed8ea31..cd9ffb5eb3 100644 --- a/apps/webapp/app/modules/asset/field-type-mapping.ts +++ b/apps/webapp/app/modules/asset/field-type-mapping.ts @@ -48,6 +48,7 @@ export function getQueryFieldType(fieldName: string): QueryFieldType { case "quantity": return "number"; case "availableToBook": + case "qrLabelApplied": return "boolean"; case "createdAt": case "updatedAt": diff --git a/apps/webapp/app/modules/asset/query.server.test.ts b/apps/webapp/app/modules/asset/query.server.test.ts index 25d730b024..471eb99c51 100644 --- a/apps/webapp/app/modules/asset/query.server.test.ts +++ b/apps/webapp/app/modules/asset/query.server.test.ts @@ -233,6 +233,13 @@ describe("parseSortingOptions", () => { expect(orderByClause).toContain("LPAD(SPLIT_PART"); }); + it("sorts QR label assignment by the applied timestamp", () => { + const { orderByClause } = parseSortingOptions(["qrLabelApplied:desc"]); + expect(orderByClause).toContain( + '"assetQrLabelAppliedAt" desc NULLS LAST' + ); + }); + it("uses custody jsonb path for custody", () => { const { orderByClause } = parseSortingOptions(["custody:desc"]); // Regression (custody-sort no-op): the `custody` column is a jsonb @@ -310,6 +317,20 @@ function getSqlString(sql: ReturnType): string { describe("generateWhereClause - special filter values", () => { const orgId = "test-org-id"; + it("filters Has ID Assigned using the QR label timestamp", () => { + const filter: Filter = { + name: "qrLabelApplied", + type: "boolean", + operator: "is", + value: true, + }; + + const result = generateWhereClause(orgId, null, [filter]); + const sql = getSqlString(result); + + expect(sql).toContain('a."qrLabelAppliedAt" IS NOT NULL'); + }); + describe("custody filter with special values", () => { it("handles 'in-custody' with is operator (includes active bookings)", () => { const filter: Filter = { @@ -1137,6 +1158,18 @@ describe("buildAdvancedAssetsQuery", () => { ); }); + it("selects the QR label timestamp in the cheap phase for advanced sorting", () => { + const sql = getQuerySqlString(build({ sortBy: ["qrLabelApplied:desc"] })); + const cheapPhase = sql.slice(0, sql.indexOf("sorted_asset_query")); + + expect(cheapPhase).toContain( + 'a."qrLabelAppliedAt" AS "assetQrLabelAppliedAt"' + ); + expect(sql).toContain( + 'ROW_NUMBER() OVER (ORDER BY "assetQrLabelAppliedAt" desc NULLS LAST, "assetId" ASC)' + ); + }); + it("keeps Category/Location joins for text search even without a name sort", () => { // The search predicate references c.name / l.name in the WHERE, so a search // must resolve those joins (independent of any sort). `c.name ILIKE` only diff --git a/apps/webapp/app/modules/asset/query.server.ts b/apps/webapp/app/modules/asset/query.server.ts index 619bafb0e8..ad76494b7c 100644 --- a/apps/webapp/app/modules/asset/query.server.ts +++ b/apps/webapp/app/modules/asset/query.server.ts @@ -429,6 +429,10 @@ function addNumberFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql { } function addBooleanFilter(whereClause: Prisma.Sql, filter: Filter): Prisma.Sql { + if (filter.name === "qrLabelApplied") { + return Prisma.sql`${whereClause} AND (a."qrLabelAppliedAt" IS NOT NULL) = ${filter.value}`; + } + return Prisma.sql`${whereClause} AND a."${Prisma.raw(filter.name)}" = ${ filter.value }`; @@ -1446,7 +1450,8 @@ type DirectAssetField = | "updatedAt" | "availableToBook" | "type" - | "quantity"; + | "quantity" + | "qrLabelApplied"; const directAssetFields: Record = { id: "assetId", @@ -1460,6 +1465,7 @@ const directAssetFields: Record = { availableToBook: "assetAvailableToBook", type: "assetType", quantity: "assetQuantity", + qrLabelApplied: "assetQrLabelAppliedAt", }; /** @@ -1624,6 +1630,8 @@ export function parseSortingOptions(sortBy: string[]): { orderByParts.push( `("assetValue" * "assetQuantity") ${field.direction}` ); + } else if (field.name === "qrLabelApplied") { + orderByParts.push(`"${columnName}" ${field.direction} NULLS LAST`); } else { // Use regular sorting for non-text columns orderByParts.push(`"${columnName}" ${field.direction}`); @@ -2021,6 +2029,8 @@ export const assetQueryFragment = (options: AssetQueryOptions = {}) => { a.title AS "assetTitle", a.description AS "assetDescription", a."sequentialId" AS "assetSequentialId", + a."qrLabelAppliedAt" AS "assetQrLabelAppliedAt", + (a."qrLabelAppliedAt" IS NOT NULL) AS "assetQrLabelApplied", a."createdAt" AS "assetCreatedAt", a."updatedAt" AS "assetUpdatedAt", a."userId" AS "assetUserId", @@ -2331,6 +2341,8 @@ export const assetReturnFragment = (options: AssetReturnOptions = {}) => { 'id', aq."assetId", 'sequentialId', aq."assetSequentialId", 'qrId', aq."qrId", + 'qrLabelAppliedAt', aq."assetQrLabelAppliedAt", + 'qrLabelApplied', aq."assetQrLabelApplied", 'title', aq."assetTitle", 'description', aq."assetDescription", 'createdAt', aq."assetCreatedAt", @@ -2753,6 +2765,7 @@ export function buildAdvancedAssetsQuery({ a.status AS "assetStatus", a.type AS "assetType", a.description AS "assetDescription", + a."qrLabelAppliedAt" AS "assetQrLabelAppliedAt", a."availableToBook" AS "assetAvailableToBook"${kitNameSelect}${categoryNameSelect}${assetModelNameSelect}${locationNameSelect}${qrIdSortSelect}${custodySortSelect}${barcodeSortSelects}${customFieldSelect} ${baseJoins} ${custodyJoins} diff --git a/apps/webapp/app/modules/asset/service.server.test.ts b/apps/webapp/app/modules/asset/service.server.test.ts index 38423f9824..bbbf8e6623 100644 --- a/apps/webapp/app/modules/asset/service.server.test.ts +++ b/apps/webapp/app/modules/asset/service.server.test.ts @@ -36,6 +36,7 @@ import { relinkAssetQrCode, renderBulkAssetTitle, updateAsset, + updateAssetQrCode, uploadDuplicateAssetMainImage, } from "./service.server"; @@ -57,6 +58,7 @@ vitest.mock("~/database/db.server", () => ({ findMany: vitest.fn().mockResolvedValue([]), findUnique: vitest.fn().mockResolvedValue(null), count: vitest.fn().mockResolvedValue(0), + create: vitest.fn().mockResolvedValue({ id: "asset-1" }), update: vitest.fn().mockResolvedValue({}), updateMany: vitest.fn().mockResolvedValue({ count: 0 }), deleteMany: vitest.fn().mockResolvedValue({ count: 0 }), @@ -75,8 +77,12 @@ vitest.mock("~/database/db.server", () => ({ findMany: vitest.fn().mockResolvedValue([]), }, qr: { + findUnique: vitest.fn().mockResolvedValue(null), update: vitest.fn().mockResolvedValue({}), }, + note: { + create: vitest.fn().mockResolvedValue({}), + }, // why: checkOutQuantity finds/creates/increments the operator-allocated // custody row; releaseQuantity finds it then deletes or decrements by // primary key. Both use `findFirst` (not `findUnique`) because the @@ -263,21 +269,177 @@ vitest.mock("./sequential-id.server", () => ({ getNextSequentialId: vitest.fn().mockResolvedValue("TST-0001"), })); +describe("createAsset", () => { + beforeEach(() => { + vitest.clearAllMocks(); + (db.asset.create as ReturnType).mockResolvedValue({ + id: "asset-1", + }); + }); + + it("does not mark the ID as assigned when creating an asset with an auto-generated QR", async () => { + await createAsset({ + title: "Camera Kit", + description: null, + categoryId: null, + userId: "user-1", + organizationId: "org-1", + valuation: null, + }); + + expect(db.asset.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + qrCodes: { + create: [ + expect.objectContaining({ + organization: { connect: { id: "org-1" } }, + user: { connect: { id: "user-1" } }, + }), + ], + }, + qrLabelAppliedAt: undefined, + }), + }) + ); + }); + + it("marks the ID as assigned when creating an asset from a linkable QR", async () => { + const qr = { + id: "qr-1", + organizationId: "org-1", + assetId: null, + kitId: null, + }; + (getQr as ReturnType).mockResolvedValue(qr); + (db.qr.findUnique as ReturnType).mockResolvedValue(qr); + + await createAsset({ + title: "Camera Kit", + description: null, + categoryId: null, + userId: "user-1", + organizationId: "org-1", + qrId: "qr-1", + valuation: null, + }); + + expect(db.asset.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + qrCodes: { connect: { id: "qr-1" } }, + qrLabelAppliedAt: expect.any(Date), + }), + }) + ); + expect(db.$transaction).toHaveBeenCalledWith(expect.any(Function), { + isolationLevel: "Serializable", + }); + }); + + it("claims an unassigned QR inside the asset creation transaction", async () => { + const qr = { + id: "qr-1", + organizationId: null, + assetId: null, + kitId: null, + }; + (getQr as ReturnType).mockResolvedValue(qr); + (db.qr.findUnique as ReturnType).mockResolvedValue(qr); + + await createAsset({ + title: "Camera Kit", + description: null, + categoryId: null, + userId: "user-1", + organizationId: "org-1", + qrId: "qr-1", + valuation: null, + }); + + expect(db.qr.update).toHaveBeenCalledWith({ + where: { id: "qr-1" }, + data: { organizationId: "org-1", userId: "user-1" }, + }); + }); + + it("does not steal a QR assigned after the initial lookup", async () => { + (getQr as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-1", + assetId: null, + kitId: null, + }); + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-1", + assetId: "other-asset", + kitId: null, + }); + + await expect( + createAsset({ + title: "Camera Kit", + description: null, + categoryId: null, + userId: "user-1", + organizationId: "org-1", + qrId: "qr-1", + valuation: null, + }) + ).rejects.toMatchObject({ status: 409, shouldBeCaptured: false }); + + expect(db.asset.create).not.toHaveBeenCalled(); + }); + + it("retries a serialization conflict while creating from an existing QR", async () => { + const qr = { + id: "qr-1", + organizationId: "org-1", + assetId: null, + kitId: null, + }; + (getQr as ReturnType).mockResolvedValue(qr); + (db.qr.findUnique as ReturnType).mockResolvedValue(qr); + (db.$transaction as ReturnType).mockRejectedValueOnce( + Object.assign(new Error("conflict"), { code: "P2034" }) + ); + + await createAsset({ + title: "Camera Kit", + description: null, + categoryId: null, + userId: "user-1", + organizationId: "org-1", + qrId: "qr-1", + valuation: null, + }); + + expect(db.$transaction).toHaveBeenCalledTimes(2); + }); +}); + describe("relinkAssetQrCode (asset)", () => { beforeEach(() => { vitest.clearAllMocks(); + (db.asset.findFirst as ReturnType).mockResolvedValue({ + qrCodes: [], + }); + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-1", + assetId: null, + kitId: null, + }); }); it("throws when QR is already linked to a kit", async () => { - //@ts-expect-error mock setup - getQr.mockResolvedValue({ + (db.qr.findUnique as ReturnType).mockResolvedValue({ id: "qr-1", organizationId: "org-1", assetId: null, kitId: "kit-1", }); - //@ts-expect-error mock setup - db.asset.findFirst.mockResolvedValue({ qrCodes: [] }); await expect( relinkAssetQrCode({ @@ -290,15 +452,9 @@ describe("relinkAssetQrCode (asset)", () => { }); it("relinks when QR is available", async () => { - //@ts-expect-error mock setup - getQr.mockResolvedValue({ - id: "qr-1", - organizationId: "org-1", - assetId: null, - kitId: null, + (db.asset.findFirst as ReturnType).mockResolvedValue({ + qrCodes: [{ id: "old-qr" }], }); - //@ts-expect-error mock setup - db.asset.findFirst.mockResolvedValue({ qrCodes: [{ id: "old-qr" }] }); await relinkAssetQrCode({ qrId: "qr-1", @@ -314,6 +470,72 @@ describe("relinkAssetQrCode (asset)", () => { expect(db.asset.update).toHaveBeenCalledWith({ where: { id: "asset-1", organizationId: "org-1" }, data: { + qrLabelAppliedAt: expect.any(Date), + qrCodes: { + set: [], + connect: { id: "qr-1" }, + }, + }, + }); + expect(db.note.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset: { connect: { id: "asset-1" } }, + user: { connect: { id: "user-1" } }, + type: "UPDATE", + content: expect.stringContaining( + "changed QR code from **old-qr** to **qr-1**" + ), + }), + }); + expect(db.$transaction).toHaveBeenCalledWith(expect.any(Function), { + isolationLevel: "Serializable", + }); + }); + + it("retries a serialization conflict", async () => { + (db.$transaction as ReturnType).mockRejectedValueOnce({ + code: "P2034", + }); + + await relinkAssetQrCode({ + qrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }); + + expect(db.$transaction).toHaveBeenCalledTimes(2); + }); +}); + +describe("updateAssetQrCode", () => { + beforeEach(() => { + vitest.clearAllMocks(); + (db.asset.findFirst as ReturnType).mockResolvedValue({ + id: "asset-1", + }); + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-1", + assetId: null, + kitId: null, + }); + (db.asset.update as ReturnType).mockResolvedValue({}); + }); + + it("marks the ID as assigned when linking a scanned QR to an existing asset", async () => { + await updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }); + + expect(db.asset.update).toHaveBeenCalledOnce(); + expect(db.asset.update).toHaveBeenCalledWith({ + where: { id: "asset-1", organizationId: "org-1" }, + data: { + qrLabelAppliedAt: expect.any(Date), qrCodes: { set: [], connect: { id: "qr-1" }, @@ -321,6 +543,122 @@ describe("relinkAssetQrCode (asset)", () => { }, }); }); + + it("claims an unclaimed QR in the same transaction as the asset link", async () => { + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: null, + assetId: null, + kitId: null, + }); + + await updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }); + + expect(db.qr.update).toHaveBeenCalledWith({ + where: { id: "qr-1" }, + data: { organizationId: "org-1", userId: "user-1" }, + }); + expect(db.asset.update).toHaveBeenCalledOnce(); + }); + + it("retries a serialization conflict", async () => { + (db.$transaction as ReturnType).mockRejectedValueOnce({ + code: "P2034", + }); + + await updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }); + + expect(db.$transaction).toHaveBeenCalledTimes(2); + }); + + it("rejects a QR owned by another organization before changing the asset", async () => { + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-2", + assetId: null, + kitId: null, + }); + + await expect( + updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }) + ).rejects.toBeInstanceOf(ShelfError); + + expect(db.qr.update).not.toHaveBeenCalled(); + expect(db.asset.update).not.toHaveBeenCalled(); + }); + + it("rejects a QR already linked to a different asset", async () => { + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-1", + assetId: "asset-2", + kitId: null, + }); + + await expect( + updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }) + ).rejects.toBeInstanceOf(ShelfError); + + expect(db.asset.update).not.toHaveBeenCalled(); + }); + + it("rejects a QR already linked to a kit", async () => { + (db.qr.findUnique as ReturnType).mockResolvedValue({ + id: "qr-1", + organizationId: "org-1", + assetId: null, + kitId: "kit-1", + }); + + await expect( + updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }) + ).rejects.toBeInstanceOf(ShelfError); + + expect(db.asset.update).not.toHaveBeenCalled(); + }); + + it("rejects a target asset outside the current organization", async () => { + (db.asset.findFirst as ReturnType).mockResolvedValue( + null + ); + + await expect( + updateAssetQrCode({ + newQrId: "qr-1", + assetId: "asset-1", + organizationId: "org-1", + userId: "user-1", + }) + ).rejects.toBeInstanceOf(ShelfError); + + expect(db.qr.update).not.toHaveBeenCalled(); + expect(db.asset.update).not.toHaveBeenCalled(); + }); }); describe("uploadDuplicateAssetMainImage", () => { @@ -568,6 +906,9 @@ describe("refreshExpiredAssetImages", () => { describe("createAsset quantity validation", () => { beforeEach(() => { vitest.clearAllMocks(); + (db.asset.create as ReturnType).mockResolvedValue({ + id: "asset-1", + }); }); it("throws when QUANTITY_TRACKED asset has no quantity", async () => { @@ -606,9 +947,13 @@ describe("createAsset quantity validation", () => { it("does not throw quantity validation for INDIVIDUAL assets", async () => { // This test verifies that INDIVIDUAL assets skip quantity validation. - // The function will proceed past validation but will fail on - // other operations (e.g., sequential ID generation) which is expected. + // The function will proceed past validation but can still fail on + // downstream writes, which is expected. // We assert the thrown error is NOT a quantity validation error. + (db.asset.create as ReturnType).mockRejectedValueOnce( + new Error("create failed") + ); + await expect( createAsset({ title: "Test Laptop", diff --git a/apps/webapp/app/modules/asset/service.server.ts b/apps/webapp/app/modules/asset/service.server.ts index 0aeba5810c..1f9a30a1e4 100644 --- a/apps/webapp/app/modules/asset/service.server.ts +++ b/apps/webapp/app/modules/asset/service.server.ts @@ -1364,6 +1364,8 @@ export async function createAsset({ const maxAttempts = 3; while (attempts < maxAttempts) { + let qrToLink = false; + try { // Generate sequential ID const sequentialId = await getNextSequentialId(organizationId); @@ -1391,12 +1393,15 @@ export async function createAsset({ */ const qr = qrId ? await getQr({ id: qrId }) : null; - const qrCodes = + qrToLink = Boolean( qr && - (qr.organizationId === organizationId || !qr.organizationId) && - qr.assetId === null && - qr.kitId === null - ? { connect: { id: qrId } } + (qr.organizationId === organizationId || !qr.organizationId) && + qr.assetId === null && + qr.kitId === null + ); + const qrCodes = + qrToLink && qr + ? { connect: { id: qr.id } } : { create: [ { @@ -1417,6 +1422,7 @@ export async function createAsset({ sequentialId, // Add the generated sequential ID user, qrCodes, + qrLabelAppliedAt: qrToLink ? new Date() : undefined, valuation, organization, availableToBook, @@ -1579,80 +1585,163 @@ export async function createAsset({ } } - // Use transaction to ensure asset creation and activity event are atomic - const asset = await db.$transaction(async (tx) => { - // SECURITY (cross-org IDOR): prove every form-supplied custom-field id - // belongs to this org before the nested create connects them. Run inside - // the tx so the ownership check shares the write's transaction (no-op - // when there are no custom-field ids). - await assertCustomFieldsBelongToOrg( - { customFieldIds: customFieldIdsToValidate, organizationId }, - tx - ); + // Existing QRs are revalidated and claimed in the same serializable + // transaction as the asset connection. New QR creation has no shared row + // to race over, so it keeps the default transaction isolation level. + const asset = await db.$transaction( + async (tx) => { + if (qrToLink && qr) { + const currentQr = await tx.qr.findUnique({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: unclaimed QRs have no organization to scope by; this transaction revalidates ownership before claiming and linking the row + where: { id: qr.id }, + select: { + id: true, + organizationId: true, + assetId: true, + kitId: true, + }, + }); - // SECURITY (cross-org IDOR): the kitId comes from form/CSV input and is - // connected by the assetKits nested create above with no org scoping of - // its own. Prove it belongs to this org before the write (same pattern - // as the assetModel / custom-field guards). - if (hasKit) { - await assertKitsBelongToOrg({ kitIds: [kitId!], organizationId }, tx); - } + if (!currentQr) { + throw new ShelfError({ + cause: null, + title: "QR code not found", + message: "This code doesn't exist.", + label: "QR", + status: 404, + shouldBeCaptured: false, + }); + } - const created = await tx.asset.create({ - data, - include: { - assetLocations: { include: { location: true } }, - user: true, - custody: true, - }, - }); + if ( + (currentQr.organizationId && + currentQr.organizationId !== organizationId) || + currentQr.assetId !== null || + currentQr.kitId !== null + ) { + throw new ShelfError({ + cause: null, + title: "QR assignment changed", + message: + "This QR code was assigned while the asset was being created. Please try again.", + label: "QR", + status: 409, + shouldBeCaptured: false, + }); + } - // Create the AssetLocation pivot row now that we have the assetId. - // Quantity is type-aware to match the sum-within-total trigger - // semantics: qty-tracked = full pool, INDIVIDUAL = 1. - if (locationId) { - await tx.assetLocation.create({ - data: { - assetId: created.id, - locationId, - organizationId, - quantity: - type === AssetType.QUANTITY_TRACKED && quantity ? quantity : 1, + if (!currentQr.organizationId) { + await tx.qr.update({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: the QR was re-read as unclaimed inside this transaction and is receiving its first organization before it is linked + where: { id: currentQr.id }, + data: { organizationId, userId }, + }); + } + } + + // SECURITY (cross-org IDOR): prove every form-supplied custom-field id + // belongs to this org before the nested create connects them. Run inside + // the tx so the ownership check shares the write's transaction (no-op + // when there are no custom-field ids). + await assertCustomFieldsBelongToOrg( + { customFieldIds: customFieldIdsToValidate, organizationId }, + tx + ); + + // SECURITY (cross-org IDOR): the kitId comes from form/CSV input and is + // connected by the assetKits nested create above with no org scoping of + // its own. Prove it belongs to this org before the write (same pattern + // as the assetModel / custom-field guards). + if (hasKit) { + await assertKitsBelongToOrg( + { kitIds: [kitId!], organizationId }, + tx + ); + } + + const created = await tx.asset.create({ + data, + include: { + assetLocations: { include: { location: true } }, + user: true, + custody: true, }, }); - } - // Activity event must be inside transaction for atomicity - await recordEvent( - { - organizationId, - actorUserId: userId, - action: "ASSET_CREATED", - entityType: "ASSET", - entityId: created.id, - assetId: created.id, - }, - tx - ); - - // Re-read so the returned shape has the pivot we just created - // (the initial create's include came back empty for it). - return locationId - ? tx.asset.findUniqueOrThrow({ - // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: `created.id` is from the `tx.asset.create` above (org-scoped via the create payload's organizationId); re-read of our own just-created row - where: { id: created.id }, - include: { - assetLocations: { include: { location: true } }, - user: true, - custody: true, + // Create the AssetLocation pivot row now that we have the assetId. + // Quantity is type-aware to match the sum-within-total trigger + // semantics: qty-tracked = full pool, INDIVIDUAL = 1. + if (locationId) { + await tx.assetLocation.create({ + data: { + assetId: created.id, + locationId, + organizationId, + quantity: + type === AssetType.QUANTITY_TRACKED && quantity + ? quantity + : 1, }, - }) - : created; - }); + }); + } + + // Activity event must be inside transaction for atomicity + await recordEvent( + { + organizationId, + actorUserId: userId, + action: "ASSET_CREATED", + entityType: "ASSET", + entityId: created.id, + assetId: created.id, + }, + tx + ); + + // Re-read so the returned shape has the pivot we just created + // (the initial create's include came back empty for it). + return locationId + ? tx.asset.findUniqueOrThrow({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: `created.id` is from the `tx.asset.create` above (org-scoped via the create payload's organizationId); re-read of our own just-created row + where: { id: created.id }, + include: { + assetLocations: { include: { location: true } }, + user: true, + custody: true, + }, + }) + : created; + }, + qrToLink + ? { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + : undefined + ); // Successfully created asset, exit the retry loop return asset; } catch (cause) { + if ( + qrToLink && + cause instanceof Error && + "code" in cause && + cause.code === "P2034" + ) { + if (attempts < maxAttempts - 1) { + attempts++; + continue; + } + + throw new ShelfError({ + cause, + title: "Asset creation conflict", + message: + "The asset or QR code changed while it was being created. Please try again.", + label, + status: 409, + shouldBeCaptured: false, + }); + } + // Check for sequential ID unique constraint violation and retry if (cause instanceof Error && "code" in cause && cause.code === "P2002") { const prismaError = cause as any; @@ -5440,55 +5529,151 @@ export async function refreshExpiredAssetImages< }); } +const QR_ASSIGNMENT_MAX_ATTEMPTS = 3; + +function isSerializationConflict(cause: unknown) { + return ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "P2034" + ); +} + +async function runQrAssignmentTransaction(operation: () => Promise) { + for (let attempt = 1; attempt <= QR_ASSIGNMENT_MAX_ATTEMPTS; attempt++) { + try { + return await operation(); + } catch (cause) { + if (!isSerializationConflict(cause)) throw cause; + if (attempt < QR_ASSIGNMENT_MAX_ATTEMPTS) continue; + + throw new ShelfError({ + cause, + title: "QR assignment changed", + message: + "Another QR assignment was saved at the same time. Please try again.", + label: "QR", + status: 409, + shouldBeCaptured: false, + }); + } + } + + throw new Error("QR assignment retry loop exited unexpectedly"); +} + export async function updateAssetQrCode({ assetId, newQrId, organizationId, + userId, }: { - organizationId: string; - assetId: string; - newQrId: string; + organizationId: Organization["id"]; + assetId: Asset["id"]; + newQrId: Qr["id"]; + userId: User["id"]; }) { - // Disconnect all existing QR codes try { - // Disconnect all existing QR codes - await db.asset - .update({ - where: { id: assetId, organizationId }, - data: { - qrCodes: { - set: [], - }, - }, - }) - .catch((cause) => { - throw new ShelfError({ - cause, - message: "Couldn't disconnect existing codes", - label, - additionalData: { assetId, organizationId, newQrId }, - }); - }); + return await runQrAssignmentTransaction(() => + db.$transaction( + async (tx) => { + const [asset, qr] = await Promise.all([ + tx.asset.findFirst({ + where: { id: assetId, organizationId }, + select: { id: true }, + }), + tx.qr.findUnique({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: unclaimed QRs have no organization to scope by; ownership is checked below before either row is mutated + where: { id: newQrId }, + }), + ]); - // Connect the new QR code - return await db.asset - .update({ - where: { id: assetId, organizationId }, - data: { - qrCodes: { - connect: { id: newQrId }, - }, + if (!asset) { + throw new ShelfError({ + cause: null, + title: "Asset not found", + message: + "This asset doesn't exist or it doesn't belong to your current organization.", + label, + status: 404, + shouldBeCaptured: false, + }); + } + + if (!qr) { + throw new ShelfError({ + cause: null, + title: "QR code not found", + message: "This code doesn't exist.", + label: "QR", + status: 404, + shouldBeCaptured: false, + }); + } + + if (qr.organizationId && qr.organizationId !== organizationId) { + throw new ShelfError({ + cause: null, + title: "QR not valid.", + message: "This QR code does not belong to your organization", + label: "QR", + status: 403, + shouldBeCaptured: false, + }); + } + + if (qr.kitId) { + throw new ShelfError({ + cause: null, + title: "QR already linked.", + message: + "You cannot link to this code because it is already linked to another kit.", + label: "QR", + status: 409, + shouldBeCaptured: false, + }); + } + + if (qr.assetId && qr.assetId !== assetId) { + throw new ShelfError({ + cause: null, + title: "QR already linked.", + message: + "You cannot link to this code because it is already linked to another asset.", + label: "QR", + status: 409, + shouldBeCaptured: false, + }); + } + + if (!qr.organizationId) { + await tx.qr.update({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: the QR was read as unclaimed in this serializable transaction and is receiving its first organization + where: { id: qr.id }, + data: { organizationId, userId }, + }); + } + + return tx.asset.update({ + where: { id: assetId, organizationId }, + data: { + qrLabelAppliedAt: new Date(), + qrCodes: { + set: [], + connect: { id: newQrId }, + }, + }, + }); }, - }) - .catch((cause) => { - throw new ShelfError({ - cause, - message: "Couldn't connect the new QR code", - label, - additionalData: { assetId, organizationId, newQrId }, - }); - }); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ) + ); } catch (cause) { + if (isLikeShelfError(cause)) { + throw cause; + } + throw new ShelfError({ cause, message: "Something went wrong while updating asset QR code", @@ -6702,87 +6887,124 @@ export async function relinkAssetQrCode({ assetId: Asset["id"]; organizationId: Organization["id"]; }) { - const [qr, user, asset] = await Promise.all([ - getQr({ id: qrId }), - getUserByID(userId, { - select: { - id: true, - firstName: true, - lastName: true, - displayName: true, - } satisfies Prisma.UserSelect, - }), - db.asset.findFirst({ - where: { id: assetId, organizationId }, - select: { qrCodes: { select: { id: true } } }, - }), - ]); + const user = await getUserByID(userId, { + select: { + id: true, + firstName: true, + lastName: true, + displayName: true, + } satisfies Prisma.UserSelect, + }); - /** User cannot link qr code of other organization */ - if (qr.organizationId && qr.organizationId !== organizationId) { - throw new ShelfError({ - cause: null, - title: "QR not valid.", - message: "This QR code does not belong to your organization", - label: "QR", - status: 403, - shouldBeCaptured: false, - }); - } + return runQrAssignmentTransaction(() => + db.$transaction( + async (tx) => { + const [qr, asset] = await Promise.all([ + tx.qr.findUnique({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: unclaimed QRs have no organization to scope by; ownership is checked below before either row is mutated + where: { id: qrId }, + }), + tx.asset.findFirst({ + where: { id: assetId, organizationId }, + select: { qrCodes: { select: { id: true } } }, + }), + ]); - if (qr.kitId) { - throw new ShelfError({ - cause: null, - title: "QR already linked.", - message: - "You cannot link to this code because its already linked to another kit. Delete the other kit to free up the code and try again.", - label: "QR", - shouldBeCaptured: false, - }); - } + if (!qr) { + throw new ShelfError({ + cause: null, + title: "QR code not found", + message: "This code doesn't exist.", + label: "QR", + status: 404, + shouldBeCaptured: false, + }); + } - if (qr.assetId && qr.assetId !== assetId) { - throw new ShelfError({ - cause: null, - title: "QR already linked.", - message: - "You cannot link to this code because its already linked to another asset. Delete the other asset to free up the code and try again.", - label: "QR", - shouldBeCaptured: false, - }); - } + if (!asset) { + throw new ShelfError({ + cause: null, + title: "Asset not found", + message: + "This asset doesn't exist or it doesn't belong to your current organization.", + label, + status: 404, + shouldBeCaptured: false, + }); + } - const oldQrCode = asset?.qrCodes[0]; + if (qr.organizationId && qr.organizationId !== organizationId) { + throw new ShelfError({ + cause: null, + title: "QR not valid.", + message: "This QR code does not belong to your organization", + label: "QR", + status: 403, + shouldBeCaptured: false, + }); + } - await Promise.all([ - db.qr.update({ - // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: lines 4646-4655 reject any qr whose organizationId differs from the caller's; an unclaimed qr (null org) is being claimed here, which is why this write sets organizationId - where: { id: qr.id }, - data: { organizationId, userId }, - }), - db.asset.update({ - where: { id: assetId, organizationId }, - data: { - qrCodes: { - set: [], - connect: { id: qr.id }, - }, + if (qr.kitId) { + throw new ShelfError({ + cause: null, + title: "QR already linked.", + message: + "You cannot link to this code because its already linked to another kit. Delete the other kit to free up the code and try again.", + label: "QR", + status: 409, + shouldBeCaptured: false, + }); + } + + if (qr.assetId && qr.assetId !== assetId) { + throw new ShelfError({ + cause: null, + title: "QR already linked.", + message: + "You cannot link to this code because its already linked to another asset. Delete the other asset to free up the code and try again.", + label: "QR", + status: 409, + shouldBeCaptured: false, + }); + } + + const oldQrCode = asset.qrCodes[0]; + + await tx.qr.update({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: ownership was checked in this serializable transaction; null means the QR is being claimed here + where: { id: qr.id }, + data: { organizationId, userId }, + }); + const updatedAsset = await tx.asset.update({ + where: { id: assetId, organizationId }, + data: { + qrLabelAppliedAt: new Date(), + qrCodes: { + set: [], + connect: { id: qr.id }, + }, + }, + }); + await tx.note.create({ + data: { + asset: { connect: { id: assetId } }, + user: { connect: { id: userId } }, + type: "UPDATE", + content: `${wrapUserLinkForNote({ + id: userId, + firstName: user.firstName, + lastName: user.lastName, + })} changed QR code ${ + oldQrCode ? `from **${oldQrCode.id}**` : "" + } to **${qrId}**.`, + }, + }); + + return updatedAsset; }, - }), - createNote({ - assetId, - userId, - organizationId, - type: "UPDATE", - content: `${wrapUserLinkForNote({ - id: userId, - firstName: user.firstName, - lastName: user.lastName, - })} changed QR code ${ - oldQrCode ? `from **${oldQrCode.id}**` : "" - } to **${qrId}**.`, - }), - ]); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ) + ); } export async function getUserAssetsTabLoaderData({ diff --git a/apps/webapp/app/modules/asset/types.ts b/apps/webapp/app/modules/asset/types.ts index 5e691cc533..81ba2835d4 100644 --- a/apps/webapp/app/modules/asset/types.ts +++ b/apps/webapp/app/modules/asset/types.ts @@ -182,6 +182,7 @@ export type AdvancedIndexAsset = Pick< | "description" | "createdAt" | "updatedAt" + | "qrLabelAppliedAt" | "userId" | "mainImage" | "thumbnailImage" @@ -198,6 +199,7 @@ export type AdvancedIndexAsset = Pick< | "availableToBook" > & { qrId: string; // QR code will always be available + qrLabelApplied: boolean; assetModelId?: string | null; assetModelName?: string | null; /** Primary kit (oldest pivot row) — mirrors the LATERAL primary-pick diff --git a/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx b/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx index 954d96723a..f02e4fe844 100644 --- a/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx @@ -951,6 +951,18 @@ export default function AssetOverview() { ) : null} +
  • + + Has ID Assigned + +
    + {asset.qrLabelAppliedAt ? ( + + ) : ( + "No" + )} +
    +
  • Created diff --git a/apps/webapp/app/routes/qr+/_private+/$qrId_.link.asset.tsx b/apps/webapp/app/routes/qr+/_private+/$qrId_.link.asset.tsx index 70e810f958..5b1fc0b6f8 100644 --- a/apps/webapp/app/routes/qr+/_private+/$qrId_.link.asset.tsx +++ b/apps/webapp/app/routes/qr+/_private+/$qrId_.link.asset.tsx @@ -194,6 +194,7 @@ export const action = async ({ newQrId: qrId, assetId, organizationId, + userId: authSession.userId, }); return redirect(`/qr/${qrId}/successful-link?type=asset`); diff --git a/apps/webapp/app/utils/csv.server.test.ts b/apps/webapp/app/utils/csv.server.test.ts index 3ad2e07ece..1ebb99e23d 100644 --- a/apps/webapp/app/utils/csv.server.test.ts +++ b/apps/webapp/app/utils/csv.server.test.ts @@ -280,6 +280,7 @@ describe("buildCsvExportDataFromAssets", () => { tags: [{ name: "photo" }, { name: "dslr" }], valuation: 1234.5, availableToBook: true, + qrLabelAppliedAt: new Date("2024-01-03T04:05:06Z"), createdAt: new Date("2024-01-02T03:04:05Z"), custody: [ { @@ -300,7 +301,10 @@ describe("buildCsvExportDataFromAssets", () => { }, { customField: { name: "purchaseDate" }, - value: { raw: "2024-02-10", valueDate: "2024-02-10" }, + value: { + raw: "2024-02-10", + valueDate: "2024-02-10T12:00:00.000Z", + }, }, { customField: { name: "amount" }, @@ -324,41 +328,42 @@ describe("buildCsvExportDataFromAssets", () => { { name: "tags", visible: true, position: 2 }, { name: "valuation", visible: true, position: 3 }, { name: "availableToBook", visible: true, position: 4 }, - { name: "createdAt", visible: true, position: 5 }, - { name: "custody", visible: true, position: 6 }, + { name: "qrLabelApplied", visible: true, position: 5 }, + { name: "createdAt", visible: true, position: 6 }, + { name: "custody", visible: true, position: 7 }, { name: "cf_isInsured", visible: true, - position: 7, + position: 8, cfType: CustomFieldType.BOOLEAN, }, { name: "cf_notes", visible: true, - position: 8, + position: 9, cfType: CustomFieldType.MULTILINE_TEXT, }, { name: "cf_purchaseDate", visible: true, - position: 9, + position: 10, cfType: CustomFieldType.DATE, }, { name: "cf_amount", visible: true, - position: 10, + position: 11, cfType: CustomFieldType.AMOUNT, }, - { name: "cf_misc", visible: true, position: 11 }, + { name: "cf_misc", visible: true, position: 12 }, { name: "cf_empty", visible: true, - position: 12, + position: 13, cfType: CustomFieldType.TEXT, }, - { name: "actions", visible: true, position: 13 }, - { name: "location", visible: false, position: 14 }, + { name: "actions", visible: true, position: 14 }, + { name: "location", visible: false, position: 15 }, ]; const [headers, row] = buildCsvExportDataFromAssets({ @@ -378,6 +383,7 @@ describe("buildCsvExportDataFromAssets", () => { '"Tags"', '"Value"', '"Available to book"', + '"Has ID Assigned"', '"Created at"', '"Custody"', '"isInsured"', @@ -394,6 +400,7 @@ describe("buildCsvExportDataFromAssets", () => { '"photo, dslr"', '"$1,234.50"', '"Yes"', + '"2024-01-03T04:05:06.000Z"', '"2024-01-02T03:04:05.000Z"', '"Jane Doe"', '"Yes"', diff --git a/apps/webapp/app/utils/csv.server.ts b/apps/webapp/app/utils/csv.server.ts index 0baf0f7af1..ab5309dbf8 100644 --- a/apps/webapp/app/utils/csv.server.ts +++ b/apps/webapp/app/utils/csv.server.ts @@ -565,6 +565,11 @@ export const buildCsvExportDataFromAssets = ({ case "qrId": value = asset.qrId; break; + case "qrLabelApplied": + value = asset.qrLabelAppliedAt + ? new Date(asset.qrLabelAppliedAt).toISOString() + : ""; + break; case "name": value = asset.title; break; diff --git a/packages/database/prisma/migrations/20260706164000_add_qr_label_assignment_tracking/migration.sql b/packages/database/prisma/migrations/20260706164000_add_qr_label_assignment_tracking/migration.sql new file mode 100644 index 0000000000..84106ad1d9 --- /dev/null +++ b/packages/database/prisma/migrations/20260706164000_add_qr_label_assignment_tracking/migration.sql @@ -0,0 +1,29 @@ +ALTER TABLE "Asset" ADD COLUMN "qrLabelAppliedAt" TIMESTAMP(3); + +-- Migrations are deployed before the new application version. Keep recording +-- assignments made by an older application process during that rollout window. +-- This only runs when an existing QR changes assets, so the QR inserted with a +-- newly-created asset remains unmarked. +CREATE OR REPLACE FUNCTION set_qr_label_applied_at_on_assignment() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW."assetId" IS NOT NULL + AND NEW."assetId" IS DISTINCT FROM OLD."assetId" THEN + UPDATE "Asset" a + SET "qrLabelAppliedAt" = CURRENT_TIMESTAMP + WHERE a.id = NEW."assetId" + AND NEW."kitId" IS NULL + AND ( + NEW."organizationId" IS NULL OR + NEW."organizationId" = a."organizationId" + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER "Qr_set_qr_label_applied_at_on_assignment" +AFTER UPDATE OF "assetId" ON "Qr" +FOR EACH ROW +EXECUTE FUNCTION set_qr_label_applied_at_on_assignment(); diff --git a/packages/database/prisma/migrations/20260706164100_backfill_qr_label_assignment/migration.sql b/packages/database/prisma/migrations/20260706164100_backfill_qr_label_assignment/migration.sql new file mode 100644 index 0000000000..c7625a7f7a --- /dev/null +++ b/packages/database/prisma/migrations/20260706164100_backfill_qr_label_assignment/migration.sql @@ -0,0 +1,26 @@ +-- Auto-generated asset QRs share the asset createdAt timestamp and are created +-- already linked to the asset. For historical data, only infer an applied +-- physical label when the QR row already existed and was later updated/linked. +-- Fresh replacement/import-created QR rows are intentionally left null because +-- their timestamps cannot prove a sticker was actually applied. +WITH inferred_label_assignments AS ( + SELECT + a.id AS "assetId", + MIN(q."updatedAt") AS "appliedAt" + FROM "Asset" a + JOIN "Qr" q + ON q."assetId" = a.id + AND q."kitId" IS NULL + AND ( + q."organizationId" IS NULL OR + q."organizationId" = a."organizationId" + ) + WHERE q."createdAt" IS DISTINCT FROM a."createdAt" + AND q."updatedAt" > q."createdAt" + GROUP BY a.id +) +UPDATE "Asset" a +SET "qrLabelAppliedAt" = i."appliedAt" +FROM inferred_label_assignments i +WHERE a.id = i."assetId" + AND a."qrLabelAppliedAt" IS NULL; diff --git a/packages/database/prisma/migrations/20260706164200_add_qr_label_assignment_index/migration.sql b/packages/database/prisma/migrations/20260706164200_add_qr_label_assignment_index/migration.sql new file mode 100644 index 0000000000..b5e2b295a8 --- /dev/null +++ b/packages/database/prisma/migrations/20260706164200_add_qr_label_assignment_index/migration.sql @@ -0,0 +1,2 @@ +CREATE INDEX "Asset_organizationId_qrLabelAppliedAt_idx" +ON "Asset"("organizationId", "qrLabelAppliedAt"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index d2312a327e..a94b288210 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -206,6 +206,7 @@ model Asset { valuation Float? @map("value") // Field to store the monetary value of an asset availableToBook Boolean @default(true) sequentialId String? // Sequential identifier for the asset (e.g., "SAM-0001") + qrLabelAppliedAt DateTime? // Set when a physical QR label is linked to the asset // Tracking method & model type AssetType @default(INDIVIDUAL) @@ -284,6 +285,7 @@ model Asset { @@index([categoryId, organizationId], name: "Asset_categoryId_organizationId_idx") @@index([assetModelId, organizationId], name: "Asset_assetModelId_organizationId_idx") @@index([sequentialId], name: "Asset_sequentialId_idx") + @@index([organizationId, qrLabelAppliedAt], name: "Asset_organizationId_qrLabelAppliedAt_idx") // Index for foreign key @@index([userId]) }