Skip to content

Latest commit

 

History

History
570 lines (477 loc) · 15.6 KB

File metadata and controls

570 lines (477 loc) · 15.6 KB

SC-MVP-06 Availability Backend Implementation Plan

Overview

This document outlines the implementation plan for the availability backend functionality, which allows staff to update their availability using a magic link token.

Implementation Steps

1. Add New Types to app/lib/repositories/types.ts

Add the following interfaces to the types file:

// Availability grid entry
export interface AvailabilityGridEntry {
  date: string // ISO date string (YYYY-MM-DD)
  timeSlotId: number
  state: AvailabilityState // 'cannot' | 'can' | 'prefer'
}

// Complete availability grid for a staff member for a week
export interface AvailabilityGrid {
  staffId: string
  staffName: string
  weekScheduleId: string
  weekStartDate: string // ISO date string
  organizationId: string
  organizationName: string
  locationId: string
  locationName: string
  timeSlots: TimeSlotDefinition[]
  entries: AvailabilityGridEntry[]
}

// Token resolution result
export interface TokenResolutionResult {
  staff: Staff
  organization: Organization
  location: Location
  weekSchedule: WeekSchedule
  timeSlots: TimeSlotDefinition[]
  currentAvailability: Availability[]
}

// Error class for availability operations
export class AvailabilityError extends Error {
  code: 'INVALID_TOKEN' | 'EXPIRED_TOKEN' | 'STAFF_NOT_FOUND' | 'SCHEDULE_NOT_FOUND'
  
  constructor(code: AvailabilityError['code'], message: string) {
    super(message)
    this.code = code
    this.name = 'AvailabilityError'
  }
}

2. Extend app/lib/repositories/invite-tokens.ts

Add a new function to resolve a token with all related data:

export async function resolveInviteTokenWithRelations(token: string): Promise<TokenResolutionResult | null> {
  // Query to get token with week schedule, staff, location, and organization
  const result = await query(
    `SELECT 
       it.id, it.token, it.week_schedule_id, it.staff_id, it.expires_at, it.created_at, it.updated_at,
       ws.week_start_date, ws.status,
       l.id as location_id, l.name as location_name, l.address,
       o.id as organization_id, o.name as organization_name,
       s.id as staff_id, s.name as staff_name, s.email as staff_email, s.role as staff_role
     FROM invite_tokens it
     JOIN week_schedules ws ON it.week_schedule_id = ws.id
     JOIN locations l ON ws.location_id = l.id
     JOIN organizations o ON l.organization_id = o.id
     LEFT JOIN staff s ON it.staff_id = s.id
     WHERE it.token = $1`,
    [token]
  )
  
  if (result.rows.length === 0) {
    return null
  }
  
  const row = result.rows[0]
  
  // Check if token is expired
  if (new Date(row.expires_at) < new Date()) {
    throw new AvailabilityError('EXPIRED_TOKEN', 'Token has expired')
  }
  
  // Get time slots
  const timeSlots = await getTimeSlots()
  
  // Get current availability if staff exists
  let currentAvailability: Availability[] = []
  if (row.staff_id) {
    currentAvailability = await getAvailabilitiesForStaffAndWeek(row.staff_id, row.week_schedule_id)
  }
  
  return {
    staff: row.staff_id ? {
      id: row.staff_id,
      name: row.staff_name,
      email: row.staff_email,
      role: row.staff_role,
      location_id: row.location_id,
      is_active: true, // Assuming active since token exists
      created_at: new Date(),
      updated_at: new Date()
    } : null,
    organization: {
      id: row.organization_id,
      name: row.organization_name,
      owner_id: '', // Not needed for availability
      created_at: new Date(),
      updated_at: new Date()
    },
    location: {
      id: row.location_id,
      name: row.location_name,
      address: row.address,
      organization_id: row.organization_id,
      created_at: new Date(),
      updated_at: new Date()
    },
    weekSchedule: {
      id: row.week_schedule_id,
      week_start_date: parseDate(row.week_start_date),
      status: row.status,
      location_id: row.location_id,
      created_at: new Date(),
      updated_at: new Date()
    },
    timeSlots,
    currentAvailability
  }
}

