united-tattoo/docs/BOOKING-WORKFLOW-FINAL-PLAN.md

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:

  1. 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
  2. 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/nextcloud
      • http://localhost:3000/api/auth/callback/nextcloud
  3. 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
  4. 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:

  1. Are there emails for all current artists in the users table?
  2. Do those emails match their Nextcloud accounts?

Once confirmed, I'll start Phase 0!