# CLAUDE.md

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

## Project Overview

Tasyakuran NFBS Bogor is a digital invitation application for graduation ceremonies, managed per cohort (angkatan) and sent to parents/guardians via WhatsApp. Built as a lightweight Express.js application designed to run on cPanel hosting with ~1GB disk space.

**Stack:**
- Backend: Express.js (ESM, Node 18+)
- Template Engine: EJS + express-ejs-layouts
- Database: MariaDB/MySQL via Drizzle ORM + mysql2
- Auth: Passport (local + Google OAuth) with MySQL session store
- Frontend: Tailwind CSS + Alpine.js
- Upload: Multer + Sharp (auto-compress to WebP)
- Encryption: AES-256-GCM for phone numbers

## Development Commands

```bash
# Development
npm run dev                 # Start with --watch flag (Node 18+ auto-restart)
npm run watch:css          # Watch Tailwind CSS changes (run in separate terminal)

# Database
npm run db:generate        # Generate Drizzle migrations from schema changes
npm run db:migrate         # Run pending migrations
npm run db:seed           # Seed initial admin user (uses ADMIN_EMAIL/PASSWORD from .env)

# Build
npm run build:css         # Build Tailwind CSS (minified for production)
npm start                 # Production start (no auto-restart)
```

**Typical development workflow:**
1. Terminal 1: `npm run dev` (starts server on port 3000)
2. Terminal 2: `npm run watch:css` (rebuilds CSS on file changes)
3. Access: http://localhost:3000

## Architecture

### Database Schema (src/db/schema.js)

Core tables:
- **users**: Admin and wali_kelas (class guardians) with role-based access
- **angkatan**: Cohorts/graduation years (e.g., "Angkatan 7, 2026")
- **kelas**: Classes within cohorts, assigned to wali_kelas
- **santri**: Students with encrypted phone numbers, linked to kelas
- **auditLog**: Audit trail for all important actions
- **sessions**: MySQL-backed express-session store

### Authentication & Authorization (src/auth/passport.js, src/middleware/auth.js)

- **Local strategy**: bcrypt password hashing (cost 10)
- **Google OAuth**: Optional, configured via GOOGLE_CLIENT_ID/SECRET
- **Middleware**:
  - `requireAuth`: Ensures user is logged in
  - `requireAdmin`: Admin-only routes
  - `ensureClassAccess`: Wali kelas can only access their assigned classes

### Security Features

- **Phone number encryption**: AES-256-GCM at-rest (src/lib/crypto.js)
  - Key from ENCRYPTION_KEY env var (64 hex chars = 32 bytes)
  - `encrypt(plain)` / `decrypt(payload)` functions
- **Password hashing**: bcrypt cost 10
- **Session security**: httpOnly, sameSite=lax, secure in production
- **Rate limiting**: 10 login attempts per 15 minutes (express-rate-limit)
- **Content sanitization**: sanitize-html for rich text invitation content
- **Audit logging**: All critical actions logged to auditLog table (src/lib/audit.js)
  - Function: `logAudit({ userId, action, entityType, entityId, metadata })`
  - Actions tracked: create, update, delete, send_wa, bulk_import
  - Entity types: angkatan, kelas, santri, user, undangan
  - Metadata stored as JSON for additional context

### File Upload System (src/lib/upload.js)

- **Multer** for multipart handling
- **Sharp** for image compression to WebP format
- Upload destinations:
  - Background images: `public/uploads/angkatan-{id}/bg-{timestamp}.webp`
  - Audio files: `public/uploads/angkatan-{id}/audio-{timestamp}.{ext}`
- Helper functions:
  - `compressImage(filePath)`: Auto-compress to WebP
  - `publicUrl(absPath)`: Convert absolute path to public URL
  - `deleteIfExists(relUrl)`: Clean up old files

### WhatsApp Integration (src/lib/wa.js)

- **normalizeWa(input)**: Converts phone numbers to 628xxx format
- **isValidWa(input)**: Validates Indonesian phone numbers
- **renderTemplate(template, vars)**: Replaces `{link}` placeholder
- **buildWaUrl(nomor, text)**: Generates WhatsApp web.whatsapp.com URL
- Opens WhatsApp in new tab, status auto-marked as sent

### Route Structure & Mounting (app.js)

Routes are mounted in this order:
1. **/** → `publicRoutes` (public invitation pages, must be first to catch `/:angkatan/:slug`)
2. **/auth** → `authRoutes` (login, logout, Google OAuth) - rate limited
3. **/dashboard** → `dashboardRoutes` (stats for admin/wali_kelas)
4. **/angkatan** → `angkatanRoutes` (cohort CRUD, invitation settings)
5. **/kelas** → `kelasRoutes` (class CRUD, wali_kelas assignment)
6. **/santri** → `santriRoutes` (student CRUD, bulk CSV import)
7. **/undangan** → `undanganRoutes` (invitation settings: drag position, rich text)
8. **/send** → `sendRoutes` (preview, test, send WhatsApp)
9. **/users** → `userRoutes` (user management, admin only)
10. **/audit** → `auditRoutes` (audit log viewer, admin only)

**Important:** Public routes are mounted at root (`/`) and must come first to avoid conflicts with other routes.

