Skip to content

andre-christi-kan, portfolio

Kadence International, full-time

Kadence K-⁠OINS

  • iOS
  • Android
  • App Store and Google Play

Kadence's online survey and panel app. Panelists sign up on their phone, are matched to surveys by demographics, answer them, earn points and cash the points out to a bank account or e-wallet. Behind the app is a NestJS microservices backend that K-OINS shares with Kadence Kepo. K-OINS runs on 4 of its services: the user service, built first during Kepo, and the survey, point and contact services, built for K-OINS. Each service has its own MongoDB database, and the services talk to each other only through RabbitMQ. The mobile app and the web front end reach the services over REST with a JWT.

Where
Kadence International, full-time employment
Role
First engineer on the team. I interviewed the internal users and benchmarked other apps, then designed and built K-OINS from scratch: its survey, point and contact services, the whole Flutter app and the infrastructure, CI/CD included. K-OINS also runs on the user service I built earlier for Kepo. A teammate built the web front end. I led the project.
Team
Me, 1 web front-end engineer, 1 designer and 2 interns (1 engineering, 1 design). I led the design direction, the technical direction and the interns. I reviewed the team's code and coordinated with project managers, QA and operations.
Dates
Jan 2022 – Nov 2024
Platforms
iOS and Android (App Store and Google Play), in Indonesia only
Stack
NestJS 10, TypeScript 5.2, MongoDB (Mongoose 7), RabbitMQ, Flutter, AWS
microservices, database per service
4
question types
11
logic outcomes: jump, disqualify, complete
3
iOS and Android from one Flutter app
Cross-platform

Services and how they talk

User, Survey, Point and Contact servicesThe mobile app calls the User service, the Survey service and the Point service over REST with a JWT. Those three and the Contact service talk to each other only through RabbitMQ, for a request and its reply and for notifications. The Survey service writes exports to the export store. The Point service sends a payout to Xendit, which calls back. The Contact service sends each notice on to message delivery. Each of the 4 services has its own MongoDB database. The User, Point and Contact services are shared with Kadence Kepo, and the Survey service is K-OINS's own.REST, JWTREST, JWTREST, JWTwrites the exportpayout and callbacksends for deliveryMobile appiOS, AndroidUser serviceshared with KepoSurvey servicePoint serviceshared with KepoContact serviceshared with KepoRabbitMQrequests, replies,notificationsMongoDBone database per serviceExport storeXenditMessage delivery
The mobile app calls the User service, the Survey service and the Point service over REST with a JWT. User service, Survey service, Point service and Contact service talk to each other only through RabbitMQ. The User, Point and Contact services are shared with Kepo. The 4 services share one MongoDB, one database per service. The Survey service writes to the export store. The Point service sends a payout to Xendit, which calls back. The Contact service sends to message delivery.

01Context and my role

I designed the service split, built the survey, point and contact services, the Flutter app and the infrastructure, and owned the CI/CD. The user service, which K-OINS also runs on, I had built earlier for Kepo. A teammate built the web front end.

I asked for a moodboard first, then recommended the fundamentals: typography, color, and the rest.

Once those were agreed, the designer built the components and the design system, then wireframes, then high-fidelity screens.

We skipped the prototype and the mockup because time was short, the team was small, and other work still had to be handled.

Each morning we held a short standup, then worked that day's cards on a basic Trello board, with no Jira. When a topic needed to continue, it stayed with the person it concerned. Everyone else went back to the work.

  1. Jan 2022Project starts

    I interviewed the internal users and benchmarked other apps, then designed K-OINS and built its survey, point and contact services and its Flutter app from scratch.

  2. Nov 2024Work ends

    The work ended when I left Kadence.

02Why Kadence built it in-house

A vendor built the panel app before K-OINS. It cost IDR 300M to build, plus IDR 20M a month for maintenance, plus IDR 20⁠–⁠30M a month for servers and services, which the vendor fee did not include. The vendor's code was very messy and the result unsatisfying: after delivery the app could not be used at all, so the IDR 300M was wasted. The vendor's app was rated 2 on the App Store and 1 on Google Play.

Built in-house, K-OINS costs Kadence only the IDR 20⁠–⁠30M a month for servers and services (team salaries aside). The IDR 300M build and the IDR 20M monthly maintenance fee are gone, Kadence owns the source code, and the team sets and follows its own standards. K-OINS is rated 4.2 on the App Store and 3.6 on Google Play, with 18k registered panelists and about 2k (11%) active a month. A survey is always running: 2–3 surveys a week.

