Database Schema
Hackorda's data layer is PostgreSQL accessed through Drizzle ORM. The schema is the source of truth for a crowd-QA / bug-bounty platform: organizations run test cycles against their products, testers file issues, admins triage and settle payouts, and a small set of platform tables (API keys, notifications, audit, rate-limiting) keep it safe to operate.
A legacy LMS / quiz / hackathon-event data model also lives in the same database. It is off the current product path — present, migrated, but not where the active QA work happens. It's documented at the end for completeness.
How the schema is organized
The schema is split by domain under src/db/schema/<domain>.ts, each file
owning its tables plus the enum constants those tables reference. Two barrels
tie it together:
src/db/schema/index.ts— re-exports every domain file in dependency order (foundational tables first, leaf domains last).src/db/schema.ts— a 12-line barrel that does nothing butexport * from "./schema/index". It exists sodrizzle.config.ts(schema: './src/db/schema.ts') and the ~108 callers thatimport … from "@/db/schema"never had to change when the single file was split into a folder. There is no monolithic schema file anymore — to add a table, drop it in the right domain file and re-export it fromschema/index.ts.
Domain files:
| File | Main tables | Purpose |
|---|---|---|
_enums.ts | roles, quizStatuses, selectionCriteria, moduleTypes, courseVisibilities | Cross-cutting const enums + their lookup/reference tables |
institutions.ts | institutions | Kazakhstan education-institution catalog (~2400 seeded rows) |
users.ts | users, userEducation, adminImpersonationLog | User profiles, education history, "view as user" audit trail |
test-cycles.ts | organizations, products, productVersions, testCycles, cycleDocuments, testers, testRuns | The QA catalog spine + cycle membership + execution sessions |
test-cases.ts | testCases, testSuites, testSuiteCases, cycleTestCases, testExecutions | Reusable structured test-case library + per-cycle plans + execution history |
issues.ts | issues, issueComments, attachments, issuePayouts, payoutBatches, issueVerifications, aiRuns | Filed bugs, the payout ledger that settles them, verification attempts, AI provenance |
integrations.ts | organizationIntegrations | External integration providers (Linear/Jira/GitHub/Anthropic) + per-org install state |
notifications.ts | notifications | In-app bell notifications, one row per (user, event) |
audit.ts | auditLog | Append-only trail of sensitive admin actions |
infra.ts | requestIdempotency, rateLimitBuckets | Per-request safety: idempotency keys + Postgres-backed rate limiting |
api-keys.ts | apiKeys | Scoped programmatic / agent access (Bearer hk_live_…) |
learning.ts | courses, modules, lessons, courseEnrollments, modulePermissions, moduleProgress, lessonProgress | Legacy LMS (Techorda academy) |
quiz.ts | questionSets, questionModules, questions, quizSessions, quizAnswers | Legacy placement-quiz engine |
events.ts | events, teams, teamInvitations, userTeams, plus submissions/scoring | Legacy hackathon / event system |
The role model
Roles are a normalized reference table, not a Postgres enum.
-
users.roleIdis anintegerFK →roles.id(roleslives in_enums.ts).rolesis a seeded lookup table (id,name,description). -
The valid values come from the
ROLESconst in_enums.ts:export const ROLES = { ADMIN: 1, STUDENT: 2, GUEST: 3, QA: 4, SUPER_ADMIN: 5, } as const;STUDENT(2) is the effective default for a normal signup;SUPER_ADMIN(5) is a tier aboveADMIN(added in migration0036_super_admin_role.sql) — super-admins are recognized as admins everywhere, plus they own the money/role-sensitive actions.
There is no
user_rolePostgres enum. Older versions of this doc described aCREATE TYPE "user_role" AS ENUM('student','admin')— that is wrong and was never in the current schema. The very first migration (0000_medical_iron_fist.sql) creates arolestable with ausers.role_idFK; there is not a singleCREATE TYPEstatement in the migration history.
roleId is a global role. Per-context roles are separate and do not
elevate global access:
- Per-cycle:
testers.roleuses theTESTER_ROLESconst —tester|lead|observer. - Per-team (legacy events):
userTeams.roleusesTEAM_ROLES—owner|member.
The core QA domain
Catalog — who is testing what
The spine is a strict ownership chain:
organizations → products → productVersions → testCycles, with testers joined
in per cycle.
| Table | Purpose |
|---|---|
organizations | The tenant. Internal / client / sponsor / partner (ORGANIZATION_TYPES). Has a slug. |
products | A product owned by an org. Carries the test url, repoUrl, a free-form discipline tag, an optional integration provider, and the payoutRequiresVerification policy default. |
productVersions | A named/numbered version of a product, optionally attached to a cycle. |
testCycles | The unit of QA work: a scoped testing window against a product (+ optional version). Carries status, dates, lead, the open-to-join toggle, the cycle kind, and verifier-bounty config. |
cycleDocuments | Notion-style markdown docs scoped to a cycle (brief / runbook / report / doc). |
testers | Cycle membership. (testCycleId, userId) unique; per-row role (TESTER_ROLES) and a notify flag. |
testCycles.kind (TEST_CYCLE_KINDS: standard | verification | feature)
drives the tester view and which issues populate the cycle's queue.
verification cycles re-test fixed bugs pulled from sourceCycleIds;
feature cycles scope to featureLabels.
Execution — sessions and what testers find
| Table | Purpose |
|---|---|
testRuns | One tester's testing session inside a cycle (in-progress / completed / abandoned). Holds environment metadata, notes, and an optional AI session recap. |
issues | A filed bug / observation / task against a cycle (and optionally a run). The richest table: repro fields, severity/priority/status, payout tracking, verification-path routing, external-tracker linkage, AI suggestions, and optional test-case linkage. |
issueComments | Threaded discussion on an issue. |
attachments | Polymorphic media table. (targetType, targetId) points at an issue, comment, cycle, cycle document, or run (ATTACHMENT_TARGET_TYPES); mediaKind is image / video / file; the blob lives in S3 (s3Key). |
Test cases — structured (vs. exploratory) testing
A reusable, product-scoped test-case library that issues can reference:
testCases + testSuites/testSuiteCases (the library), cycleTestCases
(a cycle's test plan, with a content snapshot so the plan is stable even if
the source case changes), and testExecutions (append-only run history). An
issue can link back to the testCaseId / testExecutionId that surfaced it.
Payouts — settling accepted bugs
| Table | Purpose |
|---|---|
issuePayouts | The payment ledger: one row per actual payment for an issue (amount in cents + currency + method + reference). |
payoutBatches | A grouped settlement run (e.g. "1–15 May") so testers see one stamped paycheck and the period audit survives later voids. Per-payout rows reference their batchId (soft FK). |
issueVerifications | One row per tester pass over one bug in a verification cycle (verified / still_broken). A separate table so the audit survives status changes and the same bug can collect multiple attempts. Carries a snapshotted verifier bounty (payoutAmountCents) with its own PAYOUT_STATUSES lifecycle. (issueId, verificationCycleId, verifierUserId) unique. |
Payout state on an issue itself lives in issues.payoutStatus /
payoutAmountCents / payoutCurrency (the decision); issuePayouts records
the money moving.
AI — agent provenance
| Table | Purpose |
|---|---|
aiRuns | Append-only provenance for every LLM invocation: kind, polymorphic target, model, prompt version, status, token counts, cost, latency, and the structured output. The issue-intake agent's suggestions are mirrored onto issues.aiSuggestions with an aiIntakeRunId pointer back here. |
Platform — operating the system
| Table | Purpose |
|---|---|
apiKeys | Org-scoped programmatic access. Only the SHA-256 keyHash is stored (raw key shown once); carries scopes, optional cycleIds, a rateLimit, and isActive. |
notifications | In-app bell. One row per (user, event); nullable cycleId/issueId link the bell straight to the resource. |
auditLog | Append-only "who did what, when, before/after" for payout decisions, status changes, role changes, cycle transitions. Fire-and-forget — never blocks the primary action. |
rateLimitBuckets | Postgres-backed fixed-window limiter; one row per (bucket key, window), incremented with an atomic upsert. |
requestIdempotency | Idempotency keys for unsafe-to-repeat operations (the batch-payout endpoints): claim → process → cache response → replay on retry. |
Key enums
All enums are const objects $type-tagged onto varchar columns (or
integer FKs to a reference table for the legacy _enums.ts set). Values below
are the real ones from the schema.
| Enum (const) | Column(s) | Values |
|---|---|---|
ROLES | users.roleId → roles.id | admin(1), student(2), guest(3), qa(4), super_admin(5) |
ISSUE_TYPES | issues.type | bug, observation, task |
ISSUE_SEVERITIES | issues.severity | critical, high, medium, low, trivial |
ISSUE_PRIORITIES | issues.priority | urgent, high, normal, low |
ISSUE_STATUSES | issues.status | draft, open, triaged, in_progress, fixed, verified, deferred, duplicate, rejected, cant_reproduce, wont_fix |
ISSUE_BUG_TYPES | issues.bugType | functional, visual, crash, performance, content, accessibility, security, other |
ISSUE_VERIFICATION_PATHS | issues.verificationPath | standalone, via_feature, none |
ISSUE_VERIFICATION_RESULTS | issueVerifications.result | verified, still_broken |
PAYOUT_STATUSES | issues.payoutStatus, issueVerifications.payoutStatus | pending, info_requested, approved, rejected, paid, void |
PAYMENT_METHODS | issuePayouts.method, payoutBatches.method | bank_transfer, kaspi, cash, other |
TEST_CYCLE_STATUSES | testCycles.status | planned, active, review, closed, cancelled |
TEST_CYCLE_KINDS | testCycles.kind | standard, verification, feature |
TESTER_ROLES | testers.role | tester, lead, observer |
TEST_RUN_STATUSES | testRuns.status | in_progress, completed, abandoned |
CYCLE_DOCUMENT_KINDS | cycleDocuments.kind | doc, brief, runbook, report |
ORGANIZATION_TYPES | organizations.type | internal, client, sponsor, partner |
TEST_CASE_STATUSES | testCases.status | draft, active, deprecated |
TEST_EXECUTION_STATUSES | testExecutions.status | passed, failed, blocked, skipped |
ATTACHMENT_TARGET_TYPES | attachments.targetType | test_cycle, issue, issue_comment, cycle_document, test_run |
MEDIA_KINDS | attachments.mediaKind | image, video, file |
AI_RUN_KINDS | aiRuns.kind | intake_issue, run_summary, cycle_report |
AI_RUN_STATUSES | aiRuns.status | queued, running, done, failed |
API_KEY_SCOPES | apiKeys.scopes (comma-joined) | cycles:read, cycles:write, issues:read, issues:write, issues:triage, runs:write, payouts:read, payouts:write, analytics:read, ai:write, admin:read |
INTEGRATION_PROVIDERS | products.integrationProviderSlug, organizationIntegrations.providerSlug | linear, jira, github_issues, anthropic |
AUDIT_ACTIONS | auditLog.action | issue.status_changed, issue.payout_decision, payout.batch_paid, cycle.status_changed, user.role_changed |
For exact column lists, types, and the
onDeletebehaviour of every FK, read the domain file directly — the schema is the source of truth and this table deliberately doesn't duplicate it.
Migrations
Migrations are generated with drizzle-kit (dialect: 'postgresql',
schema: './src/db/schema.ts', out: './src/db/migrations') and live in
src/db/migrations/ with the journal under migrations/meta/.
There are 41 migrations, numbered 0000_* through
0040_last_roulette.sql (the journal in meta/_journal.json has 41 entries).
The history is real and substantial — a few markers along the way:
0000_medical_iron_fist.sql— initial schema, including therolestable.0028_verification_gate.sql— verification gate on payouts.0029_payout_batches.sql— batched settlement runs.0031_scale_indexes.sql— compound indexes for the hot list paths.0032_audit_log.sql/0033_request_idempotency.sql— platform safety tables.0034_test_cases.sql— the structured test-case domain.0036_super_admin_role.sql— the 5th role.0040_last_roulette.sql— latest.
Connection, SSL & pooling
src/db/index.ts builds a pg.Pool and wraps it with
drizzle(pool) (drizzle-orm/node-postgres). Highlights:
- Pool tuning:
max(DB_POOL_MAX, default 20),idleTimeoutMillis30s,connectionTimeoutMillis10s, and a per-sessionstatement_timeout(DB_STATEMENT_TIMEOUT_MS, default 15s) set on every new connection. - SSL is mode-driven for managed Postgres:
DB_CA_CERT(verify against a CA bundle),DB_SSL_MODE(verify/require/no-verify/allow/disable), and a fail-closed default (SSL on,rejectUnauthorized: true) in production when nothing else is set. Local/non-production with nothing set runs without SSL.
For provisioning, backups, the managed-provider cutover, and SSL operational detail, see ops/database.md.