### Slug System (src/lib/slug.js)

Student names are slugified for public URLs:
- Converts to lowercase, removes special chars
- Format: `/{angkatan.nomor}/{santri-slug}`
- Example: `/7/ahmad-fauzi`

### CSV Bulk Import (src/routes/santri.js)

Format: `nis,nama,kelas,nomor_wa`

Example:
```csv
nis,nama,kelas,nomor_wa
22001,Ahmad Fauzi,XII IPA 1,08123456789
22002,Siti Aminah,XII IPA 1,628987654321
```

**Validation rules:**
- NIS must be unique per cohort
- Class name must match existing kelas exactly (case-sensitive)
- Phone numbers auto-normalized to 628xxx format via `normalizeWa()`
- Empty rows are skipped
- Download template available in UI before import

## Design System

The app uses a Vercel-inspired design language (see DESIGN.md for full spec):

**Tailwind Config (tailwind.config.js):**
- Custom colors: ink (#171717), canvas (#ffffff), canvas-soft (#fafafa)
- Custom shadows: level-1 through level-4 (stacked inset + drop shadows)
- Border radius: sm (6px), md (8px), lg (12px), pill (100px)
- Fonts: Inter (sans), JetBrains Mono (mono)

**Key Principles:**
- Sentence-case headings with negative letter-spacing
- Pill-shaped buttons (100px radius) for primary CTAs
- Stacked shadows (multiple small offsets) instead of single heavy drops
- Monospace font for technical labels only

## Important Conventions

### ESM Modules
All files use ES modules (`import`/`export`), not CommonJS. The `package.json` has `"type": "module"`.

**Getting __dirname in ESM:**
```javascript
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
```

### Environment Variables
Required vars (see .env.example):
- `DB_*`: Database connection
- `SESSION_SECRET`: Express session secret
- `ENCRYPTION_KEY`: 64-char hex for AES-256-GCM (generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)
- `ADMIN_EMAIL`, `ADMIN_PASSWORD`: Initial admin for seeding
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`: Optional OAuth

### Role-Based Access
- **admin**: Full access to all features
- **wali_kelas**: Can only access assigned classes, cannot manage users or cohorts

### Invitation Flow
1. Admin creates angkatan → creates kelas → assigns wali_kelas
2. Admin/wali_kelas bulk imports santri via CSV
3. Admin configures invitation settings (background, position, audio, template)
4. Wali_kelas edits phone numbers, previews, tests, sends via WhatsApp
5. Parents/guardians open public link to view invitation

### Testing WhatsApp
Use the "Test" button to send to your own number before bulk sending. The system:
1. Generates WhatsApp Web URL via `buildWaUrl(nomor, text)`
2. Opens in new tab with pre-filled message
3. Auto-marks status as "terkirim" (sent) when clicked
4. Does NOT actually send via API - relies on user clicking "Send" in WhatsApp Web

## cPanel Deployment Notes

- Upload all files except `node_modules` and `.env`
- Use cPanel "Setup Node.js App" interface
- Set `NODE_ENV=production` for secure cookies (requires HTTPS)
- Ensure `public/uploads/` is writable
- Run migrations via cPanel terminal: `npm run db:migrate`
- Build CSS: `npm run build:css`

## Common Tasks

### Add a new route
1. Create route file in `src/routes/` (use Express Router)
2. Import in `app.js`: `import newRoutes from './src/routes/new.js';`
3. Mount in `app.js`: `app.use('/new', newRoutes);` (order matters - see Route Structure)
4. Add auth middleware: `requireAuth` or `requireAdmin` from `src/middleware/auth.js`
5. Add audit logging for important actions via `src/lib/audit.js`

### Add a new database table
1. Define schema in `src/db/schema.js` using Drizzle ORM syntax
2. Run `npm run db:generate` to create migration file in `drizzle/`
3. Review generated SQL in migration file
4. Run `npm run db:migrate` to apply to database
5. Import new table in routes: `import { newTable } from '../db/schema.js';`

### Modify invitation template
1. Edit EJS template in `src/views/public/`
2. Ensure user-provided content uses `sanitize-html` (already applied in route handlers)
3. Test with `npm run dev` and visit `/{angkatan-nomor}/{slug}`

### Add new user role
1. Update `users` table enum in `src/db/schema.js`
2. Run `npm run db:generate` and `npm run db:migrate`
3. Add middleware check in `src/middleware/auth.js` (e.g., `requireNewRole`)
4. Update route guards in affected routes
5. Update UI conditionals in EJS templates (`<% if (user.role === 'new_role') { %>`)

### Debug session issues
- Sessions stored in MySQL `sessions` table
- Check `express-session` config in `app.js`
- Session lifetime: 7 days (maxAge: 1000 * 60 * 60 * 24 * 7)
- Secure cookies only in production (NODE_ENV=production)
- Clear sessions: `DELETE FROM sessions WHERE expires < NOW();`

### Handle file upload errors
- Check `public/uploads/` directory exists and is writable
- Sharp compression errors: ensure image is valid format
- File size limit: 2MB (set in `app.js` body parser)
- Old files cleaned up via `deleteIfExists()` in `src/lib/upload.js`
