PriorityLab: two inspections share one _priorityLabPropertyId, cancel/resync misbehave

Summary

When two Attik inspections are created for the same property/day (as observed in ATT-1475), Priority Lab dedupes them into a single appointment, both Attik inspections persist the same _priorityLabPropertyId, cancelling either one removes the PL appointment for the other, and manual resync silently does not recreate it.

Reporter's observation (from ATT-1475)

While in a different order I observed the following behavior: Created order online > did not refresh Attik > scheduled inspection internally for same day/time as online > PL appointment did not duplicate > when I cancelled one of the two orders it cancelled the PL appointment > resyncing the original inspection did not resend the appointment to PL

Root cause analysis

1. Attik's PL payload has no per-inspection external id

attik-backend/src/util/functions/priorityLab/buildPriorityLabPayload.ts lines 6-17 send inspectionDate as date-only (no time of day):

function formatInspectionDateLocal(datetime: Date, timeZone: string): string {
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone, month: 'numeric', day: 'numeric', year: 'numeric',
  });
  // ...
  return `${Number(m)}/${Number(d)}/${y}`;
}

The full payload (lines 115-146) is:

const payload: CreateUpdateAppointmentPayload = {
  firstName, lastName, phoneNumber, address, city, state, zipcode,
  inspectionDate: formatInspectionDateLocal(new Date(inspection.datetime), timeZone),
  numEmails: String(emailEntries.length),
};
if (propertyId) { payload.propertyId = propertyId; }

No Attik inspection id, slug, or other unique key. Two inspections same property/day produce identical create payloads. PL's CreateUpdateAppointment matches and returns the same confirmation (PL's property id) for both β€” which Attik then writes to both inspections' _priorityLabPropertyId.

2. Cancel cascade

attik-backend/src/util/functions/priorityLab/syncPriorityLabAppointment.ts lines 49-96 (delete branch) calls deleteAppointment with the PL property id and $unsets the field on the cancelled inspection only. The surviving inspection still references the (now-deleted) PL property id.

3. Resync silently no-ops

Two compounding reasons:

(a) BullMQ jobId is fixed per inspection and removeOnFail: false.

attik-backend/src/events/bullmq/priorityLabQueues.ts lines 32-37:

const jobIdForInspection = (inspectionId: number) => `prioritylab-${inspectionId}`;
export async function queuePriorityLabJob(data: PriorityLabSyncJob) {
  return priorityLabAppointmentQueue.add('priorityLab', data, {
    jobId: jobIdForInspection(data.inspectionId),
  });
}

Lines 9-14:

const defaultJobOptions = {
  attempts: 5,
  backoff: { type: 'exponential' as const, delay: 30000 },
  removeOnComplete: true,
  removeOnFail: false,
};

A previously-failed job for prioritylab-${inspectionId} can sit in Redis and block new adds for that key, which looks like "resync did nothing".

(b) The 442-recovery branch only recreates when propertyId was set going in.

attik-backend/src/util/functions/priorityLab/syncPriorityLabAppointment.ts lines 210-217:

if (e instanceof PriorityLabError && e.code === '442' && propertyId) {
  await Inspection.findByIdAndUpdate(inspectionId, { $unset: { _priorityLabPropertyId: 1 } });
  propertyId = undefined;
  const { propertyId: newId } = await tryUpsert(undefined);
  propIdOut = newId;
}

A non-442 error (or a success that returns the same stale id) leaves the surviving inspection still pointing at a dead PL record.

Suggested fix

  • Send a stable Attik external id (e.g. inspectionId) in the PL payload, or include enough disambiguators (time of day, slug) that PL doesn't dedupe across two Attik rows. Confirm with PL whether they support a per-call external reference.
  • Defensive: before persisting _priorityLabPropertyId, verify the returned id isn't already used by another inspection in the same company. If it is, treat it as a collision and recreate without propertyId.
  • BullMQ defaultJobOptions: change removeOnFail: false to e.g. removeOnFail: { count: 50 } so a stuck failed job doesn't permanently block new adds for the same prioritylab-{id} key.
  • Manual resync UX: surface priorityLab.lastError and skip-reasons clearly in the inspection UI so silent no-ops are visible.

Investigation steps

  • Pull the two RIA inspections (4249 Melinda Ln 9:26 and 9665 Pine Thicket 9:29 on Martin) and confirm both currently or previously held the same _priorityLabPropertyId value.
  • Check BullMQ for any failed prioritylab-{id} jobs still occupying the queue for the surviving inspection.
  • Confirm with Priority Lab whether CreateUpdateAppointment accepts and respects a per-call external reference id (Attik's inspectionId).

Related

  • ATT-1475 β€” booking race that produces the duplicate Attik inspections this issue is downstream of.

Please authenticate to join the conversation.

Upvoters
Status

Completed

Board
🏠

Main App

Date

25 days ago

Author

Linear

Subscribe to post

Get notified by email when there are changes.