Skip to content

Setup Module

Overview

The Setup module is the first-run wizard that configures a fresh Sutomo installation. It acts as a gate: until all required data is present, every request is redirected to /setup. Once complete, the application behaves normally.

The module spans both backend (PHP middleware + controllers) and frontend (Svelte wizard). It's the only module that can lock the entire application.


Concept

Initial State

A fresh installation has zero data. The system uses three levels of checks in IdentifierAppService to determine what state the application is in:

CheckWhat it answersUsed by
setup()Does a super user exist?SetupMiddleware
seeder()Is the database seeded?MaintenanceMiddleware
initial()Is public content ready?Maintenance + frontend checks

These checks cascade from most fundamental (super user) to most specific (FAQ content).

Setup Completion Tiers

isSetup() uses 5 tiers. Each tier must pass before the next is checked:

TierWhat's requiredFailure impact
1 — AuthAt least 1 User (admin)No authentication possible
2 — SeedsGeographic, gender, ethnicity, localization, religionForms missing required dropdown data
3 — Content1 School + 1 ArticlePublic pages have nothing to show
4 — BrandingCore settings + yayasan_nameHeader/footer/SEO incomplete
5 — Contact1 contact method + 1 social mediaPublic has no way to reach the school

State Machine

The wizard progresses through 5 states. Each transition is one-way — there is no "undo setup" in the application.

                     ┌──────────┐
                     │  FRESH   │
                     └────┬─────┘
                          │ Step 1: POST /api/setup/register

               ┌─────────────────────┐
               │  SUPER USER CREATED │
               └────────┬────────────┘
                        │ Step 2: POST /api/setup/seed

               ┌─────────────────────┐
               │  DATABASE SEEDED    │
               └────────┬────────────┘
                        │ Step 3: POST /api/setup/about

               ┌─────────────────────┐
               │  ABOUT CONFIGURED   │
               └────────┬────────────┘
                        │ Step 4: POST /api/setup/setting

               ┌─────────────────────┐
               │  SETUP COMPLETE     │
               │  isSetup() → true   │
               └─────────────────────┘

Flowchart

Middleware Gate

Explanation:

  1. SetupMiddleware runs first on every request. It checks IdentifierAppService::isSetup().
  2. If setup is incomplete, the user is sent to /setup — regardless of what URL they requested.
  3. If setup is complete, the request passes to MaintenanceMiddleware.
  4. MaintenanceMiddleware bypasses setup/portal/api routes entirely.
  5. If maintenance mode is ON, all non-bypassed routes show UnderConstruction.
  6. If the database is seeded (isSeeder()), users are redirected to the admin panel at /portal.
  7. Only when all checks pass does the public landing page render.

Auto-Navigation

Explanation:

  1. The backend returns 8 check items from getCheckStatuses().
  2. If checks[0].status !== 'completed' → the user must complete Step 1 (Super User).
  3. If any of checks[1..4] (Geographic, Core Settings, Gender, Localization) are pending → Step 2 (Seed).
  4. If any of checks[5..7] (Schools, Articles, About Config) are pending → Step 3 (About/Settings).
  5. If all 8 checks pass → the wizard jumps directly to Step 5 (Done).

SSE Seed Stream

SSE Event Types

EventDirectionPayloadWhen
startServer → Client{ total: 7 }All seeders begin
progressServer → Client{ label, current, total, percent }Each seeder starts
seederCompleteServer → Client{ label, current, total }Each seeder finishes
checksServer → Client{ checks: CheckItem[] }After each seeder (updated system status)
completeServer → Client{ message, checks }All seeders done
errorServer → Client{ message }A seeder failed

SSE State Machine

IDLE → CONNECTING → STREAMING → COMPLETE

                        └── ERROR → IDLE (retry)

Store Separation

The seed process uses two stores because they manage different concerns:

seed.stores.ts — Seed process state:

  • seeding: boolean — is seeding in progress?
  • progress: { label, current, total } — current seeder info
  • checks: CheckItem[] — latest system status
  • error: string | null — error message

seed-sse.stores.ts — SSE connection state:

  • connecting: boolean — is the HTTP request in flight?
  • connected: boolean — is the stream active?
  • error: string | null — connection error

Why SSE Instead of Polling

The seed process runs 7 seeders sequentially, taking several seconds. Polling would require the frontend to repeatedly ask "is it done yet?" (wasteful and laggy). SSE allows the server to push progress updates as each seeder completes, giving instant UI feedback.


Algorithms

isSetup() — Master Gate Check

File: app/Services/Common/IdentifierAppService.php

This is the single source of truth. Every request hits this check via SetupMiddleware.

php
public static function isSetup(): bool
{
    // Tier 1: Must have at least one admin user
    if (! User::query()->exists()) return false;

    // Tier 2: Seeded reference data must exist
    if (! Setting::query()->where('module', 'core')->where('group', 'location')->exists()) return false;
    if (! Gender::query()->exists() || ! Ethnicity::query()->exists()) return false;
    if (! Religion::query()->exists()) return false;
    if (! Localization::query()->exists()) return false;

    // Tier 3: Minimum public content
    if (! School::query()->exists() || ! SchoolEducationLevel::query()->exists()) return false;
    if (! Article::query()->exists()) return false;

    // Tier 4: Branding and identity
    if (! Setting::query()->where('module', 'core')->where('group', 'general')->exists()) return false;
    if (! Setting::query()->where('module', 'core')->where('key', 'yayasan_name')->exists()) return false;

    // Tier 5: Contact and social media
    if (! Setting::query()->where('module', 'contact')->exists()) return false;
    if (! Setting::query()->where('module', 'social')->exists()) return false;

    return true;
}

