From 14b2cb2742bf7b8f43a0eeff27a98cc9ee8738eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8NW=C2=A8?= <¨neroworld@mail.ru¨> Date: Sun, 5 Apr 2026 01:27:25 +0100 Subject: [PATCH] feat: add booking workflow for service businesses (salons, clinics, massage) --- .kilo/KILO_SPEC.md | 19 +- .kilo/commands/booking.md | 1541 +++++++++++++++++++++++++++++++++ .kilo/skills/booking/SKILL.md | 639 ++++++++++++++ 3 files changed, 2198 insertions(+), 1 deletion(-) create mode 100644 .kilo/commands/booking.md create mode 100644 .kilo/skills/booking/SKILL.md diff --git a/.kilo/KILO_SPEC.md b/.kilo/KILO_SPEC.md index c43e4df..fb5db5f 100644 --- a/.kilo/KILO_SPEC.md +++ b/.kilo/KILO_SPEC.md @@ -443,7 +443,8 @@ Provider availability depends on configuration. Common providers include: |---------|-------------|-------| | `/landing-page` | Create landing page CMS from HTML mockups | ollama-cloud/kimi-k2.5 | | `/commerce` | Create e-commerce site with products, cart, payments | qwen/qwen3-coder:free | -| `/blog` | Create blog/CMS with posts, comments, SEO | qwen/qwen3-coder:free | +| `/blog` | Create blog/CMS with posts, comments, SEO | qwen/qeen3-coder:free | +| `/booking` | Create booking system for services/appointments | qwen/qwen3-coder:free | | `/pipeline` | Run full agent pipeline for issue | - | | `/feature` | Full feature development pipeline | qwen/qwen3-coder:free | | `/code` | Quick code generation | qwen/qwen3-coder:free | @@ -510,6 +511,22 @@ Provider availability depends on configuration. Common providers include: - RSS/Atom feeds and sitemap generation - Media library management +### Booking System Domain + +**Location**: `.kilo/skills/booking/SKILL.md` + +**Purpose**: Domain knowledge for building booking and appointment systems. + +**Capabilities**: +- Service management with categories and pricing +- Staff scheduling and availability +- Real-time slot calculation +- Booking flow (service → staff → date/time → customer) +- Status management (pending, confirmed, completed, cancelled) +- Email/SMS notifications +- Calendar integration (Google, iCal) +- Revenue and utilization reports + --- ## File Naming Conventions diff --git a/.kilo/commands/booking.md b/.kilo/commands/booking.md new file mode 100644 index 0000000..c223c9f --- /dev/null +++ b/.kilo/commands/booking.md @@ -0,0 +1,1541 @@ +--- +description: Create full-stack booking site with Node.js, Vue, SQLite, admin panel, calendar, and Docker deployment +mode: booking +model: qwen/qwen3-coder:free +color: "#8B5CF6" +permission: + read: allow + edit: allow + write: allow + bash: allow + glob: allow + grep: allow + task: + "backend-developer": allow + "frontend-developer": allow + "system-analyst": allow + "lead-developer": allow + "sdet-engineer": allow + "code-skeptic": allow + "the-fixer": allow + "release-manager": allow + "security-auditor": allow + "browser-automation": allow +--- + +# Booking System Workflow + +Create a full-stack booking and appointment system for service businesses (salons, clinics, massage, etc.) with online booking, staff scheduling, admin management, and Docker deployment. + +## Parameters + +- `project_name`: Business name (required) +- `business_type`: Type - 'salon', 'clinic', 'massage', 'fitness', 'consulting' (default: 'salon') +- `ui_framework`: UI framework - 'vuetify', 'quasar', 'primevue' (default: 'vuetify') +- `features`: Features - 'payments,sms,calendar_sync,reminders' (default: 'all') +- `docker`: Create Docker deployment (default: true) +- `issue`: Gitea issue number for tracking (optional) + +## Overview + +``` +Requirements → Architecture → Services → Staff → Booking → Admin → Calendar → Tests → Docker → Docs +``` + +## Technology Stack + +### Frontend +| Component | Technology | +|-----------|------------| +| Framework | Vue.js 3 (Composition API) | +| UI Library | Vuetify/Quasar/PrimeVue | +| State | Pinia | +| Router | Vue Router | +| Calendar | FullCalendar/Vue Cal | +| Time Picker | Vuetify Time Picker | +| HTTP | Axios | + +### Backend +| Component | Technology | +|-----------|------------| +| Runtime | Node.js 20.x | +| Framework | Express.js | +| Database | SQLite (better-sqlite3) | +| Auth | JWT + bcrypt | +| Validation | Joi/Zod | +| Notifications | Nodemailer + SMS gateway | + +## Step 1: Requirements Analysis + +**Agent**: `@RequirementRefiner` + +### Booking Requirements Checklist + +```markdown +## User Stories + +### Customer (Public) +- [ ] View services with prices and duration +- [ ] Select service category +- [ ] Choose preferred staff member +- [ ] View available time slots +- [ ] Book appointment +- [ ] Receive confirmation (email/SMS) +- [ ] View booking details +- [ ] Cancel/reschedule booking +- [ ] View booking history +- [ ] Register account (optional) + +### Business Admin +- [ ] Manage services (CRUD) +- [ ] Manage service categories +- [ ] Manage staff members +- [ ] Set staff schedules +- [ ] Manage availability +- [ ] View all bookings +- [ ] Confirm/decline bookings +- [ ] Mark bookings complete/no-show +- [ ] View calendar (day/week/month) +- [ ] Generate reports (revenue, utilization) +- [ ] Manage customers +- [ ] Send notifications + +### Staff Member +- [ ] View own schedule +- [ ] View own bookings +- [ ] Update availability +- [ ] Mark bookings complete +- [ ] Add notes to bookings + +### Non-Functional +- [ ] Mobile-responsive booking flow +- [ ] Real-time availability updates +- [ ] Email confirmation +- [ ] SMS reminders +- [ ] Calendar sync (Google, iCal) +- [ ] Timezone support +- [ ] Multi-language support +``` + +## Step 2: Architecture Design + +**Agent**: `@SystemAnalyst` + +### Project Structure + +``` +booking/ +├── backend/ +│ ├── src/ +│ │ ├── config/ +│ │ │ ├── database.js +│ │ │ ├── auth.js +│ │ │ ├── notifications.js +│ │ │ └── calendar.js +│ │ ├── db/ +│ │ │ ├── migrations/ +│ │ │ └── seeds/ +│ │ ├── models/ +│ │ │ ├── Service.js +│ │ │ ├── Category.js +│ │ │ ├── Staff.js +│ │ │ ├── Booking.js +│ │ │ ├── Customer.js +│ │ │ └── Schedule.js +│ │ ├── routes/ +│ │ │ ├── api/ +│ │ │ │ ├── services.js +│ │ │ │ ├── availability.js +│ │ │ │ ├── bookings.js +│ │ │ │ └── calendar.js +│ │ │ └── admin/ +│ │ │ ├── services.js +│ │ │ ├── staff.js +│ │ │ ├── bookings.js +│ │ │ ├── customers.js +│ │ │ ├── reports.js +│ │ │ └── settings.js +│ │ ├── services/ +│ │ │ ├── availability.js +│ │ │ ├── booking.js +│ │ │ ├── notification/ +│ │ │ │ ├── email.js +│ │ │ │ └── sms.js +│ │ │ ├── calendar/ +│ │ │ │ ├── google.js +│ │ │ │ └── ical.js +│ │ │ └── payment/ +│ │ │ └── stripe.js +│ │ └── middleware/ +│ │ ├── auth.js +│ │ ├── staff.js +│ │ └── validation.js +│ └── tests/ +├── frontend/ +│ ├── src/ +│ │ ├── views/ +│ │ │ ├── public/ +│ │ │ │ ├── Home.vue +│ │ │ │ ├── Services.vue +│ │ │ │ ├── Booking.vue +│ │ │ │ ├── BookingConfirm.vue +│ │ │ │ └── BookingDetails.vue +│ │ │ └── admin/ +│ │ │ ├── Dashboard.vue +│ │ │ ├── Calendar.vue +│ │ │ ├── Services.vue +│ │ │ ├── Staff.vue +│ │ │ ├── Bookings.vue +│ │ │ ├── Customers.vue +│ │ │ ├── Reports.vue +│ │ │ └── Settings.vue +│ │ ├── components/ +│ │ │ ├── booking/ +│ │ │ │ ├── ServiceSelect.vue +│ │ │ │ ├── StaffSelect.vue +│ │ │ │ ├── DateTimePicker.vue +│ │ │ │ ├── CustomerForm.vue +│ │ │ │ └── BookingSummary.vue +│ │ │ └── admin/ +│ │ │ ├── CalendarView.vue +│ │ │ ├── ServiceEditor.vue +│ │ │ ├── StaffEditor.vue +│ │ │ ├── ScheduleEditor.vue +│ │ │ └── AvailabilityGrid.vue +│ │ ├── stores/ +│ │ │ ├── booking.js +│ │ │ ├── auth.js +│ │ │ └── calendar.js +│ │ └── router/ +│ │ └── index.js +│ └── tests/ +├── database/ +│ └── booking.db +├── docker/ +│ ├── Dockerfile.backend +│ ├── Dockerfile.frontend +│ └── docker-compose.yml +└── docs/ + ├── API.md + └── DEPLOYMENT.md +``` + +Use the database schema from `.kilo/skills/booking/SKILL.md`. + +## Step 3: Backend Implementation + +**Agent**: `@BackendDeveloper` + +### Service API + +```javascript +// backend/src/routes/api/services.js +const router = require('express').Router(); + +// GET /api/services - List active services +router.get('/', async (req, res, next) => { + try { + const { category_id } = req.query; + + const services = await db.services.findAll({ + is_active: true, + category_id: category_id || undefined + }); + + res.json(services); + } catch (error) { + next(error); + } +}); + +// GET /api/services/:id - Get service details +router.get('/:id', async (req, res, next) => { + try { + const service = await db.services.findById(req.params.id); + + if (!service || !service.is_active) { + return res.status(404).json({ error: 'Service not found' }); + } + + // Get staff for this service + const staff = await db.staff.findByService(service.id); + + res.json({ ...service, staff }); + } catch (error) { + next(error); + } +}); + +module.exports = router; +``` + +### Availability API + +```javascript +// backend/src/routes/api/availability.js +const router = require('express').Router(); + +// GET /api/availability - Get available slots +router.get('/', + [ + query('service_id').isInt(), + query('date').isDate(), + query('staff_id').optional().isInt() + ], + async (req, res, next) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { service_id, date, staff_id } = req.query; + + const slots = await availabilityService.getAvailableSlots({ + serviceId: parseInt(service_id), + staffId: staff_id ? parseInt(staff_id) : null, + date + }); + + res.json({ date, slots }); + } catch (error) { + next(error); + } +); + +// POST /api/availability/check - Check specific slot +router.post('/check', + [ + body('service_id').isInt(), + body('staff_id').optional().isInt(), + body('date').isDate(), + body('time').matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/) + ], + async (req, res, next) => { + try { + const { service_id, staff_id, date, time } = req.body; + + const available = await availabilityService.checkSlot({ + serviceId: service_id, + staffId: staff_id, + date, + time + }); + + res.json({ available }); + } catch (error) { + next(error); + } + } +); + +module.exports = router; +``` + +### Booking API + +```javascript +// backend/src/routes/api/bookings.js +const router = require('express').Router(); + +// POST /api/bookings - Create booking +router.post('/', + [ + body('service_id').isInt(), + body('date').isDate(), + body('time').matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/), + body('customer_name').notEmpty().isLength({ max: 100 }), + body('customer_email').isEmail(), + body('customer_phone').optional().isMobilePhone(), + body('staff_id').optional().isInt(), + body('notes').optional().isLength({ max: 500 }) + ], + async (req, res, next) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const booking = await bookingService.create(req.body); + + // Send confirmation + await notificationService.sendConfirmation(booking); + + res.status(201).json(booking); + } catch (error) { + next(error); + } + } +); + +// GET /api/bookings/:id - Get booking details +router.get('/:id', async (req, res, next) => { + try { + const booking = await bookingService.findById(req.params.id); + + if (!booking) { + return res.status(404).json({ error: 'Booking not found' }); + } + + res.json(booking); + } catch (error) { + next(error); + } +}); + +// POST /api/bookings/:id/cancel - Cancel booking +router.post('/:id/cancel', + [body('reason').optional().isLength({ max: 200 })], + async (req, res, next) => { + try { + const booking = await bookingService.cancel( + req.params.id, + req.body.reason + ); + + // Send cancellation notification + await notificationService.sendCancellation(booking); + + res.json(booking); + } catch (error) { + next(error); + } + } +); + +// POST /api/bookings/:id/reschedule - Reschedule booking +router.post('/:id/reschedule', + [ + body('date').isDate(), + body('time').matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/) + ], + async (req, res, next) => { + try { + const booking = await bookingService.reschedule( + req.params.id, + req.body.date, + req.body.time + ); + + // Send reschedule notification + await notificationService.sendReschedule(booking); + + res.json(booking); + } catch (error) { + next(error); + } + } +); + +module.exports = router; +``` + +### Availability Service + +```javascript +// backend/src/services/availability.js +class AvailabilityService { + async getAvailableSlots({ serviceId, staffId, date }) { + const service = await db.services.findById(serviceId); + if (!service || !service.is_active) { + throw new Error('Service not available'); + } + + // Get all active staff for this service + const staffMembers = staffId + ? [await db.staff.findById(staffId)] + : await db.staff.findByService(serviceId); + + const allSlots = []; + + for (const staff of staffMembers) { + if (!staff || !staff.is_active) continue; + + const slots = await this.getStaffSlots(staff, service, date); + allSlots.push(...slots.map(s => ({ ...s, staff }))); + } + + return this.mergeSlots(allSlots); + } + + async getStaffSlots(staff, service, date) { + const dayOfWeek = new Date(date).getDay(); + + // Get staff schedule for this day + const schedule = await db.staffSchedules.findOne({ + staff_id: staff.id, + day_of_week: dayOfWeek, + is_working: true + }); + + if (!schedule) return []; + + // Check time off + const timeOff = await db.staffTimeOff.findOne({ + staff_id: staff.id, + start_date: { $lte: date }, + end_date: { $gte: date } + }); + + if (timeOff) return []; + + // Get existing bookings + const bookings = await db.bookings.find({ + staff_id: staff.id, + booking_date: date, + status: { $in: ['pending', 'confirmed'] } + }); + + // Generate time slots + const slots = []; + let currentTime = this.parseTime(schedule.start_time); + const endTime = this.parseTime(schedule.end_time); + const bufferTime = service.buffer_time || 0; + const interval = service.duration + bufferTime; + + while (this.addMinutes(currentTime, interval) <= endTime) { + const slotStart = this.formatTime(currentTime); + const slotEnd = this.formatTime(this.addMinutes(currentTime, service.duration)); + + // Check if in break + const inBreak = schedule.break_start && + slotStart >= schedule.break_start && + slotStart < schedule.break_end; + + // Check if booked + const isBooked = bookings.some(b => + b.start_time <= slotStart && b.end_time > slotStart + ); + + // Check minimum notice + const slotDateTime = new Date(`${date}T${slotStart}`); + const minNotice = this.getSetting('min_booking_notice'); + const tooSoon = slotDateTime < this.addMinutes(new Date(), minNotice); + + if (!inBreak && !isBooked && !tooSoon) { + slots.push({ + start_time: slotStart, + end_time: slotEnd, + duration: service.duration + }); + } + + currentTime = this.addMinutes(currentTime, service.duration); + } + + return slots; + } + + parseTime(time) { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; + } + + formatTime(minutes) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; + } + + addMinutes(time, minutes) { + if (time instanceof Date) { + return new Date(time.getTime() + minutes * 60000); + } + return time + minutes; + } +} + +module.exports = new AvailabilityService(); +``` + +### Staff Schedule API + +```javascript +// backend/src/routes/admin/staff.js +const router = require('express').Router(); +const auth = require('../../middleware/auth'); + +// GET /api/admin/staff - List all staff +router.get('/', auth.requireAuth, async (req, res, next) => { + try { + const staff = await db.staff.findAll(); + res.json(staff); + } catch (error) { + next(error); + } +}); + +// PUT /api/admin/staff/:id/schedule - Update schedule +router.put('/:id/schedule', + auth.requireAuth, + [ + body('schedules').isArray({ min: 7, max: 7 }), + body('schedules.*.day_of_week').isInt({ min: 0, max: 6 }), + body('schedules.*.start_time').matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/), + body('schedules.*.end_time').matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/) + ], + async (req, res, next) => { + try { + const { id } = req.params; + const { schedules } = req.body; + + // Validate times + for (const schedule of schedules) { + if (schedule.start_time >= schedule.end_time) { + return res.status(400).json({ + error: 'Start time must be before end time' + }); + } + } + + // Update schedule + await db.staffSchedules.deleteByStaffId(id); + + for (const schedule of schedules) { + await db.staffSchedules.create({ + staff_id: id, + ...schedule + }); + } + + const updated = await db.staffSchedules.findByStaffId(id); + res.json(updated); + } catch (error) { + next(error); + } + } +); + +// POST /api/admin/staff/:id/time-off - Add time off +router.post('/:id/time-off', + auth.requireAuth, + [ + body('start_date').isDate(), + body('end_date').isDate(), + body('reason').optional().isLength({ max: 200 }) + ], + async (req, res, next) => { + try { + const timeOff = await db.staffTimeOff.create({ + staff_id: req.params.id, + ...req.body + }); + + res.status(201).json(timeOff); + } catch (error) { + next(error); + } + } +); + +module.exports = router; +``` + +## Step 4: Frontend Implementation + +**Agent**: `@FrontendDeveloper` + +### Public Booking Flow + +```vue + + + + +``` + +### Admin Calendar View + +```vue + + + + +``` + +## Step 5: E2E Testing + +**Agent**: `@SDETEngineer` + +```javascript +// tests/e2e/booking.spec.js +import { test, expect } from '@playwright/test'; + +test.describe('Booking Flow', () => { + test('complete booking flow', async ({ page }) => { + await page.goto('/'); + + // 1. Select service + await page.click('text=Book Now'); + await page.click('.service-card:first-child'); + await page.click('button:has-text("Continue")'); + + // 2. Select staff (optional) + await page.click('button:has-text("Continue")'); + + // 3. Select date and time + await page.click('.v-date-picker-body button:not([disabled])'); + await page.click('.time-slot:first-child'); + await page.click('button:has-text("Continue")'); + + // 4. Enter customer details + await page.fill('input[name="name"]', 'Test Customer'); + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="phone"]', '+1234567890'); + await page.click('button:has-text("Book")'); + + // 5. Verify confirmation + await expect(page.locator('.success-card')).toBeVisible(); + await expect(page.locator('.booking-number')).toBeVisible(); + }); + + test('view booking details', async ({ page }) => { + // Create booking first + const bookingId = await createTestBooking(); + + await page.goto(`/booking/${bookingId}`); + + await expect(page.locator('.service-name')).toBeVisible(); + await expect(page.locator('.booking-date')).toBeVisible(); + await expect(page.locator('.booking-time')).toBeVisible(); + }); + + test('cancel booking', async ({ page }) => { + const bookingId = await createTestBooking(); + + await page.goto(`/booking/${bookingId}`); + await page.click('button:has-text("Cancel")'); + await page.fill('textarea[name="reason"]', 'Test cancellation'); + await page.click('button:has-text("Confirm")'); + + await expect(page.locator('.status-chip')).toContainText('cancelled'); + }); +}); + +test.describe('Admin Calendar', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/admin/login'); + await page.fill('input[name="email"]', 'admin@example.com'); + await page.fill('input[name="password"]', 'admin123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/admin/dashboard'); + }); + + test('view calendar', async ({ page }) => { + await page.goto('/admin/calendar'); + + await expect(page.locator('.v-calendar')).toBeVisible(); + await expect(page.locator('.booking-event')).toHaveCountGreaterThanOrEqual(0); + }); + + test('create booking from calendar', async ({ page }) => { + await page.goto('/admin/calendar'); + + // Click on empty time slot + await page.click('.v-calendar__slot:first-child'); + + // Fill booking form + await page.selectOption('select[name="service"]', '1'); + await page.fill('input[name="customer_name"]', 'Walk-in Customer'); + await page.fill('input[name="customer_email"]', 'walkin@example.com'); + await page.click('button:has-text("Create")'); + + await expect(page.locator('.notification')).toContainText('created'); + }); + + test('confirm booking', async ({ page }) => { + const bookingId = await createTestBooking({ status: 'pending' }); + + await page.goto('/admin/calendar'); + await page.click('.booking-event:first-child'); + await page.click('button:has-text("Confirm")'); + + await expect(page.locator('.status-chip')).toContainText('confirmed'); + }); +}); + +test.describe('Staff Management', () => { + test('update staff schedule', async ({ page }) => { + await page.goto('/admin/staff'); + await page.click('.staff-row:first-child .edit-btn'); + + // Update Monday schedule + await page.fill('input[name="monday_start"]', '09:00'); + await page.fill('input[name="monday_end"]', '18:00'); + + await page.click('button:has-text("Save")'); + + await expect(page.locator('.notification')).toContainText('saved'); + }); + + test('add time off', async ({ page }) => { + await page.goto('/admin/staff/1/time-off'); + + await page.fill('input[name="start_date"]', '2024-02-01'); + await page.fill('input[name="end_date"]', '2024-02-05'); + await page.fill('input[name="reason"]', 'Vacation'); + + await page.click('button:has-text("Add")'); + + await expect(page.locator('.time-off-item')).toBeVisible(); + }); +}); +``` + +## Step 6: Notifications + +### Email Service + +```javascript +// backend/src/services/notification/email.js +const nodemailer = require('nodemailer'); +const config = require('../../config'); + +class EmailService { + constructor() { + this.transporter = nodemailer.createTransport({ + host: config.email.host, + port: config.email.port, + secure: config.email.secure, + auth: config.email.auth + }); + } + + async sendConfirmation(booking, service, staff) { + const mailOptions = { + from: config.email.from, + to: booking.customer_email, + subject: `Booking Confirmed: ${service.name}`, + html: this.renderTemplate('confirmation', { + booking, + service, + staff, + businessName: config.business.name, + businessAddress: config.business.address + }) + }; + + return this.transporter.sendMail(mailOptions); + } + + async sendReminder(booking, service) { + const mailOptions = { + from: config.email.from, + to: booking.customer_email, + subject: `Reminder: ${service.name} tomorrow at ${booking.start_time}`, + html: this.renderTemplate('reminder', { + booking, + service, + businessName: config.business.name + }) + }; + + return this.transporter.sendMail(mailOptions); + } + + async sendCancellation(booking) { + const mailOptions = { + from: config.email.from, + to: booking.customer_email, + subject: `Booking Cancelled: ${booking.booking_number}`, + html: this.renderTemplate('cancellation', { + booking, + businessName: config.business.name + }) + }; + + return this.transporter.sendMail(mailOptions); + } +} + +module.exports = new EmailService(); +``` + +### SMS Service + +```javascript +// backend/src/services/notification/sms.js +const config = require('../../config'); + +class SMSService { + constructor() { + this.provider = this.getProvider(); + } + + getProvider() { + switch (config.sms.provider) { + case 'twilio': + return new (require('twilio'))(config.sms.accountSid, config.sms.authToken); + case 'smsru': + return require('smsru'); + default: + return null; + } + } + + async send(phone, message) { + if (!this.provider) { + console.log(`SMS to ${phone}: ${message}`); + return; + } + + switch (config.sms.provider) { + case 'twilio': + return this.provider.messages.create({ + body: message, + to: phone, + from: config.sms.fromNumber + }); + case 'smsru': + return this.provider.send({ + to: phone, + msg: message, + from: config.sms.fromName + }); + } + } + + async sendConfirmation(booking, service) { + const message = `Your ${service.name} appointment is confirmed for ${this.formatDate(booking.booking_date)} at ${booking.start_time}. Booking #${booking.booking_number}`; + return this.send(booking.customer_phone, message); + } + + async sendReminder(booking, service) { + const message = `Reminder: ${service.name} in 2 hours at ${booking.start_time}. Reply C to cancel.`; + return this.send(booking.customer_phone, message); + } + + formatDate(date) { + return new Date(date).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); + } +} + +module.exports = new SMSService(); +``` + +## Step 7: Docker & Deployment + +Same structure as previous workflows. + +## Step 8: Documentation + +### API Reference + +```markdown +# Booking API + +## Public Endpoints + +### Services +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/services | List active services | +| GET | /api/services/:id | Get service details | +| GET | /api/categories | List categories | + +### Availability +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/availability?service_id=&date=&staff_id= | Get available slots | +| POST | /api/availability/check | Check specific slot | + +### Booking +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | /api/bookings | Create booking | +| GET | /api/bookings/:id | Get booking details | +| POST | /api/bookings/:id/cancel | Cancel booking | +| POST | /api/bookings/:id/reschedule | Reschedule booking | + +## Admin Endpoints + +### Services +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/admin/services | List all services | +| POST | /api/admin/services | Create service | +| PUT | /api/admin/services/:id | Update service | +| DELETE | /api/admin/services/:id | Delete service | + +### Staff +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/admin/staff | List all staff | +| POST | /api/admin/staff | Add staff | +| PUT | /api/admin/staff/:id | Update staff | +| PUT | /api/admin/staff/:id/schedule | Update schedule | +| POST | /api/admin/staff/:id/time-off | Add time off | + +### Bookings +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/admin/bookings | List bookings | +| PUT | /api/admin/bookings/:id/confirm | Confirm booking | +| PUT | /api/admin/bookings/:id/complete | Mark complete | +| PUT | /api/admin/bookings/:id/cancel | Cancel booking | + +### Reports +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/admin/reports/revenue | Revenue report | +| GET | /api/admin/reports/services | Service popularity | +| GET | /api/admin/reports/staff | Staff utilization | +``` + +## Post to Gitea + +After each step, post progress to the linked issue. + +## Quality Gates + +| Gate | Criteria | +|------|----------| +| Services | CRUD working, pricing, duration | +| Staff | Schedules working, availability | +| Availability | Real-time slots, timezone support | +| Booking | Complete flow working | +| Notifications | Email/SMS working | +| Admin | Calendar, reports working | +| Tests | E2E tests passing | +| Docker | Containers building and running | \ No newline at end of file diff --git a/.kilo/skills/booking/SKILL.md b/.kilo/skills/booking/SKILL.md new file mode 100644 index 0000000..305fcf7 --- /dev/null +++ b/.kilo/skills/booking/SKILL.md @@ -0,0 +1,639 @@ +--- +name: booking +description: Booking domain knowledge - appointments, services, staff, schedules, reservations +--- + +# Booking Skill + +## Purpose + +Provides domain knowledge for building booking and appointment systems: services, staff, schedules, availability, reservations, notifications. + +## Capabilities + +### Service Management +- Service categories +- Service duration and pricing +- Service variants (different durations) +- Required resources + +### Staff Management +- Staff profiles +- Role-based permissions +- Specializations +- Availability schedules + +### Booking Flow +- Service selection +- Staff selection (optional) +- Date/time selection +- Customer details +- Confirmation +- Payment (optional) + +### Scheduling +- Working hours +- Break times +- Days off +- Multi-location support +- Timezone handling + +### Notifications +- Email confirmations +- SMS reminders +- Calendar sync (Google, iCal) +- Push notifications + +### Admin Features +- Booking management +- Availability editor +- Reports and analytics +- Customer management + +## Database Schema + +### Services + +```sql +CREATE TABLE services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER, + name TEXT NOT NULL, + description TEXT, + duration INTEGER NOT NULL, -- minutes + price DECIMAL(10, 2) NOT NULL, + buffer_time INTEGER DEFAULT 0, -- minutes between appointments + max_per_slot INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT 1, + image_url TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES service_categories(id) +); + +CREATE TABLE service_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT, + image_url TEXT, + sort_order INTEGER DEFAULT 0 +); + +CREATE TABLE service_staff ( + service_id INTEGER NOT NULL, + staff_id INTEGER NOT NULL, + custom_price DECIMAL(10, 2), + custom_duration INTEGER, + PRIMARY KEY (service_id, staff_id), + FOREIGN KEY (service_id) REFERENCES services(id), + FOREIGN KEY (staff_id) REFERENCES staff(id) +); +``` + +### Staff + +```sql +CREATE TABLE staff ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + phone TEXT, + avatar_url TEXT, + bio TEXT, + role TEXT DEFAULT 'staff', -- 'admin', 'manager', 'staff' + is_active BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE staff_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + day_of_week INTEGER NOT NULL, -- 0=Sunday, 6=Saturday + start_time TEXT NOT NULL, -- '09:00' + end_time TEXT NOT NULL, -- '17:00' + break_start TEXT, -- '12:00' + break_end TEXT, -- '13:00' + is_working BOOLEAN DEFAULT 1, + FOREIGN KEY (staff_id) REFERENCES staff(id) +); + +CREATE TABLE staff_time_off ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staff_id INTEGER NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + reason TEXT, + FOREIGN KEY (staff_id) REFERENCES staff(id) +); +``` + +### Bookings + +```sql +CREATE TABLE bookings ( + id TEXT PRIMARY KEY, -- UUID + booking_number TEXT UNIQUE NOT NULL, + service_id INTEGER NOT NULL, + staff_id INTEGER, + customer_id INTEGER, + + -- Customer info (guest bookings allowed) + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + customer_phone TEXT, + customer_notes TEXT, + + -- Appointment details + booking_date DATE NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + + -- Status + status TEXT DEFAULT 'pending', -- 'pending', 'confirmed', 'completed', 'cancelled', 'no_show' + + -- Pricing + service_price DECIMAL(10, 2) NOT NULL, + addons_total DECIMAL(10, 2) DEFAULT 0, + discount DECIMAL(10, 2) DEFAULT 0, + total DECIMAL(10, 2) NOT NULL, + payment_method TEXT, -- 'cash', 'card', 'online' + payment_status TEXT DEFAULT 'pending', -- 'pending', 'paid', 'refunded' + + -- Metadata + source TEXT DEFAULT 'website', -- 'website', 'phone', 'walk_in' + notes TEXT, + internal_notes TEXT, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (service_id) REFERENCES services(id), + FOREIGN KEY (staff_id) REFERENCES staff(id), + FOREIGN KEY (customer_id) REFERENCES customers(id) +); + +CREATE TABLE booking_addons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + booking_id TEXT NOT NULL, + addon_id INTEGER NOT NULL, + price DECIMAL(10, 2) NOT NULL, + FOREIGN KEY (booking_id) REFERENCES bookings(id), + FOREIGN KEY (addon_id) REFERENCES service_addons(id) +); + +-- Availability cache for fast queries +CREATE TABLE availability_slots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_id INTEGER NOT NULL, + staff_id INTEGER, + date DATE NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + available BOOLEAN DEFAULT 1, + booking_id TEXT, + FOREIGN KEY (service_id) REFERENCES services(id), + FOREIGN KEY (staff_id) REFERENCES staff(id), + FOREIGN KEY (booking_id) REFERENCES bookings(id) +); +``` + +### Customers + +```sql +CREATE TABLE customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + phone TEXT, + date_of_birth DATE, + notes TEXT, + total_visits INTEGER DEFAULT 0, + total_spent DECIMAL(10, 2) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_customers_email ON customers(email); +CREATE INDEX idx_customers_phone ON customers(phone); +``` + +### Settings + +```sql +CREATE TABLE booking_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + type TEXT DEFAULT 'string' -- 'string', 'number', 'boolean', 'json' +); + +-- Default settings +INSERT INTO booking_settings (key, value, type) VALUES +('timezone', 'Europe/Moscow', 'string'), +('currency', 'RUB', 'string'), +('booking_interval', '30', 'number'), +('min_booking_notice', '60', 'number'), -- minutes +('max_booking_advance', '30', 'number'), -- days +('require_phone', '1', 'boolean'), +('require_payment', '0', 'boolean'), +('deposit_percentage', '20', 'number'), +('cancellation_hours', '24', 'number'), +('reminder_hours', '2', 'number'), +('confirmation_sms', '0', 'boolean'), +('confirmation_email', '1', 'boolean'); +``` + +## API Endpoints + +### Public API + +```yaml +# Services +GET /api/services # List all active services +GET /api/services/:id # Get service details +GET /api/categories # List service categories + +# Staff +GET /api/staff # List active staff +GET /api/staff/:id # Get staff details +GET /api/staff/:id/availability # Get staff availability + +# Availability +GET /api/availability # Get available slots +POST /api/availability/check # Check specific slot availability + +# Booking +POST /api/bookings # Create booking (guest) +GET /api/bookings/:id # Get booking details +POST /api/bookings/:id/cancel # Cancel booking +POST /api/bookings/:id/reschedule # Reschedule booking + +# Customer +GET /api/customer/bookings # Get customer's bookings (auth) +POST /api/customer/register # Register customer +``` + +### Admin API + +```yaml +# Services +GET /api/admin/services # List all services +POST /api/admin/services # Create service +PUT /api/admin/services/:id # Update service +DELETE /api/admin/services/:id # Delete service + +# Categories +GET /api/admin/categories # List categories +POST /api/admin/categories # Create category +PUT /api/admin/categories/:id # Update category +DELETE /api/admin/categories/:id # Delete category + +# Staff +GET /api/admin/staff # List all staff +POST /api/admin/staff # Add staff member +PUT /api/admin/staff/:id # Update staff +DELETE /api/admin/staff/:id # Remove staff +PUT /api/admin/staff/:id/schedule # Update schedule +POST /api/admin/staff/:id/time-off # Add time off + +# Bookings +GET /api/admin/bookings # List bookings (filters) +GET /api/admin/bookings/:id # Get booking details +PUT /api/admin/bookings/:id/confirm # Confirm booking +PUT /api/admin/bookings/:id/complete # Mark complete +PUT /api/admin/bookings/:id/cancel # Cancel booking +PUT /api/admin/bookings/:id/no-show # Mark no-show + +# Calendar +GET /api/admin/calendar # Calendar view +GET /api/admin/calendar/staff/:id # Staff calendar + +# Customers +GET /api/admin/customers # List customers +GET /api/admin/customers/:id # Customer details +GET /api/admin/customers/:id/history # Booking history + +# Reports +GET /api/admin/reports/revenue # Revenue report +GET /api/admin/reports/services # Service popularity +GET /api/admin/reports/staff # Staff utilization +GET /api/admin/reports/trends # Booking trends + +# Settings +GET /api/admin/settings # Get all settings +PUT /api/admin/settings # Update settings +``` + +## Availability Logic + +### Get Available Slots + +```javascript +// services/availability.js +async function getAvailableSlots(serviceId, staffId, date) { + const service = await db.services.findById(serviceId); + const dayOfWeek = new Date(date).getDay(); + + // Get staff schedule + const schedule = await db.staffSchedules.findOne({ + staff_id: staffId, + day_of_week: dayOfWeek, + is_working: true + }); + + if (!schedule) return []; + + // Get existing bookings + const bookings = await db.bookings.find({ + staff_id: staffId, + booking_date: date, + status: { $in: ['pending', 'confirmed'] } + }); + + // Generate slots + const slots = []; + let currentTime = parseTime(schedule.start_time); + const endTime = parseTime(schedule.end_time); + const bufferTime = service.buffer_time || 0; + + while (addMinutes(currentTime, service.duration + bufferTime) <= endTime) { + const slotStart = formatTime(currentTime); + const slotEnd = formatTime(addMinutes(currentTime, service.duration)); + + // Check if slot is available + const isBooked = bookings.some(b => + b.start_time <= slotStart && b.end_time >= slotStart || + b.start_time <= slotEnd && b.end_time >= slotEnd || + b.start_time >= slotStart && b.end_time <= slotEnd + ); + + // Check break time + const isBreak = schedule.break_start && ( + slotStart >= schedule.break_start && slotStart < schedule.break_end || + slotEnd > schedule.break_start && slotEnd <= schedule.break_end + ); + + // Check advance booking limit + const slotDateTime = new Date(`${date}T${slotStart}`); + const isTooSoon = slotDateTime < addMinutes(new Date(), settings.min_booking_notice); + + // Check past + const isPast = slotDateTime < new Date(); + + if (!isBooked && !isBreak && !isTooSoon && !isPast) { + slots.push({ + start_time: slotStart, + end_time: slotEnd, + available: true + }); + } + + currentTime = addMinutes(currentTime, service.duration); + } + + return slots; +} +``` + +### Create Booking + +```javascript +// services/booking.js +async function createBooking(bookingData) { + const { service_id, staff_id, date, time, customer_name, customer_email, customer_phone } = bookingData; + + // Validate service exists + const service = await db.services.findById(service_id); + if (!service || !service.is_active) { + throw new Error('Service not available'); + } + + // Get staff or auto-assign + let staff = staff_id ? + await db.staff.findById(staff_id) : + await autoAssignStaff(service_id, date, time); + + if (!staff) { + throw new Error('No staff available for this slot'); + } + + // Check availability + const available = await checkAvailability(service_id, staff.id, date, time); + if (!available) { + throw new Error('Slot already booked'); + } + + // Calculate end time + const endTime = addMinutes(parseTime(time), service.duration); + + // Create booking number + const bookingNumber = generateBookingNumber(); + + // Create booking + const booking = await db.bookings.create({ + id: generateUUID(), + booking_number: bookingNumber, + service_id, + staff_id: staff.id, + customer_name, + customer_email, + customer_phone, + booking_date: date, + start_time: time, + end_time: formatTime(endTime), + status: 'pending', + service_price: service.price, + total: service.price + }); + + // Create availability slot + await db.availabilitySlots.create({ + service_id, + staff_id: staff.id, + date, + start_time: time, + end_time: formatTime(endTime), + available: false, + booking_id: booking.id + }); + + // Send confirmation + await sendConfirmation(booking); + + return booking; +} +``` + +## Booking Status Flow + +``` +pending → confirmed → completed + ↓ ↓ ↓ +cancelled cancelled cancelled → refunded + ↓ +no_show +``` + +### Status Transitions + +```javascript +const STATUS_FLOW = { + pending: ['confirmed', 'cancelled'], + confirmed: ['completed', 'cancelled', 'no_show'], + completed: ['refunded'], + cancelled: ['refunded'], + no_show: [], + refunded: [] +}; + +function canTransition(currentStatus, newStatus) { + return STATUS_FLOW[currentStatus]?.includes(newStatus) || false; +} +``` + +## Notifications + +### Email Templates + +```javascript +// services/notifications/email.js +const bookingConfirmation = (booking, service, staff) => ({ + subject: `Booking Confirmed - ${service.name}`, + html: ` +