3. Create app/lib/repositories/availability-helpers.ts

Create a new file with helper functions for availability operations:

import { 
  AvailabilityGrid, 
  AvailabilityGridEntry, 
  TokenResolutionResult,
  AvailabilityError
} from './types'
import { 
  upsertAvailability,
  getAvailabilitiesForStaffAndWeek 
} from './availabilities'
import { resolveInviteTokenWithRelations } from './invite-tokens'
import { addDaysToDate } from './utils'

/**
 * Resolve a token and return all necessary data for availability management
 */
export async function resolveAvailabilityToken(token: string): Promise<TokenResolutionResult | null> {
  try {
    const result = await resolveInviteTokenWithRelations(token)
    
    if (!result) {
      throw new AvailabilityError('INVALID_TOKEN', 'Token not found')
    }
    
    if (!result.staff) {
      throw new AvailabilityError('STAFF_NOT_FOUND', 'Token is not associated with a staff member')
    }
    
    return result
  } catch (error) {
    if (error instanceof AvailabilityError) {
      throw error
    }
    throw new AvailabilityError('INVALID_TOKEN', 'Failed to resolve token')
  }
}

/**
 * Get availability grid for a token
 */
export async function getAvailabilityForToken(token: string): Promise<AvailabilityGrid | null> {
  const resolution = await resolveAvailabilityToken(token)
  
  if (!resolution) {
    return null
  }
  
  const { staff, organization, location, weekSchedule, timeSlots, currentAvailability } = resolution
  
  // Generate all dates for the week (Monday to Sunday)
  const weekDates = []
  const startDate = new Date(weekSchedule.week_start_date)
  for (let i = 0; i < 7; i++) {
    weekDates.push(addDaysToDate(startDate, i))
  }
  
  // Create grid entries for all date/time slot combinations
  const entries: AvailabilityGridEntry[] = []
  
  for (const date of weekDates) {
    for (const timeSlot of timeSlots) {
      // Find existing availability for this date/time slot
      const existing = currentAvailability.find(
        a => a.date.toISOString().split('T')[0] === date.toISOString().split('T')[0] && 
             a.time_slot_id === timeSlot.id
      )
      
      entries.push({
        date: date.toISOString().split('T')[0],
        timeSlotId: timeSlot.id,
        state: existing ? existing.state : 'cannot' // Default to 'cannot'
      })
    }
  }
  
  return {
    staffId: staff.id,
    staffName: staff.name,
    weekScheduleId: weekSchedule.id,
    weekStartDate: weekSchedule.week_start_date.toISOString().split('T')[0],
    organizationId: organization.id,
    organizationName: organization.name,
    locationId: location.id,
    locationName: location.name,
    timeSlots,
    entries
  }
}

/**
 * Update availability grid for a token
 */
export async function updateAvailabilityForToken(
  token: string, 
  entries: AvailabilityGridEntry[]
): Promise<AvailabilityGrid | null> {
  const resolution = await resolveAvailabilityToken(token)
  
  if (!resolution) {
    return null
  }
  
  const { staff, weekSchedule } = resolution
  
  // Update each availability entry
  for (const entry of entries) {
    await upsertAvailability(
      staff.id,
      weekSchedule.id,
      new Date(entry.date),
      entry.timeSlotId,
      entry.state
    )
  }
  
  // Return the updated grid
  return getAvailabilityForToken(token)
}

4. Create app/lib/actions/availability-actions.ts

Create server actions for the availability functionality:

'use server'

