) {
+ 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])
}