Your Booking is Confirmed!

+

Booking #${booking.booking_number}

+ +

Details

+ + +

Location: Your Business Name

+ + Manage Booking + +

Need to cancel? Please give us ${settings.cancellation_hours} hours notice.

+ ` +}); + +const bookingReminder = (booking, service, staff) => ({ + subject: `Reminder: ${service.name} in 2 hours`, + html: ` +

Upcoming Appointment Reminder

+

Your appointment is in 2 hours!

+ + + ` +}); +``` + +### SMS Templates + +```javascript +// services/notifications/sms.js +const templates = { + confirmation: (booking, service) => + `Your ${service.name} booking is confirmed for ${formatDate(booking.booking_date)} at ${booking.start_time}. Booking #${booking.booking_number}`, + + reminder: (booking, service) => + `Reminder: ${service.name} appointment in 2 hours at ${booking.start_time}. Reply C to cancel.`, + + cancellation: (booking) => + `Your booking #${booking.booking_number} has been cancelled.` +}; +``` + +## Calendar Integration + +### iCal Export + +```javascript +function generateICal(booking, service, staff) { + return `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Business Name//Booking System//EN +BEGIN:VEVENT +DTSTART:${formatICalDate(booking.booking_date, booking.start_time)} +DTEND:${formatICalDate(booking.booking_date, booking.end_time)} +SUMMARY:${service.name} with ${staff.name} +DESCRIPTION:Booking #${booking.booking_number} +LOCATION:Business Address +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR`; +} +``` + +## Reports + +### Revenue Report + +```sql +SELECT + DATE(booking_date) as date, + COUNT(*) as bookings, + SUM(total) as revenue, + AVG(total) as avg_booking +FROM bookings +WHERE status IN ('completed', 'confirmed') + AND booking_date BETWEEN ? AND ? +GROUP BY DATE(booking_date) +ORDER BY date; +``` + +### Staff Utilization + +```sql +SELECT + s.id, + s.name, + COUNT(b.id) as bookings, + SUM(TIMESTAMPDIFF(MINUTE, + CONCAT(b.booking_date, ' ', b.start_time), + CONCAT(b.booking_date, ' ', b.end_time) + )) / 60 as hours_booked, + COUNT(DISTINCT b.booking_date) as days_worked +FROM staff s +LEFT JOIN bookings b ON b.staff_id = s.id + AND b.status IN ('completed', 'confirmed') + AND b.booking_date BETWEEN ? AND ? +GROUP BY s.id +ORDER BY bookings DESC; +``` + +### Service Popularity + +```sql +SELECT + s.id, + s.name, + s.category_id, + COUNT(b.id) as bookings, + SUM(b.total) as revenue +FROM services s +LEFT JOIN bookings b ON b.service_id = s.id + AND b.status IN ('completed', 'confirmed') + AND b.booking_date BETWEEN ? AND ? +GROUP BY s.id +ORDER BY bookings DESC; +``` + +## Integration Points + +- Payment: Stripe, YooKassa +- Calendar: Google Calendar, iCal +- SMS: Twilio, SMS.ru +- Email: SendGrid, Mailgun +- Analytics: Google Analytics, Yandex Metrika +- CRM: Integration API for customer data \ No newline at end of file