import { 
  AvailabilityGrid, 
  AvailabilityGridEntry, 
  AvailabilityError 
} from '../repositories/types'
import { 
  getAvailabilityForToken,
  updateAvailabilityForToken
} from '../repositories/availability-helpers'

/**
 * Server action for loading availability page
 */
export async function loadAvailabilityPage(token: string): Promise<{
  success: boolean
  data?: AvailabilityGrid
  error?: string
}> {
  try {
    const grid = await getAvailabilityForToken(token)
    
    if (!grid) {
      return {
        success: false,
        error: 'Invalid or expired token'
      }
    }
    
    return {
      success: true,
      data: grid
    }
  } catch (error) {
    if (error instanceof AvailabilityError) {
      return {
        success: false,
        error: error.message
      }
    }
    
    console.error('Error loading availability page:', error)
    return {
      success: false,
      error: 'Failed to load availability'
    }
  }
}

/**
 * Server action for updating availability
 */
export async function updateAvailability(
  token: string,
  entries: AvailabilityGridEntry[]
): Promise<{
  success: boolean
  data?: AvailabilityGrid
  error?: string
}> {
  try {
    const grid = await updateAvailabilityForToken(token, entries)
    
    if (!grid) {
      return {
        success: false,
        error: 'Invalid or expired token'
      }
    }
    
    return {
      success: true,
      data: grid
    }
  } catch (error) {
    if (error instanceof AvailabilityError) {
      return {
        success: false,
        error: error.message
      }
    }
    
    console.error('Error updating availability:', error)
    return {
      success: false,
      error: 'Failed to update availability'
    }
  }
}

5. Create app/availability/[token]/page.tsx

Create the page component for the availability route:

import { notFound } from 'next/navigation'
import { loadAvailabilityPage } from '../../../lib/actions/availability-actions'

interface AvailabilityPageProps {
  params: {
    token: string
  }
}

export default async function AvailabilityPage({ params }: AvailabilityPageProps) {
  const { token } = params
  
  const result = await loadAvailabilityPage(token)
  
  if (!result.success) {
    return (
      <div className="container mx-auto px-4 py-8">
        <div className="max-w-md mx-auto bg-red-50 border border-red-200 rounded-lg p-6">
          <h1 className="text-xl font-semibold text-red-800 mb-2">Invalid Link</h1>
          <p className="text-red-600">
            {result.error || 'This availability link is invalid or has expired.'}
          </p>
        </div>
      </div>
    )
  }
  
  // For now, just display the data - UI will be implemented in SC-MVP-07
  const { data } = result
  
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-2xl font-bold mb-6">
          Availability for {data?.staffName}
        </h1>
        
        <div className="bg-white rounded-lg shadow p-6">
          <div className="mb-4">
            <p><strong>Organization:</strong> {data?.organizationName}</p>
            <p><strong>Location:</strong> {data?.locationName}</p>
            <p><strong>Week:</strong> {data?.weekStartDate}</p>
          </div>
          
          <div className="mt-6">
            <h2 className="text-lg font-semibold mb-4">Current Availability</h2>
            <pre className="bg-gray-100 p-4 rounded overflow-auto">
              {JSON.stringify(data?.entries, null, 2)}
            </pre>
          </div>
        </div>
      </div>
    </div>
  )
}

6. Add Tests

Create tests for the new functionality in app/tests/repositories/availability-helpers.test.ts:

import { describe, it, expect } from 'vitest'
import { 
  resolveAvailabilityToken,
  getAvailabilityForToken,
  updateAvailabilityForToken
} from '../../lib/repositories/availability-helpers'
import { createCompleteTestData } from '../utils/test-data'
import { createInviteTokenForStaff } from '../../lib/repositories'

