Konfigurator ← Back to App Architecture API Reference NFON FortiPortal

Boom Konfigurator

Integration Documentation — Connecting the Frontend to NFON and Fortinet FortiPortal APIs

System Architecture

The Boom Konfigurator is a self-service portal for Sunrise Business Hub customers to manage their combined KMU product (Internet, WiFi, Voice/NFON, Security/Fortinet). The architecture follows a three-tier pattern:

Frontend SPA
Vanilla JS
Rails 8.1 API
Middleware Layer
NFON API
Voice/PBX
FortiPortal API
Security/UTM
Key Design Decision: The Rails API acts as a middleware/proxy layer between the frontend and the external APIs. The frontend never communicates directly with NFON or FortiPortal. This enables credential isolation, data transformation, caching, and unified error handling.

Current State

Complete Frontend SPA

All pages, CRUD operations, i18n (DE/EN), login, navigation

Complete Rails API Layer

Auth, controllers, routes, models, JWT tokens, cookie sessions

Complete Mock Data Layer

PostgreSQL with seed data simulating NFON/Fortinet responses

Pending NFON API Integration

Replace mock Voice controller with real NFON API client

Pending FortiPortal Integration

Replace mock Security controller with real FortiPortal API client

Pending Service Monitoring

Replace mock Internet/WiFi data with real device telemetry

Tech Stack

ComponentTechnologyVersion
BackendRuby on Rails (API-only)8.1
RubyRuby via rbenv3.3.6
DatabasePostgreSQL16
Web ServerPuma behind Nginx7.2.0
FrontendVanilla JavaScript SPAES2020+
AuthJWT (HS256) in httpOnly cookies
SSLLet's Encrypt via Certbot
HostAWS EC2 (Ubuntu 22.04)

API Reference

All API endpoints are prefixed with /api/ and return JSON. Authentication is via httpOnly cookies (boom_access and boom_refresh).

Authentication

MethodEndpointDescription
POST/api/auth/loginLogin with email + password. Returns user data, sets cookies.
POST/api/auth/logoutClears auth cookies and invalidates session.
POST/api/auth/refreshRefresh access token using refresh cookie.
GET/api/auth/meReturns current user and customer details.

Login Request

POST /api/auth/login
Content-Type: application/json

{
  "email": "demo@example.ch",
  "password": "Demo2024!"
}

Login Response

{
  "user": {
    "id": 2,
    "email": "demo@example.ch",
    "username": "demo",
    "role": "customer"
  },
  "customer": {
    "id": 1,
    "company_name": "TechStart GmbH",
    "contract_number": "BOOM-2024-001",
    "boom_package": "business"
  }
}
// + Set-Cookie: boom_access=<JWT>; HttpOnly; Secure; SameSite=Lax; Path=/
// + Set-Cookie: boom_refresh=<JWT>; HttpOnly; Secure; SameSite=Lax; Path=/

Voice Endpoints (NFON)

MethodEndpointDescription
GET/api/voice/extensionsList all extensions
POST/api/voice/extensionsCreate extension
PUT/api/voice/extensions/:idUpdate extension
DELETE/api/voice/extensions/:idDelete extension
GET/api/voice/call-forwardingsList call forwardings
POST/api/voice/call-forwardingsCreate/update call forwarding
PUT/api/voice/call-forwardings/:idUpdate call forwarding
GET/api/voice/voicemailsList voicemail configs
POST/api/voice/voicemailsCreate/update voicemail
PUT/api/voice/voicemails/:idUpdate voicemail
GET/api/voice/ring-groupsList ring groups
POST/api/voice/ring-groupsCreate ring group
PUT/api/voice/ring-groups/:idUpdate ring group
DELETE/api/voice/ring-groups/:idDelete ring group
GET/api/voice/queuesList call queues
POST/api/voice/queuesCreate call queue
PUT/api/voice/queues/:idUpdate call queue
DELETE/api/voice/queues/:idDelete call queue

Extension JSON Schema

{
  "id": 1,
  "extension_number": "100",
  "display_name": "Empfang",
  "phone_number": "+41 44 123 00 00",
  "enabled": true,
  "status": "online"  // online | offline | dnd
}

Call Forwarding JSON Schema

{
  "id": 1,
  "extension_id": 1,
  "busy_forward_to": "+41 44 123 00 00",
  "no_answer_forward_to": "voicemail",
  "not_registered_forward_to": "+41 79 123 45 67",
  "no_answer_ring_time": 20
}

Voicemail JSON Schema

{
  "id": 1,
  "extension_id": 1,
  "enabled": true,
  "email_notification": true,
  "email_address": "user@example.ch",
  "greeting_message": "Please leave a message."
}

