Back to Journal

Hunting down s3Key propagation bugs across two portals

Listen to this articleAI narration
0:00 / 0:00

Two days ago we shipped S3 presigned URL uploads for lab analysis PDFs. Today was the cleanup - finding every place in both the admin and customer portals that still expected the old url field and didn't know about s3Key.

The symptoms came in waves: admins reporting PDFs that "saved" but disappeared on refresh, supervisors complaining status wasn't updating, and users getting "Result not available" on referrals that clearly had PDFs attached.

The Root Problem

When we added S3 uploads, we introduced a new field s3Key alongside the legacy url field. The upload flow worked perfectly - files went to S3, keys got saved. But the read side of the application was still checking only for url.

The s3Key Propagation ProblemAdminUploads PDFS3Stores files3Key ✓MongoDB Referralurl: undefineds3Key: "referrals/abc/file.pdf"UserTries to downloadchecks url onlyUI Result"Resultat ej tillgängligt"The DisconnectWrite saves s3Key, read checks url

Bug 1: The Phantom Save

An admin reported: "I upload a PDF, it says saved, I refresh, it's gone."

The upload component had a problematic sequence. It would set the local s3Key state, fire the updateUrl mutation, and immediately set the upload status to success - all without waiting for the database to confirm. The mutation was fire-and-forget.

The updateUrl function returns a Promise, but we weren't awaiting it. The UI showed "success" before the database confirmed the save. If the mutation failed silently, the user saw success but the data never persisted.

Race Condition in Upload FlowTime →S3 UploadSuccesssetS3Key()Local stateupdateUrl()Async (not awaited)setStatus"success"DB WriteMaybe failsThe ProblemUser sees "success" at t=480msDB write completes (or fails) at t=620ms

The fix required two changes. First, I changed from using mutate, which is fire and forget, to mutateAsync, which returns a Promise. Second, I restructured the code to await the mutation before updating local state and showing success.

The corrected sequence first awaits the updateUrl call with the s3Key, then only after that succeeds does it update the local state and set the status to success. Now the UI only shows success after the database confirms the save.

Bug 2: Missing Field in Projection

Even after fixing the save race condition, refreshing the page showed no PDF. The data was in MongoDB, but the API wasn't returning it.

MongoDB projections explicitly list which fields to return. The REFERRAL_PROJECTION constant in the data layer had over 20 fields listed - id, active, barcode, patient names, and many others. It included the legacy url field and the substances array, but s3Key was missing entirely.

The fix was simple - add s3Key with a value of 1 to the projection. But this needed to be done in both portals, admin and customer.

Bug 3: Status Logic Ignoring s3Key

A supervisor reported: "I upload the lab results PDF but the status stays as 'Skickad' instead of showing 'Positivt' or 'Negativt'."

The status determination function had a problem. It first checked if the referral is inactive, returning "Hanterad" (Handled). Then it checked if there's no url - and if so, it returned "Skickad" (Sent) early. The logic to check substances and determine positive or negative results came after that check.

When a PDF is uploaded via S3, only s3Key is set, not url. The status check saw no url and returned "Skickad", ignoring the fact that results existed.

Status Determination FlowReferral Documenturl: undefined, s3Key: "abc.pdf"Before (Broken)if (!url)url = undefined → trues3Key = "abc.pdf" (ignored)"Skickad"Returns early, never checks resultsAfter (Fixed)if (!url && !s3Key)url = undefined → trues3Key = "abc.pdf" → falseCheck substances →Evaluates to "Positivt" or "Negativt"vs

The fix changed the condition from checking only if url is falsy to checking if both url and s3Key are falsy. This pattern appeared in four places across both portals - the detail page and list page in each.

Bug 4: The Broken Header Download Button

A user pointed at their screen: "It says 'Resultat ej tillgängligt' but there's clearly a PDF right there."

The header had a download button that conditionally rendered based on whether url existed. If url was present, it showed a link to download the PDF. If not, it showed a disabled button saying "Resultat ej tillgängligt" (Result not available).

When s3Key exists but url doesn't, this showed the disabled button. The component further down the page had its own download logic that correctly checked both fields - but the header button was independent.

The fix required adding a download handler function that first checks for s3Key. If it exists, it fetches a presigned download URL from the API, then opens that URL in a new tab. If s3Key doesn't exist but url does, it falls back to opening the direct url. The button's conditional rendering was also updated to check for either url or s3Key before showing the download option.