describe('Availability Helpers', () => {
  it('should resolve a valid token', async () => {
    const { staff, weekSchedule } = await createCompleteTestData()
    
    // Create a token for the staff
    const token = await createInviteTokenForStaff({
      staffId: staff.id,
      weekScheduleId: weekSchedule.id
    })
    
    const resolution = await resolveAvailabilityToken(token.token)
    
    expect(resolution).toBeDefined()
    expect(resolution?.staff.id).toBe(staff.id)
    expect(resolution?.weekSchedule.id).toBe(weekSchedule.id)
  })
  
  it('should get availability grid for a token', async () => {
    const { staff, weekSchedule } = await createCompleteTestData()
    
    // Create a token for the staff
    const token = await createInviteTokenForStaff({
      staffId: staff.id,
      weekScheduleId: weekSchedule.id
    })
    
    const grid = await getAvailabilityForToken(token.token)
    
    expect(grid).toBeDefined()
    expect(grid?.staffId).toBe(staff.id)
    expect(grid?.entries).toHaveLength(28) // 7 days × 4 time slots
  })
  
  it('should update availability for a token', async () => {
    const { staff, weekSchedule } = await createCompleteTestData()
    
    // Create a token for the staff
    const token = await createInviteTokenForStaff({
      staffId: staff.id,
      weekScheduleId: weekSchedule.id
    })
    
    // Update availability
    const updatedGrid = await updateAvailabilityForToken(token.token, [
      {
        date: weekSchedule.week_start_date.toISOString().split('T')[0],
        timeSlotId: 1,
        state: 'prefer'
      }
    ])
    
    expect(updatedGrid).toBeDefined()
    expect(updatedGrid?.entries.find(
      e => e.date === weekSchedule.week_start_date.toISOString().split('T')[0] && 
           e.timeSlotId === 1
    )?.state).toBe('prefer')
  })
})

7. Add Server Action Tests

Create tests for the server actions in app/tests/actions/availability-actions.test.ts:

import { describe, it, expect } from 'vitest'
import { 
  loadAvailabilityPage,
  updateAvailability
} from '../../lib/actions/availability-actions'
import { createCompleteTestData } from '../utils/test-data'
import { createInviteTokenForStaff } from '../../lib/repositories'

describe('Availability Actions', () => {
  it('should load availability page', async () => {
    const { staff, weekSchedule } = await createCompleteTestData()
    
    // Create a token for the staff
    const token = await createInviteTokenForStaff({
      staffId: staff.id,
      weekScheduleId: weekSchedule.id
    })
    
    const result = await loadAvailabilityPage(token.token)
    
    expect(result.success).toBe(true)
    expect(result.data?.staffId).toBe(staff.id)
  })
  
  it('should fail with invalid token', async () => {
    const result = await loadAvailabilityPage('invalid-token')
    
    expect(result.success).toBe(false)
    expect(result.error).toContain('Invalid or expired token')
  })
  
  it('should update availability', async () => {
    const { staff, weekSchedule } = await createCompleteTestData()
    
    // Create a token for the staff
    const token = await createInviteTokenForStaff({
      staffId: staff.id,
      weekScheduleId: weekSchedule.id
    })
    
    const result = await updateAvailability(token.token, [
      {
        date: weekSchedule.week_start_date.toISOString().split('T')[0],
        timeSlotId: 1,
        state: 'prefer'
      }
    ])
    
    expect(result.success).toBe(true)
    expect(result.data?.entries.find(
      e => e.date === weekSchedule.week_start_date.toISOString().split('T')[0] && 
           e.timeSlotId === 1
    )?.state).toBe('prefer')
  })
})

Summary

This implementation provides:

  1. Type Safety: Well-defined TypeScript interfaces for all data structures
  2. Error Handling: Custom error class with specific error codes
  3. Idempotent Updates: Using the existing upsertAvailability function
  4. Server Actions: Properly structured server actions for Next.js App Router
  5. Test Coverage: Comprehensive tests for all new functionality
  6. Clean Architecture: Separation of concerns between repositories, helpers, and actions

The implementation follows the existing patterns in the codebase and maintains consistency with the current architecture.