Interview Prep Hub

India HR Conclave — Gated HR Community Platform

A private professional community for HR practitioners. Members join by invitation and approval, keep a real professional profile, write blogs, ask and answer each other's questions, request workshops, and refer peers into the network. It is deliberately a curated community — membership is reviewed, not open signup.

Layered on top of that is a points and rewards feature: members earn points for completing high-value actions — finishing their profile, following the LinkedIn page, inviting peers, publishing a blog — and redeem them in a voucher storefront where 1 point = ₹1. It is an engagement and growth mechanic, and its currency is real money.

Framing note — two things to keep straight. First: you contributed to this system, you did not architect it. Say "the platform does X" or "the approach here is X and here's why I think it's right" — not "I designed X." Second: points are a feature, not the product. The product is the community. Leading with "I worked on a rewards platform" undersells it and invites the wrong questions.

The Reframe That Makes This Interesting

A growth feature whose currency is real money is a fraud surface.

That single sentence is the best thing you can say about this system. Most gamification — badges, streaks, leaderboard points — can be sloppy, because the worst case of a double-credit is a wrong number on a profile. Here the worst case is that someone converts a duplicated credit into a voucher, and the company is out real rupees.

So a mechanic that looks like gamification is built with financial-grade discipline: an append-only ledger, deterministic idempotency keys, credits applied off the request path by a worker that re-reads the source row, and no admin endpoint that can adjust a balance at all. When an interviewer asks "isn't that over-engineered for a community site?" — that is the answer, and it is a strong one.

The secondary point, worth having ready: the incentives point the wrong way by design. You are paying people to invite more people and to produce content, which is exactly the shape of a system that attracts abuse. The approval gate on membership and the review gate on every paid submission exist because of that, not in spite of it.

The 60-Second Pitch

"The system I've been contributing to most recently is India HR Conclave — a private community platform for HR professionals. It's invite-and-approval based rather than open signup, so members are verified practitioners; they keep a professional profile, publish blogs, ask and answer each other's questions, and request workshops.

To drive engagement there's a points feature — you earn points for completing your profile, following the LinkedIn page, referring peers, publishing a blog — and you redeem them for vouchers at one rupee per point. That last part is what makes it technically interesting: it's a growth mechanic that pays out real money, so it's built with financial-grade discipline. Balances are a cache over an append-only ledger, every movement carries a deterministic idempotency key, and points are only ever credited by a background worker consuming an event — never on a request path, and there's deliberately no admin endpoint that can adjust a balance.

Architecturally it's Go microservices behind a Fiber v3 gateway, Postgres, Kafka, and a Next.js admin dashboard."

Product Surface

FeatureWhat it doesEarns points?
Membership & approvalGoogle sign-in, then a second onboarding step (company, designation, phone) and an admin review before the member is approved
Member profileName, company, designation, bio, photo, LinkedIn, areas of expertise, years of experienceYes — profile completion
BlogsMembers draft and submit; an admin reviews; approval publishes to a public feedYes — on approval
Q&AMembers post queries and answer each other — the core community interaction
WorkshopsMembers request a workshop; admins do the scheduling follow-up and then approveYes — on approval
ReferralsInvite a peer by email; the referrer earns once the referee joins and is approvedYes
Social tasksFollow the LinkedIn page and similar admin-defined actionsYes
RedemptionSSO into a Xoxoday voucher storefront; points debited, refunds credited backSpends
Admin dashboardReview queues (members, blogs, workshops, referrals), member directory, task definitions, settings, audit log

Tasks are admin-authored data, not hardcoded features — an admin defines a task with its reward, card copy, prerequisite and whether it repeats. That is why "and many more" is accurate: adding a new way to earn is a data change, not a deploy.

Architecture at a Glance

One Go module (hrbackend/v2, Go 1.26) — services are separate binaries, not separate modules, so shared code is imported as hrbackend/v2/internal/.... The gateway is a Fiber v3 reverse proxy on :8000 that does no business logic: it maps a URL prefix to a service and forwards.

ServicePortOwns
auth-service8001Google OAuth, member tokens, onboarding, email workers
user-service8002Member surface (profile, task board, blogs, referrals), public blog feed
points-service8003Ledger credits, storefront SSO and balance callbacks, reconciliation
redemption-service8004Redemption lifecycle, saga compensation, refunds
admin-service8006Admin auth (password + TOTP), review queues, settings, outbox viewer
rbac-service8007Role/permission catalog and assignment (net/http, not Fiber)
audit-log-service8008Audit events over HTTP plus a Kafka consumer
notification-service8009Stub
reward-catalog-service8010Stub

