Skip to content
30 changes: 30 additions & 0 deletions app/lib/airtable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const Tables = {
Orgs: 'Orgs',
Programs: 'Programs',
DayPasses: 'Day Passes',
Rooms: 'Rooms',
RoomBookings: 'Room Bookings',
} as const

export type TableName = (typeof Tables)[keyof typeof Tables]
Expand Down Expand Up @@ -260,5 +262,33 @@ export async function findRecord<T = Record<string, unknown>>(
return records[0] || null
}

/**
* Deletes a record by ID.
*
* @param table - Table name
* @param recordId - Airtable record ID
* @returns true if deleted, false if not found
*/
export async function deleteRecord(table: TableName, recordId: string): Promise<boolean> {
const url = `${AIRTABLE_API_URL}/${env.AIRTABLE_BASE_ID}/${encodeURIComponent(table)}/${recordId}`

const res = await fetch(url, {
method: 'DELETE',
headers: getHeaders(),
})

if (!res.ok) {
if (res.status === 404) {
return false
}
const errorData = await res.json().catch(() => ({}))
throw new Error(
`Airtable delete error: ${res.status} ${res.statusText} - ${JSON.stringify(errorData)}`
)
}

return true
}

// Re-export the escape helper for use in formulas
export { escapeAirtableString } from './airtable-helpers'
230 changes: 230 additions & 0 deletions app/lib/room-bookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
Tables,
findRecords,
findRecord,
createRecord,
updateRecord,
type AirtableRecord,
} from './airtable'
import { escapeAirtableString } from './airtable-helpers'

// Airtable field interfaces
export interface RoomFields {
Name: string
Floor?: string
'Room #'?: string
'Room Size'?: number
Status?: string
Bookable?: boolean
}

export interface RoomBookingFields {
Name?: string
Room?: string[]
'Booked By'?: string[]
Start?: string
End?: string
Purpose?: string
Status?: 'Confirmed' | 'Cancelled'
}

// Client-friendly interfaces
export interface Room {
id: string
name: string
floor?: string
roomNumber?: string
size?: number
status?: string
bookable: boolean
}

export interface Booking {
id: string
roomId: string
roomName: string
userId: string
userName?: string
startDate: Date
endDate: Date
purpose?: string
status: 'Confirmed' | 'Cancelled'
}

// Transform Airtable record to Room
function parseRoom(record: AirtableRecord<RoomFields>): Room {
return {
id: record.id,
name: record.fields.Name,
floor: record.fields.Floor,
roomNumber: record.fields['Room #'],
size: record.fields['Room Size'],
status: record.fields.Status,
bookable: record.fields.Bookable === true,
}
}

// Transform Airtable record to Booking
function parseBooking(
record: AirtableRecord<RoomBookingFields>,
roomName?: string
): Booking {
return {
id: record.id,
roomId: record.fields.Room?.[0] || '',
roomName: roomName || record.fields.Name?.split(' - ')[0] || '',
userId: record.fields['Booked By']?.[0] || '',
startDate: new Date(record.fields.Start || ''),
endDate: new Date(record.fields.End || ''),
purpose: record.fields.Purpose,
status: record.fields.Status || 'Confirmed',
}
}

/**
* Get all rooms marked as bookable
*/
export async function getBookableRooms(): Promise<Room[]> {
const records = await findRecords<RoomFields>(Tables.Rooms, '{Bookable} = TRUE()')
return records.map(parseRoom)
}

/**
* Get a single room by ID
*/
export async function getRoom(roomId: string): Promise<Room | null> {
const record = await findRecord<RoomFields>(Tables.Rooms, `RECORD_ID() = '${escapeAirtableString(roomId)}'`)
return record ? parseRoom(record) : null
}

/**
* Get bookings for a specific room in a date range
*/
export async function getBookingsForRoom(
roomId: string,
startDate: Date,
endDate: Date
): Promise<Booking[]> {
const startISO = startDate.toISOString()
const endISO = endDate.toISOString()

// Find bookings that overlap with the requested time range
// (linked record fields require client-side filtering)
const formula = `AND(
{Status} = 'Confirmed',
IS_BEFORE({Start}, '${endISO}'),
IS_AFTER({End}, '${startISO}')
)`

const records = await findRecords<RoomBookingFields>(Tables.RoomBookings, formula)

// Filter to only this room's bookings
return records
.filter((r) => r.fields.Room?.includes(roomId))
.map((r) => parseBooking(r))
}