Ring Group JSON Schema

{
  "id": 1,
  "name": "Support",
  "description": "Support team",
  "member_ids": [3, 4, 5],
  "strategy": "sequential"  // simultaneous | sequential | circular
}

Call Queue JSON Schema

{
  "id": 1,
  "name": "Hauptleitung",
  "extension": "200",
  "agent_ids": [1, 2, 3],
  "max_wait_time": 120,
  "overflow_destination": "voicemail"
}

Security Endpoints (FortiPortal)

MethodEndpointDescription
GET/api/security/profileGet security profile (level, features, blocked items)
PUT/api/security/profileUpdate security level (triggers preset loading)
GET/api/security/web-filterGet web filter categories with status
PUT/api/security/web-filterToggle web filter / update blocked categories
GET/api/security/app-controlGet app control list with status
PUT/api/security/app-controlToggle app control / update blocked apps
GET/api/security/firewall-policiesList firewall policies
POST/api/security/firewall-policiesCreate firewall policy
PUT/api/security/firewall-policies/:idUpdate firewall policy
DELETE/api/security/firewall-policies/:idDelete firewall policy

Security Profile JSON Schema

{
  "id": 1,
  "security_level": "premium",   // basic | standard | premium | custom
  "web_filter_enabled": true,
  "ips_enabled": true,
  "app_control_enabled": true,
  "blocked_categories": ["malware", "phishing", "adult", "gambling", "proxy", "streaming", "social_media"],
  "blocked_apps": ["bittorrent", "tor"],
  "allowed_apps": []
}

Firewall Policy JSON Schema

{
  "id": 1,
  "name": "Allow All Outbound",
  "policy_number": 1,
  "source_zone": "LAN",
  "dest_zone": "WAN",
  "service": "ALL",
  "action": "accept",   // accept | deny
  "enabled": true
}

Service Endpoints

MethodEndpointDescription
GET/api/services/dashboardAggregated service status for dashboard cards
GET/api/services/internetInternet connection details
GET/api/services/wifiWiFi SSIDs and device counts

NFON API Integration

Overview

NFON provides a cloud PBX system via REST API. The Boom Konfigurator manages extensions, call forwarding, voicemail, ring groups, and queues through this API. The integration requires an NFON admin account with API access for each customer.

Important: The NFON API documentation is available at portal.nfon.com (partner login required). The API base URL is typically https://api.nfon.com/api/v2 or https://portal.nfon.com/api/ depending on your NFON partner tier.

Authentication

NFON uses API key authentication. Each customer account has credentials stored in the customers table:

# Customer model has:
#   nfon_account_id  — NFON customer/account identifier
#
# Additional credentials to add in .env or Rails credentials:
#   NFON_API_BASE_URL=https://api.nfon.com/api/v2
#   NFON_API_KEY=your-nfon-api-key
#   NFON_API_SECRET=your-nfon-api-secret

API Mapping: Boom ↔ NFON

Boom EndpointNFON API CallNotes
GET /voice/extensionsGET /customers/{id}/extensionsMap extensionNumberextension_number
POST /voice/extensionsPOST /customers/{id}/extensionsCreate new PBX extension
PUT /voice/extensions/:idPUT /extensions/{extId}Update display name, status, etc.
DELETE /voice/extensions/:idDELETE /extensions/{extId}Remove extension from PBX
GET /voice/call-forwardingsGET /extensions/{extId}/forwardingPer-extension forwarding rules
POST /voice/call-forwardingsPUT /extensions/{extId}/forwardingNFON uses PUT for upsert
GET /voice/voicemailsGET /extensions/{extId}/voicemailVoicemail settings per extension
POST /voice/voicemailsPUT /extensions/{extId}/voicemailNFON uses PUT for upsert
GET /voice/ring-groupsGET /customers/{id}/huntgroupsNFON calls them "hunt groups"
POST /voice/ring-groupsPOST /customers/{id}/huntgroupsCreate hunt group
GET /voice/queuesGET /customers/{id}/queuesACD queue configuration
POST /voice/queuesPOST /customers/{id}/queuesCreate ACD queue

Service Layer Implementation

Create a new service class at app/services/nfon_service.rb that encapsulates all NFON API communication:

