Bukti Pemenuhan 17 Kriteria Unjuk Kerja (KUK) โ BNSP Skema J.620100 Web Developer
Monity adalah aplikasi finance tracker personal berbasis web yang dibangun menggunakan Next.js 14 (App Router), TypeScript, Drizzle ORM, PostgreSQL, dan Google Gemini AI. Aplikasi ini memungkinkan pengguna mencatat transaksi keuangan, mengelola rekening, mengkategorikan pengeluaran, serta mendapatkan insight berbasis AI.
| No. KUK | Nama KUK | Fitur di Monity | Status |
|---|---|---|---|
| J.620100.001 | Analisis Tools | Pemilihan Next.js, Drizzle, Zod, Gemini AI | โ Terpenuhi |
| J.620100.003 | Framework / Library | Next.js App Router + Drizzle ORM | โ Terpenuhi |
| J.620100.006 | UX Design | Dark/Light Mode, Framer Motion, Responsive | โ Terpenuhi |
| J.620100.017 | Kode Terstruktur | Modular: api/, components/, lib/, app/ | โ Terpenuhi |
| J.620100.018 | OOP / Type-safe | TypeScript interface, class Drizzle, Zod infer | โ Terpenuhi |
| J.620100.019 | Library Eksternal | bcryptjs, next-auth, @google/generative-ai | โ Terpenuhi |
| J.620100.020 | SQL / Query DB | Drizzle ORM query builder dengan filter kompleks | โ Terpenuhi |
| J.620100.021 | Akses Database | PostgreSQL via Drizzle + Supabase connection pool | โ Terpenuhi |
| J.620100.022 | Algoritma | Auto-categorize, percentage change, monthly trend | โ Terpenuhi |
| J.620100.024 | Migrasi Database | Drizzle Kit push/migrate + schema versioning | โ Terpenuhi |
| J.620100.025 | Debugging | try/catch, console.error, status code HTTP | โ Terpenuhi |
| J.620100.030 | Multimedia / Aset | SVG icons (Lucide), Gradient UI, Tailwind CSS | โ Terpenuhi |
| J.620100.032 | Code Review | Zod validation layer, type checking TypeScript | โ Terpenuhi |
| J.620100.036 | Pengujian | Error handling + Zod schema test via safeParse() | โ Terpenuhi |
| J.620100.044 | Alert / Notifikasi | HTTP status codes + JSON error response | โ Terpenuhi |
| J.620100.045 | Monitor Sistem | Gemini AI insight (spending_alert, monthly trend) | โ Terpenuhi |
| J.620100.047 | Pembaruan PL | Dependency management npm, env config, Supabase | โ Terpenuhi |
Pemilihan stack teknologi didasarkan pada analisis kebutuhan: Next.js dipilih karena mendukung SSR dan API route dalam satu project; Drizzle ORM karena type-safe dan ringan; Supabase karena menyediakan PostgreSQL managed; Zod karena validasi runtime yang konsisten dengan TypeScript.
"dependencies": { "next": "^14", // Framework SSR + API Route "drizzle-orm": "^0.x", // ORM type-safe untuk PostgreSQL "@google/generative-ai": "^0.x", // AI โ Gemini "zod": "^3", // Validasi schema runtime "next-auth": "^5", // Autentikasi modern "bcryptjs": "^2", // Hashing password "framer-motion": "^11" // Animasi UI halus }
Next.js App Router digunakan dengan pattern route.ts sebagai API handler. Data diakses
melalui Drizzle ORM dengan query builder yang type-safe.
src/app/api/transactions/route.tsexport async function GET(request: Request) { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const result = await db.query.transactions.findMany({ where: and(...conditions), with: { account: true, category: true }, orderBy: [desc(transactions.transactionDate)], }); return NextResponse.json({ transactions: result }); }
UI menggunakan Tailwind CSS dengan dark/light mode, Framer Motion untuk animasi micro-interaction, sidebar responsif (mobile hamburger menu), dan komponen reusable. Warna disesuaikan per tema secara kontekstual.
src/components/layout/sidebar.tsx// Framer Motion hover animation <motion.div whileHover={{ x: 4 }} whileTap={{ scale: 0.98 }}> <Link className={cn( "flex items-center gap-3 rounded-xl px-4 py-3 transition-all", isActive ? "bg-blue-600 text-white shadow-md" : "text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-900" )}> <Icon /> <span>{item.title}</span> </Link> </motion.div> // ThemeToggle (light/dark mode) const { theme, setTheme } = useTheme() setTheme(theme === "light" ? "dark" : "light")
Proyek diorganisir dalam struktur modular Next.js App Router: app/ (halaman & API),
components/ (UI reusable), lib/ (logika bisnis: db, ai, auth, utils,
validations), dan types/.
src/
โโโ app/
โ โโโ (auth)/ // Login, Register
โ โโโ (dashboard)/ // Dashboard, Transactions, Accounts, Insights
โ โโโ api/ // REST API handlers
โโโ components/
โ โโโ layout/ // Sidebar
โ โโโ dashboard/ // Dashboard cards
โ โโโ ui/ // Button, Card, dll
โโโ lib/
โโโ db/ // Drizzle schema + koneksi
โโโ ai/ // Gemini integration
โโโ auth/ // NextAuth config
โโโ validations.ts // Semua Zod schema
โโโ utils.ts // Helper functions
Apakah OOP di TypeScript menggunakan interface? โ Ya, interface adalah bagian inti dari OOP TypeScript. Interface berfungsi seperti blueprint/kontrak (mirip abstract class di PHP/Java): mendefinisikan struktur data tanpa implementasi, memaksa setiap objek yang menggunakannya memiliki properti yang sesuai. Di Monity, OOP diwujudkan melalui 4 pilar: interface (kontrak data), class (instantiasi objek AI), encapsulation (Zod schema memproteksi validasi), dan type inference (type-safe dari Drizzle schema).
// interface = mendefinisikan STRUKTUR data (seperti abstract class PHP) export interface FinancialSummary { totalIncome: number; // wajib ada totalExpense: number; // wajib ada balance: number; topExpenseCategories: { category: string; amount: number }[]; monthlyComparison?: { // opsional (tanda ?) currentMonth: number; previousMonth: number; percentageChange: number; }; } // Fungsi menerima kontrak interface โ tidak bisa input sembarangan async function generateFinancialInsight(summary: FinancialSummary, type: string) { // TypeScript error jika summary tidak punya totalIncome, dsb. }
// class = OOP klasik, membuat objek dari blueprint import { GoogleGenerativeAI } from "@google/generative-ai"; // new GoogleGenerativeAI() โ instantiasi class seperti PHP: new GeminiClient() const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); // Memanggil method dari instance (encapsulation) const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash-lite" }); const result = await model.generateContent(prompt);
// Type otomatis di-generate dari definisi tabel (type-safe OOP) export type Transaction = typeof transactions.$inferSelect; // untuk SELECT export type NewTransaction = typeof transactions.$inferInsert; // untuk INSERT // Type dari Zod schema (encapsulation validasi) export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
Beberapa library eksternal diintegrasikan dengan tepat sesuai kebutuhan masing-masing: bcryptjs untuk hashing, NextAuth untuk session, Gemini AI untuk insight cerdas.
src/app/api/auth/register/route.ts &
src/lib/ai/gemini.ts
// bcryptjs โ hashing password sebelum simpan ke DB import bcrypt from "bcryptjs"; const hashedPassword = await bcrypt.hash(password, 10); // @google/generative-ai โ Google Gemini AI import { GoogleGenerativeAI } from "@google/generative-ai"; const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash-lite" }); const result = await model.generateContent(prompt); // next-auth โ session autentikasi const session = await auth(); if (!session?.user?.id) return NextResponse.json({error:"Unauthorized"},{status:401});
Query kompleks menggunakan Drizzle ORM sebagai query builder type-safe yang di-compile menjadi SQL asli: WHERE multi kondisi, LEFT JOIN antar tabel, ORDER BY, LIMIT/OFFSET, dan UPDATE dengan SQL expression.
src/app/api/transactions/route.ts// SELECT transactions dengan filter + join + sort + paginasi const result = await db.query.transactions.findMany({ where: and( eq(transactions.userId, session.user.id), gte(transactions.transactionDate, query.startDate), lte(transactions.transactionDate, query.endDate) ), with: { account: true, category: true }, // LEFT JOIN otomatis orderBy: [desc(transactions.transactionDate)], limit: query.limit, // default 50 offset: query.offset, // paginasi }); // UPDATE balance rekening setelah transaksi masuk await db.update(financeAccounts) .set({ balance: sql`${financeAccounts.balance} + ${balanceChange}` }) .where(eq(financeAccounts.id, accountId));
-- SELECT: ambil transaksi dengan JOIN ke tabel account & category SELECT t.id, t.type, t.amount, t.description, t.merchant, t.transaction_date, a.id AS account_id, a.name AS account_name, a.type AS account_type, c.id AS category_id, c.name AS category_name, c.color FROM "transaction" t LEFT JOIN "finance_account" a ON t.account_id = a.id LEFT JOIN "category" c ON t.category_id = c.id WHERE t.user_id = 'user-uuid-here' AND t.transaction_date >= '2026-01-01' AND t.transaction_date <= '2026-01-31' ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT 50 OFFSET 0; -- UPDATE: kurangi atau tambah saldo rekening setelah transaksi UPDATE "finance_account" SET balance = balance + 50000, -- +income / -expense updated_at = NOW() WHERE id = 'account-uuid-here'; -- INSERT: tambah transaksi baru INSERT INTO "transaction" (id, user_id, account_id, category_id, type, amount, transaction_date) VALUES (gen_random_uuid(), 'user-uuid', 'account-uuid', 'category-uuid', 'expense', 150000, '2026-01-15') RETURNING *;
Koneksi ke PostgreSQL (Supabase) menggunakan postgres.js dengan connection pool mode.
Instance Drizzle dibuat sekali dan di-export untuk digunakan di semua API route.
src/lib/db/index.tsimport { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; const connectionString = process.env.DATABASE_URL!; // Supabase URL // prepare:false = Transaction pool mode (Supabase) const sql = postgres(connectionString, { prepare: false }); // Instance tunggal, dipakai di semua API route export const db = drizzle(sql, { schema });
Tiga algoritma utama: (1) auto-kategorisasi berdasarkan keyword matching, (2) kalkulasi persentase perubahan pengeluaran bulanan, (3) agregasi data trend 6 bulan.
src/app/api/transactions/route.ts &
src/lib/utils.ts
// Algoritma 1: Auto-kategorisasi transaksi berdasarkan merchant async function autoCategorize(merchant: string, type: string, userId: string) { const allCategories = await db.query.categories.findMany(...); const merchantLower = merchant.toLowerCase(); for (const category of allCategories) { const keywords = category.keywords.split(",").map(k => k.trim().toLowerCase()); if (keywords.some(kw => merchantLower.includes(kw))) return category; } return null; } // Algoritma 2: Hitung persentase perubahan bulanan export function calculatePercentageChange(current: number, previous: number) { if (previous === 0) return current > 0 ? 100 : 0; return ((current - previous) / previous) * 100; } // Algoritma 3: Loop 6 bulan terakhir untuk trend chart for (let i = months - 1; i >= 0; i--) { const date = new Date(now.getFullYear(), now.getMonth() - i, 1); // ... ambil data tiap bulan, hitung income & expense }
Drizzle Kit digunakan untuk generate dan menjalankan migrasi. Schema TypeScript di-compile menjadi
DDL SQL
lalu di-push ke Supabase PostgreSQL menggunakan perintah npx drizzle-kit push.
export const transactions = pgTable("transaction", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), accountId: text("account_id").notNull().references(() => financeAccounts.id), categoryId: text("category_id").references(() => categories.id, { onDelete: "set null" }), type: text("type").notNull(), amount: decimal("amount", { precision: 15, scale: 2 }).notNull(), description: text("description"), merchant: text("merchant"), transactionDate: date("transaction_date", { mode: "date" }).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), });
-- Hasil: npx drizzle-kit generate โ file .sql berikut CREATE TABLE IF NOT EXISTS "transaction" ( "id" TEXT PRIMARY KEY, "user_id" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, "account_id" TEXT NOT NULL REFERENCES "finance_account"(id) ON DELETE CASCADE, "category_id" TEXT REFERENCES "category"(id) ON DELETE SET NULL, "type" TEXT NOT NULL, "amount" DECIMAL(15,2) NOT NULL, "description" TEXT, "merchant" TEXT, "transaction_date" DATE NOT NULL, "created_at" TIMESTAMP DEFAULT NOW() NOT NULL, "updated_at" TIMESTAMP DEFAULT NOW() NOT NULL ); -- Perintah yang dijalankan developer -- $ npx drizzle-kit push โ langsung push ke Supabase -- $ npx drizzle-kit generate โ generate file .sql migrasi -- $ npx drizzle-kit migrate โ jalankan file migrasi
Setiap API route dibungkus try/catch dengan log ke console.error dan response JSON
berisi pesan error + HTTP status code yang tepat (400, 401, 404, 409, 500).
src/app/api/auth/register/route.tstry { const parsed = registerSchema.safeParse(body); if (!parsed.success) return NextResponse.json({error:"Validation failed"}, {status: 400}); if (existingUser) return NextResponse.json({error:"Email sudah terdaftar"}, {status: 409}); return NextResponse.json({message:"Registrasi berhasil", user: newUser}, {status: 201}); } catch (error) { console.error("Registration error:", error); // Log untuk debugging return NextResponse.json({error:"Terjadi kesalahan server"}, {status: 500}); }
Icon SVG dari library Lucide React digunakan di seluruh UI. Gradient background dan warna dinamis per
akun/kategori dikonfigurasi melalui properti database (icon, color).
src/components/layout/sidebar.tsx &
src/lib/db/schema.ts
// Lucide React icons (SVG) import { LayoutDashboard, Receipt, PieChart, Wallet, Target, LogOut } from "lucide-react" // Icon & warna tersimpan di DB per akun/kategori export const financeAccounts = pgTable("finance_account", { icon: text("icon"), // e.g. "wallet", "credit-card" color: text("color"), // e.g. "#3b82f6" }); // Gradient di header sidebar // bg-gradient-to-tr from-blue-600 to-indigo-600
Zod schema berfungsi sebagai lapisan validasi/review otomatis: setiap input dari client divalidasi sebelum diproses. TypeScript memberikan static type checking saat development.
src/lib/validations.ts// Zod schema = definisi aturan + validasi sekaligus export const createTransactionSchema = z.object({ accountId: z.string().min(1, "Akun wajib dipilih"), type: z.enum(["income", "expense"]), amount: z.coerce.number().positive("Nominal harus lebih dari 0"), transactionDate: z.coerce.date(), categoryId: z.string().optional(), description: z.string().optional(), }); // Digunakan di API โ validasi sebelum insert ke DB const parsed = createTransactionSchema.safeParse(body); if (!parsed.success) return NextResponse.json({error: "Validation failed"}, {status: 400});
Pengujian dilakukan melalui: (1) validasi runtime Zod dengan safeParse() sebagai unit
test fungsional, (2) session guard (unauthorized check) sebagai integration test, (3) error handling
di setiap endpoint sebagai regression test.
// Test case 1: Input tidak valid โ 400 Bad Request // POST /api/transactions body: { amount: -100 } // โ Zod: "Nominal harus lebih dari 0" โ { error: "Validation failed" } // Test case 2: Tidak login โ 401 Unauthorized // GET /api/transactions tanpa session // โ { error: "Unauthorized" } // Test case 3: Email sudah ada โ 409 Conflict // POST /api/auth/register dengan email yang sudah terdaftar // โ { error: "Email sudah terdaftar" } // Zod safeParse() โ simulasi unit test validasi const result = createTransactionSchema.safeParse({ amount: -100 }); console.log(result.success); // false โ validasi berjalan
Sistem merespons setiap permintaan dengan status HTTP yang informatif dan pesan yang jelas. Di frontend, toast notification / alert ditampilkan berdasarkan response API.
// Tabel kode status yang digunakan di Monity: // 200 OK โ Data berhasil diambil // 201 Created โ Transaksi/Akun/Insight berhasil dibuat // 400 Bad Req โ Validasi gagal (Zod error) // 401 Unauth โ Belum login // 404 Not Found โ Akun/item tidak ditemukan // 409 Conflict โ Email sudah terdaftar // 500 Server Err โ Error tidak terduga // Contoh notifikasi spesifik di insight generate: if (summary.totalIncome === 0 && summary.totalExpense === 0) { return NextResponse.json( { error: "Tidak ada data transaksi untuk periode ini" }, { status: 400 } ); }
Fitur Insight AI berfungsi sebagai monitoring keuangan: spending_alert mendeteksi lonjakan pengeluaran, monthly_summary merangkum kondisi keuangan, dan monthly trend memantau pola 6 bulan terakhir.
src/lib/ai/gemini.ts &
src/app/api/insights/generate/route.ts
// Monitoring pengeluaran: deteksi perubahan signifikan case "spending_alert": const changeInfo = comparison ? `Pengeluaran bulan ini ${comparison.percentageChange > 0 ? "naik" : "turun"} ${Math.abs(comparison.percentageChange).toFixed(1)}% dibanding bulan lalu.` : ""; // Trend monitoring 6 bulan terakhir const monthlyTrend = await getMonthlyTrend(session.user.id, 6); // โ [{month:"Jan",income:5000000,expense:3000000}, ...]
Manajemen dependensi menggunakan npm dengan package.json. Konfigurasi environment (API
key, DB URL) dikelola via .env tanpa hardcode. Supabase memungkinkan update schema
database tanpa downtime.
// .env.local โ konfigurasi environment (tidak di-commit) DATABASE_URL="postgresql://..." // Supabase connection GEMINI_API_KEY="AIza..." // Google Gemini API NEXTAUTH_SECRET="..." // NextAuth secret // Update dependensi: // $ npm update โ update semua package // $ npm audit fix โ patch security vulnerability // $ npx drizzle-kit push โ update schema DB // Gemini model mudah diperbarui di satu tempat: const geminiModel = genAI.getGenerativeModel({ model: "gemini-2.5-flash-lite", // Ganti versi di sini });
Soal berbasis kompetensi SKKNI J.620100 Web Developer. Klik "Lihat Jawaban" pada setiap soal untuk melihat pembahasan lengkap. Jawaban dapat ditampilkan langsung tanpa perlu akses khusus.
a) Framework vs Raw PHP:
b) 3 Keuntungan Laravel:
php artisan make:model Product -mcr).c) MVC untuk fitur "Daftar Produk":
// Model: app/Models/Product.php
class Product extends Model {
protected $fillable = ['name', 'price', 'stock'];
}
// Controller: app/Http/Controllers/ProductController.php
class ProductController extends Controller {
public function index() {
$products = Product::orderBy('name')->paginate(10);
return view('products.index', compact('products'));
}
}
// Route: routes/web.php
Route::get('/products', [ProductController::class, 'index']);
// View: resources/views/products/index.blade.php
@foreach($products as $p)
<tr><td>{{ $p->name }}</td><td>{{ $p->price }}</td></tr>
@endforeach
{{ $products->links() }} {{-- Pagination otomatis --}}
EnsureApproved yang memeriksa status user.
c) Daftarkan middleware tersebut pada route /dashboard/admin.a) Middleware adalah lapisan yang berjalan sebelum request masuk ke controller. Digunakan untuk autentikasi, otorisasi, logging, dsb.
b) Middleware EnsureApproved:
// app/Http/Middleware/EnsureApproved.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureApproved {
public function handle(Request $request, Closure $next) {
// Cek sudah login
if (!auth()->check()) {
return redirect('/login');
}
// Cek status approved
if (auth()->user()->status !== 'approved') {
abort(403, 'Akun belum disetujui admin.');
}
return $next($request); // Lanjut ke controller
}
}
c) Daftarkan di Route:
// routes/web.php
Route::get('/dashboard/admin', [AdminController::class, 'index'])
->middleware(['auth', 'ensure.approved']);
// bootstrap/app.php (Laravel 11) atau Kernel.php (Laravel 10)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['ensure.approved' => EnsureApproved::class]);
})
Order dengan property dan method yang menerapkan enkapsulasi.
c) Buatlah class OnlineOrder yang meng-extend Order (inheritance).a) 4 Pilar OOP:
$order->getTotal() bukan $order->total
secara langsung.OnlineOrder extends Order.
calculateShipping() beda di OnlineOrder vs
StoreOrder.
Payable dengan method pay().b) Class Order dengan Enkapsulasi:
class Order {
private int $id;
private array $items = [];
private float $total = 0;
private string $status = 'pending';
public function __construct(int $id) {
$this->id = $id;
}
public function addItem(string $name, float $price, int $qty): void {
$this->items[] = compact('name', 'price', 'qty');
$this->total += $price * $qty;
}
public function getTotal(): float { return $this->total; }
public function getStatus(): string { return $this->status; }
public function approve(): void { $this->status = 'approved'; }
}
c) Class OnlineOrder (Inheritance):
class OnlineOrder extends Order {
private string $shippingAddress;
private string $courier;
public function __construct(int $id, string $address, string $courier) {
parent::__construct($id); // Panggil constructor induk
$this->shippingAddress = $address;
$this->courier = $courier;
}
public function calculateShipping(): float {
// Override: ongkir khusus online order
return $this->courier === 'JNE' ? 15000 : 20000;
}
public function getFinalTotal(): float {
return $this->getTotal() + $this->calculateShipping();
}
}
a) 5 Produk Terlaris:
SELECT
p.product_name,
p.product_code,
SUM(od.qty) AS total_terjual,
SUM(od.total) AS total_pendapatan
FROM order_details od
JOIN products p ON od.product_id = p.id
GROUP BY p.id, p.product_name, p.product_code
ORDER BY total_terjual DESC
LIMIT 5;
b) Total Pendapatan per Bulan 2025:
SELECT
MONTH(tgl_order) AS bulan,
MONTHNAME(tgl_order) AS nama_bulan,
COUNT(id_order) AS jumlah_order,
SUM(grand_total) AS total_pendapatan
FROM orders
WHERE YEAR(tgl_order) = 2025
GROUP BY MONTH(tgl_order), MONTHNAME(tgl_order)
ORDER BY bulan;
c) Order Detail + Nama Produk:
SELECT
o.id_order,
o.customer,
o.tgl_order,
p.product_name,
od.qty,
od.harga_saat_beli,
od.total
FROM order_details od
JOIN orders o ON od.id_order = o.id_order
JOIN products p ON od.product_id = p.id
ORDER BY o.tgl_order DESC;
product_reviews
perlu dibuat dengan kolom: id, product_id (FK), user_id (FK), rating (1-5), review_text, created_at.
a) Tulis migration Laravel untuk membuat tabel ini.
b) Tulis command Artisan untuk menjalankan migrasi.
c) Jelaskan perbedaan antara migrate, migrate:rollback, dan
migrate:fresh.
a) Migration Laravel:
// database/migrations/xxxx_create_product_reviews_table.php
public function up(): void {
Schema::create('product_reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')
->constrained('products')
->onDelete('cascade');
$table->foreignId('user_id')
->constrained('users')
->onDelete('cascade');
$table->tinyInteger('rating')
->unsigned()
->comment('Nilai 1-5');
$table->text('review_text')->nullable();
$table->timestamps(); // created_at + updated_at otomatis
});
}
public function down(): void {
Schema::dropIfExists('product_reviews');
}
b) Artisan Commands:
php artisan make:migration create_product_reviews_table php artisan migrate
c) Perbedaan Perintah Migrate:
migrate โ Jalankan semua migrasi yang belum dijalankan (aman, non-destruktif).
migrate:rollback โ Batalkan batch migrasi terakhir (jalankan method
down()).
migrate:fresh โ Hapus semua tabel lalu jalankan ulang semua
migrasi dari awal. โ ๏ธ Data hilang! Hanya untuk development.a) Kompleksitas Waktu:
$products[$id]).foreach).b) Cache Produk di Laravel:
// Cache selama 10 menit
$products = Cache::remember('all_products', 600, function () {
return Product::orderBy('name')->get();
});
// Hapus cache saat ada update produk
Cache::forget('all_products');
// Atau gunakan cache tags (Redis)
Cache::tags(['products'])->remember('product_list', 600, fn() =>
Product::all()
);
c) Binary Search PHP:
function binarySearch(array $sortedProducts, string $keyword): int {
$low = 0;
$high = count($sortedProducts) - 1;
while ($low <= $high) {
$mid = intdiv($low + $high, 2);
$name = strtolower($sortedProducts[$mid]['name']);
$kw = strtolower($keyword);
if ($name === $kw) return $mid; // Ketemu
elseif ($name < $kw) $low = $mid + 1;
else $high = $mid - 1;
}
return -1; // Tidak ditemukan
}
// Kompleksitas: O(log n) โ jauh lebih efisien dari O(n)
SQLSTATE[42P01]: Undefined table: relation "order_details" does not exist
a) Apa penyebab error tersebut dan cara mengatasinya?
b) Jelaskan langkah debugging sistematis yang Anda lakukan.
c) Tulis kode penanganan error yang baik untuk query ke tabel order_details.
a) Penyebab Error:
order_details belum ada di database production, padahal
sudah ada di local.php artisan migrate) di server
production, atau file migrasi tidak ikut di-deploy.b) Langkah Debugging Sistematis:
storage/logs/laravel.log atau
tail -f storage/logs/laravel.log.
SHOW TABLES; (MySQL) atau
\dt (PostgreSQL).
php artisan migrate:status.php artisan migrate --force.c) Error Handling yang Baik:
public function getOrderDetails(int $orderId): JsonResponse {
try {
$details = OrderDetail::with('product')
->where('id_order', $orderId)
->get();
if ($details->isEmpty()) {
return response()->json(
['message' => 'Tidak ada detail order ditemukan'],
404
);
}
return response()->json(['data' => $details]);
} catch (\Illuminate\Database\QueryException $e) {
// Tangkap error spesifik DB
Log::error('DB Error getOrderDetails', [
'order_id' => $orderId,
'message' => $e->getMessage(),
]);
return response()->json(
['error' => 'Terjadi kesalahan database'],
500
);
}
}
a) 5 Prinsip UX:
b) Tabel Responsif (Pure CSS):
<style>
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { width: 100%; border-collapse: collapse; min-width: 500px; }
th, td { padding: 10px 14px; border-bottom: 1px solid #e2e8f0; text-align: left; }
@media (max-width: 600px) {
/* Stack layout untuk mobile */
table, thead, tbody, th, td, tr { display: block; }
td::before { content: attr(data-label); font-weight: 700; }
}
</style>
<div class="table-wrap"> <table> ... </table> </div>
c) Feedback Visual: Setiap aksi user harus mendapat respons visual segera (<100ms). Contoh: tombol berubah warna + teks "Ditambahkan!" saat klik "Tambah ke Keranjang", counter keranjang berubah angka, dan toast notification muncul. Tanpa feedback, user akan klik berkali-kali (double submit) karena tidak tahu aksinya berhasil.
a) Authentication vs Authorization:
b) CSRF Protection Laravel:
XSRF-TOKEN.
@csrf (blade) yang menghasilkan
hidden input token.VerifyCsrfToken membandingkan token di request dengan token session.
Jika tidak cocok โ 419 error.{{-- Blade form --}}
<form method="POST" action="/orders">
@csrf {{-- <input type="hidden" name="_token" value="..." /> --}}
...
</form>
c) Simpan Password + Register/Login:
// Register: hash password sebelum simpan
public function register(Request $request) {
$request->validate([
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password), // bcrypt
]);
Auth::login($user);
return redirect('/dashboard');
}
// Login: verifikasi dengan Hash::check
public function login(Request $request) {
$credentials = $request->only('email', 'password');
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate(); // Cegah session fixation
return redirect()->intended('/dashboard');
}
return back()->withErrors(['email' => 'Kredensial tidak valid.']);
}
a) Jenis Test di Laravel:
calculateTotal().RefreshDatabase.b) Feature Test untuk POST /orders:
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderTest extends TestCase {
use RefreshDatabase;
// Skenario 1: Berhasil buat order
public function test_user_can_create_order(): void {
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 50000]);
$response = $this->actingAs($user)->postJson('/orders', [
'items' => [['product_id' => $product->id, 'qty' => 2]],
]);
$response->assertStatus(201)
->assertJsonPath('data.status', 'pending');
$this->assertDatabaseHas('orders', ['customer' => $user->name]);
}
// Skenario 2: Gagal - belum login
public function test_unauthenticated_cannot_create_order(): void {
$response = $this->postJson('/orders', [
'items' => [['product_id' => 1, 'qty' => 1]],
]);
$response->assertStatus(401);
}
}
c) Test-Driven Development (TDD): Pendekatan di mana test ditulis sebelum kode implementasi. Siklus: Red (tulis test yang gagal) โ Green (tulis kode minimal agar test lulus) โ Refactor (perbaiki kode tanpa mengubah fungsionalitas). TDD sebaiknya diterapkan pada fitur kritis (autentikasi, payment, logic bisnis utama), namun bisa terlalu lambat untuk prototyping awal atau UI yang sering berubah.
Daftar pertanyaan yang kemungkinan besar ditanyakan asesor BNSP tentang aplikasi Monity Finance Tracker. Jawab dengan percaya diri dan rujuk ke kode nyata yang ada di proyek kamu. Klik tombol untuk lihat panduan jawaban.
Monity adalah aplikasi personal finance tracker berbasis web yang membantu pengguna mencatat dan memantau keuangan pribadi mereka.
Fitur utama:
Teknologi: Next.js 14 (App Router), TypeScript, Drizzle ORM, PostgreSQL (Supabase), NextAuth.js v5, Google Gemini AI, Tailwind CSS, Framer Motion, Zod, React Query.
Saya memilih Next.js 14 karena beberapa alasan teknis:
route.ts) dan
halaman UI ada di tempat yang sama, tidak perlu backend terpisah.Dibanding Laravel (PHP), Next.js lebih cocok untuk aplikasi real-time dengan UI interaktif karena berbasis React.
Saya menggunakan Drizzle ORM dengan postgres.js untuk koneksi
ke PostgreSQL di Supabase. Koneksi dikonfigurasi di src/lib/db/index.ts:
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
// Connection string dari environment variable
const sql = postgres(process.env.DATABASE_URL!, {
prepare: false // wajib untuk Supabase Transaction Pool mode
});
// Instance tunggal db, dipakai di semua API route
export const db = drizzle(sql, { schema });
Kenapa prepare: false? Karena Supabase menggunakan Transaction Pooler yang
tidak mendukung prepared statements.
Schema tabel dibuat di src/lib/db/schema.ts menggunakan TypeScript, lalu di-push ke
DB dengan npx drizzle-kit push.
Saya menggunakan NextAuth.js v5 yang mendukung dua metode login:
bcryptjs sebelum
disimpan ke DB. Saat login, hash dibandingkan menggunakan bcrypt.compare().
Session disimpan di tabel session (database strategy). Setiap API route di-protect
dengan pengecekan session:
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
Untuk proteksi halaman, saya menggunakan middleware di middleware.ts yang mengecek
session sebelum request sampai ke halaman dashboard.
OOP (Object-Oriented Programming) adalah paradigma yang mengorganisir kode dalam bentuk objek dengan properti dan method. 4 pilarnya: Encapsulation, Inheritance, Polymorphism, Abstraction.
Di Monity (TypeScript), OOP diwujudkan dengan:
FinancialSummary di src/lib/ai/gemini.ts.new GoogleGenerativeAI(apiKey) untuk instantiasi objek
Gemini AI.typeof transactions.$inferSelect menghasilkan
tipe otomatis dari schema database.// Interface = kontrak data (seperti abstract class PHP)
export interface FinancialSummary {
totalIncome: number;
totalExpense: number;
topExpenseCategories: { category: string; amount: number }[];
}
// Class instantiation
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash-lite" });
Saya melakukan validasi di dua lapisan:
// src/lib/validations.ts โ Zod schema
export const createTransactionSchema = z.object({
accountId: z.string().min(1, "Akun wajib dipilih"),
type: z.enum(["income", "expense"]),
amount: z.coerce.number().positive("Nominal harus lebih dari 0"),
transactionDate: z.coerce.date(),
categoryId: z.string().optional(),
description: z.string().optional(),
});
// Di API route โ validasi sebelum insert
const parsed = createTransactionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation failed" }, { status: 400 });
}
Dengan Zod, pesan error otomatis muncul dan tipe data sudah tervalidasi. Tidak ada cara bypass validasi dari client.
Saya menerapkan error handling berlapis:
try {
const parsed = createTransactionSchema.safeParse(body);
if (!parsed.success)
return NextResponse.json({ error: "Validation failed" }, { status: 400 });
// ... proses bisnis
return NextResponse.json({ transaction: result }, { status: 201 });
} catch (error) {
console.error("Transaction create error:", error); // Log ke server
return NextResponse.json({ error: "Terjadi kesalahan server" }, { status: 500 });
}
Migrasi database adalah proses mengubah struktur database (tambah tabel, kolom, relasi) secara terversi dan terkontrol, bukan manual lewat GUI.
Di Monity, saya menggunakan Drizzle Kit. Alurnya:
src/lib/db/schema.tsnpx drizzle-kit generate โ generate file SQL migrasi di folder
drizzle/npx drizzle-kit push โ apply langsung ke Supabase PostgreSQLKeuntungannya: schema tersimpan di kode (version control), perubahan bisa di-rollback, dan konsisten di semua environment (local, production).
-- File drizzle/0000_smiling_blue_blade.sql (di-generate otomatis)
CREATE TABLE "transaction" (
"id" TEXT PRIMARY KEY NOT NULL,
"user_id" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
"account_id" TEXT NOT NULL REFERENCES "finance_account"(id) ON DELETE CASCADE,
"amount" NUMERIC(15, 2) NOT NULL,
"created_at" TIMESTAMP DEFAULT now() NOT NULL
);
Saya menggunakan Drizzle ORM query builder yang menghasilkan SQL asli secara
type-safe. Di src/app/api/transactions/route.ts:
// TypeScript / Drizzle ORM
const result = await db.query.transactions.findMany({
where: and(
eq(transactions.userId, session.user.id),
gte(transactions.transactionDate, startDate), // >= tanggal awal
lte(transactions.transactionDate, endDate), // <= tanggal akhir
type ? eq(transactions.type, type) : undefined // filter income/expense
),
with: {
account: true, // LEFT JOIN ke tabel finance_account
category: true, // LEFT JOIN ke tabel category
},
orderBy: [desc(transactions.transactionDate)], // sort descending
limit: 50, // maksimal 50 per halaman
offset: page * 50, // paginasi
});
SQL yang di-generate setara dengan:
SELECT t.*, a.name as account_name, c.name as category_name FROM "transaction" t LEFT JOIN "finance_account" a ON t.account_id = a.id LEFT JOIN "category" c ON t.category_id = c.id WHERE t.user_id = $1 AND t.transaction_date >= $2 AND t.transaction_date <= $3 ORDER BY t.transaction_date DESC LIMIT 50 OFFSET 0;
Saya mengintegrasikan Google Gemini AI (model
gemini-2.5-flash-lite) untuk fitur Financial Insight. Ada 3 jenis insight:
Cara kerjanya: data transaksi dikumpulkan โ diformat menjadi summary โ dikirim ke Gemini sebagai
prompt โ Gemini menghasilkan teks insight dalam Bahasa Indonesia โ insight disimpan di tabel
insight dan ditampilkan di dashboard.
// src/lib/ai/gemini.ts
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash-lite" });
const prompt = `Kamu adalah asisten keuangan pribadi...
Data keuangan bulan ini: ${JSON.stringify(summary)}
Berikan insight singkat dalam Bahasa Indonesia...`;
const result = await model.generateContent(prompt);
const text = result.response.text();
Proyek menggunakan struktur Next.js App Router yang modular โ setiap folder punya tanggung jawab yang jelas (Separation of Concerns):
src/
โโโ app/
โ โโโ (auth)/ โ Halaman Login & Register
โ โโโ (dashboard)/ โ Dashboard, Transaksi, Akun, Insight
โ โโโ api/ โ REST API handlers (route.ts)
โ โโโ transactions/
โ โโโ accounts/
โ โโโ insights/
โ โโโ auth/
โโโ components/
โ โโโ layout/ โ Sidebar, Navbar
โ โโโ dashboard/ โ Widget summary, chart
โ โโโ transactions/ โ Form, list transaksi
โ โโโ ui/ โ Button, Modal, Dialog (reusable)
โโโ lib/
โโโ db/ โ Schema Drizzle + koneksi
โโโ ai/ โ Gemini integration
โโโ auth/ โ NextAuth config
โโโ hooks/ โ Custom React hooks
โโโ validations.ts โ Semua Zod schema
โโโ utils.ts โ Helper functions
Keuntungan: mudah ditemukan, mudah di-maintain, dan setiap bagian bisa diuji secara terpisah.
Keamanan diterapkan di beberapa lapisan:
user_id
dari session yang terverifikasi, bukan dari input client..env, tidak di-hardcode dan tidak di-commit ke git.// Setiap query selalu scope ke user yang login
const transactions = await db.query.transactions.findMany({
where: eq(transactions.userId, session.user.id), // โ WAJIB ada!
});
Saya melakukan pengujian melalui beberapa pendekatan:
Contoh skenario yang sudah diuji:
Ada beberapa tantangan teknis yang saya hadapi:
balance + balanceChange langsung di
query UPDATE untuk operasi atomic, bukan read-update terpisah.
prepare: false di konfigurasi postgres.js.
Ada beberapa fitur yang ingin saya kembangkan:
Ini menunjukkan bahwa saya memahami kebutuhan pengguna dan punya roadmap pengembangan yang jelas.