03What it does

  • Panel sign-up

    Password and Google sign-in, OTP verification by SMS, email or WhatsApp, ID-card verification, referrals, advance sign-up, and a region picker with 4 levels (province down to sub-district).

  • Survey engine

    Surveys with a lifecycle (draft, test, active, closed), categories (normal, screening, consumption, ownership), priority surveys with a cap on how many can be active, version history, and import of surveys and questions from Excel.

  • 11 question types

    Single choice, multiple choice, open text, grid single, grid multiple, grid text, grid number, date, info text, rank and number. Each has display options: single choice as radio, select or autocomplete; rank as drag-and-drop; text as short, medium, long or email.

  • Survey logic

    JUMP_TO, DISQUALIFIED and COMPLETED actions, run as PRE or POST hooks on a question, with conditions on text answers (equal, not equal, in, not in) and number answers (equal, not equal, less than, more than, or equal). Response-based filters ("is answered", "not answered").

  • Distribution

    Open surveys, or surveys targeted by criteria (gender, age range, marital status, education, location by regency or province, or a hand-picked list of users). Quotas stop new answers once full; one response per user; GPS captured with each response; targeted push notifications when a survey goes live.

  • Progress and export

    Response status (on progress, completed, disqualified), progress counts on the panelist's survey list, and a raw-data export to Excel that is uploaded to S3 and can be emailed.

  • Points and payouts

    Points are earned from surveys, referrals, advance sign-up and direct grants, and burned through withdrawals. Survey points are granted automatically or after an admin approves each response. Payouts go to a bank account or e-wallet through Xendit, and a Xendit callback reports the result. Withdrawals total up to IDR 5M a month, and one panelist can withdraw at most IDR 300K a month.

  • Notifications and legal

    Email, SMS, WhatsApp and push; terms and privacy policy with version history; contact-us; weekly report emails.

04How it works

Four services, one database each

K-OINS runs on 4 services of Kadence's NestJS microservices backend, which it shares with Kepo. I built the user service (sign-up and sign-in) first, during Kepo, then the survey, point and contact services for K-OINS. The user, point and contact services are shared with Kepo, and the survey service is K-OINS's own.

I designed K-OINS to grow until it could compete with SurveyToGo. At the start we did not know how many surveys could run at once. I prepared the code so that when the volume came we could scale horizontally and add capacity. The same applies to every service, which is why I split K-OINS by role into the Survey service, Point service and Contact service, next to the User service, rather than building one monolith.

User service
Accounts, sign-in, OTP, password reset, ID-card check, referrals, profile history, regions, weekly report, user export
Survey service
Clients and research projects, surveys (status, criteria, quota, history), questions and display, the logic engine, respondents and answers, raw-data export
Point service
Balances, earn and burn transactions, bank and e-wallet accounts, Xendit payouts and callbacks, weekly report
Contact service
Email, SMS, WhatsApp and push delivery, terms and privacy policy with versions, contact-us
  • Database per service: each service has its own MongoDB database and owns its data. Other services keep only IDs.
  • RabbitMQ between the services, and nothing else: every call from one service to another goes through RabbitMQ. When a service needs data another one owns, such as a user's profile, it sends a request and waits for the reply over RabbitMQ (request-reply). Notifications are asynchronous: the request that triggers one does not wait for it, and RabbitMQ handles its retries and timeouts. The survey service sends to user, point and contact; user to contact and point; point to contact; contact to user.
  • REST only for the clients: the mobile app and the web front end call the services over REST with a JWT. Xendit calls the point service back through its webhook.
  • Stateless identity: the panelist's profile and eligibility (terms accepted, minimum age, verified phone, ID card, advance sign-up) travel inside the JWT, so every service checks the token itself without calling the user service. The token payload can be AES-encrypted.
  • Business rules as guards: quota, survey status, date window, criteria, category and one-response-per-user each live in their own NestJS guard, so a controller reads as a list of preconditions.
  • CASL permissions on top of roles (super admin, admin, user); routes grouped as public, auth, user and admin.
  • A cap on answers: answers are rate-limited to 100 per second so the small servers do not fall over. Each answer runs its question's PRE and POST logic.
  • Small infrastructure: the user and survey services run 2 instances each behind 1 load balancer. The point and contact services run 1 instance each, with no load balancer.
  • Operations: Mongoose transactions for exports and money flows; offset pagination; weekly cron jobs for report emails; rate limiting, Helmet, Swagger docs and health checks; English and Indonesian messages.

Sign up and sign in