/**
* Get all bookings for a user
*/
export async function getUserBookings(userId: string): Promise<Booking[]> {
// Fetch confirmed bookings and filter by user client-side
// (linked record fields in Airtable formulas require lookup/rollup fields for text search)
const formula = `{Status} = 'Confirmed'`

const records = await findRecords<RoomBookingFields>(Tables.RoomBookings, formula, {
sort: [{ field: 'Start', direction: 'asc' }],
})

// Filter to only this user's bookings
return records
.filter((r) => r.fields['Booked By']?.includes(userId))
.map((r) => parseBooking(r))
}

/**
* Find conflicting bookings for a room in a time range
*/
export async function findConflicts(
roomId: string,
startDate: Date,
endDate: Date,
excludeBookingId?: string
): Promise<Booking[]> {
const startISO = startDate.toISOString()
const endISO = endDate.toISOString()

// Find bookings that overlap with the requested time range
// (linked record fields require client-side filtering)
let formula = `AND(
{Status} = 'Confirmed',
IS_BEFORE({Start}, '${endISO}'),
IS_AFTER({End}, '${startISO}')
)`

if (excludeBookingId) {
formula = `AND(
RECORD_ID() != '${escapeAirtableString(excludeBookingId)}',
{Status} = 'Confirmed',
IS_BEFORE({Start}, '${endISO}'),
IS_AFTER({End}, '${startISO}')
)`
}

const records = await findRecords<RoomBookingFields>(Tables.RoomBookings, formula)

// Filter to only this room's bookings
return records
.filter((r) => r.fields.Room?.includes(roomId))
.map((r) => parseBooking(r))
}

/**
* Create a new booking
*/
export async function createBooking(params: {
roomId: string
roomName: string
userId: string
userName: string
startDate: Date
endDate: Date
purpose?: string
}): Promise<Booking> {
const { roomId, roomName, userId, userName, startDate, endDate, purpose } = params

// Format the name field for easy identification
const dateStr = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
const name = `${roomName} - ${userName} - ${dateStr}`

const record = await createRecord<RoomBookingFields>(Tables.RoomBookings, {
Name: name,
Room: [roomId],
'Booked By': [userId],
Start: startDate.toISOString(),
End: endDate.toISOString(),
Purpose: purpose,
Status: 'Confirmed',
})

return parseBooking(record, roomName)
}

/**
* Cancel a booking (soft delete - sets status to Cancelled)
*/
export async function cancelBooking(bookingId: string): Promise<boolean> {
try {
await updateRecord<RoomBookingFields>(Tables.RoomBookings, bookingId, {
Status: 'Cancelled',
})
return true
} catch {
return false
}
}
57 changes: 57 additions & 0 deletions app/portal/api/room-bookings/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/app/lib/session'
import { cancelBooking } from '@/app/lib/room-bookings'
import { getRecord, Tables } from '@/app/lib/airtable'

interface RoomBookingFields {
'Booked By'?: string[]
}

export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession()

if (!session.isLoggedIn) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id } = await params

if (!id) {
return NextResponse.json({ error: 'Booking ID is required' }, { status: 400 })
}

try {
// Get the booking to verify ownership
const booking = await getRecord<RoomBookingFields>(Tables.RoomBookings, id)

if (!booking) {
return NextResponse.json({ error: 'Booking not found' }, { status: 404 })
}

// Check if user owns this booking (or is staff)
const userId = session.viewingAsUserId || session.userId
const bookedBy = booking.fields['Booked By']?.[0]

if (bookedBy !== userId && !session.isStaff) {
return NextResponse.json(
{ error: 'You can only cancel your own bookings' },
{ status: 403 }
)
}

// Cancel the booking
const success = await cancelBooking(id)

if (!success) {
return NextResponse.json({ error: 'Failed to cancel booking' }, { status: 500 })
}

return NextResponse.json({ success: true })
} catch (error) {
console.error('Error cancelling booking:', error)
return NextResponse.json({ error: 'Failed to cancel booking' }, { status: 500 })
}
}
Loading