Say "seven built, two stubs" rather than "nine microservices." The precision costs nothing and it reads as someone who actually works in the codebase.

The Gated Membership Model

This is the part that makes it a community rather than an app with logins, and it is under-discussed in most interviews — bring it up.

  • Sign-in and membership are separate things. Google OAuth gets you an account immediately; you are created active but not approved. Approval is an admin decision, gated on onboarding being complete — company, designation and phone. So the community stays curated without blocking anyone at the door.
  • A blocked account never receives a token. Rejected and suspended members are turned away at the callback with a distinct reason each, rather than being handed a token that the next request rejects. Rejected is checked first, so a turned-down applicant is never told they are "suspended."
  • Members are suspended, never deleted. A member is referenced by id from their tasks, referrals and every ledger row; a soft delete would leave all of that pointing at an invisible row. Suspension carries a required reason that is emailed, and it takes effect on the member's next request because the middleware re-reads the row every time.
  • Two rejection texts, deliberately separate. The reason is emailed to the applicant; an optional internal note only ever reaches the admin activity log — which is what lets a reviewer be candid without the applicant reading it.

Why a curated community changes the engineering

Real identities mean real PII — name, company, designation, phone, email — held for people whose professional reputation is the point of the network. That is why email and phone are encrypted at rest, why the admin surface that decrypts them sits behind a higher permission than the one that renders dashboards, and why a manager role can see queues but not member contact details.

The Growth Loop

Tasks are the engagement engine: each is an admin-authored definition with a reward, card copy, an optional prerequisite and a repeatable flag. A member sees a board of cards, and the board is the product surface for growth.

The referral loop, and where it pays

A member invites a peer by email. The referrer earns only when the referee actually joins and is approved — not when the invite is sent, and not when the referee signs in. That ordering is the whole anti-abuse design: paying on invite would pay for typing addresses, and paying on signup would pay for creating accounts. Paying on approval means a human reviewed a real practitioner before any money moved.

  • Referral count is capped per member by a runtime setting, not a constant.
  • The referee's address is checked for deliverability before the row is written, and a permanently-refused invitation auto-rejects the referral and returns the slot — while the bad address itself stays blocked.
  • Several members may refer the same person; each of them earns, because each did the work.
  • The reward still goes through review — it lands as a submitted task an admin approves, not as an automatic credit.

Prerequisites as a funnel

A definition can be gated behind completing another type — blogs are gated behind profile completion, for instance. That is a product funnel expressed as a data field: it sequences members through the actions the community wants, without any of it being hardcoded.

Worth knowing as a trap: because a definition is identified by its type string, anything gated behind "workshop" unlocks on the first approved workshop of any kind. Fine for a funnel, wrong if you ever want "attend this specific workshop first."

Points & Redemption — the money invariants

The invariant: members.balance == SUM(point_transactions.amount) per member.

  • The log is the truth; the balance column is a cache. Never written directly.
  • One write path. ledger.Apply in one transaction: lock the member row FOR UPDATE → check the idempotency key before the sufficiency check → refuse a debit that would go negative → insert the signed row with its resulting balance → apply a relative balance = balance + ? update.
  • Deterministic idempotency keys — built from source type, source id and action (task:<uuid>:completed), never random or client-supplied. A cross-process race that collides on the unique index is returned as already applied, not an error.
  • Append-only at the database level — a trigger rejects UPDATE and DELETE on the ledger. Corrections are new reversing rows, themselves idempotent.
  • Reconciliation recomputes the sum per member daily and reports drift rather than silently fixing it.

The two ordering details worth knowing

Idempotency before sufficiency. Check the balance first and a replayed debit for a member who has since spent down reports "insufficient balance" — a wrong, confusing answer for an operation that already succeeded. Checking the key first makes a replay report "already applied."

Relative update, not absolute. balance + ? is computed by the database from the current row, so combined with the lock a concurrent writer can never clobber the other's arithmetic.

The deliberate absence: no manual adjustment endpoint

There was an admin-only points-adjustment endpoint behind an adjust_pointspermission. It was removed — route, request type, and the permission itself. Consequence: nothing writes the ledger on an admin's request path. Every credit originates from an approved item flowing through the event pipeline; every debit from the redemption family.

It is a strong "design decision to defend" answer because the cost is real: correcting a balance now needs a deliberate change rather than a form. Defensible at this size, and a bad trade for a product with a large support operation.

