# Backend — ASIM Scheduling

## Auth Guards (dependency injection)
- `get_current_user` — any logged-in user
- `require_pengurus` — pengurus or super_admin
- `require_super_admin` — super_admin only
- Applied at router level in `main.py` via `include_router(dependencies=[...])`
- Write endpoints in mixed routers (special, daily, schedule) have individual `Depends(require_pengurus)`

## Rate Limiting
- slowapi limiter is registered in `main.py` but **not applied to any route currently**
- Rate limiting was removed from `/api/auth/login` (was 5/minute)
- **Important:** slowapi requires parameter named exactly `request: Request` (not renamed) — keep this if re-enabling

## Database Conventions

### Migrations
- No Alembic — schema changes via manual `ALTER TABLE` on the running DB
- Common pattern: `ALTER TABLE <table> ADD COLUMN IF NOT EXISTS ...`

### Transaction Safety (ALL write endpoints)
- Every `db.commit()` is wrapped in `try/except` with `db.rollback()` on failure
```python
try:
    db.commit()
    db.refresh(obj)
except Exception:
    db.rollback()
    raise HTTPException(status_code=500, detail="Gagal menyimpan data")
```

### Query Optimization
- **No N+1 queries** — all loops use pre-batched `.in_()` lookups with dict maps
- Pattern: collect all IDs → single `WHERE id IN (...)` query → build `{id: obj}` map → use in loop
- `_load_schedule_cache()` in `shift_swap.py`: pre-loads all MassSchedule, SpecialMass, SpecialEvent, MassTemplate for a batch of swaps in ~4 queries regardless of swap count
- `portal.py`: PositionType, SpecialMass, SpecialEvent, MassTemplate, Asim all pre-batched before loops

## Shift Swap System

### `request_type` Field
- `'swap'` — requester wants a specific ASIM to swap schedules (bilateral)
- `'replace'` — requester wants anyone to take their slot (one-way, open to all)

### Key Fields on `ShiftSwap`
- `requester_id` — nullable (null = admin posted on behalf of non-ASIM position like SR/Suster)
- `posted_by_admin` — boolean, true when admin creates the swap
- `offer_position_type_label` — enriched field for display when requester is null

### Admin Post to Bursa
- Admin can post on behalf of any ASIM or non-ASIM position (SR, Suster, etc.)
- Non-ASIM positions have `asim_id = null` on the assignment — still postable
- Only pengurus/super_admin can cancel swaps where `requester_id = null`

### Opsi B — Stale Swap Auto-Cancel
- When a swap is completed via `_do_swap()`, any other open/pending swap that uses the same `taker_schedule_id` + `taker_position_number` is auto-cancelled
- Prevents one schedule assignment being "given away" twice

### Taker Week Check
- Soft warning when taker already has duties in the same Sat-Fri week as the offered shift
- Non-blocking — taker can still proceed

## Weekly Schedule Generation

### Week Boundary Definition
- Week = Saturday to Friday (not Monday–Sunday)
- `get_week_start(d)` in `schedule.py`: `d - timedelta(days=(d.weekday() - 5) % 7)`

### Assignment Rules (in priority order)
1. **HARD (never relaxable):** ASIM cannot be assigned twice in the same Sat-Fri week
   - Checked by comparing `get_week_start(last_date) == get_week_start(schedule.date)`
2. **SOFT (relaxable):** No consecutive weeks — skip if `diff <= 7 days`
   - Bypassed when no strict candidates exist (`relax_consecutive=True`)

### Relax Mode Sort
When falling back to relax mode, candidates sorted by: `(assignment_count ASC, days_since_last DESC)`
- Least assigned ASIM first
- Among ties: most rested (longest since last duty) wins
- Then random choice among top tied candidates

## Special Event Generation

### Assignment Rules
1. Skip ASIM with active `unavailability` record on that date
2. Skip ASIM unavailable in the same area (`AsimAreaUnavailability` check using `pos.posisi.area_id`)
3. ~~No consecutive week check~~ — not applicable for special events

### `posisi_id` on SpecialPosition
- FK to `PositionSlot` — links special event position to a slot for area-based filtering
- Set when admin syncs positions for a special event via `MassCard`

## Maintenance Mode

### Cara Kerja
- **Middleware:** `MaintenanceMiddleware` di `main.py` — intercept semua request
- **DB:** `system_settings` table, key `maintenance_mode`, value `"true"` / `"false"`
- **Cache:** DB check di-cache 30 detik (pakai `_maintenance_cache` dict) — mengurangi DB query
- **Bypass paths** (selalu diizinkan): `/api/auth/login`, `/api/settings/maintenance-status`, `/api/health`, `/`
- **super_admin bypass:** JWT dengan `role = super_admin` tetap bisa akses semua endpoint
- **Response saat maintenance:** HTTP 503 `{"detail": "Sistem sedang dalam maintenance"}`

### Settings Endpoint
- `GET /api/settings` — super_admin only, returns `{maintenance_mode: bool, development_mode: bool}`
- `PUT /api/settings` — super_admin only, update salah satu key
- `GET /api/settings/maintenance-status` — **public** (no auth), returns `{maintenance_mode: bool}`
- Valid keys: `maintenance_mode`, `development_mode`

## Login
- Single input field — backend auto-identifies by username / email / phone / no_asim
- Rate limiting removed from `/api/auth/login` (was 5/minute)
- Password validation (change-password endpoint): min 8 chars, 1 uppercase, 1 number
- On `must_change_password: true` response → redirect to `/change-password?user_id=...`

## Seat Layout

### DB Schema
```sql
CREATE TABLE seat_layout (
    id SERIAL PRIMARY KEY,
    template_id INTEGER REFERENCES mass_templates(id) ON DELETE CASCADE NULL,
    special_mass_id INTEGER REFERENCES special_masses(id) ON DELETE CASCADE NULL,
    position_number VARCHAR(20) NOT NULL,
    view_type VARCHAR(10) NOT NULL,   -- 'duduk' atau 'altar'
    row_index INTEGER NOT NULL,
    col_index INTEGER NOT NULL,
    side VARCHAR(10) NULL,            -- 'kuning' atau 'biru'
    section VARCHAR(20) NULL          -- 'depan', 'samping_kiri', 'samping_kanan' (altar only)
);
```

- Model: `app/models/seat_layout.py`
- Schema: `app/schemas/seat_layout.py`
- Routes: `app/routes/seat_layout.py` — prefix `/api/seat-layout/`
- PUT endpoints do bulk replace (delete + insert)