class NfonService
  BASE_URL = ENV.fetch('NFON_API_BASE_URL', 'https://api.nfon.com/api/v2')
  API_KEY  = ENV.fetch('NFON_API_KEY', '')
  API_SECRET = ENV.fetch('NFON_API_SECRET', '')

  def initialize(customer)
    @account_id = customer.nfon_account_id
  end

  # --- Extensions ---
  def list_extensions
    resp = get("/customers/#{@account_id}/extensions")
    resp.map { |ext| map_extension(ext) }
  end

  def create_extension(params)
    post("/customers/#{@account_id}/extensions", map_to_nfon_ext(params))
  end

  def update_extension(nfon_ext_id, params)
    put("/extensions/#{nfon_ext_id}", map_to_nfon_ext(params))
  end

  def delete_extension(nfon_ext_id)
    delete("/extensions/#{nfon_ext_id}")
  end

  # --- Call Forwarding ---
  def get_forwarding(nfon_ext_id)
    resp = get("/extensions/#{nfon_ext_id}/forwarding")
    map_forwarding(resp)
  end

  def update_forwarding(nfon_ext_id, params)
    put("/extensions/#{nfon_ext_id}/forwarding", map_to_nfon_fwd(params))
  end

  # --- Voicemail ---
  def get_voicemail(nfon_ext_id)
    resp = get("/extensions/#{nfon_ext_id}/voicemail")
    map_voicemail(resp)
  end

  def update_voicemail(nfon_ext_id, params)
    put("/extensions/#{nfon_ext_id}/voicemail", map_to_nfon_vm(params))
  end

  private

  def connection
    @conn ||= Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.response :json
      f.headers['Authorization'] = "Bearer #{auth_token}"
      f.headers['Content-Type'] = 'application/json'
      f.adapter Faraday.default_adapter
    end
  end

  def auth_token
    # Implement NFON OAuth token exchange or API key auth
    # Cache token in Rails.cache with TTL
    Rails.cache.fetch("nfon_token_#{@account_id}", expires_in: 50.minutes) do
      resp = Faraday.post("#{BASE_URL}/auth/token", {
        api_key: API_KEY, api_secret: API_SECRET
      }.to_json, { 'Content-Type' => 'application/json' })
      JSON.parse(resp.body)['access_token']
    end
  end

  def get(path)
    resp = connection.get(path)
    handle_response(resp)
  end

  def post(path, body)
    resp = connection.post(path, body.to_json)
    handle_response(resp)
  end

  def put(path, body)
    resp = connection.put(path, body.to_json)
    handle_response(resp)
  end

  def delete(path)
    resp = connection.delete(path)
    handle_response(resp)
  end

  def handle_response(resp)
    raise NfonError, resp.body unless resp.success?
    resp.body
  end

  # --- Data Mapping ---
  def map_extension(nfon_data)
    {
      extension_number: nfon_data['extensionNumber'],
      display_name:     nfon_data['displayName'],
      phone_number:     nfon_data['ddiNumber'],
      enabled:          nfon_data['active'],
      status:           map_status(nfon_data['registrationStatus'])
    }
  end
end

Implementation Guide

Step 1: Add Faraday Gem

# Gemfile
gem 'faraday'
gem 'faraday-retry'

Step 2: Create Service File

# app/services/nfon_service.rb
# (See service layer code above)

Step 3: Update Voice Controller

Replace the mock database calls with the service:

# app/controllers/api/voice_controller.rb
module Api
  class VoiceController < BaseController
    before_action :require_auth!

    def extensions
      # BEFORE (mock):
      # exts = current_customer.extensions.order(:extension_number)
      # render json: exts.map(&:as_api_json)

      # AFTER (live):
      exts = nfon.list_extensions
      render json: exts
    end

    def create_extension
      result = nfon.create_extension(extension_params.to_h)
      render json: result, status: :created
    end

    private

    def nfon
      @nfon ||= NfonService.new(current_customer)
    end
  end
end

Step 4: Configure Environment

# .env (add these variables)
NFON_API_BASE_URL=https://api.nfon.com/api/v2
NFON_API_KEY=your_api_key_here
NFON_API_SECRET=your_api_secret_here

Step 5: Add Error Handling

# app/services/nfon_error.rb
class NfonError < StandardError
  attr_reader :status, :body

  def initialize(response)
    @status = response.status
    @body = response.body
    super("NFON API error #{@status}: #{@body}")
  end
end

# In base_controller.rb, add:
rescue_from NfonError do |e|
  render json: { error: 'Voice service unavailable', details: e.message },
         status: :service_unavailable
end

FortiPortal API Integration

Overview

Fortinet FortiPortal provides a multi-tenant security management API for FortiGate devices. The Boom Konfigurator manages security profiles, web filtering, application control, and firewall policies through this API.

Important: FortiPortal API docs are available at docs.fortinet.com/product/fortiportal. The API base URL is typically https://your-fortiportal.example.com/fpc/api/v1.

Authentication

FortiPortal uses API token authentication (bearer token) or session-based auth with username/password:

