Blink Boutique
Booking system for a lash extension boutique
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.
- Customer booking portal with service selection, date/time picker
- Admin panel with authentication, booking management, and client database
- Email notifications for booking confirmations and status updates
- SQLite database using sql.js for portable data storage
- Session-based authentication with bcrypt password hashing
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
- Service-specific durations: Different services take different amounts of time, so the availability calculator needs to check overlap against each booking's actual duration
- Persistent storage with sql.js: Since sql.js runs in-memory, the database is serialized to disk after every write to ensure data persists across restarts
- Email gracefully failing: If SMTP isn't configured, the system logs a warning but continues working - bookings can still be made without email