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 withoutpropertyId. - BullMQ
defaultJobOptions: changeremoveOnFail: falseto e.g.removeOnFail: { count: 50 }so a stuck failed job doesn't permanently block new adds for the sameprioritylab-{id}key. - Manual resync UX: surface
priorityLab.lastErrorand 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
_priorityLabPropertyIdvalue. - Check BullMQ for any failed
prioritylab-{id}jobs still occupying the queue for the surviving inspection. - Confirm with Priority Lab whether
CreateUpdateAppointmentaccepts and respects a per-call external reference id (Attik'sinspectionId).
Related
- ATT-1475 — booking race that produces the duplicate Attik inspections this issue is downstream of.
Log in to comment and vote
No comments yet
Be the first to share your thoughts.