Sign-up or sign-inSign-up sends a password or Google sign-in to the user service. The contact service then sends an OTP by SMS, email or WhatsApp. The panelist verifies it, then completes an ID-card check and a 4-level region, from province down to sub-district. Sign-in sends a password or Google sign-in, and the user service returns a JWT.Contact serviceUser servicePanelist appalt[sign up][sign in]password or Googlesend the OTPSMS, email or WhatsAppverify the OTPID-card checkprovince to sub-districtpassword or Googlea JWT
Sign-up sends a password or Google sign-in to the user service. The contact service sends an OTP by SMS, email or WhatsApp. The panelist verifies it, then completes an ID-card check and a 4-level region. Sign-in sends a password or Google sign-in, and the user service returns a JWT.

How a survey is made

How a survey is madeAn admin creates a draft on the survey service, adds questions and their logic, and can import questions from Excel. The survey is then run as a test and set active.Survey serviceAdminopt[importfrom Excel]create a draftquestions and logicimport from Excelrun as a testset it active
An admin creates a draft, adds questions and their logic, and can import questions from Excel. The survey is then run as a test and set active.

How a survey is distributed

How a survey is distributedA survey goes live as open, or targeted by criteria (gender, age range, marital status, education, location by regency or province) or a hand-picked list of users. When a targeted survey goes live, the contact service sends a push to the panelist app. A quota stops new answers once full, and one response is allowed per user.Panelist appContact serviceSurvey servicegoes live, opengender, age range,marital status, education,location by regencyor province,or a hand-picked listalt[open][targeted]quota stops newanswers once fullone responseper usertargeted pushpush notification
A survey goes live as open, or targeted by criteria or a hand-picked list. The contact service sends a push when a targeted survey goes live. A quota stops new answers once full, and one response is allowed per user.

How each question is answered

How a panelist answers one questionFor each question, the survey service runs PRE logic, the panelist gives the answer, and POST logic runs. POST ends in the next question, a jump, disqualified, or completed.Logic engineSurvey servicePanelist apploop[eachquestion]PRE logicthe answerPOST logicnext questiona jumpdisqualifiedor completed
For each question, the survey service runs PRE logic, the panelist gives the answer, and POST logic runs. POST ends in the next question, a jump, disqualified, or completed.

One panelist, one survey

One panelist, one surveyA survey goes live, open or targeted, and the survey service sends a targeted push through RabbitMQ to the contact service, without waiting for it. The contact service notifies the panelist. The panelist opens the survey, and eligibility checks the token, the category, the quota and one response. On each question, PRE logic runs, the panelist gives the answer, and POST logic ends in the next question, a jump, disqualified, or completed.Logic engineEligibilitySurvey serviceRabbitMQContact servicePanelist appsurvey goes live(open or targeted)alt[next question][a jump][disqualified][completed]loop[each question]targeted pushtargeted pushpush notificationopen the surveytoken, category,quota, one responseeligiblePRE logicthe answerPOST logicnext questiona jumpdisqualifiedcompleted
One panelist through one survey. A survey goes live, open or targeted, and the targeted push goes through RabbitMQ. Eligibility checks the token, the category, the quota and one response. On each question, PRE logic runs before the answer, and POST logic ends in the next question, a jump, disqualified, or completed.

From a completed survey to the payout

From a completed survey to the payoutThe panelist completes a survey in the app. Points are earned automatically, or after an admin approves the response. The survey service sends the earned points through RabbitMQ to the point service. The panelist cashes out to a bank account or e-wallet over REST with a JWT. The point service asks Xendit to pay out, and Xendit calls back with the result. The point service then sends a withdrawal notice through RabbitMQ to the contact service, without waiting for it, and the contact service notifies the panelist.Contact serviceXenditPoint serviceRabbitMQSurvey servicePanelist appautomatic, or after an admin approvesan admin approves the responseopt[approval required]survey completedearn pointsearn pointscash out to bankaccount or e-walletpayoutresult callbackwithdrawal noticewithdrawal noticepanelist notified
From a completed survey to the payout. Points are earned automatically, or after an admin approves the response, and the survey service sends them through RabbitMQ. The panelist cashes out to a bank account or e-wallet. Xendit pays out and calls back with the result, and the panelist is notified.

Mobile app

K-OINS needed one codebase for iOS and Android, so I built the app in Flutter. People download the app from the App Store and Google Play. We also built a simple landing page for marketing.

  • Flutter with MVVM: view models on Provider (ChangeNotifier), Dio for HTTP with one client per backend service.
  • 22 feature modules: the question screen renders all 11 question types and their display options.
  • Device features: OneSignal push (with an iOS notification service extension), secure storage for tokens, geolocation, camera and image picker for ID cards, a web view, and English and Indonesian localisation.
  • Three flavors (development, staging, production). iOS builds are obfuscated; Android release builds are split per CPU architecture.

