Claude MD

 # CLAUDE.md


This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

---

## Project Overview

**Schoolmantic** — SaaS absensi sekolah multi-tenant. Single PostgreSQL database, isolasi tenant via `school_id` di setiap tabel + middleware guard (bukan RLS). Target: 50 sekolah, 300–1000 siswa/sekolah.

**Komunikasi selalu dalam Bahasa Indonesia.**

Stack: Hono + Bun + Drizzle ORM (API) · Nuxt 4 + Vue 3 (Web) · Flutter (Mobile) · PostgreSQL 18 · S3/MinIO

---

## Struktur Monorepo

```
apps/api/        ← Hono + Bun (AKTIF — dikerjakan sekarang)
apps/web/        ← Nuxt 4 dashboard (belum ada)
apps/landing/    ← Nuxt 4 landing page (belum ada)
apps/mobile/     ← Flutter (belum ada)
```

---

## Commands (apps/api)

Semua perintah dijalankan dari `apps/api/`:

```bash
bun install               # install dependencies
bun run dev               # dev server dengan hot reload
bun run db:generate       # generate migrasi baru dari perubahan schema
bun run db:migrate        # apply migrasi ke database
bun run db:seed           # seed data (butuh DB bersih; drop + recreate jika mau ulang)
bun run db:studio         # Drizzle Studio UI di browser
bun run db:push           # push schema langsung ke DB tanpa migrasi file (dev only)
```

Database: `postgres://postgres:kansas8@localhost:5432/schoolmantic`

---

## Arsitektur Database

### Tenant model
- Setiap tabel data sekolah memiliki kolom `school_id UUID NOT NULL`.
- User memiliki banyak membership via `school_memberships(school_id, user_id, member_type)`.
- `member_type` enum: `OWNER | EMPLOYEE | GUARDIAN | STUDENT`.
- **Tidak ada tabel "pelanggan" terpisah** — pelanggan = user dengan membership `OWNER`.
- Middleware Hono membaca `X-School-Id` header → verifikasi membership aktif → inject ke context.

### Identity (unified user model)
- Satu tabel `users` untuk semua orang (SaaS owner, pelanggan, guru, wali, siswa).
- Login bisa pakai `email` atau `phone_e164`. Keduanya nullable, minimal satu wajib ada.
- HP disimpan format E.164 (`+62...`). Email lowercase.
- Password hash: **argon2id** via `Bun.password.hash()`.
- Role ditentukan dari `school_memberships`, bukan kolom di `users`.

### Hirarki role (per sekolah)
1. `is_saas_owner` flag di `users` — akses global.
2. `OWNER` membership — pelanggan, akses penuh ke sekolahnya.
3. `EMPLOYEE` membership → `employee_profiles` dengan flag `is_school_admin`, `is_homeroom_teacher`, `is_bk`, `is_treasurer`.
4. `GUARDIAN` membership — wali siswa, akses lintas sekolah (query via student membership, bukan tenant selector).
5. `STUDENT` membership — read-only.

### Schema files (`apps/api/src/db/schema/`)
| File | Tabel utama |
|---|---|
| `enums.ts` | 18 pgEnum (semua enum PG app ini) |
| `users.ts` | `users`, `refresh_tokens`, `device_push_tokens` |
| `schools.ts` | `schools` (+ kolom GPS, TZ, notif settings) |
| `memberships.ts` | `school_memberships`, `employee_profiles`, `student_profiles`, `guardian_student` |
| `academic.ts` | `academic_years`, `semesters` |
| `classes.ts` | `classes`, `student_class_enrollments` |
| `subjects.ts` | `subjects`, `teaching_assignments` |
| `schedules.ts` | `schedule_templates`, `schedule_template_slots`, `class_schedule_assignments`, `employee_schedule_assignments`, `schedule_overrides`, `holidays` |
| `attendance.ts` | `attendance_daily_students`, `attendance_daily_employees`, `attendance_events`, `attendance_devices`, `face_embeddings` |
| `leave.ts` | `leave_requests` |
| `notifications.ts` | `notification_templates`, `notifications_outbox` |
| `subscriptions.ts` | `subscription_plans`, `school_subscriptions`, `payments` |
| `audit.ts` | `audit_logs` |

