Appearance
Spec: Triage Form Invite (Initial contact made -> send the customer their form)
Status: Implemented - triggered by the Prime change-job-status webhook (UserStory1750, 2026-09-23) Date: 2026-08-04 Branch context: UserStory777DesktopAppointments; trigger moved to the webhook in UserStory1750v7TriageFormquestions
2026-09-23 (UserStory1750): the email-driven trigger in the original §2-§3 is replaced by Prime's webhook, and the
Triage Invite IngestandTriage Invite Process Eventsn8n flows are deprecated - they were never activated. §2-§3 below describe the current design; §4-§8 are unchanged.
1. Overview
Closes the first automated step of the triage process diagram: when a Prime job reaches "Initial contact made - awaiting further info", the job's primary contact is emailed and SMSed a link to the FormsPortal triage form.
Everything downstream of that link already existed - routing to a booking link, the make safe path, the case manager alert (see TriageBookingRouting-Spec.md). Until now the link itself had to be created and sent by hand.
2. The trigger
Prime's External Webhook posts every job status change to POST api/prime/webhook (PrimeWebhookController), which stores it in ethPrimeWebhookLog and returns 200. The live Prime Webhook Process Events n8n flow calls POST api/prime/webhooklog/process every 2 minutes - and immediately, because the controller nudges the flow's GET webhook (WebhookSettings:PrimeWebhookProcessEvents, fire-and-forget, so the schedule is the fallback) after storing each event - and PrimeWebhookQueueService.ProcessChangeJobStatusAsync syncs the job from Prime.
As the last step of that handler, spEthTaskQueueTriageInvite checks the synced job's primeJobs.StatusId. If it is Initial contact made - awaiting further info (TriageInviteService.InitialContactMadeStatusId, FAFF6EF9-44AF-4169-B57C-B9ED332386C8), it writes an ethTask row (TaskName TriageInvite) - unless the job already has one pending, so two status events in one batch, or a retried event, queue one invite.
The Prime status notification email to prime-jobs-events@ethosbuilding.com.au is no longer used for this.
3. Queue, then send in the same pass
At the end of every webhook batch, PrimeWebhookQueueService.ProcessQueueAsync calls TriageInviteService.ProcessQueueAsync, which sends every pending invite. A status change is normally actioned within seconds, and within one 2-minute tick if the nudge fails.
The queue is kept (rather than sending inline in the webhook handler) because the send makes two more Prime calls (job + contact sync) and queues an email and an SMS. A failure there leaves the ethTask row pending via IncrementAttempt (retried 36 hours later, the existing spEthTaskIncrementAttempt behaviour) instead of failing and replaying the webhook event. The queue step itself does fail the event if it errors, so a missed invite is retried rather than lost.
What this gives up from the old design: there is no Prime rate-limit check before the send. The webhook processing already makes Prime calls for every event without one.
The webhook reads the job's status as it is when the event is processed, not when it fired. If the job has already moved on from "Initial contact made" by then, no invite is queued.
4. The endpoints
POST api/forms/triage/process/invite - a manual drain. The normal path is PrimeWebhookQueueService calling TriageInviteService.ProcessQueueAsync at the end of each webhook batch (§3). A row that fails is left pending via IncrementAttempt and retried later; the ethTask status description records what actually went out, so "did the customer get it?" is answerable from the queue table.
POST api/forms/triage/invite - one job immediately, bypassing the queue, for manual replays and testing. Body { "JobID": "...", "JobNumber": "..." }.
400for a missing/emptyJobID- a malformed payload shouldn't be retried.500(unhandled throw) for a job that isn't in Prime or has no primary contact - those should be retried.200returns what actually happened: the code, the link, whether an existing link was reused, and which channels went out to which addresses. That last part matters on Test, where the overrides mean "sent" doesn't mean "sent to the customer".
5. What the service does (WebAPI\Services\TriageInviteService.cs)
Per queued job:
- Sync the job from Prime (
IPrimeJobService.GetJob). The notification means something just changed; the local mirror is only as fresh as the last scheduled sync, which may not have run since. - Read the job summary for the primary contact ID.
- Sync that contact from Prime (
GetandSaveContactFromPrime). The mirror's copy of their mobile/email can be staler than the job's, and those two fields are the entire point of the call. - Re-read the summary so the details used below are the freshly synced ones.
- Reuse or create the sharing code - see §6.
- Email + SMS the link, honouring the site options in §7. The email is queued to
ethEmailMessage(sent by n8n "Email Queue - Send Prod"); the SMS is queued tomakesafeSMSMessageviaspMakesafeQueueSMSMessagewithSentNULL, which is what n8n "SMS Send Queued Messages" picks up. (spMakesafeInsertSMSMessagelogs an SMS already sent - using it here meant the SMS was never delivered.) - Note each message on the job as a Prime "Customer Contact" note - the SMS text, or the email's from/to/subject and body as plain text - never synced to Endata/Nexus (UserStory1854). A message redirected by a
TriageInvite*Overridegets an Internal note instead. The note is queued inethPrimeNotificationQueueand posted later (PrimeMessageNotes-Spec.md), and a failure to queue it is logged, never thrown, so the invite isn't re-sent. Details inOptimoRoute-Appointments-Bookings-Logic.md§6.1 (UserStory1847).
Steps 1 and 3 are the two Prime calls the rate-limit guard in §3 is protecting; step 7's notes are posted by the notification queue under its own rate-limit check.
6. Idempotency
Prime can raise the same status notification more than once - a status set back and forth, a re-sent notification, an n8n retry. Minting a fresh code each time would leave the customer holding several links for one job, only the last of which they'd likely use, with the earlier ones silently still valid.
So the service asks for the job's newest still-usable code first (new spEthFormSharingCodeGetLatestActiveByJob - not expired, not used) and resends that if it exists. A repeat is a genuine resend: same link, same form, same submission. If the customer has already submitted (UsedAt set) or the code aged out, no row comes back and a new code is minted - correct for a job that's genuinely being triaged again.
Links live for 30 days.
The queue also guards against sending at all when it shouldn't (spEthTaskQueueTriageInvite, TriageInviteService.ProcessQueueAsync):
- Nothing is queued while an invite is already pending for the job, or once an invite has actually been sent for it in the last 30 days (a completed
TriageInviterow whose status starts "Sent"). A replayed or late status event, or the job bouncing off the status and back, no longer re-sends. A deliberate resend isPOST api/forms/triage/invite, which bypasses the queue. - A queued row re-checks the job's status live from Prime before sending, and completes as "Nothing sent" if the job has moved on - a retry can run 36 hours after it was queued.
- A row is given up ("Nothing sent - gave up after N failed attempts") after 3 failed attempts instead of retrying forever.
7. Testing without contacting customers
Three site options, editable in AdminPortal -> Settings -> Triage Form Invite (generic GetSiteOption/SetSiteOption mechanism, no new stored procedures):
| Option | Effect |
|---|---|
TriageInviteSendEnabled | Off means the link is still created, but nothing is sent to anyone. Unset means on, so production works without anyone creating the row. |
TriageInviteEmailOverride | When set, every invite email goes here instead of the customer. |
TriageInviteMobileOverride | When set, every invite SMS goes here instead of the customer. |
When either override is active the service logs a warning naming the redirect, so a Test run can't be mistaken for a real send.
8. Error handling
- Email and SMS are independent - a contact with a bad email address still gets the SMS, and the result says which channels went out. A channel that throws is logged and the other still goes; the row is only retried when neither could be queued, so a retry never re-sends a channel that already went.
- A contact with neither an email nor a mobile is logged at error, not warning: the job has advanced on the assumption the customer was contacted, and nobody was. The
ethTaskrow completes with "Nothing sent" rather than retrying forever, since a retry can't invent contact details. - A failed site-option read is logged and treated as unset rather than failing the invite.
9. Reminders (UserStory1952)
A customer who hasn't submitted the form gets a gentle reminder: the same link, reminder wording, by email and SMS.
- Schedule: the n8n
Triage Reminder Dailyflow callsPOST api/forms/triage/process/remindersonce a day at 10AM (Sydney time). That daily run is what limits a customer to one reminder a day. The endpoint returns{ queued }. - Who gets one (
spEthTaskQueueTriageReminders, anethTaskrow with TaskNameTriageReminder): the job is at "Initial contact made - awaiting further info" inprimeJobs, and its newest Triage link is at least 2 days old, unused and unexpired. No triage form has been submitted since that link was created, no reminder is pending, and none was sent in the last 20 hours. Fewer than 3 (TriageInviteService.MaxReminders) have been sent for that link. - Never on stale data: each row is sent by
SendInviteAsyncas for a queued invite. The job is re-synced from Prime intoprimeJobsand its live status re-checked, then the contact is re-synced. A job that has moved on completes as "Nothing sent". So does a link that was used or expired since the row was queued, because a reminder never mints a new link. - Same switches and notes as the invite:
TriageInviteSendEnabledand the two overrides (§7) apply. Every reminder email and SMS is noted on the Prime job as a Customer Contact note (§5 step 7). - A failed row retries through
IncrementAttemptand goes out on a later daily run. It gives up after 3 attempts.
10. Outstanding
- Delete the deprecated
Triage Invite IngestandTriage Invite Process Eventsn8n flows (never activated). Prime's status notification email for this status is no longer needed for the invite. - Apply
DbMigrations/Scripts/Programmable/Procedures/spEthTaskQueueTriageInvite.sqlbefore deploying WebAPI - without it everychange-job-statusevent fails and retries. - SMS and email copy is first-draft, not signed off - including the reminder wording.
- Apply
spEthTaskQueueTriageReminders.sqland deploy WebAPI, then import and activate theTriage Reminder Dailyn8n flow.

