OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / routes / dashboard.js

606 lines · javascript ref:
/**
 * Dashboard routes — provider management pages + API.
 */

import { json, html, readJson, notFound } from '../response.js'
import { requireAuth } from './auth-helpers.js'

export function registerDashboardRoutes (router, server) {
  // ---- API Endpoints ----

  // Get provider for current user (or claim unclaimed)
  router.get('/api/dashboard/provider', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    let providerId = await getProviderIdForUser(server, auth.userId)

    // Auto-claim removed — use admin panel to assign providers

    if (!providerId) {
      return json(res, { provider: null })
    }

    const providerEntry = await server.db.get(`provider:${providerId}`)
    json(res, { provider: providerEntry ? providerEntry.value : null })
  })

  // Today's schedule + summary
  router.get('/api/dashboard/today', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    const today = new Date().toISOString().split('T')[0]
    const bookings = await getBookingsForDate(server.db, providerId, today)

    // Enrich with client + package info
    for (const b of bookings) {
      const clientEntry = await server.db.get(`client:${b.clientId}`)
      b.clientName = clientEntry ? clientEntry.value.displayName : 'Unknown'
      const pkgEntry = await server.db.get(`package-detail:${b.packageId}`)
      b.packageName = pkgEntry ? pkgEntry.value.name : 'Unknown'
      b.sessionDuration = pkgEntry ? pkgEntry.value.sessionDuration : 60
    }

    // Sort by start time
    bookings.sort((a, b) => (a.startTimeLocal || '').localeCompare(b.startTimeLocal || ''))

    // Summary
    const active = bookings.filter(b => b.status !== 'cancelled')
    const revenue = 0 // Placeholder until payments wired

    json(res, {
      date: today,
      bookings,
      summary: {
        sessions: active.length,
        clients: new Set(active.map(b => b.clientId)).size,
        revenue
      }
    })
  })

  // Week overview (session counts per day)
  router.get('/api/dashboard/week', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    const url = new URL('http://x' + req.url)
    const weekOf = url.searchParams.get('weekOf') || getMonday(new Date())

    const days = {}
    for (let i = 0; i < 7; i++) {
      const date = addDays(weekOf, i)
      const bookings = await getBookingsForDate(server.db, providerId, date)
      const active = bookings.filter(b => b.status !== 'cancelled')
      days[date] = { count: active.length, bookings: active }
    }

    json(res, { weekOf, days })
  })

  // Client list
  router.get('/api/dashboard/clients', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    const url = new URL('http://x' + req.url)
    const search = (url.searchParams.get('q') || '').toLowerCase()

    const clients = []
    for await (const entry of server.db.createReadStream({
      gte: `clients-by-provider:${providerId}:`,
      lte: `clients-by-provider:${providerId}:~`
    })) {
      const clientEntry = await server.db.get(`client:${entry.value}`)
      if (!clientEntry) continue
      const client = clientEntry.value

      if (search && !(client.displayName || '').toLowerCase().includes(search) &&
          !(client.email || '').toLowerCase().includes(search)) continue

      // Get their bookings with this provider
      const bookings = []
      for await (const bEntry of server.db.createReadStream({
        gte: `bookings-by-client:${client.id}:`,
        lte: `bookings-by-client:${client.id}:~`
      })) {
        const booking = await server.db.get(`booking:${bEntry.value}`)
        if (booking && booking.value.providerId === providerId) {
          bookings.push(booking.value)
        }
      }

      client.bookingCount = bookings.length
      client.lastSession = bookings
        .filter(b => b.status === 'completed')
        .sort((a, b) => (b.date || '').localeCompare(a.date || ''))[0]?.date || null
      client.nextSession = bookings
        .filter(b => b.status === 'confirmed' && b.date >= new Date().toISOString().split('T')[0])
        .sort((a, b) => (a.date || '').localeCompare(b.date || ''))[0]?.date || null

      clients.push(client)
    }

    json(res, clients)
  })

  // Client detail
  router.get('/api/dashboard/clients/:clientId', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    const clientEntry = await server.db.get(`client:${req.params.clientId}`)
    if (!clientEntry) return notFound(res, 'Client not found')

    const client = clientEntry.value

    // All bookings for this client with this provider
    const bookings = []
    for await (const bEntry of server.db.createReadStream({
      gte: `bookings-by-client:${client.id}:`,
      lte: `bookings-by-client:${client.id}:~`
    })) {
      const booking = await server.db.get(`booking:${bEntry.value}`)
      if (booking && booking.value.providerId === providerId) {
        const b = booking.value
        const pkgEntry = await server.db.get(`package-detail:${b.packageId}`)
        b.packageName = pkgEntry ? pkgEntry.value.name : 'Unknown'
        bookings.push(b)
      }
    }

    bookings.sort((a, b) => (b.date || '').localeCompare(a.date || ''))

    json(res, { client, bookings })
  })

  // Analytics
  router.get('/api/dashboard/analytics', async (req, res) => {
    const auth = await requireAuth(req, server, 'write')
    const providerId = await getProviderIdForUser(server, auth.userId)
    if (!providerId) return json(res, { error: 'No provider profile' }, 403)

    // Count all bookings by status
    let completed = 0
    let cancelled = 0
    let noShow = 0
    let confirmed = 0
    const packageCounts = {}

    for await (const entry of server.db.createReadStream({
      gte: `bookings-by-provider:${providerId}:`,
      lte: `bookings-by-provider:${providerId}:~`
    })) {
      const bEntry = await server.db.get(`booking:${entry.value}`)
      if (!bEntry) continue
      const b = bEntry.value

      if (b.status === 'completed') completed++
      else if (b.status === 'cancelled') cancelled++
      else if (b.status === 'no-show') noShow++
      else if (b.status === 'confirmed') confirmed++

      if (!packageCounts[b.packageId]) packageCounts[b.packageId] = { count: 0 }
      packageCounts[b.packageId].count++
    }

    // Enrich package counts with names
    for (const [pkgId, data] of Object.entries(packageCounts)) {
      const pkgEntry = await server.db.get(`package-detail:${pkgId}`)
      data.name = pkgEntry ? pkgEntry.value.name : 'Unknown'
      data.price = pkgEntry ? pkgEntry.value.price : 0
    }

    // Count clients
    let clientCount = 0
    for await (const _ of server.db.createReadStream({
      gte: `clients-by-provider:${providerId}:`,
      lte: `clients-by-provider:${providerId}:~`
    })) {
      clientCount++
    }

    json(res, {
      sessions: { completed, cancelled, noShow, confirmed },
      clients: clientCount,
      packages: packageCounts
    })
  })

  // Update profile (any user)
  router.put('/api/profile', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const body = await readJson(req)
    const { displayName, email, phone } = body

    // Update user record in identity registry
    const userEntry = await server.db.get(`user:${auth.userId}`)
    const user = userEntry ? userEntry.value : { userId: auth.userId, createdAt: Date.now() }

    if (displayName !== undefined) user.displayName = displayName
    if (email !== undefined) user.email = email
    if (phone !== undefined) user.phone = phone
    user.updatedAt = Date.now()

    await server.db.put(`user:${auth.userId}`, user)

    // Also update username in identity registry if available
    if (displayName && server.authManager.identityRegistry) {
      try {
        await server.authManager.identityRegistry.setUsername(auth.userId, displayName)
      } catch {}
    }

    json(res, user)
  })

  // Get profile
  router.get('/api/profile', async (req, res) => {
    const auth = await requireAuth(req, server, 'read')
    const userEntry = await server.db.get(`user:${auth.userId}`)
    json(res, userEntry ? userEntry.value : { userId: auth.userId })
  })

  // ---- Admin API ----

  // List all users with their linked provider
  router.get('/api/admin/users', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')

    const users = []
    for await (const entry of server.db.createReadStream({ gte: 'user:', lte: 'user:~' })) {
      const u = entry.value
      // Find linked provider
      let provider = null
      for await (const pe of server.db.createReadStream({ gte: 'provider:', lte: 'provider:~' })) {
        if (pe.value.userId === u.userId) { provider = pe.value; break }
      }
      // Find identities
      const identities = []
      for await (const ie of server.db.createReadStream({ gte: 'identity:', lte: 'identity:~' })) {
        if (ie.value.userId === u.userId) identities.push({ id: ie.value.identityId, publicKey: ie.value.publicKey, method: ie.value.method })
      }
      users.push({
        userId: u.userId,
        username: u.username,
        name: u.name || u.displayName,
        email: u.email,
        globalPermissions: u.globalPermissions,
        provider: provider ? { id: provider.id, slug: provider.slug, displayName: provider.displayName } : null,
        identities,
        createdAt: u.createdAt
      })
    }
    json(res, users)
  })

  // List all providers with owner info
  router.get('/api/admin/providers', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')

    const providers = []
    for await (const entry of server.db.createReadStream({ gte: 'provider:', lte: 'provider:~' })) {
      const p = entry.value
      // Find owner
      let owner = null
      if (p.userId) {
        const userEntry = await server.db.get(`user:${p.userId}`)
        if (userEntry) owner = { userId: userEntry.value.userId, username: userEntry.value.username, name: userEntry.value.name || userEntry.value.displayName }
      }
      // Count bookings
      let bookingCount = 0
      for await (const _ of server.db.createReadStream({ gte: `bookings-by-provider:${p.id}:`, lte: `bookings-by-provider:${p.id}:~` })) {
        bookingCount++
      }
      // Gcal status
      const gcalEntry = await server.db.get(`gcal:provider:${p.id}`)
      const gcalConnected = gcalEntry ? gcalEntry.value.connected : false

      providers.push({ ...p, owner, bookingCount, gcalConnected })
    }
    json(res, providers)
  })

  // Unlink provider from user (set userId to null)
  router.put('/api/admin/providers/:providerId/unlink', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')
    const entry = await server.db.get(`provider:${req.params.providerId}`)
    if (!entry) return notFound(res, 'Provider not found')

    const provider = entry.value
    const oldUserId = provider.userId
    provider.userId = null
    provider.updatedAt = Date.now()
    await server.db.put(`provider:${provider.id}`, provider)
    // Remove user→provider index
    if (oldUserId) await server.db.del(`provider-by-user:${oldUserId}`)
    json(res, { ok: true, provider })
  })

  // Assign provider to user
  router.put('/api/admin/providers/:providerId/assign', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')
    const body = await readJson(req)
    const { userId } = body
    if (!userId) return json(res, { error: 'userId required' }, 400)

    const entry = await server.db.get(`provider:${req.params.providerId}`)
    if (!entry) return notFound(res, 'Provider not found')

    const userEntry = await server.db.get(`user:${userId}`)
    if (!userEntry) return notFound(res, 'User not found')

    const provider = entry.value
    // Remove old user index if reassigning
    if (provider.userId) await server.db.del(`provider-by-user:${provider.userId}`)
    provider.userId = userId
    provider.updatedAt = Date.now()
    await server.db.put(`provider:${provider.id}`, provider)
    // Add new user→provider index
    await server.db.put(`provider-by-user:${userId}`, provider.id)
    json(res, { ok: true, provider })
  })

  // Toggle provider active/disabled
  router.put('/api/admin/providers/:providerId/toggle', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')
    const entry = await server.db.get(`provider:${req.params.providerId}`)
    if (!entry) return notFound(res, 'Provider not found')

    const provider = entry.value
    provider.active = !provider.active
    provider.updatedAt = Date.now()
    await server.db.put(`provider:${provider.id}`, provider)
    json(res, { ok: true, active: provider.active })
  })

  // Update user permissions
  router.put('/api/admin/users/:userId/permissions', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')
    const body = await readJson(req)
    const { permissions } = body
    if (!Array.isArray(permissions)) return json(res, { error: 'permissions array required' }, 400)

    const entry = await server.db.get(`user:${req.params.userId}`)
    if (!entry) return notFound(res, 'User not found')

    const user = entry.value
    user.globalPermissions = permissions
    user.notebooks = [{ name: '*', permissions }]
    user.updatedAt = Date.now()
    await server.db.put(`user:${user.userId}`, user)
    json(res, { ok: true, user })
  })

  // System info
  router.get('/api/admin/system', async (req, res) => {
    const auth = await requireAuth(req, server, 'admin')

    let userCount = 0
    for await (const _ of server.db.createReadStream({ gte: 'user:', lte: 'user:~' })) userCount++
    let providerCount = 0
    for await (const _ of server.db.createReadStream({ gte: 'provider:', lte: 'provider:~' })) providerCount++
    let bookingCount = 0
    for await (const _ of server.db.createReadStream({ gte: 'booking:', lte: 'booking:~' })) bookingCount++

    json(res, {
      version: '0.1.0',
      uptime: process.uptime ? process.uptime() : 0,
      users: userCount,
      providers: providerCount,
      bookings: bookingCount,
      gcalConfigured: server.gcalConfigured
    })
  })

  // ---- HTML Pages ----

  // Dashboard home
  router.get('/dashboard', async (req, res) => {
    serveDashboardPage(res, server, 'home')
  })

  router.get('/dashboard/clients', async (req, res) => {
    serveDashboardPage(res, server, 'clients')
  })

  router.get('/dashboard/clients/:clientId', async (req, res) => {
    serveDashboardPage(res, server, 'client-detail', req.params)
  })

  router.get('/dashboard/packages', async (req, res) => {
    serveDashboardPage(res, server, 'packages')
  })

  router.get('/dashboard/packages/new', async (req, res) => {
    serveDashboardPage(res, server, 'package-edit')
  })

  router.get('/dashboard/packages/:id/edit', async (req, res) => {
    serveDashboardPage(res, server, 'package-edit', req.params)
  })

  router.get('/dashboard/availability', async (req, res) => {
    serveDashboardPage(res, server, 'availability')
  })

  router.get('/dashboard/analytics', async (req, res) => {
    serveDashboardPage(res, server, 'analytics')
  })

  router.get('/dashboard/settings', async (req, res) => {
    serveDashboardPage(res, server, 'settings')
  })

  // Profile page (any authenticated user)
  router.get('/profile', async (req, res) => {
    serveDashboardPage(res, server, 'profile')
  })

  // Client portal
  router.get('/portal', async (req, res) => {
    serveDashboardPage(res, server, 'portal')
  })

  // Admin pages
  router.get('/admin', async (req, res) => {
    serveDashboardPage(res, server, 'admin')
  })

  router.get('/admin/providers', async (req, res) => {
    serveDashboardPage(res, server, 'admin-providers')
  })

  router.get('/admin/users', async (req, res) => {
    serveDashboardPage(res, server, 'admin-users')
  })
}