The one exception — and its cost

Every credit requires an approving admin on the row, except profile completion, which has no reviewer by design: the member's own save mints a task already completed with no approver and enqueues the event.

Name the cost before they find it: the task type is admin-authored free text, so an admin who can rename a definition's type to profile makes every task on it self-payable. The guard matches that one string exactly to keep the blast radius small — it does not remove the risk. Saying that out loud is what separates a senior answer from a defensive one.

Transactional Outbox + Kafka

The problem: change state and publish an event, across two systems with no shared transaction. Publish first and the commit fails — you announced something that did not happen. Commit first and the publish fails — the credit silently never happens.

The pattern: the event row is written in the same transaction as the state change; a relay polls pending rows and publishes them, with bounded retries before parking a row as failed.

Delivery semantics: at-least-once, not exactly-once — and that is fine preciselybecause the ledger is idempotent. The outbox and the ledger are two halves of one design, and that is the answer to "how do you guarantee exactly-once?" You don't. You make delivery at-least-once and the effect idempotent.

The event is a trigger, never the authority

The worker consuming a completion event does not trust it: it re-reads the source row and refuses to credit unless that row is genuinely approved. A replay, a stale pre-fix event, or one published by hand pays nothing. This is the single most reusable idea in the codebase.

The deploy trap that shipped broken twice

Topics are not auto-created, so adding one in code requires provisioning it per environment. Miss that and every publish fails, the outbox row exhausts its retries and parks as failed, and the credit never happens — silently. Six topics shipped that way.

Redemption & Saga

Redemption debits up front — reserve-then-confirm — then calls the provider.

  • pending reserved, provider not yet confirmed
  • completed provider confirmed success
  • restored provider failed definitively — compensated with a reversing credit
  • pending_reconciliation the provider's answer was ambiguous (timeout / 5xx)
  • cancelled member cancelled before fulfilment

The ambiguous state is the answer to "what do you do when you don't know?"A timeout is not a failure — the provider may have issued the voucher. Auto-restoring double-pays; auto-confirming may hand out something that does not exist. So the saga stops and a human checks the provider's records. Naming a state for "genuinely unknown" instead of guessing is a senior instinct.

Double-deduct protection

Unique indexes on the provider order id and a shared idempotency key. Concurrent retries collide in the database, the transaction rolls back, and the original success envelope is returned. The database is the concurrency control, not application-level checking.

Rate limit keyed on the member, not the IP

Redemption routes are limited per member id, because many members share one corporate egress IP — an IP-keyed limit would throttle a whole company because one person was active.

PII at Rest — deterministic encryption

Member email and phone (and referee contact details) are encrypted with AES-256-GCM using a synthetic nonce derived as an HMAC of the plaintext. Same plaintext, same ciphertext.

Why determinism is the point: the ciphertext still backs a unique index and an equality lookup, so sign-in, duplicate detection and the "phone already registered" conflict all keep working on encrypted columns.

The trade-off, stated honestly: equality is observable. Anyone with database access can see that two rows hold the same value and can confirm a guessed address by encrypting it. Randomised encryption removes that leak and every lookup that makes the system work.

Two hazards

  • Never rotate the key in place. Under a new key the same address encrypts differently, the unique index cannot see the existing row, and sign-in creates a second member for one person — not reversible once points and referrals hang off both. A pinned key fingerprint makes a mismatch fatal at boot instead of corrupting quietly.
  • Mixed plaintext and ciphertext is worse than either. A pod running with encryption off breaks the equality lookups and the unique index behind duplicate detection, so a flag makes those services refuse to boot without keys rather than degrade silently.

Auth, Sessions & RBAC

Three audiences, three separate signing keys

AudienceTTLNotes
Member (Google OAuth only)24hRe-checks the member row exists and is active on every request
Admin (password → TOTP)15 minCarries a session id; role read live from the database
Storefront24hHanded to the voucher provider and echoed back on callbacks

Separate keys mean a leaked storefront token cannot be replayed as an admin token — the signature simply does not verify. The live gotcha: keys are read at package init and an unset variable is silently the empty string, so tokens still sign and verify with no security. One key is genuinely unset in this system, and knowing that is more impressive than pretending it is clean.

Role is read live, not from the token

The middleware validates the token then re-reads the admin row, so a demotion or suspension takes effect on the next request rather than at token expiry. With a 15-minute token, trusting the claim would leave a demoted admin holding their old powers for a quarter of an hour.

One live admin session, newest login wins

