Yankee-massage.zip
-- Therapist (service provider)
CREATE TABLE therapists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
rating NUMERIC(2,1) DEFAULT 0.0,
hourly_rate_cents INT NOT NULL,
skills TEXT[] NOT NULL, -- e.g., 'Swedish','Deep Tissue','Sports'
home_location GEOGRAPHY(Point,4326) NOT NULL, -- latitude/longitude
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Therapist availability slots (generated on‑the‑fly or pre‑saved)
CREATE TABLE therapist_slots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
therapist_id UUID REFERENCES therapists(id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
is_booked BOOLEAN DEFAULT FALSE,
CONSTRAINT chk_slot_duration CHECK (end_time > start_time)
);
-- Client request (temporary, used only while matching)
CREATE TABLE massage_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL,
requested_at TIMESTAMPTZ DEFAULT now(),
duration_min INT NOT NULL CHECK (duration_min IN (30,45,60,90)),
massage_type TEXT NOT NULL, -- must exist in therapist.skills
location GEOGRAPHY(Point,4326) NOT NULL,
max_distance_m INT DEFAULT 15000, -- 15 km default search radius
status TEXT NOT NULL DEFAULT 'pending' -- pending|matched|cancelled|failed
);
Why PostGIS?
It lets you compute “nearest therapist” with a single index‑supported query (ST_DWithin+ST_Distance). This is the heart of the “smart match”.
| Aspect | Description |
|--------|-------------|
| Name | Smart Booking & Therapist‑Match |
| Goal | Let a client request a massage with a single tap; the system automatically selects the optimal therapist (based on proximity, skill set, rating, and real‑time availability) and reserves a time slot that maximizes therapist utilization while respecting client constraints. |
| User Flow | 1️⃣ Client opens the app → 2️⃣ Enters a brief request (duration, massage type, optional preferences) → 3️⃣ System shows a single “Confirm Booking” button (no manual therapist selection needed) → 4️⃣ Confirmation screen with therapist photo, ETA, and a live countdown. |
| Key Benefits | • Faster checkout (conversion ↑)
• Higher therapist occupancy (revenue ↑)
• Lower cancellation rate (customer satisfaction ↑) |
| Tech Stack (suggested) | • Backend: Node.js / Express (or Django/Flask)
• Database: PostgreSQL (with PostGIS for geo‑queries)
• Real‑time layer: Socket.io (or WebSockets)
• Mobile: React Native / Swift / Kotlin
• CI/CD: GitHub Actions + Docker | yankee-massage.zip
| Method | URL | Body / Query | Description |
|--------|-----|--------------|-------------|
| POST | /api/v1/requests | clientId, durationMin, massageType, lat, lng, maxDistanceM? | Creates a massage_requests row and triggers find_best_therapist. Returns either status:"matched", match:… or status:"failed", reason:"no‑therapist". |
| GET | /api/v1/requests/:id | – | Polling endpoint (if you prefer client‑side polling). Returns current status (pending, matched, failed). |
| POST | /api/v1/requests/:id/cancel | – | Allows the client to cancel a pending request before a match is made. |
| GET | /api/v1/therapists/:id/profile | – | Returns therapist photo, bio, rating, and ETA (computed from distance). Used on the confirmation screen. | -- Therapist (service provider) CREATE TABLE therapists (
All endpoints should be protected with JWT‑based auth (or your existing auth scheme). Why PostGIS
// RequestScreen.tsx
import useState from 'react';
import Button, ActivityIndicator, Text, View from 'react-native';
import socket from '../socket';
export default function RequestScreen(clientId)
const [loading, setLoading] = useState(false);
const [match, setMatch] = useState(null);
const [error, setError] = useState(null);
const startRequest = async () =>
setLoading(true);
const resp = await fetch('/api/v1/requests',
method: 'POST',
headers: 'Content-Type':'application/json','Authorization':`Bearer $token`,
body: JSON.stringify(
clientId,
durationMin: 60,
massageType: 'Swedish',
lat: userLocation.lat,
lng: userLocation.lng,
)
);
const data = await resp.json();
if (data.status === 'matched')
setMatch(data.match);
// subscribe to live updates (cancellation, therapist ETA)
socket.emit('joinMatchRoom', data.match.id);
else
setLoading(false);
;
// Listen for realtime changes (e.g., therapist on‑the‑way updates)
useEffect(() =>
socket.on('matchUpdate', (payload) => setMatch(prev => (...prev, ...payload)));
return () => socket.off('matchUpdate');
, []);
return (
<View style=flex:1, justifyContent:'center', alignItems:'center'>
loading && <ActivityIndicator size="large"/>
!loading && !match && <Button title="Get a Massage" onPress=startRequest/>
error && <Text style=color:'red'>error</Text>
match && (
<View>
<Text>Therapist: match.therapistName</Text>
<Text>ETA: match.etaMinutes min</Text>
<Text>Price: $(match.price_cents/100).toFixed(2)</Text>
<Button title="Cancel" onPress=/* call cancel endpoint *//>
</View>
)
</View>
);
The UI only shows a single “Confirm” button – the heavy lifting is done on the server. This reduces friction and improves conversion.