05Decisions and trade-offs

Business

Built in-house, not with the vendor

Why
The vendor's app could not be used after delivery, so the IDR 300M build was wasted. In-house, that build fee and the IDR 20M monthly maintenance are gone. Kadence owns the source code and pays only the IDR 20⁠–⁠30M a month for servers and services, team salaries aside.

Admin approval as a point mode

Why
Surveys that need quality checks hold points until an admin approves the response. The others pay out automatically.

Infrastructure

Four services by role, not one monolith

Why
I designed K-OINS to grow until it could compete with SurveyToGo. At the start we did not know how many surveys could run at once. I prepared the code so that when the volume came we could scale horizontally and add capacity. The same applies to every service, so I split K-OINS by role into the Survey service, Point service and Contact service, next to the User service from Kepo.

One database per service

Why
Each service has its own MongoDB database and owns its data.
Cost
Other services keep only IDs.

RabbitMQ between the services

Why
We wanted reliable services that produce and consume events easily. A fetch of another service's data is a request-reply over RabbitMQ, and a notification never holds up the request that sent it. A service that is down does not lose its messages: they wait in its queue until it is back. Services know only the broker, not each other's addresses, so a second instance of the user or survey service takes work from the same queue.
Cost
One more piece of infrastructure to run, and a request-reply still waits for the other service's answer.

Answers capped at 100 per second

Why
The servers are small, and each answer runs its question's PRE and POST logic, so the cap keeps them from falling over.
Cost
Answers stop at 100 per second. The cap is configured, not a measured peak.

Other

Eligibility in the token

Why
Fewer calls to the user service on every survey request.
Cost
That a change to a panelist's profile applies only after the next token refresh.

One Flutter codebase for iOS and Android

Why
K-OINS needed one codebase for both stores, so I built the app in Flutter. People download it from the App Store and Google Play.

06Testing and delivery

  • Tests: Jest unit tests cover each service, with a 100% coverage threshold.
  • CI/CD (GitHub Actions from the start): for staging and production, test and lint workflows, then a two-stage Docker Buildx build pushed to Docker Hub with version and commit tags, the runner's IP added to the AWS security group for the deploy, and an SSH step that pulls the image and restarts the containers on AWS EC2.
  • Mobile builds: run by hand with script aliases per flavor.
  • Local quality gates: Husky git hooks, ESLint.

CI/CD and where the services run

CI/CD for the 4 services Kadence K-OINS runs on, and where they runA push for staging or production starts GitHub Actions, which runs the tests and lint. A two-stage Docker Buildx build pushes the image to Docker Hub. The runner's IP is allowed in the AWS security group, then the pipeline connects to AWS EC2 over SSH, and each service pulls the image from Docker Hub and restarts. On AWS EC2 the user and survey services run 2 instances each behind 1 load balancer, and the point and contact services run 1 instance each. Mobile builds are run by hand, with one script per build flavor.imagepullPush for staging orproduction:GitHub Actions runstests, lintDocker Buildxtwo-stage buildDocker HubRunner IP allowedin the AWS security group,SSH to AWS EC2:pull the image, restartPoint and contact services1 instance eachUser service2 instancesSurvey service2 instancesLoad balancerMobile builds by hand,one script per flavor
CI/CD for the 4 services K-OINS runs on, and where they run. The infrastructure is kept apart from the service diagrams.

07Stack

Backend
  • Node.js 18
  • TypeScript 5.2
  • NestJS 10
  • Mongoose 7 (MongoDB)
  • NestJS Schedule
  • NestJS i18n
  • Swagger
Auth and access
  • JWT (Passport) with optional AES payload encryption
  • Google sign-in
  • API keys
  • CASL
Files and data
  • AWS S3
  • SheetJS (xlsx) for Excel import and export
Payments
  • Xendit payouts and callbacks
Between services
  • RabbitMQ (request-reply and asynchronous messages)
Messaging
  • SendGrid (email)
  • SMS and WhatsApp gateways
  • OneSignal (push)
Security
  • Helmet
  • NestJS Throttler
Mobile
  • Flutter 3.13+
  • Dart 2.19+
  • Provider (MVVM)
  • Dio
  • OneSignal
  • flutter_secure_storage
  • geolocator
  • camera
  • image_picker
  • webview_flutter
Testing
  • Jest 29
Delivery
  • Docker
  • Docker Hub
  • GitHub Actions
  • AWS EC2
  • a load balancer