# .env credentials
FORTIPORTAL_API_URL=https://your-fortiportal.example.com/fpc/api/v1
FORTIPORTAL_USERNAME=api-admin
FORTIPORTAL_PASSWORD=your-password
# or
FORTIPORTAL_API_TOKEN=your-api-token

API Mapping: Boom ↔ FortiPortal

Boom EndpointFortiPortal API CallNotes
GET /security/profileGET /organizations/{id}/securityprofileUTM security profile
PUT /security/profilePUT /organizations/{id}/securityprofileApply preset or custom settings
GET /security/web-filterGET /webfilterprofiles/{id}FortiGuard category filter
PUT /security/web-filterPUT /webfilterprofiles/{id}Toggle categories
GET /security/app-controlGET /applicationprofiles/{id}Application signatures
PUT /security/app-controlPUT /applicationprofiles/{id}Block/allow applications
GET /security/firewall-policiesGET /firewallpolicies?org={id}Policy list for org
POST /security/firewall-policiesPOST /firewallpoliciesCreate policy
PUT /security/firewall-policies/:idPUT /firewallpolicies/{id}Update policy
DELETE /security/firewall-policies/:idDELETE /firewallpolicies/{id}Delete policy

Service Layer Implementation

class FortiPortalService
  BASE_URL = ENV.fetch('FORTIPORTAL_API_URL')

  def initialize(customer)
    @org_id = customer.fortinet_account_id
  end

  # --- Security Profile ---
  def get_profile
    resp = get("/organizations/#{@org_id}/securityprofile")
    map_profile(resp)
  end

  def update_profile(level)
    case level
    when 'basic'
      apply_basic_preset
    when 'standard'
      apply_standard_preset
    when 'premium'
      apply_premium_preset
    end
  end

  # --- Web Filter ---
  def get_web_filter
    resp = get("/webfilterprofiles/#{web_filter_profile_id}")
    map_web_filter(resp)
  end

  def update_web_filter(params)
    put("/webfilterprofiles/#{web_filter_profile_id}",
        map_to_forti_webfilter(params))
  end

  # --- Firewall Policies ---
  def list_policies
    resp = get("/firewallpolicies?org=#{@org_id}")
    resp.map { |p| map_policy(p) }
  end

  def create_policy(params)
    post("/firewallpolicies", map_to_forti_policy(params))
  end

  private

  def connection
    @conn ||= Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.response :json
      f.headers['Authorization'] = "Bearer #{api_token}"
      f.adapter Faraday.default_adapter
    end
  end

  def api_token
    if ENV['FORTIPORTAL_API_TOKEN'].present?
      ENV['FORTIPORTAL_API_TOKEN']
    else
      # Session auth: login and cache token
      Rails.cache.fetch('fortiportal_token', expires_in: 55.minutes) do
        resp = Faraday.post("#{BASE_URL}/auth/signin", {
          username: ENV['FORTIPORTAL_USERNAME'],
          password: ENV['FORTIPORTAL_PASSWORD']
        }.to_json, 'Content-Type' => 'application/json')
        JSON.parse(resp.body)['token']
      end
    end
  end

  # --- Data Mapping ---
  def map_policy(forti_data)
    {
      id:            forti_data['policyid'],
      name:          forti_data['name'],
      policy_number: forti_data['policyid'],
      source_zone:   forti_data['srcintf'].join(','),
      dest_zone:     forti_data['dstintf'].join(','),
      service:       forti_data['service'].map{|s| s['name']}.join(','),
      action:        forti_data['action'],
      enabled:       forti_data['status'] == 'enable'
    }
  end

  # Map Boom security levels to FortiPortal presets
  def apply_basic_preset
    update_web_filter(enabled: true, categories: ['malware', 'phishing'])
    update_app_control(enabled: false)
  end
end

Implementation Guide

Step 1: Create Service File

# app/services/forti_portal_service.rb
# (See service layer code above)

Step 2: Update Security Controller

# app/controllers/api/security_controller.rb
module Api
  class SecurityController < BaseController
    before_action :require_auth!

    def profile
      # BEFORE (mock):
      # sp = current_customer.security_profile
      # render json: sp.as_api_json

      # AFTER (live):
      profile = forti.get_profile
      render json: profile
    end

    def firewall_policies
      # BEFORE (mock):
      # fps = current_customer.firewall_policies.order(:policy_number)
      # render json: fps.map(&:as_api_json)

      # AFTER (live):
      policies = forti.list_policies
      render json: policies
    end

    private

    def forti
      @forti ||= FortiPortalService.new(current_customer)
    end
  end
end

Step 3: Configure Environment

