Blink Boutique

Booking system for a lash extension boutique

Node.js Express SQL.js Bcrypt Nodemailer

Overview

A full-featured booking system for a lash extension boutique with two distinct interfaces: a customer-facing booking portal and a secure admin panel for managing appointments.

Key Features

Smart Time Slot Availability

The system calculates available time slots by considering service duration, existing bookings, and admin-blocked slots. It uses interval overlap detection to prevent double-bookings even when services have different durations.

function isTimeSlotAvailable(date, startTime, serviceKey, excludeBookingId = null) {
    const serviceInfo = queryOne('SELECT duration_minutes FROM service_durations WHERE service_key = ?', [serviceKey]);
    const newDuration = serviceInfo.duration_minutes;
    
    // Convert time to minutes since midnight
    const [startHour, startMin] = startTime.split(':').map(Number);
    const newStart = startHour * 60 + startMin;
    const newEnd = newStart + newDuration;
    
    // Get existing bookings, excluding cancelled and current booking if editing
    const existingBookings = queryAll(
        'SELECT b.time, sd.duration_minutes FROM bookings b JOIN service_durations sd ON b.service = sd.service_key WHERE b.date = ? AND b.status != "cancelled"' + 
        (excludeBookingId ? ' AND b.id != ?' : ''),
        excludeBookingId ? [date, excludeBookingId] : [date]
    );
    
    // Check each booking for overlap using interval arithmetic
    for (const booking of existingBookings) {
        const [bHour, bMin] = booking.time.split(':').map(Number);
        const bStart = bHour * 60 + bMin;
        const bEnd = bStart + booking.duration_minutes;
        
        // Overlap exists when: newStart < bEnd && newEnd > bStart
        if (newStart < bEnd && newEnd > bStart) {
            return false;
        }
    }
    return true;
}

Dynamic Booking Updates

The admin can edit any booking field (date, time, service, status) and the system automatically checks for conflicts and sends email notifications when status changes.

app.patch('/api/bookings/:id', requireAuth, async (req, res) => {
    const { status, date, time, name, email, phone, service } = req.body;
    const booking = queryOne('SELECT * FROM bookings WHERE id = ?', [parseInt(req.params.id)]);
    
    // Check for conflicts if date/time/service is being changed
    if (!isTimeSlotAvailable(checkDate, checkTime, checkService, parseInt(req.params.id))) {
        return res.status(409).json({ error: 'Time slot is not available' });
    }
    
    // Build dynamic UPDATE query from provided fields
    const updates = [];
    const params = [];
    if (status !== undefined) { updates.push('status = ?'); params.push(status); }
    if (date !== undefined) { updates.push('date = ?'); params.push(date); }
    if (time !== undefined) { updates.push('time = ?'); params.push(time); }
    // ... other fields
    
    runQuery(`UPDATE bookings SET ${updates.join(', ')} WHERE id = ?`, [...params, parseInt(req.params.id)]);
    
    // Send email notification on status change
    if (status && status !== booking.status) {
        await sendBookingEmail(updatedBooking, 'status_update');
    }
});

Parallel Data Fetching

The admin calendar loads bookings, available slots, and blocked slots in parallel using Promise.all for better performance.

// Fetch all calendar data in parallel
const [availResponse, bookingsResponse, blockedResponse] = await Promise.all([
    fetch(`/api/available-slots/admin?startDate=${startDate}&endDate=${endDate}`),
    fetch(`/api/bookings?startDate=${startDate}&endDate=${endDate}`),
    fetch(`/api/blocked-slots?startDate=${startDate}&endDate=${endDate}`)
]);

const availableSlots = await availResponse.json();
const bookings = await bookingsResponse.json();
const blockedSlots = await blockedResponse.json();

// Organize data by date for calendar rendering
const availByDate = {};
availableSlots.forEach(s => {
    if (!availByDate[s.date]) availByDate[s.date] = [];
    availByDate[s.date].push(s);
});

Session-Based Authentication Middleware

The admin panel is protected by session-based authentication. The middleware handles both API requests (returning JSON) and page requests (redirecting to login).

const requireAuth = (req, res, next) => {
    if (req.session.adminId) {
        next();
    } else {
        const accept = req.get('Accept') || '';
        if (accept.includes('application/json')) {
            // API call - return JSON with redirect hint
            res.status(401).json({ error: 'Unauthorized', redirect: '/admin/login.html' });
        } else {
            // Page request - redirect to login
            res.redirect('/admin/login.html');
        }
    }
};

Challenges & Solutions