Bug 5: The Missing Download API Route

A user impersonating through the admin portal tried to download a PDF on the customer portal: 405 Method Not Allowed.

The network tab showed a POST to /api/download returning 405. That route existed in the admin portal but didn't exist in the customer portal at all.

Missing API RouteAdmin Portal/api/upload ✓/api/download ✓lib/s3.ts ✓AWS SDK installed ✓Customer Portal/api/upload ✗/api/download ✗lib/s3.ts ✗AWS SDK not installed ✗

The customer portal needed several additions. First, the AWS SDK dependencies - @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner - needed to be installed.

Second, I created an S3 utility library that imports the necessary commands from the AWS SDK. It reads configuration from environment variables for the bucket name, region, and credentials. A singleton pattern ensures the S3 client is reused across requests. The createPresignedDownloadUrl function generates signed URLs that expire after one hour by default.

Third, I created the download API route. The POST handler first checks for authentication using the session. If no valid session exists, it returns a 401 Unauthorized error. Then it parses the key from the request body, validates it's a non-empty string, generates the presigned download URL, and returns it as JSON.

The authentication check is important - the customer portal requires session auth, which works for both regular users logging in via BankID or email and impersonated users where an admin is viewing as a customer.

Bug 6: Status Filter in Database Queries

The "Skickad" filter in the admin dashboard wasn't working correctly. Referrals with S3 uploads were showing up as "sent" when they should show based on their results.

The database query for the "skickad" status filter was checking if the url field doesn't exist. This matched any document without a url field - including those with s3Key.

The fix requires both fields to be missing. The updated filter uses a $and clause that combines two $or conditions. The first checks that url either doesn't exist, is null, or is an empty string. The second does the same for s3Key. Only when both conditions are true does the referral match the "skickad" filter.

This pattern appeared in four places - getReferralsPaginated and getReferralsCount in both portals.

Bug 7: Email Notifications Missing s3Key

When an admin uploads a PDF, the customer should get notified. The notification trigger checked if updateData contains substances or url, and if the referral has a customer - then it sends the notification email.

S3 uploads set s3Key, not url. So no notification was sent. The fix added s3Key to the condition, checking if updateData contains substances or url or s3Key.

The Full Audit

After the initial bugs, I ran a comprehensive search across both codebases for any remaining s3Key issues. I used grep to search for patterns like "referral.url", "!referral.url", and url exists checks in MongoDB queries.

All s3Key Issues Found and Fixed#PortalIssueStatus1AdminMutation not awaited in uploadFixed ✓2Boths3Key missing from REFERRAL_PROJECTIONFixed ✓3AdminStatus logic only checks url (detail page)Fixed ✓4AdminStatus logic only checks url (list page)Fixed ✓5PortalStatus logic only checks url (detail page)Fixed ✓6AdminHeader download button only checks urlFixed ✓7PortalDownload button only checks urlFixed ✓8Portal/api/download route missing entirelyFixed ✓9Both"Skickad" status filter only checks urlFixed ✓10AdminEmail notification missing s3Key checkFixed ✓10 issues found and fixed across 12 files

Bonus: OrgId Fix Script for Referrals

While investigating, I noticed we had a script for fixing orgId on test reports but not on referrals. Same data model, same problem - referrals created before orgId was enforced need backfilling.

I created a fix script that loads all organizations from the counties collection, builds a mapping from county name in lowercase to org ID, then iterates through each mapping. For each county, it updates all referrals where the county name matches case-insensitively and the orgId is either missing, null, empty, or different from the correct value. It sets the orgId and updates the timestamp, then logs how many referrals were updated for each county.

Summary

Adding a new field to an existing system requires touching every place that reads that data. The S3 upload feature worked perfectly in isolation - the bug was in all the existing code that didn't know to look for the new field.

In the admin portal, I modified the referral detail page for the download handler, awaiting the mutation, and status logic. The referral list page got status logic updates. The referral API route got the email notification trigger fix. And the referral data layer got projection and status filter updates.

In the customer portal, I modified the referral detail page for download handler and status logic, the referral list page for status logic, and the referral data layer for projection and status filters.

I also created new files in the customer portal: the S3 utility library and the download API route.

Finally, I added the AWS SDK dependencies to the customer portal: @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner.

Lesson learned: when adding a new way to store data like s3Key versus url, grep for every reference to the old way and update them all. The bugs are always in the code you forgot existed.