# .env (add these variables)
FORTIPORTAL_API_URL=https://your-fortiportal.example.com/fpc/api/v1
FORTIPORTAL_API_TOKEN=your-api-token
# OR session auth:
FORTIPORTAL_USERNAME=api-admin
FORTIPORTAL_PASSWORD=your-password

Step 4: Hybrid Mode (Recommended for Migration)

During migration, you can run in hybrid mode where some data comes from the live API and some from the local database:

# config/application.rb or an initializer
config.x.use_live_nfon = ENV.fetch('USE_LIVE_NFON', 'false') == 'true'
config.x.use_live_fortinet = ENV.fetch('USE_LIVE_FORTINET', 'false') == 'true'

# In controller:
def extensions
  if Rails.configuration.x.use_live_nfon
    render json: nfon.list_extensions
  else
    render json: current_customer.extensions.order(:extension_number).map(&:as_api_json)
  end
end

Environment Configuration

Required Environment Variables

VariableDescriptionRequired
SECRET_KEY_BASERails secret for JWT signingYes
DATABASE_URLPostgreSQL connection stringYes
RAILS_ENVEnvironment (production)Yes
NFON_API_BASE_URLNFON API base URLFor live
NFON_API_KEYNFON API keyFor live
NFON_API_SECRETNFON API secretFor live
FORTIPORTAL_API_URLFortiPortal API URLFor live
FORTIPORTAL_API_TOKENFortiPortal API tokenFor live
USE_LIVE_NFONEnable live NFON (true/false)Optional
USE_LIVE_FORTINETEnable live FortiPortal (true/false)Optional

Database Schema

The local database stores customer data, user accounts, and cached/mock data. When switching to live APIs, the voice and security tables can serve as a local cache for faster reads.

Tables

TablePurposeLive Mode
usersAuthentication accountsAlways local
customersCustomer profiles + API account IDsAlways local
sessionsJWT refresh token trackingAlways local
extensionsPBX extensionsCache or NFON live
call_forwardingsCall forwarding rulesCache or NFON live
voicemailsVoicemail configurationCache or NFON live
ring_groupsHunt groups / ring groupsCache or NFON live
call_queuesACD queuesCache or NFON live
security_profilesUTM security settingsCache or FortiPortal live
firewall_policiesFirewall policy rulesCache or FortiPortal live

Key Relationships

Customer (1) ──> (*) Extension ──> (1) CallForwarding
                                  ──> (1) Voicemail
Customer (1) ──> (*) RingGroup
Customer (1) ──> (*) CallQueue
Customer (1) ──> (1) SecurityProfile
Customer (1) ──> (*) FirewallPolicy
Customer (1) ──> (1) User

Deployment Steps

Going Live with NFON

  1. Obtain NFON Partner API credentials from your NFON account manager
  2. Add faraday and faraday-retry gems, run bundle install
  3. Create app/services/nfon_service.rb with the service layer code
  4. Create app/services/nfon_error.rb for error handling
  5. Update .env with NFON API credentials
  6. Set USE_LIVE_NFON=true in environment
  7. Test with a staging NFON account first
  8. Map nfon_account_id in the customers table for each customer
  9. Restart Puma to load new code

Going Live with FortiPortal

  1. Obtain FortiPortal API access from your Fortinet partner portal
  2. Create app/services/forti_portal_service.rb
  3. Update .env with FortiPortal credentials
  4. Set USE_LIVE_FORTINET=true in environment
  5. Map fortinet_account_id in the customers table
  6. Test security level presets map correctly to FortiPortal profiles
  7. Verify firewall policy CRUD against live FortiGate device
  8. Restart Puma
Tip: Use the hybrid mode (USE_LIVE_NFON / USE_LIVE_FORTINET flags) to gradually migrate one service at a time. This allows you to test NFON integration while keeping Fortinet on mock data, or vice versa.

Server Details

# SSH access
ssh ubuntu@16.171.52.195

# Application root
/home/ubuntu/sbhboom/

# Start Puma
cd /home/ubuntu/sbhboom
export PATH=/home/ubuntu/.rbenv/bin:/home/ubuntu/.rbenv/shims:$PATH
eval "$(cat .env | sed 's/^/export /')"
rm -f tmp/sockets/puma.sock
nohup bundle exec puma -b unix:///home/ubuntu/sbhboom/tmp/sockets/puma.sock \
  -e production > /tmp/puma_sbhboom.log 2>&1 & disown

# Domain
https://sbhboom.accountplan.ch

# Demo credentials
Email: demo@example.ch
Password: Demo2024!


Boom Konfigurator — Sunrise Business Hub © 2024–2026