// ---- Helpers ----

async function getProviderIdForUser (server, userId) {
  // Fast path: use index
  const indexed = await server.db.get(`provider-by-user:${userId}`)
  if (indexed) return indexed.value

  // Fallback: scan (for providers created before index existed)
  for await (const entry of server.db.createReadStream({
    gte: 'provider:',
    lte: 'provider:~'
  })) {
    if (entry.value.userId === userId) {
      // Backfill index
      await server.db.put(`provider-by-user:${userId}`, entry.value.id)
      return entry.value.id
    }
  }
  return null
}

async function getBookingsForDate (db, providerId, date) {
  const bookings = []
  for await (const entry of db.createReadStream({
    gte: `bookings-by-date:${providerId}:${date}:`,
    lte: `bookings-by-date:${providerId}:${date}:~`
  })) {
    const bookingEntry = await db.get(`booking:${entry.value}`)
    if (bookingEntry) bookings.push(bookingEntry.value)
  }
  return bookings
}

function getMonday (d) {
  const date = new Date(d)
  const day = date.getDay()
  const diff = date.getDate() - day + (day === 0 ? -6 : 1)
  date.setDate(diff)
  return date.toISOString().split('T')[0]
}

function addDays (dateStr, days) {
  const d = new Date(dateStr + 'T12:00:00Z')
  d.setUTCDate(d.getUTCDate() + days)
  return d.toISOString().split('T')[0]
}

