35 KiB
Booking Workflow - Final Implementation Plan
Version: 3.0 FINAL
Date: January 9, 2025
Status: Ready for Implementation with All Decisions Made
✅ Critical Decisions (LOCKED IN)
| Decision | Choice | Rationale |
|---|---|---|
| Customer Auth | ❌ NO LOGIN | Zero friction, book in 2 minutes |
| Staff Auth | ✅ Nextcloud OAuth2 | Already configured, SSO |
| Email Service | ✅ Resend | Free tier, easy, modern |
| Monitoring | ✅ Cloudflare + Sentry Free | Best of both worlds |
| Artist Data | ✅ D1 (existing) | NO MIGRATION NEEDED |
| Artist Linking | ✅ Via users.email | Simple FK join |
| Timezone | ✅ America/Denver | Hardcoded for Colorado |
🎯 Authentication Architecture (FINAL)
Two-Tier System
┌──────────────────────────────────────────────┐
│ CUSTOMERS (Public) │
│ ❌ NO ACCOUNT REQUIRED │
│ ❌ NO LOGIN │
│ ✅ Just email + phone │
│ ✅ Book anonymously │
│ ✅ Receive confirmation via email │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ ARTISTS & ADMINS (Internal) │
│ ✅ Nextcloud OAuth2 Login │
│ ✅ Already configured! │
│ ✅ Client ID: PZmqmi9vxYjtyWzt... │
│ ✅ Single Sign-On │
└──────────────────────────────────────────────┘
🗄️ Artist Data Architecture (SOLVED!)
Current Schema (NO CHANGES NEEDED!)
users table:
├─ id TEXT (PK)
├─ email TEXT (← LINK TO NEXTCLOUD)
├─ name TEXT
├─ role TEXT (ARTIST, SHOP_ADMIN, etc.)
└─ ...
artists table:
├─ id TEXT (PK)
├─ user_id TEXT (FK → users.id) ← JOIN HERE!
├─ name TEXT
├─ bio TEXT
├─ specialties TEXT (JSON)
├─ instagram_handle TEXT
├─ is_active BOOLEAN
└─ ...
artist_calendars table (NEW):
├─ id TEXT (PK)
├─ artist_id TEXT (FK → artists.id)
├─ calendar_url TEXT
└─ ...
Linking Strategy
// To link artist to Nextcloud calendar:
const artist = await db.prepare(`
SELECT a.*, u.email
FROM artists a
JOIN users u ON a.user_id = u.id
WHERE a.id = ?
`).bind(artistId).first()
// Now we have artist.email!
// This matches their Nextcloud user email
// Their calendar URL: portal.united-tattoos.com/remote.php/dav/calendars/{email}/personal/
No Migration Needed! Just use the existing FK relationship.
👥 Artist Lifecycle Management
When Artist Joins
Option 1: Nextcloud First (Recommended)
1. Admin creates Nextcloud user
- Email: artist@email.com
- Group: "artist"
- Calendar created automatically
2. Admin creates User in D1
- Email: artist@email.com (matches Nextcloud)
- Role: ARTIST
3. Admin creates Artist profile in D1
- user_id: [user from step 2]
- Bio, specialties, etc.
4. Admin links calendar
- Via /admin/calendars UI
- Auto-generates calendar URL from user email
Option 2: D1 First
1. Admin creates User + Artist in D1
2. Admin creates Nextcloud user (manually)
- Must use SAME email as D1 user
3. Admin links calendar via UI
When Artist Leaves
-- Don't delete! Just deactivate
UPDATE artists SET is_active = FALSE WHERE id = ?;
UPDATE users SET role = 'CLIENT' WHERE id = ?;
-- Keep their:
✅ Portfolio images (history)
✅ Past appointments (records)
✅ Calendar config (can reactivate)
-- They lose:
❌ Access to admin dashboard
❌ Visibility on website
❌ Ability to receive new bookings
Artist Updates
Name/Bio change: Update in D1 (web reflects immediately)
Email change: Update users.email AND Nextcloud email (both must match!)
Calendar change: Update via /admin/calendars UI
🔐 Nextcloud OAuth2 Configuration
Already Configured!
Nextcloud Details:
- Base URL:
https://portal.united-tattoos.com - Client ID:
PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI - Client Secret:
tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
Callback URLs (need to verify these are set in Nextcloud):
- Dev:
http://localhost:3000/api/auth/callback/nextcloud - Prod:
https://united-tattoos.com/api/auth/callback/nextcloud
User Info Endpoint:
https://portal.united-tattoos.com/ocs/v2.php/cloud/user?format=json
📧 Resend Email Configuration
Credentials
API Key: re_NkMnKyNY_5eHUS1Ajj24GgmTNajHVeehQ
DNS Configuration (Cloudflare)
Add these records to united-tattoos.com in Cloudflare DNS:
1. Go to Resend Dashboard → Domains → Add united-tattoos.com
2. Resend will show you 3 DNS records to add
3. Add them to Cloudflare:
Type: TXT
Name: resend._domainkey.united-tattoos.com
Value: [Resend will provide this - looks like "p=MIGfMA0GCSqGSIb3..."]
Type: TXT
Name: _dmarc.united-tattoos.com
Value: v=DMARC1; p=none;
Type: MX (if you want to receive emails)
Name: united-tattoos.com
Priority: 10
Value: feedback-smtp.us-east-1.amazonses.com
Sender Address: bookings@united-tattoos.com
📋 FINAL Implementation Plan
PHASE 0: Foundation (1-2 days)
0.1 Database Migration for Anonymous Bookings
File: sql/migrations/20250110_anonymous_bookings.sql
-- Allow anonymous bookings - store customer info directly on appointment
ALTER TABLE appointments ADD COLUMN client_name TEXT;
ALTER TABLE appointments ADD COLUMN client_email TEXT;
ALTER TABLE appointments ADD COLUMN client_phone TEXT;
-- client_id becomes optional (NULL for anonymous bookings)
-- In D1/SQLite we can't easily make FK optional, so we'll handle in app logic
0.2 Set Up Nextcloud OAuth2 Provider
File: lib/auth.ts (UPDATE)
import NextAuth, { NextAuthOptions } from "next-auth"
import { getDB } from "./db"
export const authOptions: NextAuthOptions = {
providers: [
{
id: "nextcloud",
name: "Nextcloud",
type: "oauth",
authorization: {
url: "https://portal.united-tattoos.com/apps/oauth2/authorize",
params: { scope: "openid profile email" },
},
token: "https://portal.united-tattoos.com/apps/oauth2/api/v1/token",
userinfo: "https://portal.united-tattoos.com/ocs/v2.php/cloud/user?format=json",
clientId: process.env.NEXTCLOUD_CLIENT_ID,
clientSecret: process.env.NEXTCLOUD_CLIENT_SECRET,
profile(profile) {
// Nextcloud returns user info in ocs.data
return {
id: profile.ocs.data.id,
name: profile.ocs.data.displayname,
email: profile.ocs.data.email,
image: null, // Nextcloud avatar handling can be added later
}
},
},
],
callbacks: {
async session({ session, token }) {
// Add user ID and role from database
if (session.user && token.email) {
const db = getDB()
const user = await db
.prepare('SELECT id, role FROM users WHERE email = ?')
.bind(token.email)
.first()
if (user) {
session.user.id = user.id
session.user.role = user.role
}
}
return session
},
async signIn({ user, account, profile }) {
// Verify user is in allowed Nextcloud groups
// Could call Nextcloud API to check group membership
// For now, just allow anyone with Nextcloud account
return true
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
}
export const handler = NextAuth(authOptions)
Environment Variables:
NEXTCLOUD_CLIENT_ID=PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI
NEXTCLOUD_CLIENT_SECRET=tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
NEXTCLOUD_BASE_URL=https://portal.united-tattoos.com
0.3 Create Admin Middleware
File: middleware.ts (UPDATE)
import { withAuth } from "next-auth/middleware"
import { NextResponse } from "next/server"
export default withAuth(
function middleware(req) {
const token = req.nextauth.token
const path = req.nextUrl.pathname
// Admin routes require SHOP_ADMIN or SUPER_ADMIN
if (path.startsWith('/admin')) {
if (!token || (token.role !== 'SHOP_ADMIN' && token.role !== 'SUPER_ADMIN')) {
return NextResponse.redirect(new URL('/auth/signin', req.url))
}
}
// Artist dashboard requires ARTIST role
if (path.startsWith('/artist-dashboard')) {
if (!token || token.role !== 'ARTIST') {
return NextResponse.redirect(new URL('/auth/signin', req.url))
}
}
return NextResponse.next()
},
{
callbacks: {
authorized: ({ token, req }) => {
const path = req.nextUrl.pathname
// Public routes - no auth needed
if (path === '/' ||
path.startsWith('/artists') ||
path.startsWith('/book') ||
path.startsWith('/aftercare') ||
path.startsWith('/api/artists') ||
path.startsWith('/api/appointments') && req.method === 'POST' ||
path.startsWith('/api/caldav/availability')) {
return true
}
// Protected routes
if (path.startsWith('/admin') || path.startsWith('/artist-dashboard')) {
return !!token
}
// All other routes public
return true
},
},
}
)
export const config = {
matcher: ['/admin/:path*', '/artist-dashboard/:path*'],
}
0.4 Set Up Resend Email Service
Install:
npm install resend
File: lib/email.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
const FROM_EMAIL = 'United Tattoo <bookings@united-tattoos.com>'
export async function sendBookingConfirmationEmail(appointment: {
id: string
client_name: string
client_email: string
client_phone: string
artist_name: string
start_time: string
end_time: string
description: string
}) {
const startTime = new Date(appointment.start_time)
const formattedDate = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Denver',
}).format(startTime)
const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone: 'America/Denver',
}).format(startTime)
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: appointment.client_email,
subject: '✨ Your Tattoo Booking Request - United Tattoo',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #1a1a1a; font-size: 28px; margin-bottom: 10px;">Booking Request Received! ✅</h1>
<p style="color: #666; font-size: 16px;">We can't wait to create something amazing with you</p>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 8px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #1a1a1a; font-size: 20px;">Appointment Details</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-weight: 500;">Artist:</td>
<td style="padding: 8px 0; color: #1a1a1a; font-weight: 600;">${appointment.artist_name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-weight: 500;">Date:</td>
<td style="padding: 8px 0; color: #1a1a1a; font-weight: 600;">${formattedDate}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-weight: 500;">Time:</td>
<td style="padding: 8px 0; color: #1a1a1a; font-weight: 600;">${formattedTime}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-weight: 500; vertical-align: top;">Description:</td>
<td style="padding: 8px 0; color: #1a1a1a;">${appointment.description}</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin-bottom: 25px;">
<p style="margin: 0; color: #856404;"><strong>⏳ Pending Approval</strong></p>
<p style="margin: 5px 0 0; color: #856404; font-size: 14px;">Your request is being reviewed by ${appointment.artist_name}</p>
</div>
<h2 style="color: #1a1a1a; font-size: 20px; margin-bottom: 15px;">What Happens Next?</h2>
<ol style="color: #666; padding-left: 20px;">
<li style="margin-bottom: 10px;">Your artist will review your request and check their availability (usually within 24 hours)</li>
<li style="margin-bottom: 10px;">You'll receive another email when your appointment is confirmed</li>
<li style="margin-bottom: 10px;">Once confirmed, you can pay your deposit at the shop or via a secure payment link</li>
<li style="margin-bottom: 10px;">Show up ready! Bring a valid ID and arrive 10 minutes early</li>
</ol>
<div style="background: #e7f3ff; padding: 20px; border-radius: 8px; margin-top: 25px;">
<h3 style="margin-top: 0; color: #0066cc; font-size: 18px;">Need to Make Changes?</h3>
<p style="color: #333; margin-bottom: 10px;">Contact us:</p>
<p style="margin: 5px 0; color: #333;">📞 <a href="tel:+17195551234" style="color: #0066cc; text-decoration: none;">(719) 555-1234</a></p>
<p style="margin: 5px 0; color: #333;">✉️ <a href="mailto:info@united-tattoos.com" style="color: #0066cc; text-decoration: none;">info@united-tattoos.com</a></p>
<p style="margin: 5px 0; color: #333;">📍 123 Main St, Fountain, CO 80817</p>
</div>
<div style="text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<p style="color: #999; font-size: 14px; margin: 0;">United Tattoo - Fountain, Colorado</p>
<p style="color: #999; font-size: 12px; margin: 5px 0;">You're receiving this because you requested an appointment</p>
</div>
</body>
</html>
`,
})
if (error) {
throw new Error(`Failed to send email: ${error.message}`)
}
return data
} catch (error) {
console.error('Email send failed:', error)
// Don't throw - we don't want email failure to break booking
// Just log it and continue
return null
}
}
export async function sendBookingStatusChangeEmail(appointment: {
id: string
client_name: string
client_email: string
artist_name: string
start_time: string
status: string
description: string
}) {
const startTime = new Date(appointment.start_time)
const formattedDate = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'America/Denver',
}).format(startTime)
const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone: 'America/Denver',
}).format(startTime)
const statusConfig = {
CONFIRMED: {
subject: '✅ Your Tattoo Appointment is Confirmed!',
title: 'Great News! Your Appointment is Confirmed',
message: `${appointment.artist_name} has confirmed your tattoo appointment!`,
action: 'You can pay your deposit at the shop or we\'ll send you a secure payment link separately.',
color: '#28a745',
},
CANCELLED: {
subject: '📅 Appointment Update - United Tattoo',
title: 'Appointment Rescheduling Needed',
message: 'We need to reschedule your appointment due to a scheduling conflict.',
action: 'Please call us at (719) 555-1234 to find a new time that works for you.',
color: '#dc3545',
},
}
const config = statusConfig[appointment.status as keyof typeof statusConfig]
if (!config) return null // Don't send for other statuses
try {
const { data, error } = await resend.emails.send({
from: FROM_EMAIL,
to: appointment.client_email,
subject: config.subject,
html: `
<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: ${config.color}; font-size: 28px; margin-bottom: 10px;">${config.title}</h1>
<p style="color: #666; font-size: 16px;">${config.message}</p>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 8px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #1a1a1a; font-size: 20px;">Appointment Details</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666;">Artist:</td>
<td style="padding: 8px 0; color: #1a1a1a; font-weight: 600;">${appointment.artist_name}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;">Date:</td>
<td style="padding: 8px 0; color: #1a1a1a; font-weight: 600;">${formattedDate}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666;">Time:</td>
<td style="padding: 8px 0; color: #1a1a1a; font-weight: 600;">${formattedTime}</td>
</tr>
</table>
</div>
<p style="color: #333; font-size: 16px;">${config.action}</p>
<div style="text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<p style="color: #999; font-size: 14px;">Questions? Call (719) 555-1234</p>
<p style="color: #999; font-size: 12px; margin-top: 10px;">United Tattoo - Fountain, CO</p>
</div>
</body>
</html>
`,
})
if (error) {
console.error('Email error:', error)
return null
}
return data
} catch (error) {
console.error('Status change email failed:', error)
return null
}
}
// Send notification to artist when new booking received
export async function sendArtistBookingNotification(appointment: {
id: string
client_name: string
artist_email: string
artist_name: string
start_time: string
description: string
}) {
const startTime = new Date(appointment.start_time)
const formattedDate = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
timeZone: 'America/Denver',
}).format(startTime)
const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone: 'America/Denver',
}).format(startTime)
try {
await resend.emails.send({
from: FROM_EMAIL,
to: appointment.artist_email,
subject: '🔔 New Booking Request',
html: `
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #1a1a1a;">New Booking Request</h1>
<p>You have a new tattoo appointment request:</p>
<div style="background: #fff3cd; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>Client:</strong> ${appointment.client_name}</p>
<p><strong>Requested Date:</strong> ${formattedDate}</p>
<p><strong>Requested Time:</strong> ${formattedTime}</p>
<p><strong>Description:</strong> ${appointment.description}</p>
</div>
<p><strong>Next Steps:</strong></p>
<ol>
<li>Check your Nextcloud calendar - this appears as "REQUEST: ${appointment.client_name}"</li>
<li>Log in to the admin dashboard to approve or reschedule</li>
<li>Or edit the calendar event in Nextcloud (remove "REQUEST:" to approve)</li>
</ol>
<p>
<a href="https://united-tattoos.com/admin/bookings" style="display: inline-block; background: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin-top: 10px;">
View in Dashboard
</a>
</p>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
United Tattoo Admin System
</p>
</body>
</html>
`,
})
} catch (error) {
console.error('Artist notification email failed:', error)
return null
}
}
Environment Variable:
RESEND_API_KEY=re_NkMnKyNY_5eHUS1Ajj24GgmTNajHVeehQ
0.5 Install Dependencies
npm install resend date-fns-tz @sentry/nextjs
PHASE 1: Anonymous Customer Booking (2-3 days)
1.1 Update Appointments API for Anonymous Bookings
File: app/api/appointments/route.ts (MAJOR UPDATE)
const createAppointmentSchema = z.object({
artistId: z.string().min(1),
// NO clientId - this is anonymous!
clientName: z.string().min(2, "Name is required"),
clientEmail: z.string().email("Valid email is required"),
clientPhone: z.string().min(10, "Phone number is required"),
title: z.string().min(1),
description: z.string().optional(),
startTime: z.string().datetime(),
endTime: z.string().datetime(),
depositAmount: z.number().optional(),
notes: z.string().optional(),
})
export async function POST(request: NextRequest, ...) {
try {
// NO AUTHENTICATION CHECK - Public endpoint for customer bookings
if (!Flags.BOOKING_ENABLED) {
return bookingDisabledResponse()
}
const body = await request.json()
const validatedData = createAppointmentSchema.parse(body)
const db = getDB(context?.env)
// Check CalDAV availability (Nextcloud is source of truth)
const startDate = new Date(validatedData.startTime)
const endDate = new Date(validatedData.endTime)
const availabilityCheck = await checkArtistAvailability(
validatedData.artistId,
startDate,
endDate,
context
)
if (!availabilityCheck.available) {
return NextResponse.json(
{
error: 'Time slot not available',
reason: availabilityCheck.reason || 'Selected time slot conflicts with existing booking.'
},
{ status: 409 }
)
}
// Create appointment with customer contact info (no user ID)
const appointmentId = crypto.randomUUID()
const insertStmt = db.prepare(`
INSERT INTO appointments (
id, artist_id, client_id, client_name, client_email, client_phone,
title, description, start_time, end_time,
status, deposit_amount, notes, created_at, updated_at
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`)
await insertStmt.bind(
appointmentId,
validatedData.artistId,
validatedData.clientName,
validatedData.clientEmail,
validatedData.clientPhone,
validatedData.title,
validatedData.description || null,
validatedData.startTime,
validatedData.endTime,
validatedData.depositAmount || null,
validatedData.notes || null
).run()
// Fetch created appointment with artist info
const appointment = await db.prepare(`
SELECT
a.*,
ar.name as artist_name,
u.email as artist_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON ar.user_id = u.id
WHERE a.id = ?
`).bind(appointmentId).first()
// Sync to CalDAV (non-blocking)
try {
await syncAppointmentToCalendar(appointment as any, context)
} catch (syncError) {
console.error('CalDAV sync failed:', syncError)
}
// Send emails (non-blocking)
try {
// Email to customer
await sendBookingConfirmationEmail(appointment as any)
// Email to artist
await sendArtistBookingNotification({
id: appointment.id,
client_name: appointment.client_name,
artist_email: appointment.artist_email,
artist_name: appointment.artist_name,
start_time: appointment.start_time,
description: appointment.description,
})
} catch (emailError) {
console.error('Email notification failed:', emailError)
}
return NextResponse.json({ appointment }, { status: 201 })
} catch (error) {
// ... error handling
}
}
1.2 Update Booking Form (Remove All Auth!)
File: components/booking-form.tsx
REMOVE these lines:
// DELETE THIS:
import { useSession } from 'next-auth/react'
const { data: session } = useSession()
// DELETE THIS:
if (!session?.user) {
toast.error('Please sign in')
router.push('/auth/signin')
return
}
UPDATE handleSubmit:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!bookingEnabled) {
toast.error('Booking temporarily unavailable')
return
}
// NO SESSION CHECK!
if (!selectedArtist || !appointmentStart || !appointmentEnd) {
toast.error('Please complete all required fields')
return
}
createBooking.mutate({
artistId: selectedArtist.id,
clientName: `${formData.firstName} ${formData.lastName}`,
clientEmail: formData.email,
clientPhone: formData.phone,
title: `Tattoo: ${formData.tattooDescription.substring(0, 50)}`,
description: formData.tattooDescription,
startTime: appointmentStart,
endTime: appointmentEnd,
depositAmount: formData.depositAmount,
notes: formData.specialRequests,
}, {
onSuccess: (data) => {
router.push(`/book/confirm/${data.appointment.id}`)
}
})
}
1.3 Update Confirmation Page (Public, No Auth!)
File: app/book/confirm/[id]/page.tsx
// NO getServerSession!
// NO authentication check!
// Page is PUBLIC - anyone with link can view
async function getBooking(id: string) {
const db = getDB()
const booking = await db.prepare(`
SELECT
a.*,
ar.name as artist_name,
ar.instagram_handle
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
WHERE a.id = ?
`).bind(id).first()
return booking
}
export default async function BookingConfirmationPage({ params }) {
const booking = await getBooking(params.id)
if (!booking) {
notFound()
}
// Uses client_name, client_email from appointment (no user table needed!)
// ... rest of confirmation page
}
🔄 Artist Lifecycle Workflows
New Artist Joins
Step 1: Create in Nextcloud
Admin → Nextcloud Users → Add User
Name: Artist Name
Email: artist@email.com
Groups: [artist]
→ Nextcloud auto-creates personal calendar
Step 2: Create in D1
-- Create user record
INSERT INTO users (id, email, name, role, created_at)
VALUES ('uuid', 'artist@email.com', 'Artist Name', 'ARTIST', CURRENT_TIMESTAMP);
-- Create artist profile
INSERT INTO artists (id, user_id, name, bio, specialties, is_active, created_at)
VALUES ('uuid', 'user_id_from_above', 'Artist Name', 'Bio...', '["Style1","Style2"]', TRUE, CURRENT_TIMESTAMP);
Step 3: Link Calendar
Admin → /admin/calendars → Configure
Artist: [Select from dropdown]
Calendar URL: https://portal.united-tattoos.com/remote.php/dav/calendars/artist@email.com/personal/
→ Saves to artist_calendars table
Artist Leaves
-- Deactivate (don't delete - preserve history)
UPDATE artists SET is_active = FALSE WHERE id = 'artist_id';
-- Optional: Change user role so they can't access admin
UPDATE users SET role = 'CLIENT' WHERE id = 'user_id';
-- Calendar config stays (for historical bookings)
-- Portfolio images stay (preserved history)
-- Past appointments stay (records)
Result:
- ✅ Artist disappears from website
- ✅ Can't receive new bookings
- ✅ Can't log into admin dashboard
- ✅ Historical data preserved
- ✅ Can reactivate later if they return
Artist Email Changes
-- Update in BOTH places
UPDATE users SET email = 'new@email.com' WHERE id = 'user_id';
-- Nextcloud: Admin must change email there too
-- Then update calendar URL:
UPDATE artist_calendars
SET calendar_url = 'https://portal.united-tattoos.com/remote.php/dav/calendars/new@email.com/personal/'
WHERE artist_id = 'artist_id';
Important: Email must match in both systems!
🎯 Complete Workflow Diagram
┌─────────────────────────────────────────────────────┐
│ CUSTOMER JOURNEY (No Login!) │
└─────────────────────────────────────────────────────┘
1. Visit united-tattoos.com/book
2. Select artist (from D1 database)
3. Fill form: name, email, phone, tattoo details
4. Select date/time → Real-time availability check (CalDAV)
5. Submit → Creates PENDING appointment in D1
6. Redirect to /book/confirm/[id] (public page)
7. Receive confirmation email via Resend
┌─────────────────────────────────────────────────────┐
│ SYSTEM AUTOMATION │
└─────────────────────────────────────────────────────┘
8. Appointment syncs to Nextcloud calendar
Title: "REQUEST: John Doe - Dragon Tattoo"
9. Artist receives email notification
10. Artist sees request in Nextcloud calendar app
┌─────────────────────────────────────────────────────┐
│ ARTIST/ADMIN APPROVAL (Via Web) │
└─────────────────────────────────────────────────────┘
11. Artist/admin logs in via Nextcloud OAuth2
12. Views /admin/bookings dashboard
13. Clicks "Approve" on pending request
14. Status → CONFIRMED in D1
15. Syncs to Nextcloud (removes "REQUEST:" prefix)
16. Customer receives "Confirmed" email
OR
┌─────────────────────────────────────────────────────┐
│ ARTIST/ADMIN APPROVAL (Via Nextcloud) │
└─────────────────────────────────────────────────────┘
11. Artist opens Nextcloud calendar on phone/desktop
12. Sees "REQUEST: John Doe - Dragon Tattoo"
13. Edits event, removes "REQUEST:" prefix
14. Saves event
15. Background worker (every 5 min) detects change
16. Status → CONFIRMED in D1
17. Customer receives "Confirmed" email
🎯 Implementation Order (FINAL)
Phase 0: Foundation (Days 1-2)
- Run database migration for anonymous bookings
- Set up Resend email (add DNS records)
- Test email sending
- Configure Nextcloud OAuth2 in NextAuth
- Test OAuth2 login
- Create admin middleware
- Install Sentry (optional)
Phase 1: Customer Booking (Days 3-5)
- Create use-bookings.ts hook
- Update booking form (remove auth, add API call)
- Update appointments API for anonymous bookings
- Create confirmation page (public)
- Test full booking flow
Phase 2: Admin Dashboards (Days 6-9)
- Create use-calendar-configs.ts hook
- Build calendar configuration UI
- Build bookings dashboard with DataTable
- Add approve/reject functionality
- Test admin workflows
Phase 3: Background Sync (Days 10-12)
- Add status detection to calendar-sync.ts
- Create background sync worker
- Configure wrangler.toml with cron
- Deploy worker
- Test Nextcloud → Web sync
Phase 4: Testing & Deploy (Days 13-15)
- End-to-end testing
- Load testing
- Deploy to production
- Monitor for 48 hours
🔧 Artist Linking - HOW IT WORKS
The Connection Chain
Website Booking Form:
↓
Selects artist (from D1 artists table)
↓
Artist has user_id (FK to users table)
↓
Users table has email
↓
Email matches Nextcloud user
↓
Nextcloud user has calendar
↓
artist_calendars table links artist.id → calendar URL
Example Query
-- Get artist with calendar info
SELECT
a.id as artist_id,
a.name as artist_name,
u.email as artist_email,
ac.calendar_url
FROM artists a
JOIN users u ON a.user_id = u.id
LEFT JOIN artist_calendars ac ON a.id = ac.artist_id
WHERE a.id = ?;
Result:
artist_id: "uuid-123"
artist_name: "Christy Lumberg"
artist_email: "christy@example.com" ← Links to Nextcloud!
calendar_url: "https://portal.united-tattoos.com/remote.php/dav/calendars/christy@example.com/personal/"
📝 Pre-Implementation Checklist
✅ You Need To Do:
-
Set up Resend domain (15 minutes)
- Go to resend.com/domains
- Add united-tattoos.com
- Copy the 3 DNS records
- Add to Cloudflare DNS
- Verify domain
-
Verify Nextcloud OAuth2 callback URLs (2 minutes)
- Log into portal.united-tattoos.com
- Settings → Security → OAuth 2.0
- Check callback URLs include:
https://united-tattoos.com/api/auth/callback/nextcloudhttp://localhost:3000/api/auth/callback/nextcloud
-
Verify users table has emails (1 minute)
-- Run in Wrangler D1: SELECT u.email, a.name FROM artists a JOIN users u ON a.user_id = u.id WHERE a.is_active = TRUE;- Should show all artists with their emails
- These emails MUST match Nextcloud user emails
-
Optional: Set up Sentry (10 minutes)
- Go to sentry.io
- Create project
- Copy DSN
- Add to environment variables
🚀 Ready to Implement?
I have everything I need:
- ✅ Resend API key
- ✅ Nextcloud OAuth2 credentials
- ✅ Domain ownership confirmed
- ✅ Architecture decided
Just need confirmation on:
- Are there emails for all current artists in the users table?
- Do those emails match their Nextcloud accounts?
Once confirmed, I'll start Phase 0!