03 — Database Schema
Semua tabel database ELJoy, ditulis dalam Drizzle ORM schema format (TypeScript). Setiap tabel dilengkapi penjelasan kolom dan relasi.
Enum Types
import { pgEnum } from 'drizzle-orm/pg-core';
// Level CEFR untuk kemampuan bahasa
export const cefrLevelEnum = pgEnum('cefr_level', [
'pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2',
]);
// Tipe assessment
export const assessmentTypeEnum = pgEnum('assessment_type', [
'ice_breaker', // Assessment pertama (tanpa login)
'universal', // Assessment lengkap (tanpa login)
'adaptive_confirmation', // Konfirmasi setelah register
'continuous', // Assessment berkelanjutan (otomatis)
]);
// Status assessment session
export const assessmentStatusEnum = pgEnum('assessment_status', [
'in_progress', 'completed', 'abandoned',
]);
// Mode speaking practice (12 mode)
export const practiceModeEnum = pgEnum('practice_mode', [
'repeat_after_me',
'shadowing',
'role_play',
'daily_life_conversation',
'storytelling',
'opinion_giving',
'discussion',
'debate',
'problem_solving',
'picture_description',
'create_your_speech',
'pronunciation_drill',
]);
// Status mission
export const missionStatusEnum = pgEnum('mission_status', [
'available', // Bisa dikerjakan
'in_progress', // Sedang dikerjakan
'completed', // Selesai
'skipped', // Dilewati user
'expired', // Melewati batas waktu
]);
// Status conversation session
export const conversationStatusEnum = pgEnum('conversation_status', [
'active', 'completed', 'abandoned',
]);
// Role dalam conversation
export const messageRoleEnum = pgEnum('message_role', [
'user', 'assistant', 'system',
]);
// Status subscription
export const subscriptionStatusEnum = pgEnum('subscription_status', [
'active', 'cancelled', 'expired', 'past_due',
]);
// Status payment
export const paymentStatusEnum = pgEnum('payment_status', [
'pending', 'paid', 'failed', 'refunded', 'expired',
]);
// Billing period
export const billingPeriodEnum = pgEnum('billing_period', [
'monthly', 'yearly',
]);
// User membership tier
export const membershipTierEnum = pgEnum('membership_tier', [
'free', 'member',
]);
// Priority level untuk missions
export const priorityEnum = pgEnum('priority_level', [
'high', 'medium', 'low',
]);
// Difficulty level konten
export const difficultyEnum = pgEnum('difficulty_level', [
'beginner', 'elementary', 'intermediate',
'upper_intermediate', 'advanced', 'proficient',
]);
1. Auth & User Tables
users
Tabel utama user. Dikelola bersama Better Auth.
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').primaryKey(), // nanoid
email: text('email').notNull().unique(),
name: text('name').notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'), // URL avatar
tier: membershipTierEnum('tier').default('free').notNull(),
// Metadata
nativeLanguage: text('native_language').default('id'), // ISO 639-1
timezone: text('timezone').default('Asia/Jakarta'),
onboardingCompleted: boolean('onboarding_completed').default(false).notNull(),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
sessions (Auth)
Session login. Dikelola oleh Better Auth.
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
accounts (OAuth)
Koneksi ke provider OAuth. Dikelola oleh Better Auth.
export const accounts = pgTable('accounts', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(), // 'google', 'apple', 'credential'
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
scope: text('scope'),
password: text('password'), // Hashed, only for 'credential' provider
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
verifications
Token verifikasi (email verification, password reset). Dikelola oleh Better Auth.
export const verifications = pgTable('verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(), // email address
value: text('value').notNull(), // token
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
user_preferences
Preferensi user (terpisah dari tabel users agar tidak membengkak).
export const userPreferences = pgTable('user_preferences', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().unique()
.references(() => users.id, { onDelete: 'cascade' }),
// UI Preferences
theme: text('theme').default('system'), // 'light' | 'dark' | 'system'
uiLanguage: text('ui_language').default('id'), // 'id' | 'en'
// Audio Preferences
ttsSpeed: real('tts_speed').default(1.0), // 0.5 - 2.0
ttsVoice: text('tts_voice').default('alloy'), // OpenAI voice ID
muteSfx: boolean('mute_sfx').default(false),
autoPlayTts: boolean('auto_play_tts').default(true),
// Learning Preferences
dailyGoalMinutes: integer('daily_goal_minutes').default(15),
reminderTime: text('reminder_time').default('19:00'), // HH:mm
reminderEnabled: boolean('reminder_enabled').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
2. Assessment Tables
learner_profiles
Profil kemampuan bahasa Inggris user — diupdate setiap kali ada assessment atau practice.
import { jsonb, integer, real } from 'drizzle-orm/pg-core';
export const learnerProfiles = pgTable('learner_profiles', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().unique()
.references(() => users.id, { onDelete: 'cascade' }),
// CEFR Level
cefrLevel: cefrLevelEnum('cefr_level').default('pre_a1').notNull(),
cefrConfidence: real('cefr_confidence').default(0), // 0-1, seberapa yakin dengan level ini
// 7 Dimensi Skill Radar (0-100)
skillScores: jsonb('skill_scores').notNull().default('{}'),
// Type: {
// pronunciation: number,
// grammar: number,
// vocabulary: number,
// fluency: number,
// coherence: number,
// confidence: number,
// engagement: number,
// }
// Aggregated Stats
totalSessions: integer('total_sessions').default(0).notNull(),
totalSpeakingMinutes: integer('total_speaking_minutes').default(0).notNull(),
averageScore: real('average_score').default(0),
// Streak
currentStreak: integer('current_streak').default(0).notNull(),
longestStreak: integer('longest_streak').default(0).notNull(),
lastActiveDate: text('last_active_date'), // YYYY-MM-DD
// XP & Level
totalXp: integer('total_xp').default(0).notNull(),
currentLevel: integer('current_level').default(1).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
assessment_sessions
Setiap kali user menjalani assessment (Ice Breaker, Universal, Adaptive).
export const assessmentSessions = pgTable('assessment_sessions', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id), // Nullable untuk guest
guestId: text('guest_id'), // ID sementara untuk guest
type: assessmentTypeEnum('type').notNull(),
status: assessmentStatusEnum('status').default('in_progress').notNull(),
// Results (diisi saat completed)
cefrResult: cefrLevelEnum('cefr_result'),
overallScore: real('overall_score'),
skillScores: jsonb('skill_scores'),
// Type: sama dengan learner_profiles.skillScores
// Metadata
totalQuestions: integer('total_questions').default(0),
answeredQuestions: integer('answered_questions').default(0),
metadata: jsonb('metadata'),
// Type: { device: string, browser: string, duration_ms: number }
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
assessment_answers
Setiap jawaban dalam satu assessment session.
export const assessmentAnswers = pgTable('assessment_answers', {
id: text('id').primaryKey(),
sessionId: text('session_id').notNull()
.references(() => assessmentSessions.id, { onDelete: 'cascade' }),
questionIndex: integer('question_index').notNull(),
questionText: text('question_text').notNull(),
questionType: text('question_type').notNull(), // 'open_ended', 'repeat', 'describe'
// User's answer
audioUrl: text('audio_url'), // URL ke file audio user
transcript: text('transcript'), // Hasil STT
// AI Evaluation
score: real('score'), // 0-100
evaluation: jsonb('evaluation'),
// Type: {
// pronunciation: number,
// grammar: number,
// fluency: number,
// feedback: string,
// corrections: Array<{ original: string, corrected: string, explanation: string }>,
// }
durationMs: integer('duration_ms'), // Berapa lama user bicara
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
3. Learning Journey Tables
learning_blueprints
Rencana belajar personal yang di-generate AI berdasarkan profil user.
export const learningBlueprints = pgTable('learning_blueprints', {
id: text('id').primaryKey(),
userId: text('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// Blueprint Data (AI Generated)
title: text('title').notNull(), // "Your Path to Confident Communication"
summary: text('summary').notNull(), // Ringkasan rencana belajar
blueprintData: jsonb('blueprint_data').notNull(),
// Type: {
// currentLevel: CefrLevel,
// targetLevel: CefrLevel,
// estimatedWeeks: number,
// focusAreas: string[],
// phases: Array<{
// name: string,
// description: string,
// durationWeeks: number,
// objectives: string[],
// }>,
// }
version: integer('version').default(1).notNull(), // Blueprint bisa di-regenerate
isActive: boolean('is_active').default(true).notNull(),
generatedAt: timestamp('generated_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
learning_objectives
Target belajar spesifik yang dipecah dari blueprint.
export const learningObjectives = pgTable('learning_objectives', {
id: text('id').primaryKey(),
userId: text('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
blueprintId: text('blueprint_id').notNull()
.references(() => learningBlueprints.id, { onDelete: 'cascade' }),
title: text('title').notNull(), // "Improve Meeting Vocabulary"
description: text('description'),
targetSkill: text('target_skill').notNull(), // 'vocabulary', 'pronunciation', etc.
cefrTarget: cefrLevelEnum('cefr_target'),
// Progress (0-100)
progress: real('progress').default(0).notNull(),
status: text('status').default('not_started').notNull(),
// 'not_started' | 'in_progress' | 'completed'
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
missions
Misi harian yang diberikan ke user.
export const missions = pgTable('missions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
objectiveId: text('objective_id')
.references(() => learningObjectives.id),
// Mission Config
title: text('title').notNull(), // "Practice Meeting Discussion"
description: text('description'),
mode: practiceModeEnum('mode').notNull(),
priority: priorityEnum('priority').default('medium').notNull(),
// Content Link
contentId: text('content_id')
.references(() => speakingContents.id),
difficulty: difficultyEnum('difficulty').notNull(),
// Status & Result
status: missionStatusEnum('status').default('available').notNull(),
score: real('score'), // 0-100
xpEarned: integer('xp_earned').default(0),
// Scheduling
scheduledDate: text('scheduled_date').notNull(), // YYYY-MM-DD
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
4. Conversation Tables
conversation_sessions
Setiap sesi percakapan/latihan speaking dengan AI.
export const conversationSessions = pgTable('conversation_sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
missionId: text('mission_id')
.references(() => missions.id),
// Session Config
mode: practiceModeEnum('mode').notNull(),
topicTitle: text('topic_title').notNull(),
contentId: text('content_id')
.references(() => speakingContents.id),
difficulty: difficultyEnum('difficulty').notNull(),
// Status
status: conversationStatusEnum('status').default('active').notNull(),
turnCount: integer('turn_count').default(0).notNull(),
durationMs: integer('duration_ms').default(0),
// Final Evaluation (diisi saat session selesai)
overallScore: real('overall_score'),
evaluation: jsonb('evaluation'),
// Type: {
// pronunciation: number,
// grammar: number,
// fluency: number,
// vocabulary: number,
// coherence: number,
// strengths: string[],
// improvements: string[],
// detailedFeedback: string,
// }
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
conversation_messages
Setiap pesan dalam percakapan (user atau AI).
export const conversationMessages = pgTable('conversation_messages', {
id: text('id').primaryKey(),
sessionId: text('session_id').notNull()
.references(() => conversationSessions.id, { onDelete: 'cascade' }),
role: messageRoleEnum('role').notNull(), // 'user' | 'assistant' | 'system'
content: text('content').notNull(), // Teks pesan
// Audio (opsional)
audioUrl: text('audio_url'), // URL audio (user recording / AI TTS)
audioDurationMs: integer('audio_duration_ms'),
// Untuk pesan user: hasil evaluasi AI
evaluation: jsonb('evaluation'),
// Type: {
// pronunciation: number | null,
// corrections: Array<{ word: string, issue: string, suggestion: string }>,
// feedback: string | null,
// }
turnIndex: integer('turn_index').notNull(), // Urutan dalam percakapan
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
5. Content Tables
content_domains
Domain konten level tertinggi.
export const contentDomains = pgTable('content_domains', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(), // "Business English"
nameId: text('name_id').notNull(), // "Bahasa Inggris Bisnis" (Indonesian)
description: text('description'),
icon: text('icon'), // Emoji atau URL icon
sortOrder: integer('sort_order').default(0).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
content_topics
Topik di dalam setiap domain.
export const contentTopics = pgTable('content_topics', {
id: text('id').primaryKey(),
domainId: text('domain_id').notNull()
.references(() => contentDomains.id, { onDelete: 'cascade' }),
name: text('name').notNull(), // "Meeting & Discussion"
nameId: text('name_id').notNull(), // Indonesian translation
description: text('description'),
// CEFR eligibility range
cefrMin: cefrLevelEnum('cefr_min').default('a1').notNull(),
cefrMax: cefrLevelEnum('cefr_max').default('c2').notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
speaking_contents
Konten latihan speaking yang actual.
export const speakingContents = pgTable('speaking_contents', {
id: text('id').primaryKey(),
topicId: text('topic_id').notNull()
.references(() => contentTopics.id, { onDelete: 'cascade' }),
title: text('title').notNull(), // "Weekly Team Standup"
titleId: text('title_id'), // Indonesian translation
description: text('description'),
// Content Type & Mode
mode: practiceModeEnum('mode').notNull(),
difficulty: difficultyEnum('difficulty').notNull(),
// Stimulus Data (berbeda per mode)
stimulusData: jsonb('stimulus_data').notNull(),
// Type varies by mode:
//
// repeat_after_me / shadowing:
// { sentences: Array<{ text: string, audioUrl: string }> }
//
// role_play / daily_life_conversation:
// { scenario: string, aiRole: string, userRole: string, context: string }
//
// storytelling / opinion_giving:
// { prompt: string, guidingQuestions: string[], prepTimeSeconds: number }
//
// picture_description:
// { imageUrl: string, instruction: string }
//
// debate:
// { topic: string, userPosition: string, aiPosition: string }
//
// problem_solving:
// { scenario: string, problem: string, constraints: string[] }
// CEFR eligibility
cefrMin: cefrLevelEnum('cefr_min').default('a1').notNull(),
cefrMax: cefrLevelEnum('cefr_max').default('c2').notNull(),
// Stats
timesUsed: integer('times_used').default(0).notNull(),
avgScore: real('avg_score'),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
6. Membership & Payment Tables
subscription_plans
Paket berlangganan yang tersedia.
export const subscriptionPlans = pgTable('subscription_plans', {
id: text('id').primaryKey(),
name: text('name').notNull(), // "Monthly", "Yearly"
description: text('description'),
period: billingPeriodEnum('period').notNull(),
priceIdr: integer('price_idr').notNull(), // Harga dalam Rupiah
originalPriceIdr: integer('original_price_idr'), // Harga coret (untuk diskon)
// Features included
features: jsonb('features').notNull(),
// Type: {
// unlimitedAssessment: boolean,
// unlimitedPractice: boolean,
// personalBlueprint: boolean,
// dailyMissions: boolean,
// progressTracking: boolean,
// prioritySupport: boolean,
// }
isActive: boolean('is_active').default(true).notNull(),
isPopular: boolean('is_popular').default(false).notNull(), // Badge "Paling Populer"
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
subscriptions
Langganan aktif user.
export const subscriptions = pgTable('subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
planId: text('plan_id').notNull()
.references(() => subscriptionPlans.id),
status: subscriptionStatusEnum('status').default('active').notNull(),
startsAt: timestamp('starts_at', { withTimezone: true }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
cancelReason: text('cancel_reason'),
// Auto-renew
autoRenew: boolean('auto_renew').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
payments
Riwayat pembayaran.
export const payments = pgTable('payments', {
id: text('id').primaryKey(),
userId: text('user_id').notNull()
.references(() => users.id, { onDelete: 'cascade' }),
subscriptionId: text('subscription_id')
.references(() => subscriptions.id),
// Amount
amount: integer('amount').notNull(), // Dalam Rupiah
currency: text('currency').default('IDR').notNull(),
// Payment Gateway
gatewayName: text('gateway_name').notNull(), // 'midtrans'
gatewayRef: text('gateway_ref'), // Transaction ID dari gateway
gatewayData: jsonb('gateway_data'), // Raw response dari gateway
paymentMethod: text('payment_method'), // 'gopay', 'bca_va', 'credit_card'
status: paymentStatusEnum('status').default('pending').notNull(),
paidAt: timestamp('paid_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
7. Admin & System Tables
audit_logs
Log semua aksi penting di sistem (untuk admin tracking).
export const auditLogs = pgTable('audit_logs', {
id: text('id').primaryKey(),
actorId: text('actor_id'), // User atau admin yang melakukan aksi
actorType: text('actor_type').notNull(), // 'user' | 'admin' | 'system'
action: text('action').notNull(), // 'user.register', 'content.create', 'payment.received'
resource: text('resource').notNull(), // 'user', 'content', 'payment'
resourceId: text('resource_id'),
details: jsonb('details'), // Data tambahan tentang aksi
ipAddress: text('ip_address'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
system_configs
Konfigurasi sistem yang bisa diubah tanpa deploy.
export const systemConfigs = pgTable('system_configs', {
key: text('key').primaryKey(), // 'max_daily_missions', 'free_assessment_limit'
value: jsonb('value').notNull(),
description: text('description'),
updatedBy: text('updated_by'),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
Ringkasan Tabel
| # | Tabel | Jumlah Kolom | Relasi Utama |
|---|---|---|---|
| 1 | users | 11 | → sessions, accounts, learner_profiles |
| 2 | sessions | 7 | → users |
| 3 | accounts | 11 | → users |
| 4 | verifications | 5 | - |
| 5 | user_preferences | 12 | → users |
| 6 | learner_profiles | 14 | → users |
| 7 | assessment_sessions | 12 | → users |
| 8 | assessment_answers | 10 | → assessment_sessions |
| 9 | learning_blueprints | 9 | → users |
| 10 | learning_objectives | 10 | → users, learning_blueprints |
| 11 | missions | 14 | → users, learning_objectives, speaking_contents |
| 12 | conversation_sessions | 13 | → users, missions, speaking_contents |
| 13 | conversation_messages | 9 | → conversation_sessions |
| 14 | content_domains | 8 | - |
| 15 | content_topics | 9 | → content_domains |
| 16 | speaking_contents | 14 | → content_topics |
| 17 | subscription_plans | 10 | - |
| 18 | subscriptions | 10 | → users, subscription_plans |
| 19 | payments | 13 | → users, subscriptions |
| 20 | audit_logs | 8 | - |
| 21 | system_configs | 4 | - |
Total: 21 tabel