Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/KO

Multi-Country Ultrasound Delivery Service and Console

Multi-Country Ultrasound Delivery Service and Console

Extending Korea's ultrasound delivery to three countries with an operations console

Source recordings locked in Korea

The plan was to take a service that pushed prenatal ultrasound recordings from clinics to expectant mothers' phones and ship the same thing in Indonesia, Vietnam, and the United States. The recordings, along with the hospital and barcode data, lived in a separate system already running in Korea (the code calls it mommybox), and the premise was to integrate with that system without modifying it.

The conditions differed by country. Payments went through app store in-app purchase in Indonesia and the US, and through VNPAY, the local payment gateway, in Vietnam. The app shipped in five languages: EN, ID, VI, KO, ES. What a recording cost, or whether it was free at all, was a per-country setting as well.

Copy and per-country settings had to be editable per country from the admin, which is why the project needed a CMS.

This piece covers only two things: the connection that borrows recordings from the source system, and the license that decides who is allowed to watch them. Running MySQL and MongoDB side by side, the two-region deployment, and secret management each deserve their own piece, so I fold them away here. The read/write split gets a short summary near the end.

System boundary and external dependencies
System boundary and external dependencies

Changing things without store review

The recordings stayed in the existing system, connected through hospital barcodes only.

Copying the video files and the hospital master over would leave the two systems drifting apart forever. Instead, a barcode was registered on each baby and the source system was queried whenever something was needed. The local database holds only what was built here: likes, comments, view counts, and shares, all keyed by ultrasoundUUID. The source API could not be touched, and some of its endpoints only accept one barcode at a time, so request-scoped batching with DataLoader had to be in place to keep every GraphQL field from firing its own call.

The NestJS + GraphQL monorepo was split into three apps: apps/app, apps/admin, and apps/batch-job.

I wanted the three to share domain logic (libs/modules) while keeping authentication and deployment lifecycles completely separate. The app server uses JWT, the admin server uses express session cookies with an SMS second factor, and the batch job is not running at all most of the time. The app has to read what the admin just wrote, so a single shared database was not negotiable. The boundary is therefore drawn only at the deployment unit, and there is exactly one repository layer, in libs.

Every string, icon, and weekly content item that reaches the app moved out to the server and the CDN.

Copy lives in the language_pack table and comes down as i18next resource JSON straight from /app-translation/resources?lng=&ns=. Icons are exported as SVG by a Figma plugin from the selected component, uploaded to S3, and served through CloudFront. The point was to let these be changed from the admin instead of waiting for store review. A CacheInterceptor sits on those responses, so nothing takes effect immediately. The admin screen says "It may take up to a day for changes to take effect.", and in exchange there is a view that puts the DEV and PROD values side by side.

Deployment and infrastructure
Deployment and infrastructure

Borrowed recordings, computed viewing rights

The recordings are not in the local database. Once a hospital barcode is attached to a baby, MommyboxService asks the source system for that barcode's recordings, and only likes, comments, and share links are layered on top. It runs the other way too. When a hospital uploads a new recording, the source system calls /ultrasound/webhook/ultrasoundAdded; that hook finds the baby by barcode, creates the stats row, automatically restarts any paused license, and, if the barcode belongs to a voucher, grants a license dated from the first recording. Payments arrive on two tracks, in-app purchase receipt validation and VNPAY IPN, and both merge into the same purchases table. The license is created where they merge.

Core data model
Core data model

A license whose expiry moves itself by the number of paused days

The first idea is to put startAt and endAt on purchase_licenses and UPDATE endAt whenever an operator hits pause, adding the paused days back on restart. The problem is that nobody can later explain why a given user's expiry date is what it is. Pause and restart repeat several times, an operator pulls the start date forward and pushes the end date back by hand in between, and the whole story gets flattened into one column. When a support ticket arrives there is no way to reverse any of it.