### Aturan soft delete + unique constraint
Semua unique constraint natural-key menggunakan **partial index** `WHERE deleted_at IS NULL`:
```sql
-- contoh yang sudah ada di schema:
CREATE UNIQUE INDEX ON users (email) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX ON student_profiles (school_id, nis) WHERE deleted_at IS NULL;
```
Jangan pakai `UNIQUE` biasa di kolom yang soft-deletable.

### Absensi: 2 tabel terpisah
- `attendance_daily_students` — 1 baris per siswa per hari (status final).
- `attendance_daily_employees` — 1 baris per pegawai per hari (status final + selfie GPS).
- `attendance_events` — raw event append-only dari biofinger / selfie (tidak diedit).
- Status enum: `HADIR | TERLAMBAT | IZIN | SAKIT | ALPHA | LIBUR | DISPENSASI`.

### Notifikasi (outbox pattern)
- `notifications_outbox` diisi oleh API saat event terjadi.
- Worker eksternal (terpisah dari project ini) polling tabel ini untuk kirim WA/Telegram.
- FCM push dikirim langsung dari API (Firebase Admin SDK).

### Jadwal & override
Engine resolusi: cari `schedule_overrides` aktif untuk scope (SCHOOL > CLASS > EMPLOYEE) → fallback ke `class_schedule_assignments` / `employee_schedule_assignments` → fallback ke `schedule_templates`.

---

## Pola Penting untuk Implementasi Berikutnya

### Tenant guard (Hono middleware yang belum dibuat)
```typescript
// Setiap route yang butuh school context:
app.use('/api/v1/schools/:schoolId/*', requireAuth, requireSchoolContext);

// requireSchoolContext harus:
// 1. Ambil schoolId dari c.req.param('schoolId') atau header X-School-Id
// 2. Query school_memberships WHERE school_id = ? AND user_id = ? AND ended_at IS NULL
// 3. Set c.set('schoolId', ...) dan c.set('memberType', ...)
// 4. Return 403 jika tidak ada membership aktif
```

### Drizzle query pattern
```typescript
import { db, schema } from '~/db';
import { eq, and, isNull } from 'drizzle-orm';

// Selalu filter school_id untuk tabel tenant-scoped:
const rows = await db
  .select()
  .from(schema.classes)
  .where(and(eq(schema.classes.schoolId, schoolId), isNull(schema.classes.deletedAt)));
```

### Normalisasi HP
Format HP input bisa `08xxx` atau `628xxx` atau `+628xxx` — normalisasi ke E.164 di service layer sebelum simpan ke DB.

---

## Konteks Bisnis Penting
- **Trial 30 hari** otomatis saat sekolah baru dibuat. Status: `TRIAL → GRACE (7 hari) → SUSPENDED`.
- Sekolah `SUSPENDED` → semua endpoint kecuali billing return 402.
- Selfie pegawai **wajib dalam radius GPS** sekolah (`schools.gps_radius_meters`, default 100m).
- Timezone **per sekolah** (`schools.timezone`, default `Asia/Jakarta`) — semua kalkulasi tanggal absensi pakai TZ sekolah.
- Wali siswa bisa punya anak di **beberapa sekolah** — dashboard wali tidak punya tenant selector, query via `guardian_student` join.
- Audit log **wajib** untuk: perubahan role/flag pegawai, koreksi absensi manual, approve/reject izin, perubahan status subscription/payment, soft delete master data.

---

## Seed Data (referensi)
Data seed tersedia di `src/db/seed.ts`. Password semua user: **`password123`**.

| Akun | Email / HP | Role |
|---|---|---|
| SaaS Owner | admin@schoolmantic.com / +62811000000000 | SaaS Owner |
| Pelanggan | pelanggan@example.com / +62811000000001 | Owner SD Mantic Bekasi |
| Admin Sekolah | admin.sekolah@example.com / +62811000000002 | School Admin |
| Guru 1–10 | +62811000001001 s.d. +62811000001010 | Teacher |
| Siswa 1–50 | +62811000002001 s.d. +62811000002050 | Student |
| Wali 1–50 | +62811000003001 s.d. +62811000003050 | Guardian |

Sekolah: **SD Mantic Bekasi**`Asia/Jakarta`, GPS Bekasi, 2 kelas (1A & 1B), 10 guru, 50 siswa, 10 mapel, jadwal reguler Senin–Jumat 07:00–14:00.

---

## Roadmap
Lihat `ROADMAP.md` untuk 15 fase sampai produksi. Fase saat ini: **Fase 3 — API Master Data CRUD**.

Comments

Popular posts from this blog

Roadmap