The token carries a session id validated against a session row on every request; a new login revokes live sessions in one locked transaction. Two distinct 401 codes — one only ever meaning a genuine take-over, one for everything else — because telling an admin who merely signed out that their account signed in elsewhere would be worse than saying nothing.

Permissions are compiled in

The RBAC service enumerates the same grant table the middleware enforces, so the catalog cannot drift from what is enforced — and creating a custom role honestly returns 405 "roles are fixed" rather than faking success. Three permissions are super-only, and they are the entire difference between super and admin.

Schema Ownership & Boot Order

The gateway does real work before it listens: connect, backfill blank statuses, de-duplicate rows so a new partial unique index can be created, migrate every model, seed opening-balance ledger rows, and install the append-only trigger. All idempotent, all re-run every boot, any failure fatal. No service migrates anything — one list, no drift.

The check-constraint trap

GORM creates a check constraint on a table that lacks one and never alters an existing one. So a new enum value passes on a fresh database and is rejected on every older one — which is how a "send back for rework" status shipped broken: 500s in every live environment while passing every test, because test databases are always fresh. The fix is an idempotent constraint re-assertion on boot.

Even as a contributor this is worth citing — not as your bug, but as the lesson: a test suite that always starts from a fresh schema is structurally blind to migration bugs.

The boot-order window

The gateway starts last, so against an empty database there is a window where services query tables that do not exist. Ordinary queries recover. What does not: a Kafka message consumed in that window is lost, because the consumer commits its offset on read.

Gateway Lessons (fasthttp / proxying)

Connection pooling, and why POST is not retried

Symptom: an admin clicks Approve, gets a 5xx, clicks again, and it works. Cause: the client hands out a pooled connection without checking how long it has been idle, so a connection the upstream already closed gets reused and the write fails. Fix: cut the idle connection lifetime below the upstream's.

Why it only hit mutations: fasthttp replays a failed request only when it judges it idempotent, and the test is literally GET, HEAD or PUT. GETs were retried transparently — every page load looked fine — while POSTs surfaced the error.

And why adding a POST retry would be wrong: "closed before reading the request" and "read the request then died before answering" surface as the same error, so a retry cannot tell a request that never arrived from one already applied. Replaying an approve would approve twice — and here, pay twice.

X-Forwarded-For is set, never appended

On an untrusted hop an inbound XFF is attacker input, so the gateway replaces it rather than appending a chain whose left-hand entries are forged. Downstream services only believe the header when the peer is a configured trusted proxy — which is why a spoofed header can never win a rate-limit exemption. The flip side: put any proxy or tunnel in front and every caller starts looking like that proxy, so the fix is naming the front proxy, not widening the whitelist.

Two smaller ones

  • A rate-limit max of 0 is deliberately not honoured — Fiber reads it as "skip the limiter", so an env-var typo would silently open the gateway.
  • Fiber v3's proxy blocks private IPs by default as an SSRF guard, but every upstream here is a Docker hostname on a private range. Allowing them is safe only because the upstream host always comes from a fixed map and never from the request.

The Next.js 16 Admin Dashboard

Token rotation has to happen in two places

The auth callback rotates the 15-minute token into the session cookie — but that lands on the response, too late for a request already in flight. So the fetch layer rotates too: decode, refresh if lapsed, retry once on a 401, write the cookie back. The symptom was that the first mutation attempted more than fifteen minutes after the cookie was written failed, and the retry "worked" only because the failed attempt had healed the cookie.

Consequence: anything passed to that fetch must be replayable — a string or FormData, never a request stream, because a 401 replays it.

A dead session must be cleared server-side

Redirecting a 401 straight to the home page loops: a render cannot clear a cookie, so the proxy still reads the dead session as live and re-renders into another 401. And non-GET requests get a 401 JSON body rather than a redirect, because fetch follows a 302 as a GET and a client checking res.ok would read the login page as success.

The hydration bug that was really a timezone bug

The container renders in UTC and the reviewer's browser in IST, so any row timestamped after 18:30 UTC was written by the server as one date and re-rendered by the client as the next — a hydration error, and a row whose date depended on who drew it. Fix: pin both locale and time zone at format time.

Counting rows is not fetching them

The approvals page used to fetch several queues at a hundred rows each — fully-joined rows carrying decrypted PII — purely to render a few integers for tab labels, and reported a queue of 101 as 100. Counts now come from each list's own pagination total, and only the visible page is fetched.

Design Decisions You Should Be Able to Discuss

Frame these as "here is how the platform handles it and why I think that's right" — the understanding is what is being tested, and it survives a follow-up in a way that claiming the design does not.