// ---- Page Renderer ----

function serveDashboardPage (res, server, view, params = {}) {
  const prefix = server.pathPrefix

  const page = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dashboard — hyper-booking</title>
  <link rel="stylesheet" href="${prefix}/web/css/app.css">
  <link rel="stylesheet" href="${prefix}/web/css/dashboard.css">
</head>
<body>
  <div class="dashboard-layout">
    <nav class="dashboard-sidebar" id="sidebar">
      <div class="sidebar-header">
        <a href="${prefix}/dashboard" class="sidebar-logo">hyper-booking</a>
      </div>
      <div class="sidebar-nav">
        <a href="${prefix}/dashboard" class="nav-item ${view === 'home' ? 'active' : ''}">
          <span class="nav-icon">&#128197;</span> Calendar
        </a>
        <a href="${prefix}/dashboard/clients" class="nav-item ${view === 'clients' || view === 'client-detail' ? 'active' : ''}">
          <span class="nav-icon">&#128101;</span> Clients
        </a>
        <a href="${prefix}/dashboard/packages" class="nav-item ${view === 'packages' || view === 'package-edit' ? 'active' : ''}">
          <span class="nav-icon">&#128230;</span> Packages
        </a>
        <a href="${prefix}/dashboard/availability" class="nav-item ${view === 'availability' ? 'active' : ''}">
          <span class="nav-icon">&#128340;</span> Availability
        </a>
        <a href="${prefix}/dashboard/analytics" class="nav-item ${view === 'analytics' ? 'active' : ''}">
          <span class="nav-icon">&#128200;</span> Analytics
        </a>
        <a href="${prefix}/dashboard/settings" class="nav-item ${view === 'settings' ? 'active' : ''}">
          <span class="nav-icon">&#9881;</span> Settings
        </a>
        <div class="sidebar-divider"></div>
        <a href="${prefix}/portal" class="nav-item ${view === 'portal' ? 'active' : ''}">
          <span class="nav-icon">&#128100;</span> Client Portal
        </a>
        <a href="${prefix}/profile" class="nav-item ${view === 'profile' ? 'active' : ''}">
          <span class="nav-icon">&#128272;</span> Profile
        </a>
        <a href="${prefix}/admin" class="nav-item ${view === 'admin' || view.startsWith('admin') ? 'active' : ''}" id="admin-link">
          <span class="nav-icon">&#128737;</span> Admin
        </a>
      </div>
    </nav>

    <main class="dashboard-main">
      <header class="dashboard-header">
        <button class="sidebar-toggle" onclick="document.getElementById('sidebar').classList.toggle('open')">&#9776;</button>
        <span id="header-title">${viewTitle(view)}</span>
        <div class="header-right">
          <span id="user-name"></span>
        </div>
      </header>

      <div class="dashboard-content" id="content">
        <p class="empty">Loading...</p>
      </div>
    </main>
  </div>

  <script src="${prefix}/web/js/identity.js"></script>
  <script src="${prefix}/web/js/app.js"></script>
  <script src="${prefix}/web/js/dashboard.js"></script>
  <script>
    initDashboard('${view}', ${JSON.stringify(params)})
  </script>
</body>
</html>`

  html(res, page)
}

function viewTitle (view) {
  const titles = {
    home: 'Calendar',
    clients: 'Clients',
    'client-detail': 'Client Detail',
    packages: 'Packages',
    'package-edit': 'Edit Package',
    availability: 'Availability',
    analytics: 'Analytics',
    settings: 'Settings',
    profile: 'Profile',
    portal: 'Client Portal',
    admin: 'Admin',
    'admin-providers': 'Manage Providers',
    'admin-users': 'Manage Users'
  }
  return titles[view] || 'Dashboard'
}