The license row keeps only startAt and durationInDays, and PAUSE, RESTART, and MANUAL_UPDATE are stacked into purchase_license_changed append-only. On every read, getLicenseEnabledDateRanges replays that log in order and recomputes "the period you may watch" as an array of ranges. A paused stretch is cut out and its length appended after the last range; a pause that has not been restarted yet closes everything from today onward, then reopens the remaining time starting tomorrow. One step further, I set the access check to be not "is this person subscribed today" but "does this recording's uploadedAt fall inside an active range".

Once a license is a sum of events rather than a state, the Change logs modal in the admin console only has to render that table as it is. The calculation is a pure function, so a rule like "if an unrestarted pause exists, the end date must move back by that much" can be pinned down in a unit test with nothing but dates. Anchoring the decision on upload time also merges per-recording billing and time-based subscription into a single rule. A user holding several licenses gets their ranges merged with mergeDateRanges and judged in one pass, and a policy change like "anything uploaded before 13:00 on 31 January 2023 is free" came down to one condition in front of the range comparison.

A mutation request sends its reads to primary as well

Reads to the replica, writes to the master. Textbook separation, and doing exactly that in GraphQL walks you straight into strange bugs.

I made PrismaService request-scoped and had it parse the query document out of the GraphQL request body to decide up front whether this request is a mutation or a query. If it is a mutation, every access inside that request goes to primary; if it is a query, they all go to the replica.

The default now falls on the correct side, so most resolvers can simply write this.prismaService.client().

What a user can do

Get into the operations console with an email and an SMS code
Get into the operations console with an email and an SMS code

Find a user and see the baby, barcodes, and payments on one screen
Find a user and see the baby, barcodes, and payments on one screen

Pause a user's ultrasound license or adjust its period by hand
Pause a user's ultrasound license or adjust its period by hand

Find one payment and refund it
Find one payment and refund it

Issue a barcode voucher to hand out to a hospital
Issue a barcode voucher to hand out to a hospital

Set the price tier, the terms, and the store copy per country
Set the price tier, the terms, and the store copy per country

Edit the copy that ships in the app and promote it to production
Edit the copy that ships in the app and promote it to production

Request the settlement spreadsheet and watch the progress
Request the settlement spreadsheet and watch the progress

Fill in the weekly pregnancy content and the home card order per country
Fill in the weekly pregnancy content and the home card order per country

Compose a push or a popup and send it to one country or hospital
Compose a push or a popup and send it to one country or hospital

Tune the per country chatbot prompt and daily questions, and read the conversations
Tune the per country chatbot prompt and daily questions, and read the conversations

Handle community reports and publish notices and magazines
Handle community reports and publish notices and magazines

Check the hospital and clinic records and read the feedback that comes in
Check the hospital and clinic records and read the feedback that comes in

Manage admin accounts and audit logs, and use the internal tools
Manage admin accounts and audit logs, and use the internal tools

Open an ultrasound link and watch it without the app
Open an ultrasound link and watch it without the app

Open an invite link and join as a baby's family
Open an invite link and join as a baby's family

1 / 1

Three ORMs in one repository

The most embarrassing part is that OpenAI API keys were left hardcoded in the source, one per country (libs/modules/momitalk-ai-chat/lib/momitalk-ai-chat.service.ts). Every other secret moved to Doppler and this one place never got cleaned up. If I built it again, this would be done in the first week.

The data access layer is not clean either. Prisma was laid over code that started with TypeORM, then Kysely raw queries were added on top, so files that read the same table three different ways live side by side. That is what a migration looks like when it loses to the feature schedule before it is finished. A directory still named EXPORT_AGGREGATE_PAID_VOUCHERS_BY_MONTHLY copy is there for the same reason.

Tests exist only where being wrong costs money, like the license period calculation, and there are no resolver-level contract tests. I was not keeping all of it beautiful, but I did try to leave a trail wherever money and access rights were on the line.

Read next

Marketing Site for Tax Agency and Management SaaS

Wecake — 2021