Why is the balance a column at all if the ledger is the truth?

Read performance. Summing every transaction on every read is fine at a thousand members and not fine later, and almost every read wants only the current number. The column is a cache with three protections: it is only ever written by a relative update inside the same locked transaction as the row that justifies it, the trigger makes the log un-rewritable, and a daily job recomputes the sum and reports drift. If they disagree, the log wins.

Why not just credit the points in the approval request?

So the two fail independently. The approval is a decision that must be recorded whether or not the credit succeeds; the credit must eventually happen whether or not the admin's connection survived. The outbox makes the second durable and retryable without holding the first open.

The cost, worth naming: the response cannot report the new balance, so the card flips to completed while the balance catches up on the next poll — and a permanently failed event means an approved-but-unpaid item, visible only in a super-only operator view.

Why does the referrer earn on approval rather than on signup?

Because you are paying for a member, not for an email address. Paying on invite pays for typing addresses; paying on signup pays for creating accounts. Paying when the referee is approved means a human reviewed a real practitioner before money moved. In a community that is curated by design, the approval was going to happen anyway — so tying the reward to it costs nothing and removes the obvious abuse.

Why can a blog be sent back for changes but never rejected?

Rejected is not an editable state, so rejecting would strand the member's work with no way to fix it. Redo is the only way back, unlimited cycles, and it requires a reviewer comment because the member is emailed it. It is enforced at the endpoint, not just hidden in the dashboard — a button removed from one client is not a rule.

Why is the workshop follow-up status independent from the approval decision?

They used to interlock, which let an admin approve from "scheduling in progress" and meant the two could deadlock if either were tightened. They are separate concerns: one is the admin's record of scheduling work, the other is a decision. Both gates were removed rather than reordered, and tests pin the independence in both directions.

Why are members suspended and never deleted?

A member is referenced by id from their tasks, referrals and every ledger row. A soft delete drops them from default queries while all of that keeps pointing at them — dangling references and an invisible row nothing can undo. Suspension is a status the middleware re-checks every request, so it takes effect immediately, and it carries a required reason that is emailed.

Failure Modes Worth Understanding

Attribution rule: if you personally worked on one of these, tell it as your story. If you did not, tell it as "a problem the team hit and what I took from it" — that is still a strong answer, and it is the honest one. Do not narrate someone else's debugging in the first person.

1. Approve fails, second click works

Intermittent 5xx on mutations only, never on page loads. Stale pooled connections plus a client that silently retries GET but not POST. Lesson: know your HTTP client's retry policy, because it decides which of your bugs are visible.

2. Redo works on every fresh database and nowhere else

A new enum value existed only where the check constraint had been created fresh. Lesson: a test suite that always starts from a clean schema cannot see migration bugs.

3. Everyone shares one rate-limit bucket

A tunnel in front of the gateway made every caller look like the tunnel. Lesson: "trust nothing" and "sits behind a proxy" are configurations that must change together.

4. Sign-in returns a duplicate key error

A soft-deleted member is invisible to a default query but a plain unique index still holds their email, so the lookup missed and the insert collided. Lesson: soft delete and unique indexes disagree unless the index is partial.

5. A list filter 500s with "column reference is ambiguous"

A shared query scope grew a join, and an unqualified column both tables have became ambiguous. Lesson: qualify predicates in any reusable scope, even when it looks redundant.

Traps to Prepare For

  • "Isn't a ledger over-engineered for a community site?" — The community does not need one; the points feature does, because a point is a rupee and redeemable. A growth mechanic that pays real money is a fraud surface. Note also what deliberately did not get its own machinery: blogs and workshops reuse the plain task pipeline rather than growing separate state machines.
  • "Why nine services?" — Be honest: the split follows team and deploy boundaries more than load, two are stubs, and the gateway holds no business logic. The genuine benefit is isolating the money path from everything else.
  • "What did you personally do?" — Have two or three specifics ready and lead with them. Then the systems observations land as understanding rather than as borrowed credit.
  • "How is this tested?" — A central test tree mirroring the packages, black-box test packages, and a throwaway Postgres container rather than SQLite, because the code relies on Postgres-only semantics like SELECT … FOR UPDATE and multi-NULL unique indexes. Substituting SQLite would make the tests agree with each other and disagree with production.
  • "What's still broken?" — One signing key is unset, two services are stubs, there are no dashboard tests, and asynchronous email bounces are not handled. Volunteering known gaps reads as ownership.