This document outlines the implementation plan for the availability backend functionality, which allows staff to update their availability using a magic link token.
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'
}
}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
}
}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)
}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'
}
}
}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>
)
}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')
})
})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')
})
})This implementation provides:
- Type Safety: Well-defined TypeScript interfaces for all data structures
- Error Handling: Custom error class with specific error codes
- Idempotent Updates: Using the existing
upsertAvailabilityfunction - Server Actions: Properly structured server actions for Next.js App Router
- Test Coverage: Comprehensive tests for all new functionality
- 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.