Logic: Each return false acts as a gate. The first missing item immediately rejects. This means the check is O(n) in the worst case but typically fails fast at Tier 1 or 2.


seeder() — Database Readiness

File: app/Services/Common/IdentifierAppService.php

php
public static function seeder(): array
{
    return [
        'geographic'   => Setting::query()->where('module', 'core')->where('group', 'location')->exists(),
        'gender'       => Gender::query()->exists(),
        'ethnicity'    => Ethnicity::query()->exists(),
        'localization' => Localization::query()->exists(),
        'religion'     => Religion::query()->exists(),
    ];
}

Purpose: Returns individual booleans so the frontend can show which seeders have/haven't run.

isSeeder() convenience method:

php
public static function isSeeder(): bool
{
    $s = self::seeder();
    return $s['geographic'] && $s['gender'] && $s['ethnicity']
        && $s['localization'] && $s['religion'];
}

Why this exists separately from isSetup(): isSeeder() is intentionally less strict. Once the database has its fundamental seed data (regions, genders, religions), the user is redirected to the admin portal even if branding and contact info aren't configured yet. The assumption is an admin can finish via the panel.


initial() — Content Readiness

File: app/Services/Common/IdentifierAppService.php

php
public static function initial(): array
{
    return [
        'school'       => School::query()->exists() && SchoolEducationLevel::query()->exists(),
        'news'         => Article::query()->exists(),
        'ratings'      => Rating::query()->exists(),
        'banner'       => Banner::query()->exists(),
        'announcement' => Announcement::query()->exists(),
        'faq'          => Faq::query()->exists(),
    ];
}

Purpose: Fine-grained content readiness. Each key maps to a feature that can be independently ready or not. The wizard checks these to determine which steps to show.

Relationship to isSetup():

  • isSetup() checks initial()['school'] and initial()['news'] (mandatory).
  • initial()['ratings'], ['banner'], etc. are not required by isSetup() — they're nice-to-have content that doesn't block the application.

Frontend Auto Navigation

File: resources/js/modules/landing/setup/form/shared/services/step-control-actions.ts

Algorithm

typescript
export function computeInitialStep(checks: CheckItem[]): number {
    if (checks.length === 0) return 1;

    // Step 1: Super User
    if (checks[0]?.status !== 'completed') return 1;

    // Step 2: Seed (checks 1-4 must ALL be completed)
    const seedChecks = checks.slice(1, 5);
    if (seedChecks.some((c) => c.status !== 'completed')) return 2;

    // Step 3: About/Settings (checks 5-7 must ALL be completed)
    const aboutChecks = checks.slice(5, 8);
    if (aboutChecks.some((c) => c.status !== 'completed')) return 3;

    // Everything complete → Done
    return 5;
}

Integration in Index.svelte

svelte
<script lang="ts">
    let currentStep = $state(computeInitialStep(initialChecks));
    //                                      ↑ computed immediately
    //                                        NOT in a $effect

    $effect(() => {
        const unsub = stepControl.subscribe((s) => {
            currentStep = s.currentStep;  // catches subsequent navigation
        });
        return () => unsub();
    });
</script>

Critical rule: computeInitialStep() must be called synchronously when the component initializes, not deferred to a $effect. Using $state(1) and updating later causes a render flash and potential SSR hydration mismatch.

Mapping: Backend Checks → Wizard Steps

Backend Check IndexCheck LabelWizard StepFailure State
0Super User Account1 - Super UserNo admin exists
1Geographic Data2 - SeedCities/states not seeded
2Core Settings2 - SeedApp settings missing
3Gender, Ethnicity & Religion2 - SeedForm dropdowns empty
4Localization2 - SeedLocales not available
5Schools3 - AboutNo school to manage
6Articles / News3 - AboutPublic news empty
7About Us Configurations3 - AboutBranding incomplete

Key Files

Backend

FileRole
app/Http/Middleware/Setup/SetupMiddleware.phpGates every request. Redirects to /setup if isSetup() returns false. Bypasses /setup* and /api/setup*.
app/Http/Middleware/Setup/MaintenanceMiddleware.phpSecond gate. Shows maintenance page or redirects to /portal if seeded. Bypasses /setup*, /portal/*, /api/setup*.
app/Services/Common/IdentifierAppService.phpCentralized state checks. Contains setup(), seeder(), initial(), isSetup(), isSeeder(), isMaintenance().
app/Http/Controllers/Landing/SetupController.phpServes the Inertia setup page + handles all step APIs (register, seed, about, setting, status).
routes/api/setup.phpAPI route definitions for all setup endpoints.
routes/web/landing/setup.phpWeb route for the /setup Inertia page.
database/migrations/0000_runner_migration.phpCustom migration runner that reads MIGRATION_RUNNER_FOLDERS env.

Frontend

FileRole
resources/js/pages/landing/Setup.svelteInertia page component. Receives checks + setupComplete from backend. Renders wizard or completion screen.
resources/js/modules/landing/setup/form/Index.svelteWizard entry point. Computes initial step, renders the correct step component.
resources/js/modules/landing/setup/form/shared/stores/form-setup-step-control.stores.tsStep navigation state (next, prev, goToStep, configure).
resources/js/modules/landing/setup/form/shared/services/step-control-actions.tsPure functions: computeInitialStep(), computeStepLabels().
resources/js/modules/landing/setup/form/super-user/Step 1 — Super user registration with OTP.
resources/js/modules/landing/setup/form/seed/Step 2 — SSE-based database seeding with real-time progress.
resources/js/modules/landing/setup/form/about/Step 3 — Foundation about, contact, social media forms.
resources/js/modules/landing/setup/form/setting/Step 4 — Theme, appearance, contact/social configuration.
resources/js/modules/landing/setup/tutorial/Sidebar tutorial panel showing setup status.